import "server-only"; import { promises as fs } from "node:fs"; import { uploadFile, type StorageUploadFunction } from "@/lib/storage/service"; import { normalizeDetectedFontNames } from "@/lib/agents/psd-font-matching"; import { fetchRemoteAssetBuffer } from "@/lib/agents/psd-remote-asset"; import { analyzeTextLayerPixels, normalizeHexColor } from "@/lib/agents/psd-text-layout"; import { isAgentPsdImageLayerSlot, isAgentPsdTextLayerSlot, type AgentPsdTemplateImageContentBounds, type AgentPsdTemplateLayerSlot, } from "@/lib/agents/psd-template-config"; const DEFAULT_PSD_TEMPLATE_ASSET_CONCURRENCY = 4; type PsdTemplateLayerAssetProfile = "fast-runtime" | "full"; type PsdFontSetEntry = { Name?: string; }; type PsdTextStyleColorValue = { Values?: number[]; Type?: number; }; type PsdTextStyleRun = { StyleSheet?: { StyleSheetData?: { Font?: number; FontSize?: number; Leading?: number; AutoLeading?: number | boolean; Tracking?: number; FillColor?: PsdTextStyleColorValue; }; }; }; 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; detectedTextColor?: string; } 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 extractHexColorFromTextStyle(value?: PsdTextStyleColorValue) { const channels = Array.isArray(value?.Values) ? value.Values : []; if (channels.length < 4) { return undefined; } const red = Number(channels[1]); const green = Number(channels[2]); const blue = Number(channels[3]); if (![red, green, blue].every((channel) => Number.isFinite(channel))) { return undefined; } return normalizeHexColor( `#${Math.round(Math.max(0, Math.min(1, red)) * 255) .toString(16) .padStart(2, "0")}${Math.round(Math.max(0, Math.min(1, green)) * 255) .toString(16) .padStart(2, "0")}${Math.round(Math.max(0, Math.min(1, blue)) * 255) .toString(16) .padStart(2, "0")}` ); } 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 firstColorRun = styleRuns.find((styleRun) => Boolean(extractHexColorFromTextStyle(styleRun?.StyleSheet?.StyleSheetData?.FillColor)) ); const style = firstValidRun?.StyleSheet?.StyleSheetData; const colorStyle = firstColorRun?.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, detectedTextColor: extractHexColorFromTextStyle(colorStyle?.FillColor), }; } 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, detectedTextColor: 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, detectedTextColor: textStyleMetrics.detectedTextColor, }, }); } return result; } async function parsePsdBuffer(buffer: ArrayBuffer) { const Psd = await getPsdConstructor(); return Psd.parse(buffer) as PsdRootLike; } async function fetchRemoteBuffer(url: string) { const buffer = await fetchRemoteAssetBuffer(url, { label: "PSD 模板源文件", timeoutMs: 40_000, retries: 3, }); return buffer.buffer.slice( buffer.byteOffset, buffer.byteOffset + buffer.byteLength ) as ArrayBuffer; } function toSourceArrayBuffer(value: ArrayBuffer | Buffer) { if (Buffer.isBuffer(value)) { return value.buffer.slice( value.byteOffset, value.byteOffset + value.byteLength ) as ArrayBuffer; } return value; } async function readLocalPsdSourceBuffer(filePath: string) { const buffer = await fs.readFile(filePath); return buffer.buffer.slice( buffer.byteOffset, buffer.byteOffset + buffer.byteLength ) as ArrayBuffer; } async function resolvePsdSourceBuffer(params: { fileUrl?: string; filePath?: string; fileBuffer?: ArrayBuffer | Buffer; }) { if (params.fileBuffer) { return toSourceArrayBuffer(params.fileBuffer); } const localFilePath = String(params.filePath || "").trim(); if (localFilePath) { try { return await readLocalPsdSourceBuffer(localFilePath); } catch (error) { console.warn("[PSD Template] failed to read local PSD cache, falling back:", { filePath: localFilePath, error, }); } } const fileUrl = String(params.fileUrl || "").trim(); if (fileUrl) { return fetchRemoteBuffer(fileUrl); } throw new Error("PSD 模板源文件不可用"); } 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 parsePsdTemplateFileFromSource(params: { fileUrl?: string; filePath?: string; fileBuffer?: ArrayBuffer | Buffer; }) { const buffer = await resolvePsdSourceBuffer(params); 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[], }; } export async function loadPsdTemplateSource(params: { fileUrl?: string; filePath?: string; fileBuffer?: ArrayBuffer | Buffer; }) { const buffer = await resolvePsdSourceBuffer(params); 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, }; } function toArrayBuffer(buffer: Buffer) { return buffer.buffer.slice( buffer.byteOffset, buffer.byteOffset + buffer.byteLength ) as ArrayBuffer; } async function mapWithConcurrency( items: T[], concurrency: number, mapper: (item: T, index: number) => Promise ) { const results = new Array(items.length); const workerCount = Math.max(1, Math.min(Math.round(concurrency), items.length || 1)); let nextIndex = 0; 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 shouldPrepareLayerPreview( layer: AgentPsdTemplateLayerSlot, assetProfile: PsdTemplateLayerAssetProfile ) { return assetProfile === "full" || layer.isEditable; } function shouldPrepareLayerSource( layer: AgentPsdTemplateLayerSlot, assetProfile: PsdTemplateLayerAssetProfile ) { if (assetProfile === "full") { return true; } return isAgentPsdImageLayerSlot(layer) && layer.isEditable; } export async function preparePsdTemplateRenderAssets(params: { templateId: string; fileUrl?: string; filePath?: string; fileBuffer?: ArrayBuffer | Buffer; layers: AgentPsdTemplateLayerSlot[]; forceRefreshLayerAssets?: boolean; assetProfile?: PsdTemplateLayerAssetProfile; assetConcurrency?: number; uploadAsset?: StorageUploadFunction; }) { const assetProfile = params.assetProfile || "fast-runtime"; const assetConcurrency = params.assetConcurrency || DEFAULT_PSD_TEMPLATE_ASSET_CONCURRENCY; const uploadAsset = params.uploadAsset || uploadFile; const psdBuffer = await resolvePsdSourceBuffer({ fileUrl: params.fileUrl, filePath: params.filePath, fileBuffer: params.fileBuffer, }); 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]; const preparedLayerResults = await mapWithConcurrency( nextLayers, assetConcurrency, async (targetLayer) => { let preparedLayer = targetLayer; const shouldUploadPreview = shouldPrepareLayerPreview(targetLayer, assetProfile) && (params.forceRefreshLayerAssets || !targetLayer.previewUrl); const shouldUploadSource = shouldPrepareLayerSource(targetLayer, assetProfile) && (params.forceRefreshLayerAssets || !String(targetLayer.sourceUrl || "").trim()); const shouldAnalyzeText = targetLayer.isEditable && isAgentPsdTextLayerSlot(targetLayer) && targetLayer.styleSource !== "custom"; const shouldResolveImageBounds = targetLayer.isEditable && isAgentPsdImageLayerSlot(targetLayer); if ( !shouldUploadPreview && !shouldUploadSource && !shouldAnalyzeText && !shouldResolveImageBounds ) { return preparedLayer; } const entry = nodeMap.get(targetLayer.layerId); if (!entry?.node || typeof entry.node.composite !== "function") { return preparedLayer; } try { const rgba = await entry.node.composite!(); const visibleContentBounds = resolveVisibleContentBounds({ rgba, width: entry.layer.width, height: entry.layer.height, }); if (shouldUploadPreview) { const thumbnailBuffer = await createLayerThumbnailBuffer({ rgba, width: entry.layer.width, height: entry.layer.height, }); const uploadedPreview = await uploadAsset( `agent-psd-layer-preview-${params.templateId}-${sanitizeFileSegment(targetLayer.layerId, targetLayer.id)}.png`, toArrayBuffer(thumbnailBuffer) ); preparedLayer = { ...preparedLayer, previewUrl: uploadedPreview.url, previewPath: uploadedPreview.pathname, } satisfies AgentPsdTemplateLayerSlot; } if (shouldUploadSource) { const sourceBuffer = await rgbaToPngBuffer( rgba, entry.layer.width, entry.layer.height ); const uploadedSource = await uploadAsset( `agent-psd-layer-source-${params.templateId}-${sanitizeFileSegment(targetLayer.layerId, targetLayer.id)}.png`, toArrayBuffer(sourceBuffer) ); preparedLayer = { ...preparedLayer, sourceUrl: uploadedSource.url, sourcePath: uploadedSource.pathname, } satisfies AgentPsdTemplateLayerSlot; } if (shouldAnalyzeText) { const analysis = analyzeTextLayerPixels({ rgba, width: entry.layer.width, height: entry.layer.height, }); if (analysis && isAgentPsdTextLayerSlot(preparedLayer)) { let nextLayer = preparedLayer; if (normalizeHexColor(preparedLayer.textColor) === "#111111") { nextLayer = { ...nextLayer, textColor: analysis.dominantColor, }; } if (preparedLayer.textAlign === "left" && analysis.inferredTextAlign !== "left") { nextLayer = { ...nextLayer, textAlign: analysis.inferredTextAlign, }; } if ( preparedLayer.paddingX <= 0 && Number.isFinite(Number(analysis.suggestedPaddingX)) && Number(analysis.suggestedPaddingX) > 0 ) { nextLayer = { ...nextLayer, paddingX: Math.max(0, Number(analysis.suggestedPaddingX)), }; } if ( preparedLayer.paddingY <= 0 && Number.isFinite(Number(analysis.suggestedPaddingY)) && Number(analysis.suggestedPaddingY) > 0 ) { nextLayer = { ...nextLayer, paddingY: Math.max(0, Number(analysis.suggestedPaddingY)), }; } preparedLayer = nextLayer; } } if (!isAgentPsdImageLayerSlot(targetLayer) || !targetLayer.isEditable) { if (isAgentPsdImageLayerSlot(preparedLayer)) { preparedLayer = { ...preparedLayer, contentBounds: visibleContentBounds, } satisfies AgentPsdTemplateLayerSlot; } return preparedLayer; } if (isAgentPsdImageLayerSlot(preparedLayer)) { preparedLayer = { ...preparedLayer, 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, } satisfies AgentPsdTemplateLayerSlot; } } catch (error) { console.warn("[PSD Template] Failed to prepare editable image layer assets:", { layerId: targetLayer.layerId, error, }); } return preparedLayer; } ); preparedLayerResults.forEach((layer, index) => { nextLayers[index] = layer; }); const baseEntries = [...layerEntries] .reverse() .filter( (entry) => !editableLayerIdSet.has(entry.layer.layerId) && entry.node && typeof entry.node.composite === "function" ); const baseCompositeResults = await mapWithConcurrency( baseEntries, assetConcurrency, async (entry, index) => { 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) { return null; } return { index, input: fittedOverlay, }; } catch (error) { console.warn("[PSD Template] Failed to compose base layer:", { layerId: entry.layer.layerId, error, }); return null; } } ); const baseCompositeInputs = baseCompositeResults .filter( ( result ): result is { index: number; input: { input: Buffer; left: number; top: number }; } => Boolean(result) ) .sort((left, right) => left.index - right.index) .map((result) => result.input); 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, }; }