967 lines
27 KiB
TypeScript
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,
|
|
};
|
|
}
|