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"; 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"; } function estimateGlyphWidth(char: string, fontSize: number, letterSpacing: number) { if (char === " ") return fontSize * 0.36 + letterSpacing; if (/[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(char)) { return fontSize + letterSpacing; } if (/[A-Z0-9]/.test(char)) return fontSize * 0.66 + letterSpacing; return fontSize * 0.58 + letterSpacing; } function wrapTextByWidth(params: { text: string; maxWidth: number; fontSize: number; letterSpacing: number; maxLines: number; }) { const { text, maxWidth, fontSize, letterSpacing, maxLines } = params; const paragraphs = String(text || "") .replace(/\r\n/g, "\n") .split("\n"); const lines: string[] = []; function pushLine(line: string) { if (lines.length < maxLines) { lines.push(line); } } for (const paragraph of paragraphs) { if (lines.length >= maxLines) break; if (!paragraph) { pushLine(""); continue; } let currentLine = ""; let currentWidth = 0; for (const char of paragraph) { const charWidth = estimateGlyphWidth(char, fontSize, letterSpacing); if (currentLine && currentWidth + charWidth > maxWidth) { pushLine(currentLine); currentLine = char; currentWidth = charWidth; if (lines.length >= maxLines) break; } else { currentLine += char; currentWidth += charWidth; } } if (lines.length >= maxLines) break; if (currentLine || paragraph.length === 0) { pushLine(currentLine); } } return lines.slice(0, maxLines); } async function createTextOverlayBuffer(params: { width: number; height: number; text: string; fontBuffer?: Buffer; font: AgentPsdFont | undefined; fontFamily: string; 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, fontSize, fontWeight, lineHeight, letterSpacing, textColor, textAlign, paddingX, paddingY, maxLines, } = params; const normalizedText = String(text || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); const explicitLineCount = Math.max(1, normalizedText.split("\n").length); const availableHeight = Math.max(8, height - paddingY * 2); const adjustedFontSize = explicitLineCount > 1 && fontSize * Math.max(1, lineHeight) * explicitLineCount > availableHeight * 1.18 ? Math.max( 10, Math.floor((availableHeight / (explicitLineCount * Math.max(1, lineHeight))) * 100) / 100 ) : fontSize; const availableWidth = Math.max(8, width - paddingX * 2); const lineHeightPx = Math.max(adjustedFontSize, adjustedFontSize * lineHeight); const heightLimitedMaxLines = Math.max( 1, Math.floor((height - paddingY * 2 + adjustedFontSize * 0.2) / lineHeightPx) ); const resolvedMaxLines = Math.max(1, Math.min(maxLines, heightLimitedMaxLines)); const lines = wrapTextByWidth({ text: normalizedText, maxWidth: availableWidth, fontSize: adjustedFontSize, letterSpacing, maxLines: resolvedMaxLines, }); if (lines.length === 0) { return null; } const escapedFamily = escapeXml(fontFamily || "sans-serif"); const fontFaceCss = fontBuffer ? `@font-face { font-family: "${escapedFamily}"; src: url("data:${getFontMimeType(font, fontFamily)};base64,${fontBuffer.toString("base64")}"); }` : ""; 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; textAssignments: Record; fonts: AgentPsdFont[]; }) { const { template, imageAssignments, 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 fit = layer.replaceMode === "stretch" ? "fill" : layer.replaceMode === "contain" ? "contain" : "cover"; let overlayBuffer = await sharp(sourceBuffer) .rotate() .resize({ width: layer.width, height: layer.height, fit, 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, top: layer.y, width: layer.width, height: layer.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 overlayBuffer = await createTextOverlayBuffer({ width: layer.width, height: layer.height, text, fontBuffer, font, fontFamily: layer.fontFamily || font?.family || "sans-serif", 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; }