import { NextResponse } from "next/server"; import { upsertSystemSetting } from "@/lib/db/queries"; import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin"; import { findBestMatchingPsdFont } from "@/lib/agents/psd-font-matching"; import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings"; import { AGENT_PSD_TEMPLATES_KEY, type AgentPsdTemplate, type AgentPsdTemplateLayerSlot, type AgentPsdTemplateMutationResponse, } from "@/lib/agents/psd-template-config"; import { type ParsedPsdTemplateLayer, parsePsdTemplateFile, preparePsdTemplateRenderAssets, } from "@/lib/agents/psd-template-parser"; import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings"; import { uploadFile } from "@/lib/storage/service"; export const runtime = "nodejs"; function jsonError(status: number, error: string) { const payload: AgentPsdTemplateMutationResponse = { success: false, error, }; return NextResponse.json(payload, { status }); } function toArrayBuffer(buffer: Buffer) { return buffer.buffer.slice( buffer.byteOffset, buffer.byteOffset + buffer.byteLength ) as ArrayBuffer; } function getErrorMessage(error: unknown) { if (error instanceof Error && error.message.trim()) { return error.message.trim(); } return "PSD 解析失败"; } function getCanvasOverlapArea(params: { x: number; y: number; width: number; height: number; canvasWidth: number; canvasHeight: number; }) { const left = Math.max(0, params.x); const top = Math.max(0, params.y); const right = Math.min(params.canvasWidth, params.x + params.width); const bottom = Math.min(params.canvasHeight, params.y + params.height); return Math.max(0, right - left) * Math.max(0, bottom - top); } function pickPrimaryImageLayerId(params: { parsedLayers: Awaited>["layers"]; canvasWidth: number; canvasHeight: number; }) { const { parsedLayers, canvasWidth, canvasHeight } = params; const imageLayers = parsedLayers.filter((layer) => layer.slotType === "image"); if (imageLayers.length === 0) { return null; } const backgroundPattern = /(背景|底图|bg|background)/i; const scored = imageLayers.map((layer) => { const overlapArea = getCanvasOverlapArea({ x: layer.x, y: layer.y, width: layer.width, height: layer.height, canvasWidth, canvasHeight, }); const layerArea = Math.max(1, layer.width * layer.height); const coversWholeCanvas = overlapArea >= canvasWidth * canvasHeight * 0.96 && layer.x <= 0 && layer.y <= 0 && layer.width >= canvasWidth * 0.96 && layer.height >= canvasHeight * 0.96; const backgroundPenalty = backgroundPattern.test(layer.layerName) || backgroundPattern.test(layer.layerPath) || coversWholeCanvas ? 0.45 : 1; return { layer, score: Math.max(overlapArea, layerArea * 0.5) * backgroundPenalty, }; }); scored.sort((left, right) => right.score - left.score); return scored[0]?.layer.id || null; } function shouldAutoEditableTextLayer( layer: ParsedPsdTemplateLayer ) { const text = String(layer.defaultText || "").replace(/\s+/g, ""); if (!text) { return false; } return ( layer.width >= 80 || layer.height >= 30 || text.length >= 4 || /[A-Za-z0-9]{3,}/.test(text) ); } function buildInitialTemplateLayers(params: { parsedLayers: Awaited>["layers"]; fonts: Awaited>; canvasWidth: number; canvasHeight: number; }) { const { parsedLayers, fonts, canvasWidth, canvasHeight } = params; const primaryImageLayerId = pickPrimaryImageLayerId({ parsedLayers, canvasWidth, canvasHeight, }); return parsedLayers.map((layer): AgentPsdTemplateLayerSlot => { if (layer.slotType === "text") { const matchedFont = findBestMatchingPsdFont({ detectedFontNames: layer.detectedFontNames || [], fonts, }); const isEditable = shouldAutoEditableTextLayer(layer); return { ...layer, slotType: "text", isEditable, isRequired: false, placeholder: layer.defaultText || layer.layerName, detectedFontName: layer.detectedFontName, detectedFontNames: layer.detectedFontNames, fontId: matchedFont?.font.id, fontFamily: matchedFont?.font.family, fontLabel: matchedFont?.font.name, fontFileUrl: matchedFont?.font.fileUrl, fontContentType: matchedFont?.font.contentType, fontSize: Math.max( 10, Math.round( Number( layer.detectedFontSize || Math.max(16, Math.min(layer.height, Math.round(layer.height * 0.78))) ) ) ), lineHeight: Number.isFinite(Number(layer.detectedLineHeight)) && Number(layer.detectedLineHeight) > 0 ? Number(layer.detectedLineHeight) : 1.2, letterSpacing: Number.isFinite(Number(layer.detectedLetterSpacing)) ? Number(layer.detectedLetterSpacing) : 0, fontWeight: 500, textColor: "#111111", textAlign: "left", paddingX: 0, paddingY: 0, maxLines: 4, }; } const isPrimaryImageLayer = layer.id === primaryImageLayerId; return { ...layer, slotType: "image", isEditable: isPrimaryImageLayer, isRequired: false, isDefaultResultSource: isPrimaryImageLayer, allowUserUploadOverride: isPrimaryImageLayer, replaceMode: "cover", }; }); } async function updateTemplate( templateId: string, updatedBy: string, updater: (template: AgentPsdTemplate) => AgentPsdTemplate ) { const templates = await readAgentPsdTemplates(); const currentTemplate = templates.find((entry) => entry.id === templateId); if (!currentTemplate) { return null; } const nextTemplate = updater(currentTemplate); await upsertSystemSetting({ key: AGENT_PSD_TEMPLATES_KEY, value: templates.map((entry) => (entry.id === templateId ? nextTemplate : entry)), updatedBy, }); return nextTemplate; } async function updateParseProgress(params: { templateId: string; updatedBy: string; progress: number; stage: string; error?: string; }) { const { templateId, updatedBy, progress, stage, error } = params; return updateTemplate(templateId, updatedBy, (template) => ({ ...template, parseStatus: "processing", parseProgress: progress, parseStage: stage, parseError: error, updatedAt: new Date().toISOString(), })); } export async function POST( _request: Request, context: { params: Promise<{ id: string }> } ) { const session = await requireAgentWorkflowAdmin().catch(() => null); if (!session?.user?.id) { return jsonError(401, "未登录"); } const { id } = await context.params; try { const templates = await readAgentPsdTemplates(); const currentTemplate = templates.find((entry) => entry.id === id); if (!currentTemplate) { return jsonError(404, "未找到 PSD 模板"); } if (currentTemplate.parseStatus === "processing") { return jsonError(409, "该模板正在解析中,请稍后刷新"); } await updateParseProgress({ templateId: id, updatedBy: session.user.id, progress: 5, stage: "准备解析 PSD", error: undefined, }); const response = await fetch(currentTemplate.fileUrl, { cache: "no-store", signal: AbortSignal.timeout(60_000), }); if (!response.ok) { throw new Error(`PSD 文件下载失败 (${response.status})`); } await updateParseProgress({ templateId: id, updatedBy: session.user.id, progress: 18, stage: "正在读取 PSD 文件", error: undefined, }); const fileBuffer = await response.arrayBuffer(); const parsedTemplate = await parsePsdTemplateFile(fileBuffer); if (parsedTemplate.layers.length === 0) { throw new Error("PSD 中未识别到可配置图层"); } await updateParseProgress({ templateId: id, updatedBy: session.user.id, progress: 42, stage: "正在生成模板预览", error: undefined, }); const uploadedPreview = await uploadFile( `agent-psd-preview-${id}-${Date.now()}.png`, toArrayBuffer(parsedTemplate.previewBuffer) ); await updateParseProgress({ templateId: id, updatedBy: session.user.id, progress: 58, stage: "正在匹配字体与初始化图层", error: undefined, }); const fonts = await readAgentPsdFonts().catch(() => []); const initialLayers = buildInitialTemplateLayers({ parsedLayers: parsedTemplate.layers, fonts, canvasWidth: parsedTemplate.width, canvasHeight: parsedTemplate.height, }); await updateParseProgress({ templateId: id, updatedBy: session.user.id, progress: 78, stage: "正在生成图层预览和遮罩", error: undefined, }); const preparedAssets = await preparePsdTemplateRenderAssets({ templateId: id, fileUrl: currentTemplate.fileUrl, layers: initialLayers, }); await updateParseProgress({ templateId: id, updatedBy: session.user.id, progress: 92, stage: "正在生成模板底图", error: undefined, }); const uploadedRenderBase = await uploadFile( `agent-psd-base-${id}-${Date.now()}.png`, toArrayBuffer(preparedAssets.renderBaseBuffer) ); const hasEditableLayers = preparedAssets.layers.some((layer) => layer.isEditable); const nextStatus = currentTemplate.status === "active" || (currentTemplate.layers.length === 0 && hasEditableLayers) ? "active" : currentTemplate.status; const updatedTemplate = await updateTemplate(id, session.user.id, (template) => ({ ...template, previewUrl: uploadedPreview.url, previewPath: uploadedPreview.pathname, renderBaseUrl: uploadedRenderBase.url, renderBasePath: uploadedRenderBase.pathname, width: parsedTemplate.width, height: parsedTemplate.height, layerCount: parsedTemplate.layers.length, layers: preparedAssets.layers, status: nextStatus, parseStatus: "completed", parseProgress: 100, parseStage: "解析完成", parseError: undefined, updatedAt: new Date().toISOString(), })); if (!updatedTemplate) { return jsonError(404, "未找到 PSD 模板"); } return NextResponse.json({ success: true, template: updatedTemplate, } satisfies AgentPsdTemplateMutationResponse); } catch (error) { const message = getErrorMessage(error); const existingTemplate = (await readAgentPsdTemplates()).find((entry) => entry.id === id); if (existingTemplate) { const fallbackStatus = existingTemplate.parseStatus === "completed" && existingTemplate.layers.length > 0 ? "completed" : "failed"; await updateTemplate(id, session.user.id, (template) => ({ ...template, parseStatus: fallbackStatus, parseProgress: fallbackStatus === "completed" ? 100 : 0, parseStage: fallbackStatus === "completed" ? "沿用上次成功解析结果" : undefined, parseError: fallbackStatus === "completed" ? `重新解析失败:${message}` : message, updatedAt: new Date().toISOString(), })).catch(() => undefined); } console.error("[PSD Templates][Admin] parse failed:", error); return jsonError(502, message); } }