import "server-only"; import { uploadFile } from "@/lib/storage/service"; import { fetchRemoteAssetBuffer } from "@/lib/agents/psd-remote-asset"; import { hasAgentPsdTemplateStoredLayerSource, 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 { normalizeHexColor, resolveTextLayout } from "@/lib/agents/psd-text-layout"; async function getSharp() { const sharp = await import("sharp"); return sharp.default; } 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 resolveTextOverlayFrame(params: { layerWidth: number; layerHeight: number; contentWidth: number; contentHeight: number; textAlign: "left" | "center" | "right"; topOffset?: number; }) { const width = Math.max(params.layerWidth, params.contentWidth); const height = Math.max(params.layerHeight, params.contentHeight); const leftOffset = params.textAlign === "right" ? params.layerWidth - width : params.textAlign === "center" ? (params.layerWidth - width) / 2 : 0; return { width, height, leftOffset, topOffset: Number.isFinite(Number(params.topOffset)) ? Number(params.topOffset) : 0, }; } 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, autoFit: false, preferNoWrap: true, }); 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" ? resolvedLayout.contentWidth / 2 : textAlign === "right" ? resolvedLayout.contentWidth - paddingX : paddingX; const tspans = lines .map((line, index) => { 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; textColorAssignments?: Record; fonts: AgentPsdFont[]; }) { const { template, imageAssignments, imagePlacements, textAssignments, textColorAssignments = {}, fonts, } = params; const sharp = await getSharp(); 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])); async function readCachedBuffer(url: string, label = "PSD 模板资源") { const normalizedUrl = String(url || "").trim(); if (!normalizedUrl) { throw new Error("Missing remote asset URL"); } if (!remoteBufferCache.has(normalizedUrl)) { remoteBufferCache.set( normalizedUrl, await fetchRemoteAssetBuffer(normalizedUrl, { label, timeoutMs: 35_000, retries: 3, }) ); } return remoteBufferCache.get(normalizedUrl)!; } 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 appendStoredLayer(layer: AgentPsdTemplate["layers"][number]) { if (!hasAgentPsdTemplateStoredLayerSource(layer)) { throw new Error( `PSD 模板图层“${layer.layerName}”缺少已打包资源,请在后台重新解析该模板` ); } const sourceUrl = String(layer.sourceUrl || "").trim(); try { const overlayBuffer = await readCachedBuffer(sourceUrl, "PSD 图层素材"); 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, }); throw new Error( `PSD 模板图层“${layer.layerName}”资源加载失败,请稍后重试或在后台重新解析该模板` ); } } for (const layer of [...template.layers].reverse()) { if (!layer.isEditable) { await appendStoredLayer(layer); continue; } if (isAgentPsdImageLayerSlot(layer)) { const assignmentUrl = String(imageAssignments[layer.id] || "").trim(); if (!assignmentUrl) { await appendStoredLayer(layer); continue; } const sourceBuffer = await readCachedBuffer(assignmentUrl, "PSD 套版素材"); const normalizedSourceBuffer = await sharp(sourceBuffer).rotate().png().toBuffer(); const metadata = await sharp(normalizedSourceBuffer).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, contentBounds: layer.contentBounds, placement: imagePlacements[layer.id], }); const resizedOverlayBuffer = await sharp(normalizedSourceBuffer) .resize({ width: Math.max(1, Math.round(placementFrame.width)), height: Math.max(1, Math.round(placementFrame.height)), fit: layer.replaceMode === "contain" ? "contain" : layer.replaceMode === "stretch" ? "fill" : "cover", background: { r: 0, g: 0, b: 0, alpha: 0, }, }) .png() .toBuffer(); const slotOverlay = await fitOverlayBufferIntoCanvas({ input: resizedOverlayBuffer, left: placementFrame.left, top: placementFrame.top, width: placementFrame.width, height: placementFrame.height, canvasWidth: layer.width, canvasHeight: layer.height, }); let overlayBuffer = await sharp({ create: { width: Math.max(1, Math.round(layer.width)), height: Math.max(1, Math.round(layer.height)), channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0, }, }, }) .composite(slotOverlay ? [slotOverlay] : []) .png() .toBuffer(); if (layer.maskUrl) { try { const maskBuffer = await sharp( await readCachedBuffer(layer.maskUrl, "PSD 蒙版资源") ) .resize({ width: Math.max(1, Math.round(layer.width)), height: Math.max(1, Math.round(layer.height)), fit: "fill", background: { r: 0, g: 0, b: 0, alpha: 0, }, }) .png() .toBuffer(); 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 didAppend = await appendBufferOverlay({ input: overlayBuffer, left: layer.x, top: layer.y, width: layer.width, height: layer.height, }); if (!didAppend) continue; continue; } if (isAgentPsdTextLayerSlot(layer)) { const text = String(textAssignments[layer.id] || layer.defaultText || "").trim(); const defaultText = String(layer.defaultText || "").trim(); const textColor = normalizeHexColor( textColorAssignments[layer.id] || layer.textColor, layer.textColor ); const hasColorChange = normalizeHexColor(textColor, layer.textColor) !== normalizeHexColor(layer.textColor); if (!text || (text === defaultText && !hasColorChange)) { await appendStoredLayer(layer); continue; } const font = layer.fontId ? fontMap.get(layer.fontId) : undefined; const fontBuffer = font?.fileUrl ? await readCachedBuffer(font.fileUrl, "PSD 字体资源").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, 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 || layer.detectedFontNames?.[0] || layer.detectedFontName || font?.family || "sans-serif", renderFontFamilies: runtimeFont ? [ runtimeFont.fontPostscriptName, runtimeFont.fontFullName, runtimeFont.fontFamily, ].filter((value): value is string => Boolean(String(value || "").trim())) : [ ...(layer.detectedFontNames || []), ...(layer.detectedFontName ? [layer.detectedFontName] : []), ].filter((value): value is string => Boolean(String(value || "").trim())), renderFontFamily: runtimeFont?.fontFamily, useExactFontFace: Boolean(runtimeFont?.fontPostscriptName || runtimeFont?.fontFullName), fontSize: layer.fontSize, fontWeight: layer.fontWeight, lineHeight: layer.lineHeight, letterSpacing: layer.letterSpacing, textColor, textAlign: layer.textAlign, paddingX: layer.paddingX, paddingY: layer.paddingY, maxLines: layer.maxLines, }); if (!overlayBuffer) continue; const overlayLayout = resolveTextLayout({ text, width: layer.width, height: layer.height, fontSize: layer.fontSize, lineHeight: layer.lineHeight, letterSpacing: layer.letterSpacing, paddingX: layer.paddingX, paddingY: layer.paddingY, maxLines: layer.maxLines, autoFit: false, preferNoWrap: true, }); const overlayFrame = resolveTextOverlayFrame({ layerWidth: layer.width, layerHeight: layer.height, contentWidth: overlayLayout.contentWidth, contentHeight: overlayLayout.contentHeight, textAlign: layer.textAlign, topOffset: -overlayLayout.visualOffsetY, }); const didAppend = await appendBufferOverlay({ input: overlayBuffer, left: layer.x + overlayFrame.leftOffset, top: layer.y + overlayFrame.topOffset, width: overlayFrame.width, height: overlayFrame.height, }); if (!didAppend) continue; } } 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; }