import "server-only"; import { uploadFile } from "@/lib/storage/service"; import { normalizeDetectedFontNames } from "@/lib/agents/psd-font-matching"; import { analyzeTextLayerPixels, normalizeHexColor } from "@/lib/agents/psd-text-layout"; import { isAgentPsdImageLayerSlot, isAgentPsdTextLayerSlot, type AgentPsdTemplateImageContentBounds, type AgentPsdTemplateLayerSlot, } from "@/lib/agents/psd-template-config"; type PsdFontSetEntry = { Name?: string; }; type PsdTextStyleRun = { StyleSheet?: { StyleSheetData?: { Font?: number; FontSize?: number; Leading?: number; AutoLeading?: number | boolean; Tracking?: number; }; }; }; type PsdTypeToolObjectSettingAliBlockLike = { transformXX?: number; transformXY?: number; transformYX?: number; transformYY?: number; }; type PsdTextPropertiesLike = { DocumentResources?: { FontSet?: PsdFontSetEntry[]; }; ResourceDict?: { FontSet?: PsdFontSetEntry[]; }; EngineDict?: { StyleRun?: { RunArray?: PsdTextStyleRun[]; }; }; }; type PsdLayerLike = { type?: string; name?: string; children?: PsdLayerLike[]; width?: number; height?: number; left?: number; top?: number; isHidden?: boolean; text?: string; textProperties?: PsdTextPropertiesLike; additionalProperties?: Record; composite?: (effect?: boolean, composed?: boolean) => Promise; }; type PsdRootLike = { width: number; height: number; children: PsdLayerLike[]; composite: () => Promise; }; export interface ParsedPsdTemplateLayer { id: string; layerId: string; layerName: string; layerPath: string; depth: number; x: number; y: number; width: number; height: number; slotType: "image" | "text"; defaultText?: string; detectedFontName?: string; detectedFontNames?: string[]; detectedFontSize?: number; detectedLineHeight?: number; detectedLetterSpacing?: number; } export interface ParsedPsdTemplatePayload { width: number; height: number; previewBuffer: Buffer; layers: ParsedPsdTemplateLayer[]; } interface PsdLayerNodeEntry { node: PsdLayerLike; layer: ParsedPsdTemplateLayer; } export interface PsdTemplateSourceLayerEntry { layer: ParsedPsdTemplateLayer; composite: () => Promise; } function resolveVisibleContentBounds(params: { rgba: Uint8ClampedArray; width: number; height: number; }): AgentPsdTemplateImageContentBounds | undefined { const { rgba, width, height } = params; const pixelCount = width * height; let minX = width; let minY = height; let maxX = -1; let maxY = -1; for (let index = 0; index < pixelCount; index += 1) { const alpha = rgba[index * 4 + 3] || 0; if (alpha <= 0) { continue; } const x = index % width; const y = Math.floor(index / width); if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } if (maxX < minX || maxY < minY) { return undefined; } return { left: minX, top: minY, width: maxX - minX + 1, height: maxY - minY + 1, }; } function normalizeLayerName(value: unknown, fallback: string) { const text = String(value || "").trim(); return text || fallback; } 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 getPsdConstructor() { const module = await import("@webtoon/psd"); return module.default; } async function getSharp() { const sharp = await import("sharp"); return sharp.default; } 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 createLayerThumbnailBuffer(params: { rgba: Uint8ClampedArray; width: number; height: number; maxSize?: number; }) { const { rgba, width, height, maxSize = 160 } = params; const sharp = await getSharp(); return sharp(Buffer.from(rgba), { raw: { width, height, channels: 4, }, }) .resize({ width: maxSize, height: maxSize, fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0, }, }) .png() .toBuffer(); } function extractLayerId(node: PsdLayerLike, fallback: string) { const additional = node.additionalProperties; const layerIdRecord = additional && typeof additional === "object" ? (additional.lyid as { value?: unknown } | undefined) : undefined; const layerId = layerIdRecord?.value; if (typeof layerId === "number" || typeof layerId === "string") { const normalized = String(layerId).trim(); if (normalized) return normalized; } return fallback; } function extractDetectedFontNames(textProperties?: PsdTextPropertiesLike) { const fontSet = textProperties?.DocumentResources?.FontSet || textProperties?.ResourceDict?.FontSet || []; const styleRuns = textProperties?.EngineDict?.StyleRun?.RunArray || []; const candidates: string[] = []; for (const styleRun of styleRuns) { const fontIndex = Number(styleRun?.StyleSheet?.StyleSheetData?.Font); if (Number.isInteger(fontIndex) && fontIndex >= 0 && fontIndex < fontSet.length) { const fontName = String(fontSet[fontIndex]?.Name || "").trim(); if (fontName) { candidates.push(fontName); } } } for (const fontEntry of fontSet) { const fontName = String(fontEntry?.Name || "").trim(); if (fontName) { candidates.push(fontName); } } return normalizeDetectedFontNames(candidates); } function extractTextTransformScale(additionalProperties?: Record) { const textTransform = additionalProperties?.TySh as PsdTypeToolObjectSettingAliBlockLike | undefined; const transformXX = Number(textTransform?.transformXX); const transformXY = Number(textTransform?.transformXY); const transformYX = Number(textTransform?.transformYX); const transformYY = Number(textTransform?.transformYY); const scaleX = Number.isFinite(transformXX) || Number.isFinite(transformXY) ? Math.hypot(Number.isFinite(transformXX) ? transformXX : 0, Number.isFinite(transformXY) ? transformXY : 0) : 1; const scaleY = Number.isFinite(transformYX) || Number.isFinite(transformYY) ? Math.hypot(Number.isFinite(transformYX) ? transformYX : 0, Number.isFinite(transformYY) ? transformYY : 0) : 1; return { scaleX: scaleX > 0 ? scaleX : 1, scaleY: scaleY > 0 ? scaleY : 1, }; } function extractTextStyleMetrics( textProperties?: PsdTextPropertiesLike, additionalProperties?: Record ) { const styleRuns = textProperties?.EngineDict?.StyleRun?.RunArray || []; const firstValidRun = styleRuns.find((styleRun) => { const style = styleRun?.StyleSheet?.StyleSheetData; return Number.isFinite(Number(style?.FontSize)); }); const style = firstValidRun?.StyleSheet?.StyleSheetData; const fontSizeValue = Number(style?.FontSize); const leadingValue = Number(style?.Leading); const trackingValue = Number(style?.Tracking); const { scaleX, scaleY } = extractTextTransformScale(additionalProperties); const detectedFontSize = Number.isFinite(fontSizeValue) && fontSizeValue > 0 ? Number((fontSizeValue * scaleY).toFixed(3)) : undefined; const detectedLineHeight = detectedFontSize && Number.isFinite(leadingValue) && leadingValue > 0 ? Number((((leadingValue * scaleY) / detectedFontSize).toFixed(3))) : style?.AutoLeading ? 1.2 : undefined; const detectedLetterSpacing = Number.isFinite(fontSizeValue) && fontSizeValue > 0 && Number.isFinite(trackingValue) ? Number((((trackingValue / 1000) * fontSizeValue) * scaleX).toFixed(3)) : undefined; return { detectedFontSize, detectedLineHeight, detectedLetterSpacing, }; } function collectPsdLayerEntries( nodes: PsdLayerLike[], ancestors: string[] = [], depth = 0, result: PsdLayerNodeEntry[] = [], orderRef: { current: number } = { current: 0 } ) { for (const node of nodes) { if (!node || typeof node !== "object") continue; const nodeType = String(node.type || ""); if (nodeType === "Group") { const groupName = normalizeLayerName(node.name, `分组${orderRef.current + 1}`); collectPsdLayerEntries( Array.isArray(node.children) ? node.children : [], [...ancestors, groupName], depth + 1, result, orderRef ); continue; } if (nodeType !== "Layer") { continue; } const width = Math.max(0, Math.round(Number(node.width || 0))); const height = Math.max(0, Math.round(Number(node.height || 0))); if (width <= 0 || height <= 0 || node.isHidden) { continue; } const order = orderRef.current; orderRef.current += 1; const layerName = normalizeLayerName(node.name, `图层${order + 1}`); const fallbackLayerId = `${ancestors.join("/")}/${layerName}#${order + 1}`; const layerId = extractLayerId(node, fallbackLayerId); const defaultText = String(node.text || "").trim(); const detectedFontNames = defaultText ? extractDetectedFontNames(node.textProperties) : []; const textStyleMetrics = defaultText ? extractTextStyleMetrics(node.textProperties, node.additionalProperties) : { detectedFontSize: undefined, detectedLineHeight: undefined, detectedLetterSpacing: undefined, }; result.push({ node, layer: { id: `layer-${sanitizeFileSegment(layerId, `layer-${order + 1}`)}`, layerId, layerName, layerPath: [...ancestors, layerName].join(" / "), depth, x: Math.round(Number(node.left || 0)), y: Math.round(Number(node.top || 0)), width, height, slotType: defaultText ? "text" : "image", defaultText: defaultText || undefined, detectedFontName: detectedFontNames[0] || undefined, detectedFontNames: detectedFontNames.length > 0 ? detectedFontNames : undefined, detectedFontSize: textStyleMetrics.detectedFontSize, detectedLineHeight: textStyleMetrics.detectedLineHeight, detectedLetterSpacing: textStyleMetrics.detectedLetterSpacing, }, }); } return result; } async function parsePsdBuffer(buffer: ArrayBuffer) { const Psd = await getPsdConstructor(); return Psd.parse(buffer) as PsdRootLike; } async function fetchRemoteBuffer(url: string) { const response = await fetch(url, { cache: "no-store", signal: AbortSignal.timeout(20_000), }); if (!response.ok) { throw new Error(`PSD file download failed: ${response.status}`); } return await response.arrayBuffer(); } export async function parsePsdTemplateFile( buffer: ArrayBuffer ): Promise { const psd = await parsePsdBuffer(buffer); const width = Math.max(1, Math.round(Number(psd.width || 0))); const height = Math.max(1, Math.round(Number(psd.height || 0))); const previewRgba = await psd.composite(); const previewBuffer = await rgbaToPngBuffer(previewRgba, width, height); const layers = collectPsdLayerEntries(Array.isArray(psd.children) ? psd.children : []).map( (entry) => entry.layer ); return { width, height, previewBuffer, layers, }; } export async function parsePsdTemplateFileFromUrl(fileUrl: string) { const buffer = await fetchRemoteBuffer(fileUrl); return parsePsdTemplateFile(buffer); } export async function loadPsdTemplateSourceFromUrl(fileUrl: string) { const buffer = await fetchRemoteBuffer(fileUrl); const psd = await parsePsdBuffer(buffer); const layerEntries = collectPsdLayerEntries(Array.isArray(psd.children) ? psd.children : []); return { width: Math.max(1, Math.round(Number(psd.width || 0))), height: Math.max(1, Math.round(Number(psd.height || 0))), layers: layerEntries .filter((entry) => entry.node && typeof entry.node.composite === "function") .map((entry) => ({ layer: entry.layer, composite: () => entry.node.composite!(), })) satisfies PsdTemplateSourceLayerEntry[], }; } async function createTransparentCanvasBuffer(width: number, height: number) { const sharp = await getSharp(); const canvasWidth = Math.max(1, Math.round(Number(width || 0))); const canvasHeight = Math.max(1, Math.round(Number(height || 0))); return sharp({ create: { width: canvasWidth, height: canvasHeight, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0, }, }, }) .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, }; } export async function preparePsdTemplateRenderAssets(params: { templateId: string; fileUrl: string; layers: AgentPsdTemplateLayerSlot[]; forceRefreshLayerAssets?: boolean; }) { const psdBuffer = await fetchRemoteBuffer(params.fileUrl); const psd = await parsePsdBuffer(psdBuffer); const layerEntries = collectPsdLayerEntries(Array.isArray(psd.children) ? psd.children : []); const nodeMap = new Map(layerEntries.map((entry) => [entry.layer.layerId, entry])); const editableLayerIdSet = new Set( params.layers.filter((layer) => layer.isEditable).map((layer) => layer.layerId) ); const nextLayers = [...params.layers]; for (const targetLayer of nextLayers) { const entry = nodeMap.get(targetLayer.layerId); if (!entry?.node || typeof entry.node.composite !== "function") { continue; } try { const rgba = await entry.node.composite(); const visibleContentBounds = resolveVisibleContentBounds({ rgba, width: entry.layer.width, height: entry.layer.height, }); if (params.forceRefreshLayerAssets || !targetLayer.previewUrl) { const thumbnailBuffer = await createLayerThumbnailBuffer({ rgba, width: entry.layer.width, height: entry.layer.height, }); const uploadedPreview = await uploadFile( `agent-psd-layer-preview-${params.templateId}-${sanitizeFileSegment(targetLayer.layerId, targetLayer.id)}.png`, thumbnailBuffer.buffer.slice( thumbnailBuffer.byteOffset, thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength ) as ArrayBuffer ); const layerIndex = nextLayers.findIndex((layer) => layer.id === targetLayer.id); if (layerIndex >= 0) { nextLayers[layerIndex] = { ...nextLayers[layerIndex], previewUrl: uploadedPreview.url, previewPath: uploadedPreview.pathname, }; } } if (params.forceRefreshLayerAssets || !String(targetLayer.sourceUrl || "").trim()) { const sourceBuffer = await rgbaToPngBuffer(rgba, entry.layer.width, entry.layer.height); const uploadedSource = await uploadFile( `agent-psd-layer-source-${params.templateId}-${sanitizeFileSegment(targetLayer.layerId, targetLayer.id)}.png`, sourceBuffer.buffer.slice( sourceBuffer.byteOffset, sourceBuffer.byteOffset + sourceBuffer.byteLength ) as ArrayBuffer ); const layerIndex = nextLayers.findIndex((layer) => layer.id === targetLayer.id); if (layerIndex >= 0) { nextLayers[layerIndex] = { ...nextLayers[layerIndex], sourceUrl: uploadedSource.url, sourcePath: uploadedSource.pathname, }; } } if (isAgentPsdTextLayerSlot(targetLayer) && targetLayer.styleSource !== "custom") { const analysis = analyzeTextLayerPixels({ rgba, width: entry.layer.width, height: entry.layer.height, }); const layerIndex = nextLayers.findIndex((layer) => layer.id === targetLayer.id); if (analysis && layerIndex >= 0 && isAgentPsdTextLayerSlot(nextLayers[layerIndex])) { const currentLayer = nextLayers[layerIndex]; let nextLayer = currentLayer; if (normalizeHexColor(currentLayer.textColor) === "#111111") { nextLayer = { ...nextLayer, textColor: analysis.dominantColor, }; } if (currentLayer.textAlign === "left" && analysis.inferredTextAlign !== "left") { nextLayer = { ...nextLayer, textAlign: analysis.inferredTextAlign, }; } if ( currentLayer.paddingX <= 0 && Number.isFinite(Number(analysis.suggestedPaddingX)) && Number(analysis.suggestedPaddingX) > 0 ) { nextLayer = { ...nextLayer, paddingX: Math.max(0, Number(analysis.suggestedPaddingX)), }; } if ( currentLayer.paddingY <= 0 && Number.isFinite(Number(analysis.suggestedPaddingY)) && Number(analysis.suggestedPaddingY) > 0 ) { nextLayer = { ...nextLayer, paddingY: Math.max(0, Number(analysis.suggestedPaddingY)), }; } if (nextLayer !== currentLayer) { nextLayers[layerIndex] = nextLayer; } } } if ( !isAgentPsdImageLayerSlot(targetLayer) || !targetLayer.isEditable ) { const layerIndex = nextLayers.findIndex((layer) => layer.id === targetLayer.id); if (layerIndex >= 0 && isAgentPsdImageLayerSlot(nextLayers[layerIndex])) { nextLayers[layerIndex] = { ...nextLayers[layerIndex], contentBounds: visibleContentBounds, }; } continue; } const layerIndex = nextLayers.findIndex((layer) => layer.id === targetLayer.id); if (layerIndex >= 0 && isAgentPsdImageLayerSlot(nextLayers[layerIndex])) { nextLayers[layerIndex] = { ...nextLayers[layerIndex], contentBounds: visibleContentBounds, // Do not auto-reuse the original PSD image alpha as a hard replacement mask. // Otherwise newly assigned images get trapped inside the source image silhouette. maskUrl: undefined, maskPath: undefined, }; } } catch (error) { console.warn("[PSD Template] Failed to prepare editable image layer assets:", { layerId: targetLayer.layerId, error, }); } } const baseCompositeInputs: Array<{ input: Buffer; left: number; top: number }> = []; for (const entry of [...layerEntries].reverse()) { if (editableLayerIdSet.has(entry.layer.layerId)) { continue; } if (!entry.node || typeof entry.node.composite !== "function") { continue; } try { const rgba = await entry.node.composite(); const overlayBuffer = await rgbaToPngBuffer(rgba, entry.layer.width, entry.layer.height); const fittedOverlay = await fitOverlayBufferIntoCanvas({ input: overlayBuffer, left: entry.layer.x, top: entry.layer.y, width: entry.layer.width, height: entry.layer.height, canvasWidth: Math.max(1, Math.round(Number(psd.width || 0))), canvasHeight: Math.max(1, Math.round(Number(psd.height || 0))), }); if (!fittedOverlay) { continue; } baseCompositeInputs.push(fittedOverlay); } catch (error) { console.warn("[PSD Template] Failed to compose base layer:", { layerId: entry.layer.layerId, error, }); } } const sharp = await getSharp(); const transparentBaseBuffer = await createTransparentCanvasBuffer(psd.width, psd.height); const renderBaseBuffer = await sharp(transparentBaseBuffer) .composite(baseCompositeInputs) .png() .toBuffer(); return { layers: nextLayers, renderBaseBuffer, }; }