import "server-only"; import { uploadFile } from "@/lib/storage/service"; import { loadPsdTemplateSourceFromUrl } from "@/lib/agents/psd-template-parser"; import { isAgentPsdImageLayerSlot, isAgentPsdTextLayerSlot, type AgentPsdTemplate, } from "@/lib/agents/psd-template-config"; import type { AgentPsdFont } from "@/lib/agents/psd-font-config"; import { ensurePsdFontAvailableForRender } from "@/lib/agents/psd-font-runtime"; import { normalizeAgentPsdImagePlacement, resolveAgentPsdImagePlacementFrame } from "@/lib/agents/psd-image-placement"; import { resolveTextLayout } from "@/lib/agents/psd-text-layout"; async function getSharp() { const sharp = await import("sharp"); return sharp.default; } async function fetchRemoteBuffer(url: string) { const response = await fetch(url, { cache: "no-store", signal: AbortSignal.timeout(25_000), }); if (!response.ok) { throw new Error(`Resource download failed: ${response.status}`); } 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; top: number; width: number; height: number; canvasWidth: number; canvasHeight: number; }) { const sharp = await getSharp(); const originalWidth = Math.max(1, Math.round(Number(params.width || 0))); const originalHeight = Math.max(1, Math.round(Number(params.height || 0))); let left = Math.round(Number(params.left || 0)); let top = Math.round(Number(params.top || 0)); let extractLeft = 0; let extractTop = 0; let targetWidth = originalWidth; let targetHeight = originalHeight; if (left < 0) { extractLeft = Math.min(originalWidth, Math.abs(left)); targetWidth -= extractLeft; left = 0; } if (top < 0) { extractTop = Math.min(originalHeight, Math.abs(top)); targetHeight -= extractTop; top = 0; } if (left >= params.canvasWidth || top >= params.canvasHeight) { return null; } targetWidth = Math.min(targetWidth, params.canvasWidth - left); targetHeight = Math.min(targetHeight, params.canvasHeight - top); if (targetWidth <= 0 || targetHeight <= 0) { return null; } const outputBuffer = extractLeft > 0 || extractTop > 0 || targetWidth !== originalWidth || targetHeight !== originalHeight ? await sharp(params.input) .extract({ left: extractLeft, top: extractTop, width: targetWidth, height: targetHeight, }) .png() .toBuffer() : params.input; return { input: outputBuffer, left, top, }; } function sanitizeFileSegment(value: string, fallback: string) { const normalized = value .trim() .replace(/\s+/g, "-") .replace(/[^a-zA-Z0-9_-]/g, "-") .replace(/-+/g, "-") .replace(/^-+|-+$/g, "") .toLowerCase(); return normalized || fallback; } function escapeXml(value: string) { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function getFontMimeType(font: AgentPsdFont | undefined, fontFamily: string) { const contentType = String(font?.contentType || "").trim(); if (contentType) return contentType; const family = fontFamily.toLowerCase(); if (family.endsWith(".woff2")) return "font/woff2"; if (family.endsWith(".woff")) return "font/woff"; if (family.endsWith(".otf")) return "font/otf"; return "font/ttf"; } const GENERIC_FONT_FAMILIES = new Set(["sans-serif", "serif", "monospace", "cursive", "fantasy", "system-ui"]); const CJK_FALLBACK_FONT_FAMILIES = [ "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "WenQuanYi Zen Hei", "SimHei", "Arial Unicode MS", "sans-serif", ] as const; function textContainsCjkGlyphs(value: string) { return /[\u3400-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(String(value || "")); } function buildSvgFontFamilyStack(fontFamilies: string[], text: string) { const familyTokens = new Set(); for (const family of fontFamilies) { const primaryFamily = String(family || "").trim(); if (primaryFamily) { familyTokens.add(primaryFamily); } } if (textContainsCjkGlyphs(text)) { for (const fallbackFamily of CJK_FALLBACK_FONT_FAMILIES) { familyTokens.add(fallbackFamily); } } else { familyTokens.add("sans-serif"); } return Array.from(familyTokens) .map((family) => GENERIC_FONT_FAMILIES.has(family) ? family : `"${escapeXml(family)}"` ) .join(", "); } async function createTextOverlayBuffer(params: { width: number; height: number; text: string; fontBuffer?: Buffer; font: AgentPsdFont | undefined; fontFamily: string; renderFontFamilies?: string[]; renderFontFamily?: string; useExactFontFace?: boolean; fontSize: number; fontWeight: number; lineHeight: number; letterSpacing: number; textColor: string; textAlign: "left" | "center" | "right"; paddingX: number; paddingY: number; maxLines: number; }) { const { width, height, text, fontBuffer, font, fontFamily, renderFontFamilies, renderFontFamily, useExactFontFace, fontSize, fontWeight, lineHeight, letterSpacing, textColor, textAlign, paddingX, paddingY, maxLines, } = params; const normalizedText = String(text || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); const resolvedLayout = resolveTextLayout({ text: normalizedText, width, height, fontSize, lineHeight, letterSpacing, paddingX, paddingY, maxLines, }); const adjustedFontSize = resolvedLayout.fontSize; const lineHeightPx = resolvedLayout.lineHeightPx; const lines = resolvedLayout.lines; if (lines.length === 0) { return null; } const primaryFamilies = Array.from( new Set( [...(renderFontFamilies || []), renderFontFamily, fontFamily] .map((family) => String(family || "").trim()) .filter(Boolean) ) ); const fontFamilyStack = buildSvgFontFamilyStack(primaryFamilies, normalizedText); const fontFaceCss = fontBuffer ? primaryFamilies .map((family) => { const escapedFamily = escapeXml(family || "sans-serif"); return `@font-face { font-family: "${escapedFamily}"; src: url("data:${getFontMimeType(font, family)};base64,${fontBuffer.toString("base64")}"); }`; }) .join("\n") : ""; const clipId = `clip-${Math.random().toString(36).slice(2, 10)}`; const anchor = textAlign === "center" ? "middle" : textAlign === "right" ? "end" : "start"; const x = textAlign === "center" ? width / 2 : textAlign === "right" ? width - paddingX : paddingX; const tspans = lines .map((line, index) => { const y = paddingY + fontSize + index * lineHeightPx; const baselineY = paddingY + adjustedFontSize + index * lineHeightPx; return `${escapeXml(line)}`; }) .join(""); const svg = ` ${tspans} `; const sharp = await getSharp(); return sharp(Buffer.from(svg)).png().toBuffer(); } export async function renderAgentPsdTemplate(params: { template: AgentPsdTemplate; imageAssignments: Record; imagePlacements: Record>; textAssignments: Record; fonts: AgentPsdFont[]; }) { 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 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(); if (!normalizedUrl) { throw new Error("Missing remote asset URL"); } if (!remoteBufferCache.has(normalizedUrl)) { remoteBufferCache.set(normalizedUrl, await fetchRemoteBuffer(normalizedUrl)); } return remoteBufferCache.get(normalizedUrl)!; } const compositeInputs: Array<{ input: Buffer; left: number; top: number }> = []; 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) { await appendOriginalLayer(layer.layerId); continue; } const sourceBuffer = await readCachedBuffer(assignmentUrl); const metadata = await sharp(sourceBuffer).metadata(); const placementFrame = resolveAgentPsdImagePlacementFrame({ containerWidth: layer.width, containerHeight: layer.height, imageWidth: Math.max(1, Number(metadata.width || 1)), imageHeight: Math.max(1, Number(metadata.height || 1)), replaceMode: layer.replaceMode, placement: imagePlacements[layer.id], }); let overlayBuffer = await sharp(sourceBuffer) .rotate() .resize({ width: Math.max(1, Math.round(placementFrame.width)), height: Math.max(1, Math.round(placementFrame.height)), fit: "fill", background: { r: 0, g: 0, b: 0, alpha: 0, }, }) .png() .toBuffer(); if (layer.maskUrl) { const maskBuffer = await readCachedBuffer(layer.maskUrl); overlayBuffer = await sharp(overlayBuffer) .composite([ { input: maskBuffer, blend: "dest-in", }, ]) .png() .toBuffer(); } const fittedOverlay = await fitOverlayBufferIntoCanvas({ 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); continue; } if (isAgentPsdTextLayerSlot(layer)) { const text = String(textAssignments[layer.id] || layer.defaultText || "").trim(); 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; const runtimeFont = font && fontBuffer ? await ensurePsdFontAvailableForRender({ font, fontBuffer, preferredFamily: layer.fontFamily || font.family, }).catch(() => null) : null; const overlayBuffer = await createTextOverlayBuffer({ width: layer.width, height: layer.height, text, fontBuffer, font, fontFamily: layer.fontFamily || font?.family || "sans-serif", renderFontFamilies: runtimeFont ? [ runtimeFont.fontPostscriptName, runtimeFont.fontFullName, runtimeFont.fontFamily, ].filter((value): value is string => Boolean(String(value || "").trim())) : undefined, renderFontFamily: runtimeFont?.fontFamily, useExactFontFace: Boolean(runtimeFont?.fontPostscriptName || runtimeFont?.fontFullName), fontSize: layer.fontSize, fontWeight: layer.fontWeight, lineHeight: layer.lineHeight, letterSpacing: layer.letterSpacing, textColor: layer.textColor, textAlign: layer.textAlign, paddingX: layer.paddingX, paddingY: layer.paddingY, maxLines: layer.maxLines, }); if (!overlayBuffer) continue; const fittedOverlay = await fitOverlayBufferIntoCanvas({ input: overlayBuffer, left: layer.x, top: layer.y, width: layer.width, height: layer.height, canvasWidth, canvasHeight, }); if (!fittedOverlay) continue; compositeInputs.push(fittedOverlay); } } 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(); const uploaded = await uploadFile( `agent-psd-render-${sanitizeFileSegment(template.id, "template")}-${Date.now()}.png`, resultBuffer.buffer.slice( resultBuffer.byteOffset, resultBuffer.byteOffset + resultBuffer.byteLength ) as ArrayBuffer ); return uploaded; }