ai-chatbot/lib/agents/psd-template-parser.ts
Your Name 19970915fd
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: optimize psd template asset backfill
2026-05-09 11:28:38 +08:00

967 lines
27 KiB
TypeScript

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<string, unknown>;
composite?: (effect?: boolean, composed?: boolean) => Promise<Uint8ClampedArray>;
};
type PsdRootLike = {
width: number;
height: number;
children: PsdLayerLike[];
composite: () => Promise<Uint8ClampedArray>;
};
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<Uint8ClampedArray>;
}
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<string, unknown>) {
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<string, unknown>
) {
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<ParsedPsdTemplatePayload> {
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<T, R>(
items: T[],
concurrency: number,
mapper: (item: T, index: number) => Promise<R>
) {
const results = new Array<R>(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,
};
}