import "server-only"; import { uploadFile } from "@/lib/storage/service"; import { fetchRemoteAssetBuffer } from "@/lib/agents/psd-remote-asset"; import { loadPsdTemplateSource } from "@/lib/agents/psd-template-parser"; import { hasAgentPsdTemplateStoredLayerSource, isAgentPsdImageLayerSlot, isAgentPsdLayerSuperLocked, isAgentPsdTemplateLayerUserEditable, isAgentPsdTextLayerSlot, resolveAgentPsdTextLayerEffectiveAlign, type AgentPsdTemplate, type AgentPsdTemplateImageLayerSlot, } 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"; type AgentPsdTextPositionOffset = { offsetX: number; offsetY: number; }; const PSD_RENDER_PNG_COMPRESSION_LEVEL = Math.max( 0, Math.min( 9, Number.parseInt(process.env.AGENT_PSD_RENDER_PNG_COMPRESSION_LEVEL || "4", 10) ) ); const PSD_RENDER_PNG_OPTIONS = { compressionLevel: Number.isFinite(PSD_RENDER_PNG_COMPRESSION_LEVEL) ? PSD_RENDER_PNG_COMPRESSION_LEVEL : 4, adaptiveFiltering: false, } as const; function clampTextPositionOffsetValue(value: number) { if (!Number.isFinite(value)) { return 0; } return Math.max(-5000, Math.min(5000, value)); } function normalizeTextPositionOffset( value?: Partial | null ): AgentPsdTextPositionOffset { return { offsetX: Number( clampTextPositionOffsetValue(Number(value?.offsetX || 0)).toFixed(2) ), offsetY: Number( clampTextPositionOffsetValue(Number(value?.offsetY || 0)).toFixed(2) ), }; } 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(PSD_RENDER_PNG_OPTIONS) .toBuffer() : params.input; return { input: outputBuffer, left, top, width: targetWidth, height: targetHeight, }; } 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; } async function mapWithConcurrency( items: T[], concurrency: number, mapper: (item: T, index: number) => Promise ) { const results = new Array(items.length); let nextIndex = 0; const workerCount = Math.max( 1, Math.min(items.length, Math.round(Number(concurrency || 1))) ); await Promise.all( Array.from({ length: workerCount }, async () => { while (nextIndex < items.length) { const currentIndex = nextIndex; nextIndex += 1; results[currentIndex] = await mapper(items[currentIndex]!, currentIndex); } }) ); return results; } function getStoredLayerSourceUrl(layer: AgentPsdTemplate["layers"][number]) { return ( String(layer.sourceUrl || "").trim() || String(layer.previewUrl || "").trim() ); } function getDefaultSourceImageLayer( layers: AgentPsdTemplate["layers"] ) { const editableImageLayers = layers.filter( (layer): layer is AgentPsdTemplateImageLayerSlot => layer.isEditable && isAgentPsdImageLayerSlot(layer) ); return ( editableImageLayers.find((layer) => layer.isDefaultResultSource) || editableImageLayers[0] || null ); } 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(PSD_RENDER_PNG_OPTIONS).toBuffer(); } export async function renderAgentPsdTemplate(params: { template: AgentPsdTemplate; hiddenLayerIds?: string[]; layerOrderIds?: string[]; imageAssignments: Record; imagePlacements: Record< string, ReturnType >; textAssignments: Record; textColorAssignments?: Record; layerPositionOffsets?: Record>; textPositionOffsets?: Record>; fonts: AgentPsdFont[]; }) { const { template, hiddenLayerIds = [], layerOrderIds = [], imageAssignments, imagePlacements, textAssignments, textColorAssignments = {}, layerPositionOffsets = {}, textPositionOffsets = {}, 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])); const hiddenLayerIdSet = new Set( hiddenLayerIds .map((layerId) => String(layerId || "").trim()) .filter(Boolean) ); const layerMap = new Map(template.layers.map((layer) => [layer.id, layer])); const orderedTemplateLayers = [ ...layerOrderIds .map((layerId) => layerMap.get(String(layerId || "").trim()) || null) .filter((layer): layer is AgentPsdTemplate["layers"][number] => Boolean(layer) ), ...template.layers.filter( (layer) => !layerOrderIds.includes(layer.id) ), ]; const renderBaseUrl = String(template.renderBaseUrl || "").trim(); const hasDedicatedRenderBase = Boolean(renderBaseUrl) && renderBaseUrl !== String(template.previewUrl || "").trim(); const templateLayerOrderIds = template.layers.map((layer) => layer.id); const isUsingTemplateLayerOrder = orderedTemplateLayers.length === templateLayerOrderIds.length && orderedTemplateLayers.every( (layer, index) => layer.id === templateLayerOrderIds[index] ); const defaultSourceImageLayerId = getDefaultSourceImageLayer(orderedTemplateLayers)?.id || ""; const hasImageAssignment = (layer: AgentPsdTemplate["layers"][number]) => isAgentPsdImageLayerSlot(layer) && Boolean(String(imageAssignments[layer.id] || "").trim()); const shouldRenderAsStaticLayer = ( layer: AgentPsdTemplate["layers"][number] ) => !isAgentPsdTemplateLayerUserEditable(layer) && !hasImageAssignment(layer); const hasHiddenStaticLayer = orderedTemplateLayers.some( (layer) => shouldRenderAsStaticLayer(layer) && hiddenLayerIdSet.has(layer.id) ); const hasVisibleAssignedImageLayer = orderedTemplateLayers.some( (layer) => !hiddenLayerIdSet.has(layer.id) && hasImageAssignment(layer) ); const hasStaticLayerPositionOffset = orderedTemplateLayers.some((layer) => { if ( isAgentPsdTemplateLayerUserEditable(layer) || isAgentPsdLayerSuperLocked(layer) ) { return false; } const offset = normalizeTextPositionOffset(layerPositionOffsets[layer.id]); return Math.abs(offset.offsetX) > 0.01 || Math.abs(offset.offsetY) > 0.01; }); const renderSegments = (template.renderSegments || []).filter((segment) => Boolean(String(segment.url || "").trim()) ); const shouldUseRenderSegments = hasVisibleAssignedImageLayer && renderSegments.length > 0 && isUsingTemplateLayerOrder && !hasHiddenStaticLayer && !hasStaticLayerPositionOffset; const shouldUseRenderBaseFallback = hasDedicatedRenderBase && !hasHiddenStaticLayer && !hasVisibleAssignedImageLayer; const shouldHydrateLayerSourcesFromPsd = hasVisibleAssignedImageLayer && !shouldUseRenderSegments; const renderSegmentByBeforeLayerId = new Map( renderSegments .filter((segment) => String(segment.beforeLayerId || "").trim()) .map((segment) => [String(segment.beforeLayerId || "").trim(), segment]) ); const topRenderSegments = renderSegments.filter( (segment) => !String(segment.beforeLayerId || "").trim() ); function getLayerPositionOffset(layerId: string) { const layer = layerMap.get(layerId); if (layer && isAgentPsdLayerSuperLocked(layer)) { return normalizeTextPositionOffset(); } return normalizeTextPositionOffset(layerPositionOffsets[layerId]); } 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)!; } async function prefetchRenderAssets() { const assetCandidates: Array<{ url: string; label: string; optional?: boolean; }> = []; const pushAsset = ( url: string | undefined, label: string, optional = false ) => { const normalizedUrl = String(url || "").trim(); if (!normalizedUrl) return; assetCandidates.push({ url: normalizedUrl, label, optional }); }; if (shouldUseRenderBaseFallback) { pushAsset(renderBaseUrl, "PSD 模板底图"); } if (shouldUseRenderSegments) { for (const segment of renderSegments) { pushAsset(segment.url, "PSD 模板快速导出分层"); } } for (const layer of orderedTemplateLayers) { if (hiddenLayerIdSet.has(layer.id)) { continue; } const storedLayerSourceUrl = getStoredLayerSourceUrl(layer); if (shouldRenderAsStaticLayer(layer)) { if (shouldUseRenderBaseFallback || shouldUseRenderSegments) { continue; } pushAsset( storedLayerSourceUrl, "PSD 图层素材", shouldUseRenderBaseFallback ); continue; } if (isAgentPsdImageLayerSlot(layer)) { const assignmentUrl = String(imageAssignments[layer.id] || "").trim(); if (assignmentUrl) { pushAsset(assignmentUrl, "PSD 套版素材"); pushAsset(layer.maskUrl, "PSD 蒙版资源", true); if (layer.forceClipMask && layer.clipMaskLayerId) { pushAsset( getStoredLayerSourceUrl(layerMap.get(layer.clipMaskLayerId) || layer), "PSD 剪切蒙版目标资源", true ); } else if (layer.forceClipMask) { pushAsset( getStoredLayerSourceUrl(layer), "PSD 自身剪切蒙版资源", true ); } } else { pushAsset( storedLayerSourceUrl, "PSD 图层素材", shouldUseRenderBaseFallback ); } continue; } if (isAgentPsdTextLayerSlot(layer)) { const hasTextAssignment = Object.hasOwn(textAssignments, layer.id); const defaultText = String(layer.defaultText || "").trim(); const text = String( hasTextAssignment ? (textAssignments[layer.id] ?? "") : defaultText ).trim(); if (!text) { if (!hasTextAssignment) { pushAsset( storedLayerSourceUrl, "PSD 图层素材", shouldUseRenderBaseFallback ); } } else { const font = layer.fontId ? fontMap.get(layer.fontId) : undefined; pushAsset(font?.fileUrl, "PSD 字体资源", true); } } } const uniqueAssetMap = new Map< string, { url: string; label: string; optional?: boolean } >(); for (const asset of assetCandidates) { const previous = uniqueAssetMap.get(asset.url); uniqueAssetMap.set(asset.url, { ...asset, label: previous?.label || asset.label, optional: previous ? Boolean(previous.optional) && Boolean(asset.optional) : Boolean(asset.optional), }); } const uniqueAssets = Array.from(uniqueAssetMap.values()); await mapWithConcurrency(uniqueAssets, 6, async (asset) => { try { await readCachedBuffer(asset.url, asset.label); } catch (error) { if (!asset.optional) { throw error; } } }); } const compositeInputs: Array<{ input: Buffer; left: number; top: number }> = []; let psdSourceLayerEntryMapPromise: Promise< Map< string, { layer: { width: number; height: number }; composite: () => Promise; } > > | null = null; const psdSourceLayerBufferPromiseMap = new Map>(); async function getPsdSourceLayerEntryMap() { if (!shouldHydrateLayerSourcesFromPsd) { return null; } if (!psdSourceLayerEntryMapPromise) { psdSourceLayerEntryMapPromise = loadPsdTemplateSource({ fileUrl: template.fileUrl, filePath: template.filePath, }).then((source) => { const entryMap = new Map< string, { layer: { width: number; height: number }; composite: () => Promise; } >(); for (const entry of source.layers) { entryMap.set(entry.layer.layerId, { layer: { width: entry.layer.width, height: entry.layer.height, }, composite: entry.composite, }); } return entryMap; }); } return psdSourceLayerEntryMapPromise; } async function readPsdSourceLayerBuffer( layer: AgentPsdTemplate["layers"][number] ) { if (!shouldHydrateLayerSourcesFromPsd) { return null; } if (!psdSourceLayerBufferPromiseMap.has(layer.layerId)) { psdSourceLayerBufferPromiseMap.set( layer.layerId, (async () => { try { const entryMap = await getPsdSourceLayerEntryMap(); const entry = entryMap?.get(layer.layerId); if (!entry) { return null; } const rgba = await entry.composite(); return sharp(Buffer.from(rgba), { raw: { width: Math.max(1, Math.round(entry.layer.width || layer.width)), height: Math.max(1, Math.round(entry.layer.height || layer.height)), channels: 4, }, }) .png(PSD_RENDER_PNG_OPTIONS) .toBuffer(); } catch (error) { console.warn("[PSD Template] failed to hydrate layer from PSD source:", { templateId: template.id, layerId: layer.layerId, error, }); return null; } })() ); } return psdSourceLayerBufferPromiseMap.get(layer.layerId)!; } async function readReusableLayerBuffer( layer: AgentPsdTemplate["layers"][number] ) { const sourceUrl = getStoredLayerSourceUrl(layer); if (sourceUrl) { try { return await readCachedBuffer(sourceUrl, "PSD 图层素材"); } catch (error) { const hydratedBuffer = await readPsdSourceLayerBuffer(layer); if (hydratedBuffer) { return hydratedBuffer; } throw error; } } return readPsdSourceLayerBuffer(layer); } 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], options: { optional?: boolean } = {} ) { const sourceUrl = getStoredLayerSourceUrl(layer); if ((!hasAgentPsdTemplateStoredLayerSource(layer) && !sourceUrl) || !sourceUrl) { const hydratedBuffer = await readPsdSourceLayerBuffer(layer); if (hydratedBuffer) { const layerPositionOffset = getLayerPositionOffset(layer.id); return appendBufferOverlay({ input: hydratedBuffer, left: layer.x + layerPositionOffset.offsetX, top: layer.y + layerPositionOffset.offsetY, width: layer.width, height: layer.height, }); } if (options.optional) { return false; } throw new Error( `PSD 模板图层“${layer.layerName}”缺少已打包资源,请在后台重新解析该模板` ); } const layerPositionOffset = getLayerPositionOffset(layer.id); try { const overlayBuffer = await readReusableLayerBuffer(layer); if (!overlayBuffer) { if (options.optional) { return false; } throw new Error("Missing PSD layer asset"); } const didAppend = await appendBufferOverlay({ input: overlayBuffer, left: layer.x + layerPositionOffset.offsetX, top: layer.y + layerPositionOffset.offsetY, width: layer.width, height: layer.height, }); if (didAppend) { return true; } return false; } catch (error) { console.warn( "[PSD Template] failed to reuse stored layer asset during render:", { templateId: template.id, layerId: layer.layerId, error, } ); if (options.optional) { return false; } throw new Error( `PSD 模板图层“${layer.layerName}”资源加载失败,请稍后重试或在后台重新解析该模板` ); } } await prefetchRenderAssets(); let didAppendRenderBase = false; async function appendRenderBaseOverlay() { if (!shouldUseRenderBaseFallback || didAppendRenderBase) { return; } didAppendRenderBase = true; const renderBaseBuffer = await readCachedBuffer(renderBaseUrl, "PSD 模板底图"); await appendBufferOverlay({ input: renderBaseBuffer, left: 0, top: 0, width: canvasWidth, height: canvasHeight, }); } const appendedRenderSegmentIds = new Set(); async function appendRenderSegmentOverlay( segment: (typeof renderSegments)[number] | undefined ) { if ( !shouldUseRenderSegments || !segment || appendedRenderSegmentIds.has(segment.id) ) { return; } appendedRenderSegmentIds.add(segment.id); const segmentBuffer = await readCachedBuffer( segment.url, "PSD 模板快速导出分层" ); await appendBufferOverlay({ input: segmentBuffer, left: 0, top: 0, width: canvasWidth, height: canvasHeight, }); } async function appendRenderSegmentBeforeLayer(layerId: string) { await appendRenderSegmentOverlay(renderSegmentByBeforeLayerId.get(layerId)); } async function appendTopRenderSegments() { for (const segment of topRenderSegments) { await appendRenderSegmentOverlay(segment); } } const appendedImageAssignmentLayerIds = new Set(); async function appendAssignedImageLayer(layer: AgentPsdTemplateImageLayerSlot) { if (appendedImageAssignmentLayerIds.has(layer.id)) { return; } const isDefaultSourceImageLayer = layer.id === defaultSourceImageLayerId; const assignmentUrl = String(imageAssignments[layer.id] || "").trim(); if (!assignmentUrl || hiddenLayerIdSet.has(layer.id)) { return; } appendedImageAssignmentLayerIds.add(layer.id); const sourceBuffer = await readCachedBuffer(assignmentUrl, "PSD 套版素材"); const clipMaskLayer = layer.forceClipMask && layer.clipMaskLayerId ? layerMap.get(layer.clipMaskLayerId) : null; if ( clipMaskLayer && clipMaskLayer.id !== layer.id && hiddenLayerIdSet.has(clipMaskLayer.id) ) { return; } const renderFrameLayer = clipMaskLayer || layer; const maskSourceLayer = clipMaskLayer || (layer.forceClipMask ? layer : null); const renderFrameContentBounds = clipMaskLayer && isAgentPsdImageLayerSlot(clipMaskLayer) ? clipMaskLayer.contentBounds : layer.contentBounds; const sourceImage = sharp(sourceBuffer, { failOn: "none" }).rotate(); const metadata = await sourceImage.clone().metadata(); const placementFrame = resolveAgentPsdImagePlacementFrame({ containerWidth: renderFrameLayer.width, containerHeight: renderFrameLayer.height, imageWidth: Math.max(1, Number(metadata.width || 1)), imageHeight: Math.max(1, Number(metadata.height || 1)), replaceMode: layer.replaceMode, contentBounds: renderFrameContentBounds, placement: imagePlacements[layer.id], }); const resizedOverlayBuffer = await sourceImage .clone() .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(PSD_RENDER_PNG_OPTIONS) .toBuffer(); const slotOverlay = await fitOverlayBufferIntoCanvas({ input: resizedOverlayBuffer, left: placementFrame.left, top: placementFrame.top, width: placementFrame.width, height: placementFrame.height, canvasWidth: renderFrameLayer.width, canvasHeight: renderFrameLayer.height, }); if (!slotOverlay) { return; } const layerPositionOffset = getLayerPositionOffset(renderFrameLayer.id); const shouldApplyMask = Boolean(layer.maskUrl || maskSourceLayer); if (!shouldApplyMask) { if (isDefaultSourceImageLayer) { await appendRenderBaseOverlay(); } await appendBufferOverlay({ input: slotOverlay.input, left: renderFrameLayer.x + layerPositionOffset.offsetX + slotOverlay.left, top: renderFrameLayer.y + layerPositionOffset.offsetY + slotOverlay.top, width: slotOverlay.width, height: slotOverlay.height, }); return; } let overlayBuffer = await sharp({ create: { width: Math.max(1, Math.round(renderFrameLayer.width)), height: Math.max(1, Math.round(renderFrameLayer.height)), channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0, }, }, }) .composite([slotOverlay]) .png(PSD_RENDER_PNG_OPTIONS) .toBuffer(); try { const rawMaskBuffer = layer.maskUrl ? await readCachedBuffer(layer.maskUrl, "PSD 蒙版资源") : await readReusableLayerBuffer(maskSourceLayer!); if (!rawMaskBuffer) { throw new Error("Missing PSD layer mask source"); } const maskBuffer = await sharp(rawMaskBuffer) .resize({ width: Math.max(1, Math.round(renderFrameLayer.width)), height: Math.max(1, Math.round(renderFrameLayer.height)), fit: "fill", background: { r: 0, g: 0, b: 0, alpha: 0, }, }) .png(PSD_RENDER_PNG_OPTIONS) .toBuffer(); overlayBuffer = await sharp(overlayBuffer) .composite([ { input: maskBuffer, blend: "dest-in", }, ]) .png(PSD_RENDER_PNG_OPTIONS) .toBuffer(); } catch (error) { console.warn( "[PSD Template] failed to apply layer mask during render:", { templateId: template.id, layerId: layer.layerId, error, } ); } if (isDefaultSourceImageLayer) { await appendRenderBaseOverlay(); } await appendBufferOverlay({ input: overlayBuffer, left: renderFrameLayer.x + layerPositionOffset.offsetX, top: renderFrameLayer.y + layerPositionOffset.offsetY, width: renderFrameLayer.width, height: renderFrameLayer.height, }); } if ( shouldUseRenderBaseFallback && (!defaultSourceImageLayerId || hiddenLayerIdSet.has(defaultSourceImageLayerId)) ) { await appendRenderBaseOverlay(); } for (const layer of [...orderedTemplateLayers].reverse()) { if (shouldUseRenderSegments && isAgentPsdTemplateLayerUserEditable(layer)) { await appendRenderSegmentBeforeLayer(layer.id); } if (hiddenLayerIdSet.has(layer.id)) { continue; } if (!isAgentPsdTemplateLayerUserEditable(layer)) { if (shouldUseRenderBaseFallback || shouldUseRenderSegments) { continue; } await appendStoredLayer(layer, { optional: shouldUseRenderBaseFallback }); continue; } if (isAgentPsdImageLayerSlot(layer)) { const isDefaultSourceImageLayer = layer.id === defaultSourceImageLayerId; const assignmentUrl = String(imageAssignments[layer.id] || "").trim(); if (!assignmentUrl) { await appendStoredLayer(layer, { optional: shouldUseRenderBaseFallback, }); if (isDefaultSourceImageLayer) { await appendRenderBaseOverlay(); } continue; } await appendAssignedImageLayer(layer); continue; } if (isAgentPsdTextLayerSlot(layer)) { const hasTextAssignment = Object.hasOwn(textAssignments, layer.id); const defaultText = String(layer.defaultText || "").trim(); const text = String( hasTextAssignment ? (textAssignments[layer.id] ?? "") : defaultText ).trim(); const textColor = normalizeHexColor( textColorAssignments[layer.id] || layer.textColor, layer.textColor ); const layerPositionOffset = getLayerPositionOffset(layer.id); const textPositionOffset = normalizeTextPositionOffset( textPositionOffsets[layer.id] ); if (!text) { if (!hasTextAssignment) { await appendStoredLayer(layer, { optional: shouldUseRenderBaseFallback, }); } continue; } const textAlign = resolveAgentPsdTextLayerEffectiveAlign(layer); 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, 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, topOffset: -overlayLayout.visualOffsetY, }); const didAppend = await appendBufferOverlay({ input: overlayBuffer, left: layer.x + layerPositionOffset.offsetX + textPositionOffset.offsetX + overlayFrame.leftOffset, top: layer.y + layerPositionOffset.offsetY + textPositionOffset.offsetY + overlayFrame.topOffset, width: overlayFrame.width, height: overlayFrame.height, }); if (!didAppend) continue; } } await appendTopRenderSegments(); const transparentCanvas = await sharp({ create: { width: canvasWidth, height: canvasHeight, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0, }, }, }) .png(PSD_RENDER_PNG_OPTIONS) .toBuffer(); const resultBuffer = await sharp(transparentCanvas) .composite(compositeInputs) .png(PSD_RENDER_PNG_OPTIONS) .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; }