From fb6b2170bdda0ed931b8263608802ea751eda196 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 1 Apr 2026 22:36:45 +0800 Subject: [PATCH] fix: harden psd template rendering --- .../admin/psd-templates/[id]/parse/route.ts | 2 + app/api/admin/psd-templates/[id]/route.ts | 105 +---------- app/api/admin/psd-templates/route.ts | 115 +----------- app/api/psd-templates/render/route.ts | 32 +++- app/api/psd-templates/route.ts | 42 ++++- lib/agents/psd-template-config.ts | 3 + lib/agents/psd-template-parser.ts | 13 +- lib/agents/psd-template-renderer.ts | 168 ++++++++++++++---- lib/agents/psd-template-repair.ts | 159 +++++++++++++++++ 9 files changed, 383 insertions(+), 256 deletions(-) create mode 100644 lib/agents/psd-template-repair.ts diff --git a/app/api/admin/psd-templates/[id]/parse/route.ts b/app/api/admin/psd-templates/[id]/parse/route.ts index b306ee768..04ff661b2 100644 --- a/app/api/admin/psd-templates/[id]/parse/route.ts +++ b/app/api/admin/psd-templates/[id]/parse/route.ts @@ -5,6 +5,7 @@ import { findBestMatchingPsdFont } from "@/lib/agents/psd-font-matching"; import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings"; import { AGENT_PSD_TEMPLATES_KEY, + AGENT_PSD_TEMPLATE_ASSET_VERSION, type AgentPsdTemplate, type AgentPsdTemplateLayerSlot, type AgentPsdTemplateMutationResponse, @@ -356,6 +357,7 @@ export async function POST( height: parsedTemplate.height, layerCount: parsedTemplate.layers.length, layers: preparedAssets.layers, + assetVersion: AGENT_PSD_TEMPLATE_ASSET_VERSION, status: nextStatus, parseStatus: "completed", parseProgress: 100, diff --git a/app/api/admin/psd-templates/[id]/route.ts b/app/api/admin/psd-templates/[id]/route.ts index 922cab51e..6d83b8a7d 100644 --- a/app/api/admin/psd-templates/[id]/route.ts +++ b/app/api/admin/psd-templates/[id]/route.ts @@ -5,6 +5,7 @@ import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin"; import { findBestMatchingPsdFont } from "@/lib/agents/psd-font-matching"; import { AGENT_PSD_TEMPLATES_KEY, + AGENT_PSD_TEMPLATE_ASSET_VERSION, getEditableAgentPsdTemplateLayers, isAgentPsdTextLayerSlot, normalizeAgentPsdTemplateStatus, @@ -12,10 +13,10 @@ import { type AgentPsdTemplateMutationResponse, } from "@/lib/agents/psd-template-config"; import { - parsePsdTemplateFileFromUrl, preparePsdTemplateRenderAssets, } from "@/lib/agents/psd-template-parser"; import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings"; +import { repairAgentPsdTemplateAssetsIfNeeded } from "@/lib/agents/psd-template-repair"; import { uploadFile } from "@/lib/storage/service"; import { readAgentPsdTemplates, @@ -40,105 +41,6 @@ function jsonError(status: number, error: string) { return NextResponse.json(payload, { status }); } -async function repairTemplateLayerPreviewsIfNeeded(template: NonNullable>[number]>) { - if (template.parseStatus !== "completed") { - return template; - } - - const needsRepair = template.layers.some( - (layer) => - !String(layer.previewUrl || "").trim() || !String(layer.sourceUrl || "").trim() - ); - const fonts = await readAgentPsdFonts().catch(() => []); - const needsFontDetection = template.layers.some( - (layer) => - isAgentPsdTextLayerSlot(layer) && - (!String(layer.detectedFontName || "").trim() || - !Array.isArray(layer.detectedFontNames) || - layer.detectedFontNames.length === 0) - ); - - let nextLayers = template.layers; - if (needsFontDetection) { - try { - const parsedTemplate = await parsePsdTemplateFileFromUrl(template.fileUrl); - const parsedTextLayerMap = new Map( - parsedTemplate.layers - .filter((layer) => layer.slotType === "text") - .map((layer) => [layer.layerId, layer]) - ); - nextLayers = nextLayers.map((layer) => { - if (!isAgentPsdTextLayerSlot(layer)) return layer; - const parsedLayer = parsedTextLayerMap.get(layer.layerId); - if (!parsedLayer) return layer; - return { - ...layer, - detectedFontName: parsedLayer.detectedFontName || layer.detectedFontName, - detectedFontNames: parsedLayer.detectedFontNames || layer.detectedFontNames, - }; - }); - } catch (error) { - console.warn("[PSD Templates][Admin] failed to parse PSD font metadata:", { - templateId: template.id, - error, - }); - } - } - - nextLayers = nextLayers.map((layer) => { - if (!isAgentPsdTextLayerSlot(layer) || layer.fontId) { - return layer; - } - - const matchedFont = findBestMatchingPsdFont({ - detectedFontNames: [ - ...((layer.detectedFontNames || []).filter(Boolean)), - ...(layer.detectedFontName ? [layer.detectedFontName] : []), - ], - fonts, - }); - if (!matchedFont) { - return layer; - } - - return { - ...layer, - fontId: matchedFont.font.id, - fontFamily: matchedFont.font.family, - fontLabel: matchedFont.font.name, - fontFileUrl: matchedFont.font.fileUrl, - fontContentType: matchedFont.font.contentType, - }; - }); - const hasFontRepair = nextLayers.some( - (layer, index) => JSON.stringify(layer) !== JSON.stringify(template.layers[index]) - ); - - if (!needsRepair && !hasFontRepair) { - return template; - } - - try { - const preparedAssets = await preparePsdTemplateRenderAssets({ - templateId: template.id, - fileUrl: template.fileUrl, - layers: nextLayers, - }); - - return { - ...template, - layers: preparedAssets.layers, - updatedAt: new Date().toISOString(), - }; - } catch (error) { - console.warn("[PSD Templates][Admin] failed to repair missing layer previews:", { - templateId: template.id, - error, - }); - return template; - } -} - export async function GET( request: NextRequest, context: { params: Promise<{ id: string }> } @@ -157,7 +59,7 @@ export async function GET( return jsonError(404, "未找到 PSD 模板"); } - const repairedTemplate = await repairTemplateLayerPreviewsIfNeeded(template); + const repairedTemplate = await repairAgentPsdTemplateAssetsIfNeeded(template); if (repairedTemplate !== template) { await upsertSystemSetting({ key: AGENT_PSD_TEMPLATES_KEY, @@ -310,6 +212,7 @@ export async function PUT( renderBasePath: uploadedRenderBase.pathname, layers: nextLayers, layerCount: Math.max(currentTemplate.layerCount, nextLayers.length), + assetVersion: AGENT_PSD_TEMPLATE_ASSET_VERSION, updatedAt: new Date().toISOString(), }; diff --git a/app/api/admin/psd-templates/route.ts b/app/api/admin/psd-templates/route.ts index e4684f3a4..3536be434 100644 --- a/app/api/admin/psd-templates/route.ts +++ b/app/api/admin/psd-templates/route.ts @@ -2,11 +2,10 @@ import { randomUUID } from "node:crypto"; import { NextRequest, NextResponse } from "next/server"; import { upsertSystemSetting } from "@/lib/db/queries"; import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin"; -import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings"; -import { findBestMatchingPsdFont } from "@/lib/agents/psd-font-matching"; import { uploadFile } from "@/lib/storage/service"; import { AGENT_PSD_TEMPLATES_KEY, + AGENT_PSD_TEMPLATE_ASSET_VERSION, normalizeAgentPsdTemplateStatus, upsertAgentPsdTemplate, type AgentPsdTemplate, @@ -14,10 +13,7 @@ import { type AgentPsdTemplateListResponse, type AgentPsdTemplateMutationResponse, } from "@/lib/agents/psd-template-config"; -import { - parsePsdTemplateFileFromUrl, - preparePsdTemplateRenderAssets, -} from "@/lib/agents/psd-template-parser"; +import { repairAgentPsdTemplateAssetsIfNeeded } from "@/lib/agents/psd-template-repair"; import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings"; export const runtime = "nodejs"; @@ -61,107 +57,6 @@ function toArrayBuffer(buffer: Buffer) { ) as ArrayBuffer; } -async function repairTemplateLayerPreviewsIfNeeded(params: { - template: AgentPsdTemplate; - updatedBy: string; -}) { - const { template } = params; - if (template.parseStatus !== "completed") { - return template; - } - - const needsRepair = template.layers.some( - (layer) => - !String(layer.previewUrl || "").trim() || !String(layer.sourceUrl || "").trim() - ); - const fonts = await readAgentPsdFonts().catch(() => []); - const needsFontDetection = template.layers.some( - (layer) => - layer.slotType === "text" && - (!String(layer.detectedFontName || "").trim() || - !Array.isArray(layer.detectedFontNames) || - layer.detectedFontNames.length === 0) - ); - - let nextLayers = template.layers; - if (needsFontDetection) { - try { - const parsedTemplate = await parsePsdTemplateFileFromUrl(template.fileUrl); - const parsedTextLayerMap = new Map( - parsedTemplate.layers - .filter((layer) => layer.slotType === "text") - .map((layer) => [layer.layerId, layer]) - ); - nextLayers = nextLayers.map((layer) => { - if (layer.slotType !== "text") return layer; - const parsedLayer = parsedTextLayerMap.get(layer.layerId); - if (!parsedLayer) return layer; - return { - ...layer, - detectedFontName: parsedLayer.detectedFontName || layer.detectedFontName, - detectedFontNames: parsedLayer.detectedFontNames || layer.detectedFontNames, - }; - }); - } catch (error) { - console.warn("[PSD Templates][Admin] failed to parse PSD font metadata:", { - templateId: template.id, - error, - }); - } - } - - nextLayers = nextLayers.map((layer) => { - if (layer.slotType !== "text") return layer; - if (layer.fontId) return layer; - - const detectedFontNames = [ - ...((layer.detectedFontNames || []).filter(Boolean)), - ...(layer.detectedFontName ? [layer.detectedFontName] : []), - ]; - const matchedFont = findBestMatchingPsdFont({ - detectedFontNames, - fonts, - }); - if (!matchedFont) return layer; - - return { - ...layer, - fontId: matchedFont.font.id, - fontFamily: matchedFont.font.family, - fontLabel: matchedFont.font.name, - fontFileUrl: matchedFont.font.fileUrl, - fontContentType: matchedFont.font.contentType, - }; - }); - const hasFontRepair = nextLayers.some( - (layer, index) => JSON.stringify(layer) !== JSON.stringify(template.layers[index]) - ); - - if (!needsRepair && !hasFontRepair) { - return template; - } - - try { - const preparedAssets = await preparePsdTemplateRenderAssets({ - templateId: template.id, - fileUrl: template.fileUrl, - layers: nextLayers, - }); - - return { - ...template, - layers: preparedAssets.layers, - updatedAt: new Date().toISOString(), - } satisfies AgentPsdTemplate; - } catch (error) { - console.warn("[PSD Templates][Admin] failed to repair missing layer previews:", { - templateId: template.id, - error, - }); - return template; - } -} - export async function GET() { const session = await requireAgentWorkflowAdmin().catch(() => null); if (!session?.user?.id) { @@ -174,10 +69,7 @@ export async function GET() { let hasRepairedTemplate = false; for (const template of templates) { - const repairedTemplate = await repairTemplateLayerPreviewsIfNeeded({ - template, - updatedBy: session.user.id, - }); + const repairedTemplate = await repairAgentPsdTemplateAssetsIfNeeded(template); repairedTemplates.push(repairedTemplate); if (repairedTemplate !== template) { hasRepairedTemplate = true; @@ -289,6 +181,7 @@ export async function POST(request: NextRequest) { height: 1, layerCount: 0, layers: [] as AgentPsdTemplateLayerSlot[], + assetVersion: AGENT_PSD_TEMPLATE_ASSET_VERSION, createdAt: now, updatedAt: now, uploadedById: session.user.id, diff --git a/app/api/psd-templates/render/route.ts b/app/api/psd-templates/render/route.ts index d78c150d4..7ca8e1abf 100644 --- a/app/api/psd-templates/render/route.ts +++ b/app/api/psd-templates/render/route.ts @@ -1,7 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; +import { upsertSystemSetting } from "@/lib/db/queries"; import { + AGENT_PSD_TEMPLATES_KEY, getEditableAgentPsdTemplateLayers, isAgentPsdImageLayerSlot, isAgentPsdTemplateReady, @@ -10,6 +12,7 @@ import { } from "@/lib/agents/psd-template-config"; import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings"; import { normalizeAgentPsdImagePlacement } from "@/lib/agents/psd-image-placement"; +import { repairAgentPsdTemplateAssetsIfNeeded } from "@/lib/agents/psd-template-repair"; import { renderAgentPsdTemplate } from "@/lib/agents/psd-template-renderer"; import { resolveAgentPsdTemplateTextStyles } from "@/lib/agents/psd-template-style-resolver"; import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings"; @@ -56,19 +59,36 @@ export async function POST(request: NextRequest) { try { const templates = await readAgentPsdTemplates(); - const template = templates.find( + const templateIndex = templates.findIndex( (entry) => entry.id === body.templateId && entry.status === "active" && isAgentPsdTemplateReady(entry) ); + const template = templateIndex >= 0 ? templates[templateIndex] : undefined; if (!template) { return jsonError(404, "PSD 模板不存在或未启用"); } - const resolvedTemplate = await resolveAgentPsdTemplateTextStyles(template).catch( - () => template + const repairedTemplate = await repairAgentPsdTemplateAssetsIfNeeded(template); + if (repairedTemplate !== template) { + await upsertSystemSetting({ + key: AGENT_PSD_TEMPLATES_KEY, + value: templates.map((entry, index) => + index === templateIndex ? repairedTemplate : entry + ), + updatedBy: session.user.id, + }).catch((error) => { + console.warn("[PSD Templates] failed to persist repaired template before render:", { + templateId: template.id, + error, + }); + }); + } + + const resolvedTemplate = await resolveAgentPsdTemplateTextStyles(repairedTemplate).catch( + () => repairedTemplate ); const editableLayers = getEditableAgentPsdTemplateLayers(resolvedTemplate); if (editableLayers.length === 0) { @@ -139,6 +159,10 @@ export async function POST(request: NextRequest) { } satisfies AgentPsdTemplateRenderResponse); } catch (error) { console.error("[PSD Templates] render failed:", error); - return jsonError(502, "PSD 模板渲染失败"); + const message = + error instanceof Error && String(error.message || "").trim() + ? error.message + : "PSD 模板渲染失败"; + return jsonError(502, message); } } diff --git a/app/api/psd-templates/route.ts b/app/api/psd-templates/route.ts index 549371a48..784c98266 100644 --- a/app/api/psd-templates/route.ts +++ b/app/api/psd-templates/route.ts @@ -1,12 +1,15 @@ import { NextRequest, NextResponse } from "next/server"; +import { upsertSystemSetting } from "@/lib/db/queries"; import { auth } from "@/app/(auth)/auth"; import { + AGENT_PSD_TEMPLATES_KEY, getEditableAgentPsdTemplateLayers, isAgentPsdTextLayerSlot, isAgentPsdTemplateReady, type AgentPsdTemplateListResponse, } from "@/lib/agents/psd-template-config"; import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings"; +import { repairAgentPsdTemplateAssetsIfNeeded } from "@/lib/agents/psd-template-repair"; import { resolveAgentPsdTemplateTextStyles } from "@/lib/agents/psd-template-style-resolver"; import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings"; @@ -30,12 +33,49 @@ export async function GET(request: NextRequest) { try { const requestedBrand = String(request.nextUrl.searchParams.get("brand") || "").trim(); + const allTemplates = await readAgentPsdTemplates(); + const repairedTemplateMap = new Map< + string, + Awaited>[number] + >(); + + for (const template of allTemplates) { + if ( + template.status !== "active" || + !isAgentPsdTemplateReady(template) || + (requestedBrand ? template.brandName !== requestedBrand : false) || + getEditableAgentPsdTemplateLayers(template).length === 0 + ) { + continue; + } + + const repairedTemplate = await repairAgentPsdTemplateAssetsIfNeeded(template); + if (repairedTemplate !== template) { + repairedTemplateMap.set(template.id, repairedTemplate); + } + } + + const sourceTemplates = + repairedTemplateMap.size > 0 + ? allTemplates.map((template) => repairedTemplateMap.get(template.id) || template) + : allTemplates; + + if (repairedTemplateMap.size > 0) { + await upsertSystemSetting({ + key: AGENT_PSD_TEMPLATES_KEY, + value: sourceTemplates, + updatedBy: session.user.id, + }).catch((error) => { + console.warn("[PSD Templates] failed to persist repaired assets:", error); + }); + } + const fonts = (await readAgentPsdFonts().catch( () => [] as Awaited> )) as Awaited>; const fontMap = new Map(fonts.map((font) => [font.id, font])); const templates = await Promise.all( - (await readAgentPsdTemplates()) + sourceTemplates .filter((template) => template.status === "active") .filter(isAgentPsdTemplateReady) .filter((template) => diff --git a/lib/agents/psd-template-config.ts b/lib/agents/psd-template-config.ts index 82e3738bb..11b09caf9 100644 --- a/lib/agents/psd-template-config.ts +++ b/lib/agents/psd-template-config.ts @@ -1,4 +1,5 @@ export const AGENT_PSD_TEMPLATES_KEY = "agent_psd_templates"; +export const AGENT_PSD_TEMPLATE_ASSET_VERSION = 3; export type AgentPsdTemplateStatus = "active" | "disabled"; export type AgentPsdTemplateParseStatus = @@ -88,6 +89,7 @@ export interface AgentPsdTemplate { height: number; layerCount: number; layers: AgentPsdTemplateLayerSlot[]; + assetVersion?: number; createdAt: string; updatedAt: string; uploadedById?: string; @@ -399,6 +401,7 @@ export function parseAgentPsdTemplates(value: unknown) { height, layerCount, layers, + assetVersion: normalizePositiveInteger(record.assetVersion), createdAt, updatedAt, uploadedById: normalizeText(record.uploadedById) || undefined, diff --git a/lib/agents/psd-template-parser.ts b/lib/agents/psd-template-parser.ts index 78fc4af87..35407d765 100644 --- a/lib/agents/psd-template-parser.ts +++ b/lib/agents/psd-template-parser.ts @@ -502,6 +502,7 @@ export async function preparePsdTemplateRenderAssets(params: { templateId: string; fileUrl: string; layers: AgentPsdTemplateLayerSlot[]; + forceRefreshLayerAssets?: boolean; }) { const psdBuffer = await fetchRemoteBuffer(params.fileUrl); const psd = await parsePsdBuffer(psdBuffer); @@ -520,9 +521,9 @@ export async function preparePsdTemplateRenderAssets(params: { } try { - const rgba = await entry.node.composite(false, true); + const rgba = await entry.node.composite(); - if (!targetLayer.previewUrl) { + if (params.forceRefreshLayerAssets || !targetLayer.previewUrl) { const thumbnailBuffer = await createLayerThumbnailBuffer({ rgba, width: entry.layer.width, @@ -545,7 +546,7 @@ export async function preparePsdTemplateRenderAssets(params: { } } - if (!String(targetLayer.sourceUrl || "").trim()) { + if (params.forceRefreshLayerAssets || !String(targetLayer.sourceUrl || "").trim()) { const sourceBuffer = await rgbaToPngBuffer(rgba, entry.layer.width, entry.layer.height); const uploadedSource = await uploadFile( `agent-psd-layer-source-${params.templateId}-${sanitizeFileSegment(targetLayer.layerId, targetLayer.id)}.png`, @@ -606,7 +607,11 @@ export async function preparePsdTemplateRenderAssets(params: { } } - if (!isAgentPsdImageLayerSlot(targetLayer) || !targetLayer.isEditable || targetLayer.maskUrl) { + if ( + !isAgentPsdImageLayerSlot(targetLayer) || + !targetLayer.isEditable || + (!params.forceRefreshLayerAssets && targetLayer.maskUrl) + ) { continue; } diff --git a/lib/agents/psd-template-renderer.ts b/lib/agents/psd-template-renderer.ts index 4bfcf935d..9da5528a4 100644 --- a/lib/agents/psd-template-renderer.ts +++ b/lib/agents/psd-template-renderer.ts @@ -305,12 +305,13 @@ export async function renderAgentPsdTemplate(params: { }) { const { template, imageAssignments, imagePlacements, textAssignments, fonts } = params; const sharp = await getSharp(); - const source = await loadPsdTemplateSourceFromUrl(template.fileUrl); - const canvasWidth = Math.max(1, Math.round(Number(source.width || template.width || 1))); - const canvasHeight = Math.max(1, Math.round(Number(source.height || template.height || 1))); + const canvasWidth = Math.max(1, Math.round(Number(template.width || 1))); + const canvasHeight = Math.max(1, Math.round(Number(template.height || 1))); const remoteBufferCache = new Map(); const fontMap = new Map(fonts.map((font) => [font.id, font])); - const sourceLayerMap = new Map(source.layers.map((entry) => [entry.layer.layerId, entry])); + let sourceLayerMapPromise: + | Promise>["layers"][number]>> + | null = null; async function readCachedBuffer(url: string) { const normalizedUrl = String(url || "").trim(); @@ -325,9 +326,44 @@ export async function renderAgentPsdTemplate(params: { return remoteBufferCache.get(normalizedUrl)!; } + async function getSourceLayerMap() { + if (!sourceLayerMapPromise) { + sourceLayerMapPromise = loadPsdTemplateSourceFromUrl(template.fileUrl).then( + (source) => new Map(source.layers.map((entry) => [entry.layer.layerId, entry])) + ); + } + + return sourceLayerMapPromise; + } + const compositeInputs: Array<{ input: Buffer; left: number; top: number }> = []; + async function appendBufferOverlay(params: { + input: Buffer; + left: number; + top: number; + width: number; + height: number; + }) { + const fittedOverlay = await fitOverlayBufferIntoCanvas({ + input: params.input, + left: params.left, + top: params.top, + width: params.width, + height: params.height, + canvasWidth, + canvasHeight, + }); + if (!fittedOverlay) { + return false; + } + + compositeInputs.push(fittedOverlay); + return true; + } + async function appendOriginalLayer(layerId: string) { + const sourceLayerMap = await getSourceLayerMap(); const sourceLayer = sourceLayerMap.get(layerId); if (!sourceLayer) return; const rgba = await sourceLayer.composite(); @@ -336,29 +372,81 @@ export async function renderAgentPsdTemplate(params: { sourceLayer.layer.width, sourceLayer.layer.height ); - const fittedOverlay = await fitOverlayBufferIntoCanvas({ + await appendBufferOverlay({ input: overlayBuffer, left: sourceLayer.layer.x, top: sourceLayer.layer.y, width: sourceLayer.layer.width, height: sourceLayer.layer.height, - canvasWidth, - canvasHeight, }); - if (!fittedOverlay) return; - compositeInputs.push(fittedOverlay); + } + + async function appendStoredLayer(layer: AgentPsdTemplate["layers"][number]) { + const sourceUrl = String(layer.sourceUrl || "").trim(); + if (!sourceUrl) { + await appendOriginalLayer(layer.layerId); + return; + } + + try { + const overlayBuffer = await readCachedBuffer(sourceUrl); + const didAppend = await appendBufferOverlay({ + input: overlayBuffer, + left: layer.x, + top: layer.y, + width: layer.width, + height: layer.height, + }); + if (didAppend) { + return; + } + } catch (error) { + console.warn("[PSD Template] failed to reuse stored layer asset during render:", { + templateId: template.id, + layerId: layer.layerId, + error, + }); + } + + await appendOriginalLayer(layer.layerId); + } + + const hasDedicatedRenderBase = + Boolean(String(template.renderBasePath || "").trim()) || + (Boolean(String(template.renderBaseUrl || "").trim()) && + String(template.renderBaseUrl || "").trim() !== String(template.previewUrl || "").trim()); + let hasBaseOverlay = false; + + if (hasDedicatedRenderBase && template.renderBaseUrl) { + try { + const baseBuffer = await readCachedBuffer(template.renderBaseUrl); + hasBaseOverlay = await appendBufferOverlay({ + input: baseBuffer, + left: 0, + top: 0, + width: canvasWidth, + height: canvasHeight, + }); + } catch (error) { + console.warn("[PSD Template] failed to reuse render base during export:", { + templateId: template.id, + error, + }); + } } for (const layer of [...template.layers].reverse()) { if (!layer.isEditable) { - await appendOriginalLayer(layer.layerId); + if (!hasBaseOverlay) { + await appendOriginalLayer(layer.layerId); + } continue; } if (isAgentPsdImageLayerSlot(layer)) { const assignmentUrl = String(imageAssignments[layer.id] || "").trim(); if (!assignmentUrl) { - await appendOriginalLayer(layer.layerId); + await appendStoredLayer(layer); continue; } @@ -390,30 +478,34 @@ export async function renderAgentPsdTemplate(params: { .toBuffer(); if (layer.maskUrl) { - const maskBuffer = await readCachedBuffer(layer.maskUrl); - overlayBuffer = await sharp(overlayBuffer) - .composite([ - { - input: maskBuffer, - blend: "dest-in", - }, - ]) - .png() - .toBuffer(); + try { + const maskBuffer = await readCachedBuffer(layer.maskUrl); + overlayBuffer = await sharp(overlayBuffer) + .composite([ + { + input: maskBuffer, + blend: "dest-in", + }, + ]) + .png() + .toBuffer(); + } catch (error) { + console.warn("[PSD Template] failed to apply layer mask during render:", { + templateId: template.id, + layerId: layer.layerId, + error, + }); + } } - const fittedOverlay = await fitOverlayBufferIntoCanvas({ + const didAppend = await appendBufferOverlay({ input: overlayBuffer, left: layer.x + placementFrame.left, top: layer.y + placementFrame.top, width: placementFrame.width, height: placementFrame.height, - canvasWidth, - canvasHeight, }); - if (!fittedOverlay) continue; - - compositeInputs.push(fittedOverlay); + if (!didAppend) continue; continue; } @@ -421,12 +513,22 @@ export async function renderAgentPsdTemplate(params: { const text = String(textAssignments[layer.id] || layer.defaultText || "").trim(); const defaultText = String(layer.defaultText || "").trim(); if (!text || text === defaultText) { - await appendOriginalLayer(layer.layerId); + await appendStoredLayer(layer); continue; } const font = layer.fontId ? fontMap.get(layer.fontId) : undefined; - const fontBuffer = font?.fileUrl ? await readCachedBuffer(font.fileUrl) : undefined; + const fontBuffer = font?.fileUrl + ? await readCachedBuffer(font.fileUrl).catch((error) => { + console.warn("[PSD Template] failed to load render font, falling back to family stack:", { + templateId: template.id, + layerId: layer.layerId, + fontId: font.id, + error, + }); + return undefined; + }) + : undefined; const runtimeFont = font && fontBuffer ? await ensurePsdFontAvailableForRender({ font, @@ -463,18 +565,14 @@ export async function renderAgentPsdTemplate(params: { if (!overlayBuffer) continue; - const fittedOverlay = await fitOverlayBufferIntoCanvas({ + const didAppend = await appendBufferOverlay({ input: overlayBuffer, left: layer.x, top: layer.y, width: layer.width, height: layer.height, - canvasWidth, - canvasHeight, }); - if (!fittedOverlay) continue; - - compositeInputs.push(fittedOverlay); + if (!didAppend) continue; } } diff --git a/lib/agents/psd-template-repair.ts b/lib/agents/psd-template-repair.ts new file mode 100644 index 000000000..3f8e15045 --- /dev/null +++ b/lib/agents/psd-template-repair.ts @@ -0,0 +1,159 @@ +import "server-only"; + +import { findBestMatchingPsdFont } from "@/lib/agents/psd-font-matching"; +import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings"; +import { + AGENT_PSD_TEMPLATE_ASSET_VERSION, + isAgentPsdImageLayerSlot, + isAgentPsdTextLayerSlot, + type AgentPsdTemplate, +} from "@/lib/agents/psd-template-config"; +import { + parsePsdTemplateFileFromUrl, + preparePsdTemplateRenderAssets, +} from "@/lib/agents/psd-template-parser"; +import { uploadFile } from "@/lib/storage/service"; + +function hasMissingLayerAssets(template: AgentPsdTemplate) { + return template.layers.some( + (layer) => + !String(layer.previewUrl || "").trim() || + !String(layer.sourceUrl || "").trim() || + (isAgentPsdImageLayerSlot(layer) && + layer.isEditable && + !String(layer.maskUrl || "").trim()) + ); +} + +function needsTextMetadataRepair(template: AgentPsdTemplate) { + return template.layers.some( + (layer) => + isAgentPsdTextLayerSlot(layer) && + (!String(layer.detectedFontName || "").trim() || + !Array.isArray(layer.detectedFontNames) || + layer.detectedFontNames.length === 0) + ); +} + +function hasDedicatedRenderBase(template: AgentPsdTemplate) { + const renderBaseUrl = String(template.renderBaseUrl || "").trim(); + const previewUrl = String(template.previewUrl || "").trim(); + return Boolean(String(template.renderBasePath || "").trim()) || ( + Boolean(renderBaseUrl) && + renderBaseUrl !== previewUrl + ); +} + +function toArrayBuffer(buffer: Buffer) { + return buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength + ) as ArrayBuffer; +} + +export async function repairAgentPsdTemplateAssetsIfNeeded( + template: AgentPsdTemplate +) { + if (template.parseStatus !== "completed") { + return template; + } + + const needsAssetVersionRefresh = + Number(template.assetVersion || 0) !== AGENT_PSD_TEMPLATE_ASSET_VERSION; + const needsRepair = needsAssetVersionRefresh || hasMissingLayerAssets(template); + const needsRenderBaseRepair = !hasDedicatedRenderBase(template); + const fonts = await readAgentPsdFonts().catch(() => []); + const needsFontDetection = needsTextMetadataRepair(template); + + let nextLayers = template.layers; + if (needsFontDetection) { + try { + const parsedTemplate = await parsePsdTemplateFileFromUrl(template.fileUrl); + const parsedTextLayerMap = new Map( + parsedTemplate.layers + .filter((layer) => layer.slotType === "text") + .map((layer) => [layer.layerId, layer]) + ); + nextLayers = nextLayers.map((layer) => { + if (!isAgentPsdTextLayerSlot(layer)) return layer; + const parsedLayer = parsedTextLayerMap.get(layer.layerId); + if (!parsedLayer) return layer; + return { + ...layer, + detectedFontName: parsedLayer.detectedFontName || layer.detectedFontName, + detectedFontNames: parsedLayer.detectedFontNames || layer.detectedFontNames, + }; + }); + } catch (error) { + console.warn("[PSD Template] failed to repair parsed text metadata:", { + templateId: template.id, + error, + }); + } + } + + nextLayers = nextLayers.map((layer) => { + if (!isAgentPsdTextLayerSlot(layer) || layer.fontId) { + return layer; + } + + const matchedFont = findBestMatchingPsdFont({ + detectedFontNames: [ + ...((layer.detectedFontNames || []).filter(Boolean)), + ...(layer.detectedFontName ? [layer.detectedFontName] : []), + ], + fonts, + }); + if (!matchedFont) { + return layer; + } + + return { + ...layer, + fontId: matchedFont.font.id, + fontFamily: matchedFont.font.family, + fontLabel: matchedFont.font.name, + fontFileUrl: matchedFont.font.fileUrl, + fontContentType: matchedFont.font.contentType, + }; + }); + + const hasFontRepair = nextLayers.some( + (layer, index) => JSON.stringify(layer) !== JSON.stringify(template.layers[index]) + ); + + if (!needsRepair && !hasFontRepair && !needsRenderBaseRepair) { + return template; + } + + try { + const preparedAssets = await preparePsdTemplateRenderAssets({ + templateId: template.id, + fileUrl: template.fileUrl, + layers: nextLayers, + forceRefreshLayerAssets: needsAssetVersionRefresh, + }); + const uploadedRenderBase = + needsRenderBaseRepair + ? await uploadFile( + `agent-psd-base-${template.id}-${Date.now()}.png`, + toArrayBuffer(preparedAssets.renderBaseBuffer) + ) + : null; + + return { + ...template, + renderBaseUrl: uploadedRenderBase?.url || template.renderBaseUrl, + renderBasePath: uploadedRenderBase?.pathname || template.renderBasePath, + layers: preparedAssets.layers, + assetVersion: AGENT_PSD_TEMPLATE_ASSET_VERSION, + updatedAt: new Date().toISOString(), + } satisfies AgentPsdTemplate; + } catch (error) { + console.warn("[PSD Template] failed to repair template assets:", { + templateId: template.id, + error, + }); + return template; + } +}