import "server-only"; import { uploadFile } from "@/lib/storage/service"; 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()); } 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 availableWidth = Math.max(8, width - paddingX * 2); const lineHeightPx = Math.max(fontSize, fontSize * lineHeight); const heightLimitedMaxLines = Math.max( 1, Math.floor((height - paddingY * 2 + fontSize * 0.2) / lineHeightPx) ); const resolvedMaxLines = Math.max(1, Math.min(maxLines, heightLimitedMaxLines)); const lines = wrapTextByWidth({ text, maxWidth: availableWidth, fontSize, 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; 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 baseBuffer = await fetchRemoteBuffer(template.renderBaseUrl || template.previewUrl); const remoteBufferCache = new Map(); const fontMap = new Map(fonts.map((font) => [font.id, font])); 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 }> = []; for (const layer of template.layers) { if (!layer.isEditable) continue; if (isAgentPsdImageLayerSlot(layer)) { const assignmentUrl = String(imageAssignments[layer.id] || "").trim(); if (!assignmentUrl) 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(); } compositeInputs.push({ input: overlayBuffer, left: layer.x, top: layer.y, }); continue; } if (isAgentPsdTextLayerSlot(layer)) { const text = String(textAssignments[layer.id] || layer.defaultText || "").trim(); if (!text) 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; compositeInputs.push({ input: overlayBuffer, left: layer.x, top: layer.y, }); } } const resultBuffer = await sharp(baseBuffer) .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; }