diff --git a/lib/agents/psd-template-parser.ts b/lib/agents/psd-template-parser.ts index 379750425..c8494761e 100644 --- a/lib/agents/psd-template-parser.ts +++ b/lib/agents/psd-template-parser.ts @@ -87,6 +87,11 @@ interface PsdLayerNodeEntry { layer: ParsedPsdTemplateLayer; } +export interface PsdTemplateSourceLayerEntry { + layer: ParsedPsdTemplateLayer; + composite: () => Promise; +} + function normalizeLayerName(value: unknown, fallback: string) { const text = String(value || "").trim(); return text || fallback; @@ -308,6 +313,23 @@ export async function parsePsdTemplateFileFromUrl(fileUrl: string) { return parsePsdTemplateFile(buffer); } +export async function loadPsdTemplateSourceFromUrl(fileUrl: string) { + const buffer = await fetchRemoteBuffer(fileUrl); + const psd = await parsePsdBuffer(buffer); + const layerEntries = collectPsdLayerEntries(Array.isArray(psd.children) ? psd.children : []); + + return { + width: Math.max(1, Math.round(Number(psd.width || 0))), + height: Math.max(1, Math.round(Number(psd.height || 0))), + layers: layerEntries + .filter((entry) => entry.node && typeof entry.node.composite === "function") + .map((entry) => ({ + layer: entry.layer, + composite: () => entry.node.composite!(), + })) satisfies PsdTemplateSourceLayerEntry[], + }; +} + async function createTransparentCanvasBuffer(width: number, height: number) { const sharp = await getSharp(); const canvasWidth = Math.max(1, Math.round(Number(width || 0))); @@ -491,7 +513,7 @@ export async function preparePsdTemplateRenderAssets(params: { const baseCompositeInputs: Array<{ input: Buffer; left: number; top: number }> = []; - for (const entry of layerEntries) { + for (const entry of [...layerEntries].reverse()) { if (editableLayerIdSet.has(entry.layer.layerId)) { continue; } diff --git a/lib/agents/psd-template-renderer.ts b/lib/agents/psd-template-renderer.ts index 7acd0b5d8..674e99588 100644 --- a/lib/agents/psd-template-renderer.ts +++ b/lib/agents/psd-template-renderer.ts @@ -1,6 +1,7 @@ import "server-only"; import { uploadFile } from "@/lib/storage/service"; +import { loadPsdTemplateSourceFromUrl } from "@/lib/agents/psd-template-parser"; import { isAgentPsdImageLayerSlot, isAgentPsdTextLayerSlot, @@ -26,6 +27,19 @@ async function fetchRemoteBuffer(url: string) { return Buffer.from(await response.arrayBuffer()); } +async function rgbaToPngBuffer(rgba: Uint8ClampedArray, width: number, height: number) { + const sharp = await getSharp(); + return sharp(Buffer.from(rgba), { + raw: { + width, + height, + channels: 4, + }, + }) + .png() + .toBuffer(); +} + async function fitOverlayBufferIntoCanvas(params: { input: Buffer; left: number; @@ -285,12 +299,12 @@ export async function renderAgentPsdTemplate(params: { }) { const { template, imageAssignments, textAssignments, fonts } = params; const sharp = await getSharp(); - const baseBuffer = await fetchRemoteBuffer(template.renderBaseUrl || template.previewUrl); - const baseMetadata = await sharp(baseBuffer).metadata(); - const canvasWidth = Math.max(1, Math.round(Number(baseMetadata.width || template.width || 1))); - const canvasHeight = Math.max(1, Math.round(Number(baseMetadata.height || template.height || 1))); + 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 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])); async function readCachedBuffer(url: string) { const normalizedUrl = String(url || "").trim(); @@ -307,12 +321,40 @@ export async function renderAgentPsdTemplate(params: { const compositeInputs: Array<{ input: Buffer; left: number; top: number }> = []; - for (const layer of template.layers) { - if (!layer.isEditable) continue; + async function appendOriginalLayer(layerId: string) { + const sourceLayer = sourceLayerMap.get(layerId); + if (!sourceLayer) return; + const rgba = await sourceLayer.composite(); + const overlayBuffer = await rgbaToPngBuffer( + rgba, + sourceLayer.layer.width, + sourceLayer.layer.height + ); + const fittedOverlay = await fitOverlayBufferIntoCanvas({ + 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); + } + + for (const layer of [...template.layers].reverse()) { + if (!layer.isEditable) { + await appendOriginalLayer(layer.layerId); + continue; + } if (isAgentPsdImageLayerSlot(layer)) { const assignmentUrl = String(imageAssignments[layer.id] || "").trim(); - if (!assignmentUrl) continue; + if (!assignmentUrl) { + await appendOriginalLayer(layer.layerId); + continue; + } const sourceBuffer = await readCachedBuffer(assignmentUrl); const fit = @@ -368,7 +410,11 @@ export async function renderAgentPsdTemplate(params: { if (isAgentPsdTextLayerSlot(layer)) { const text = String(textAssignments[layer.id] || layer.defaultText || "").trim(); - if (!text) continue; + const defaultText = String(layer.defaultText || "").trim(); + if (!text || text === defaultText) { + await appendOriginalLayer(layer.layerId); + continue; + } const font = layer.fontId ? fontMap.get(layer.fontId) : undefined; const fontBuffer = font?.fileUrl ? await readCachedBuffer(font.fileUrl) : undefined; @@ -407,7 +453,23 @@ export async function renderAgentPsdTemplate(params: { } } - const resultBuffer = await sharp(baseBuffer) + const transparentCanvas = await sharp({ + create: { + width: canvasWidth, + height: canvasHeight, + channels: 4, + background: { + r: 0, + g: 0, + b: 0, + alpha: 0, + }, + }, + }) + .png() + .toBuffer(); + + const resultBuffer = await sharp(transparentCanvas) .composite(compositeInputs) .png() .toBuffer();