1292 lines
37 KiB
TypeScript
1292 lines
37 KiB
TypeScript
import "server-only";
|
|
|
|
import { uploadFile } from "@/lib/storage/service";
|
|
import { fetchRemoteAssetBuffer } from "@/lib/agents/psd-remote-asset";
|
|
import { loadPsdTemplateSource } from "@/lib/agents/psd-template-parser";
|
|
import {
|
|
hasAgentPsdTemplateStoredLayerSource,
|
|
isAgentPsdImageLayerSlot,
|
|
isAgentPsdLayerSuperLocked,
|
|
isAgentPsdTemplateLayerUserEditable,
|
|
isAgentPsdTextLayerSlot,
|
|
resolveAgentPsdTextLayerEffectiveAlign,
|
|
type AgentPsdTemplate,
|
|
type AgentPsdTemplateImageLayerSlot,
|
|
} from "@/lib/agents/psd-template-config";
|
|
import type { AgentPsdFont } from "@/lib/agents/psd-font-config";
|
|
import { ensurePsdFontAvailableForRender } from "@/lib/agents/psd-font-runtime";
|
|
import {
|
|
normalizeAgentPsdImagePlacement,
|
|
resolveAgentPsdImagePlacementFrame,
|
|
} from "@/lib/agents/psd-image-placement";
|
|
import {
|
|
normalizeHexColor,
|
|
resolveTextLayout,
|
|
} from "@/lib/agents/psd-text-layout";
|
|
|
|
type AgentPsdTextPositionOffset = {
|
|
offsetX: number;
|
|
offsetY: number;
|
|
};
|
|
|
|
const PSD_RENDER_PNG_COMPRESSION_LEVEL = Math.max(
|
|
0,
|
|
Math.min(
|
|
9,
|
|
Number.parseInt(process.env.AGENT_PSD_RENDER_PNG_COMPRESSION_LEVEL || "4", 10)
|
|
)
|
|
);
|
|
const PSD_RENDER_PNG_OPTIONS = {
|
|
compressionLevel: Number.isFinite(PSD_RENDER_PNG_COMPRESSION_LEVEL)
|
|
? PSD_RENDER_PNG_COMPRESSION_LEVEL
|
|
: 4,
|
|
adaptiveFiltering: false,
|
|
} as const;
|
|
|
|
function clampTextPositionOffsetValue(value: number) {
|
|
if (!Number.isFinite(value)) {
|
|
return 0;
|
|
}
|
|
return Math.max(-5000, Math.min(5000, value));
|
|
}
|
|
|
|
function normalizeTextPositionOffset(
|
|
value?: Partial<AgentPsdTextPositionOffset> | null
|
|
): AgentPsdTextPositionOffset {
|
|
return {
|
|
offsetX: Number(
|
|
clampTextPositionOffsetValue(Number(value?.offsetX || 0)).toFixed(2)
|
|
),
|
|
offsetY: Number(
|
|
clampTextPositionOffsetValue(Number(value?.offsetY || 0)).toFixed(2)
|
|
),
|
|
};
|
|
}
|
|
|
|
async function getSharp() {
|
|
const sharp = await import("sharp");
|
|
return sharp.default;
|
|
}
|
|
|
|
async function fitOverlayBufferIntoCanvas(params: {
|
|
input: Buffer;
|
|
left: number;
|
|
top: number;
|
|
width: number;
|
|
height: number;
|
|
canvasWidth: number;
|
|
canvasHeight: number;
|
|
}) {
|
|
const sharp = await getSharp();
|
|
const originalWidth = Math.max(1, Math.round(Number(params.width || 0)));
|
|
const originalHeight = Math.max(1, Math.round(Number(params.height || 0)));
|
|
let left = Math.round(Number(params.left || 0));
|
|
let top = Math.round(Number(params.top || 0));
|
|
let extractLeft = 0;
|
|
let extractTop = 0;
|
|
let targetWidth = originalWidth;
|
|
let targetHeight = originalHeight;
|
|
|
|
if (left < 0) {
|
|
extractLeft = Math.min(originalWidth, Math.abs(left));
|
|
targetWidth -= extractLeft;
|
|
left = 0;
|
|
}
|
|
|
|
if (top < 0) {
|
|
extractTop = Math.min(originalHeight, Math.abs(top));
|
|
targetHeight -= extractTop;
|
|
top = 0;
|
|
}
|
|
|
|
if (left >= params.canvasWidth || top >= params.canvasHeight) {
|
|
return null;
|
|
}
|
|
|
|
targetWidth = Math.min(targetWidth, params.canvasWidth - left);
|
|
targetHeight = Math.min(targetHeight, params.canvasHeight - top);
|
|
|
|
if (targetWidth <= 0 || targetHeight <= 0) {
|
|
return null;
|
|
}
|
|
|
|
const outputBuffer =
|
|
extractLeft > 0 ||
|
|
extractTop > 0 ||
|
|
targetWidth !== originalWidth ||
|
|
targetHeight !== originalHeight
|
|
? await sharp(params.input)
|
|
.extract({
|
|
left: extractLeft,
|
|
top: extractTop,
|
|
width: targetWidth,
|
|
height: targetHeight,
|
|
})
|
|
.png(PSD_RENDER_PNG_OPTIONS)
|
|
.toBuffer()
|
|
: params.input;
|
|
|
|
return {
|
|
input: outputBuffer,
|
|
left,
|
|
top,
|
|
width: targetWidth,
|
|
height: targetHeight,
|
|
};
|
|
}
|
|
|
|
function sanitizeFileSegment(value: string, fallback: string) {
|
|
const normalized = value
|
|
.trim()
|
|
.replace(/\s+/g, "-")
|
|
.replace(/[^a-zA-Z0-9_-]/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.toLowerCase();
|
|
return normalized || fallback;
|
|
}
|
|
|
|
async function mapWithConcurrency<T, R>(
|
|
items: T[],
|
|
concurrency: number,
|
|
mapper: (item: T, index: number) => Promise<R>
|
|
) {
|
|
const results = new Array<R>(items.length);
|
|
let nextIndex = 0;
|
|
const workerCount = Math.max(
|
|
1,
|
|
Math.min(items.length, Math.round(Number(concurrency || 1)))
|
|
);
|
|
|
|
await Promise.all(
|
|
Array.from({ length: workerCount }, async () => {
|
|
while (nextIndex < items.length) {
|
|
const currentIndex = nextIndex;
|
|
nextIndex += 1;
|
|
results[currentIndex] = await mapper(items[currentIndex]!, currentIndex);
|
|
}
|
|
})
|
|
);
|
|
|
|
return results;
|
|
}
|
|
|
|
function getStoredLayerSourceUrl(layer: AgentPsdTemplate["layers"][number]) {
|
|
return (
|
|
String(layer.sourceUrl || "").trim() ||
|
|
String(layer.previewUrl || "").trim()
|
|
);
|
|
}
|
|
|
|
function getDefaultSourceImageLayer(
|
|
layers: AgentPsdTemplate["layers"]
|
|
) {
|
|
const editableImageLayers = layers.filter(
|
|
(layer): layer is AgentPsdTemplateImageLayerSlot =>
|
|
layer.isEditable && isAgentPsdImageLayerSlot(layer)
|
|
);
|
|
|
|
return (
|
|
editableImageLayers.find((layer) => layer.isDefaultResultSource) ||
|
|
editableImageLayers[0] ||
|
|
null
|
|
);
|
|
}
|
|
|
|
function resolveTextOverlayFrame(params: {
|
|
layerWidth: number;
|
|
layerHeight: number;
|
|
contentWidth: number;
|
|
contentHeight: number;
|
|
textAlign: "left" | "center" | "right";
|
|
topOffset?: number;
|
|
}) {
|
|
const width = Math.max(params.layerWidth, params.contentWidth);
|
|
const height = Math.max(params.layerHeight, params.contentHeight);
|
|
const leftOffset =
|
|
params.textAlign === "right"
|
|
? params.layerWidth - width
|
|
: params.textAlign === "center"
|
|
? (params.layerWidth - width) / 2
|
|
: 0;
|
|
|
|
return {
|
|
width,
|
|
height,
|
|
leftOffset,
|
|
topOffset: Number.isFinite(Number(params.topOffset))
|
|
? Number(params.topOffset)
|
|
: 0,
|
|
};
|
|
}
|
|
|
|
function escapeXml(value: string) {
|
|
return value
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function getFontMimeType(font: AgentPsdFont | undefined, fontFamily: string) {
|
|
const contentType = String(font?.contentType || "").trim();
|
|
if (contentType) return contentType;
|
|
const family = fontFamily.toLowerCase();
|
|
if (family.endsWith(".woff2")) return "font/woff2";
|
|
if (family.endsWith(".woff")) return "font/woff";
|
|
if (family.endsWith(".otf")) return "font/otf";
|
|
return "font/ttf";
|
|
}
|
|
|
|
const GENERIC_FONT_FAMILIES = new Set([
|
|
"sans-serif",
|
|
"serif",
|
|
"monospace",
|
|
"cursive",
|
|
"fantasy",
|
|
"system-ui",
|
|
]);
|
|
const CJK_FALLBACK_FONT_FAMILIES = [
|
|
"Noto Sans CJK SC",
|
|
"Noto Sans SC",
|
|
"Source Han Sans SC",
|
|
"Microsoft YaHei",
|
|
"PingFang SC",
|
|
"Hiragino Sans GB",
|
|
"WenQuanYi Zen Hei",
|
|
"SimHei",
|
|
"Arial Unicode MS",
|
|
"sans-serif",
|
|
] as const;
|
|
|
|
function textContainsCjkGlyphs(value: string) {
|
|
return /[\u3400-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(String(value || ""));
|
|
}
|
|
|
|
function buildSvgFontFamilyStack(fontFamilies: string[], text: string) {
|
|
const familyTokens = new Set<string>();
|
|
|
|
for (const family of fontFamilies) {
|
|
const primaryFamily = String(family || "").trim();
|
|
if (primaryFamily) {
|
|
familyTokens.add(primaryFamily);
|
|
}
|
|
}
|
|
|
|
if (textContainsCjkGlyphs(text)) {
|
|
for (const fallbackFamily of CJK_FALLBACK_FONT_FAMILIES) {
|
|
familyTokens.add(fallbackFamily);
|
|
}
|
|
} else {
|
|
familyTokens.add("sans-serif");
|
|
}
|
|
|
|
return Array.from(familyTokens)
|
|
.map((family) =>
|
|
GENERIC_FONT_FAMILIES.has(family) ? family : `"${escapeXml(family)}"`
|
|
)
|
|
.join(", ");
|
|
}
|
|
|
|
async function createTextOverlayBuffer(params: {
|
|
width: number;
|
|
height: number;
|
|
text: string;
|
|
fontBuffer?: Buffer;
|
|
font: AgentPsdFont | undefined;
|
|
fontFamily: string;
|
|
renderFontFamilies?: string[];
|
|
renderFontFamily?: string;
|
|
useExactFontFace?: boolean;
|
|
fontSize: number;
|
|
fontWeight: number;
|
|
lineHeight: number;
|
|
letterSpacing: number;
|
|
textColor: string;
|
|
textAlign: "left" | "center" | "right";
|
|
paddingX: number;
|
|
paddingY: number;
|
|
maxLines: number;
|
|
}) {
|
|
const {
|
|
width,
|
|
height,
|
|
text,
|
|
fontBuffer,
|
|
font,
|
|
fontFamily,
|
|
renderFontFamilies,
|
|
renderFontFamily,
|
|
useExactFontFace,
|
|
fontSize,
|
|
fontWeight,
|
|
lineHeight,
|
|
letterSpacing,
|
|
textColor,
|
|
textAlign,
|
|
paddingX,
|
|
paddingY,
|
|
maxLines,
|
|
} = params;
|
|
|
|
const normalizedText = String(text || "")
|
|
.replace(/\r\n/g, "\n")
|
|
.replace(/\r/g, "\n");
|
|
const resolvedLayout = resolveTextLayout({
|
|
text: normalizedText,
|
|
width,
|
|
height,
|
|
fontSize,
|
|
lineHeight,
|
|
letterSpacing,
|
|
paddingX,
|
|
paddingY,
|
|
maxLines,
|
|
autoFit: false,
|
|
preferNoWrap: true,
|
|
});
|
|
const adjustedFontSize = resolvedLayout.fontSize;
|
|
const lineHeightPx = resolvedLayout.lineHeightPx;
|
|
const lines = resolvedLayout.lines;
|
|
|
|
if (lines.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const primaryFamilies = Array.from(
|
|
new Set(
|
|
[...(renderFontFamilies || []), renderFontFamily, fontFamily]
|
|
.map((family) => String(family || "").trim())
|
|
.filter(Boolean)
|
|
)
|
|
);
|
|
const fontFamilyStack = buildSvgFontFamilyStack(
|
|
primaryFamilies,
|
|
normalizedText
|
|
);
|
|
const fontFaceCss = fontBuffer
|
|
? primaryFamilies
|
|
.map((family) => {
|
|
const escapedFamily = escapeXml(family || "sans-serif");
|
|
return `@font-face { font-family: "${escapedFamily}"; src: url("data:${getFontMimeType(font, family)};base64,${fontBuffer.toString("base64")}"); }`;
|
|
})
|
|
.join("\n")
|
|
: "";
|
|
const clipId = `clip-${Math.random().toString(36).slice(2, 10)}`;
|
|
const anchor =
|
|
textAlign === "center" ? "middle" : textAlign === "right" ? "end" : "start";
|
|
const x =
|
|
textAlign === "center"
|
|
? resolvedLayout.contentWidth / 2
|
|
: textAlign === "right"
|
|
? resolvedLayout.contentWidth - paddingX
|
|
: paddingX;
|
|
|
|
const tspans = lines
|
|
.map((line, index) => {
|
|
const baselineY = paddingY + adjustedFontSize + index * lineHeightPx;
|
|
return `<tspan x="${x}" y="${baselineY}">${escapeXml(line)}</tspan>`;
|
|
})
|
|
.join("");
|
|
|
|
const svg = `
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="${resolvedLayout.contentWidth}" height="${resolvedLayout.contentHeight}" viewBox="0 0 ${resolvedLayout.contentWidth} ${resolvedLayout.contentHeight}">
|
|
<defs>
|
|
<style>
|
|
${fontFaceCss}
|
|
.text { font-family: ${fontFamilyStack}; font-size: ${adjustedFontSize}px; font-weight: ${useExactFontFace ? "normal" : fontWeight}; fill: ${textColor}; letter-spacing: ${letterSpacing}px; }
|
|
</style>
|
|
<clipPath id="${clipId}">
|
|
<rect x="0" y="0" width="${resolvedLayout.contentWidth}" height="${resolvedLayout.contentHeight}" />
|
|
</clipPath>
|
|
</defs>
|
|
<g clip-path="url(#${clipId})">
|
|
<text class="text" text-anchor="${anchor}">${tspans}</text>
|
|
</g>
|
|
</svg>
|
|
`;
|
|
|
|
const sharp = await getSharp();
|
|
return sharp(Buffer.from(svg)).png(PSD_RENDER_PNG_OPTIONS).toBuffer();
|
|
}
|
|
|
|
export async function renderAgentPsdTemplate(params: {
|
|
template: AgentPsdTemplate;
|
|
hiddenLayerIds?: string[];
|
|
layerOrderIds?: string[];
|
|
imageAssignments: Record<string, string>;
|
|
imagePlacements: Record<
|
|
string,
|
|
ReturnType<typeof normalizeAgentPsdImagePlacement>
|
|
>;
|
|
textAssignments: Record<string, string>;
|
|
textColorAssignments?: Record<string, string>;
|
|
layerPositionOffsets?: Record<string, Partial<AgentPsdTextPositionOffset>>;
|
|
textPositionOffsets?: Record<string, Partial<AgentPsdTextPositionOffset>>;
|
|
fonts: AgentPsdFont[];
|
|
}) {
|
|
const {
|
|
template,
|
|
hiddenLayerIds = [],
|
|
layerOrderIds = [],
|
|
imageAssignments,
|
|
imagePlacements,
|
|
textAssignments,
|
|
textColorAssignments = {},
|
|
layerPositionOffsets = {},
|
|
textPositionOffsets = {},
|
|
fonts,
|
|
} = params;
|
|
const sharp = await getSharp();
|
|
const canvasWidth = Math.max(1, Math.round(Number(template.width || 1)));
|
|
const canvasHeight = Math.max(1, Math.round(Number(template.height || 1)));
|
|
const remoteBufferCache = new Map<string, Buffer>();
|
|
const fontMap = new Map(fonts.map((font) => [font.id, font]));
|
|
const hiddenLayerIdSet = new Set(
|
|
hiddenLayerIds
|
|
.map((layerId) => String(layerId || "").trim())
|
|
.filter(Boolean)
|
|
);
|
|
const layerMap = new Map(template.layers.map((layer) => [layer.id, layer]));
|
|
const orderedTemplateLayers = [
|
|
...layerOrderIds
|
|
.map((layerId) => layerMap.get(String(layerId || "").trim()) || null)
|
|
.filter((layer): layer is AgentPsdTemplate["layers"][number] =>
|
|
Boolean(layer)
|
|
),
|
|
...template.layers.filter(
|
|
(layer) => !layerOrderIds.includes(layer.id)
|
|
),
|
|
];
|
|
const renderBaseUrl = String(template.renderBaseUrl || "").trim();
|
|
const hasDedicatedRenderBase =
|
|
Boolean(renderBaseUrl) &&
|
|
renderBaseUrl !== String(template.previewUrl || "").trim();
|
|
const templateLayerOrderIds = template.layers.map((layer) => layer.id);
|
|
const isUsingTemplateLayerOrder =
|
|
orderedTemplateLayers.length === templateLayerOrderIds.length &&
|
|
orderedTemplateLayers.every(
|
|
(layer, index) => layer.id === templateLayerOrderIds[index]
|
|
);
|
|
const defaultSourceImageLayerId =
|
|
getDefaultSourceImageLayer(orderedTemplateLayers)?.id || "";
|
|
const hasImageAssignment = (layer: AgentPsdTemplate["layers"][number]) =>
|
|
isAgentPsdImageLayerSlot(layer) &&
|
|
Boolean(String(imageAssignments[layer.id] || "").trim());
|
|
const shouldRenderAsStaticLayer = (
|
|
layer: AgentPsdTemplate["layers"][number]
|
|
) => !isAgentPsdTemplateLayerUserEditable(layer) && !hasImageAssignment(layer);
|
|
const hasHiddenStaticLayer = orderedTemplateLayers.some(
|
|
(layer) =>
|
|
shouldRenderAsStaticLayer(layer) &&
|
|
hiddenLayerIdSet.has(layer.id)
|
|
);
|
|
const hasVisibleAssignedImageLayer = orderedTemplateLayers.some(
|
|
(layer) =>
|
|
!hiddenLayerIdSet.has(layer.id) && hasImageAssignment(layer)
|
|
);
|
|
const hasStaticLayerPositionOffset = orderedTemplateLayers.some((layer) => {
|
|
if (
|
|
isAgentPsdTemplateLayerUserEditable(layer) ||
|
|
isAgentPsdLayerSuperLocked(layer)
|
|
) {
|
|
return false;
|
|
}
|
|
const offset = normalizeTextPositionOffset(layerPositionOffsets[layer.id]);
|
|
return Math.abs(offset.offsetX) > 0.01 || Math.abs(offset.offsetY) > 0.01;
|
|
});
|
|
const renderSegments = (template.renderSegments || []).filter((segment) =>
|
|
Boolean(String(segment.url || "").trim())
|
|
);
|
|
const shouldUseRenderSegments =
|
|
hasVisibleAssignedImageLayer &&
|
|
renderSegments.length > 0 &&
|
|
isUsingTemplateLayerOrder &&
|
|
!hasHiddenStaticLayer &&
|
|
!hasStaticLayerPositionOffset;
|
|
const shouldUseRenderBaseFallback =
|
|
hasDedicatedRenderBase &&
|
|
!hasHiddenStaticLayer &&
|
|
!hasVisibleAssignedImageLayer;
|
|
const shouldHydrateLayerSourcesFromPsd =
|
|
hasVisibleAssignedImageLayer && !shouldUseRenderSegments;
|
|
const renderSegmentByBeforeLayerId = new Map(
|
|
renderSegments
|
|
.filter((segment) => String(segment.beforeLayerId || "").trim())
|
|
.map((segment) => [String(segment.beforeLayerId || "").trim(), segment])
|
|
);
|
|
const topRenderSegments = renderSegments.filter(
|
|
(segment) => !String(segment.beforeLayerId || "").trim()
|
|
);
|
|
|
|
function getLayerPositionOffset(layerId: string) {
|
|
const layer = layerMap.get(layerId);
|
|
if (layer && isAgentPsdLayerSuperLocked(layer)) {
|
|
return normalizeTextPositionOffset();
|
|
}
|
|
return normalizeTextPositionOffset(layerPositionOffsets[layerId]);
|
|
}
|
|
|
|
async function readCachedBuffer(url: string, label = "PSD 模板资源") {
|
|
const normalizedUrl = String(url || "").trim();
|
|
if (!normalizedUrl) {
|
|
throw new Error("Missing remote asset URL");
|
|
}
|
|
|
|
if (!remoteBufferCache.has(normalizedUrl)) {
|
|
remoteBufferCache.set(
|
|
normalizedUrl,
|
|
await fetchRemoteAssetBuffer(normalizedUrl, {
|
|
label,
|
|
timeoutMs: 35_000,
|
|
retries: 3,
|
|
})
|
|
);
|
|
}
|
|
|
|
return remoteBufferCache.get(normalizedUrl)!;
|
|
}
|
|
|
|
async function prefetchRenderAssets() {
|
|
const assetCandidates: Array<{
|
|
url: string;
|
|
label: string;
|
|
optional?: boolean;
|
|
}> = [];
|
|
const pushAsset = (
|
|
url: string | undefined,
|
|
label: string,
|
|
optional = false
|
|
) => {
|
|
const normalizedUrl = String(url || "").trim();
|
|
if (!normalizedUrl) return;
|
|
assetCandidates.push({ url: normalizedUrl, label, optional });
|
|
};
|
|
|
|
if (shouldUseRenderBaseFallback) {
|
|
pushAsset(renderBaseUrl, "PSD 模板底图");
|
|
}
|
|
if (shouldUseRenderSegments) {
|
|
for (const segment of renderSegments) {
|
|
pushAsset(segment.url, "PSD 模板快速导出分层");
|
|
}
|
|
}
|
|
|
|
for (const layer of orderedTemplateLayers) {
|
|
if (hiddenLayerIdSet.has(layer.id)) {
|
|
continue;
|
|
}
|
|
|
|
const storedLayerSourceUrl = getStoredLayerSourceUrl(layer);
|
|
if (shouldRenderAsStaticLayer(layer)) {
|
|
if (shouldUseRenderBaseFallback || shouldUseRenderSegments) {
|
|
continue;
|
|
}
|
|
pushAsset(
|
|
storedLayerSourceUrl,
|
|
"PSD 图层素材",
|
|
shouldUseRenderBaseFallback
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (isAgentPsdImageLayerSlot(layer)) {
|
|
const assignmentUrl = String(imageAssignments[layer.id] || "").trim();
|
|
if (assignmentUrl) {
|
|
pushAsset(assignmentUrl, "PSD 套版素材");
|
|
pushAsset(layer.maskUrl, "PSD 蒙版资源", true);
|
|
if (layer.forceClipMask && layer.clipMaskLayerId) {
|
|
pushAsset(
|
|
getStoredLayerSourceUrl(layerMap.get(layer.clipMaskLayerId) || layer),
|
|
"PSD 剪切蒙版目标资源",
|
|
true
|
|
);
|
|
} else if (layer.forceClipMask) {
|
|
pushAsset(
|
|
getStoredLayerSourceUrl(layer),
|
|
"PSD 自身剪切蒙版资源",
|
|
true
|
|
);
|
|
}
|
|
} else {
|
|
pushAsset(
|
|
storedLayerSourceUrl,
|
|
"PSD 图层素材",
|
|
shouldUseRenderBaseFallback
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (isAgentPsdTextLayerSlot(layer)) {
|
|
const hasTextAssignment = Object.hasOwn(textAssignments, layer.id);
|
|
const defaultText = String(layer.defaultText || "").trim();
|
|
const text = String(
|
|
hasTextAssignment ? (textAssignments[layer.id] ?? "") : defaultText
|
|
).trim();
|
|
|
|
if (!text) {
|
|
if (!hasTextAssignment) {
|
|
pushAsset(
|
|
storedLayerSourceUrl,
|
|
"PSD 图层素材",
|
|
shouldUseRenderBaseFallback
|
|
);
|
|
}
|
|
} else {
|
|
const font = layer.fontId ? fontMap.get(layer.fontId) : undefined;
|
|
pushAsset(font?.fileUrl, "PSD 字体资源", true);
|
|
}
|
|
}
|
|
}
|
|
|
|
const uniqueAssetMap = new Map<
|
|
string,
|
|
{ url: string; label: string; optional?: boolean }
|
|
>();
|
|
for (const asset of assetCandidates) {
|
|
const previous = uniqueAssetMap.get(asset.url);
|
|
uniqueAssetMap.set(asset.url, {
|
|
...asset,
|
|
label: previous?.label || asset.label,
|
|
optional: previous
|
|
? Boolean(previous.optional) && Boolean(asset.optional)
|
|
: Boolean(asset.optional),
|
|
});
|
|
}
|
|
const uniqueAssets = Array.from(uniqueAssetMap.values());
|
|
|
|
await mapWithConcurrency(uniqueAssets, 6, async (asset) => {
|
|
try {
|
|
await readCachedBuffer(asset.url, asset.label);
|
|
} catch (error) {
|
|
if (!asset.optional) {
|
|
throw error;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
const compositeInputs: Array<{ input: Buffer; left: number; top: number }> =
|
|
[];
|
|
let psdSourceLayerEntryMapPromise: Promise<
|
|
Map<
|
|
string,
|
|
{
|
|
layer: { width: number; height: number };
|
|
composite: () => Promise<Uint8ClampedArray>;
|
|
}
|
|
>
|
|
> | null = null;
|
|
const psdSourceLayerBufferPromiseMap = new Map<string, Promise<Buffer | null>>();
|
|
|
|
async function getPsdSourceLayerEntryMap() {
|
|
if (!shouldHydrateLayerSourcesFromPsd) {
|
|
return null;
|
|
}
|
|
|
|
if (!psdSourceLayerEntryMapPromise) {
|
|
psdSourceLayerEntryMapPromise = loadPsdTemplateSource({
|
|
fileUrl: template.fileUrl,
|
|
filePath: template.filePath,
|
|
}).then((source) => {
|
|
const entryMap = new Map<
|
|
string,
|
|
{
|
|
layer: { width: number; height: number };
|
|
composite: () => Promise<Uint8ClampedArray>;
|
|
}
|
|
>();
|
|
for (const entry of source.layers) {
|
|
entryMap.set(entry.layer.layerId, {
|
|
layer: {
|
|
width: entry.layer.width,
|
|
height: entry.layer.height,
|
|
},
|
|
composite: entry.composite,
|
|
});
|
|
}
|
|
return entryMap;
|
|
});
|
|
}
|
|
|
|
return psdSourceLayerEntryMapPromise;
|
|
}
|
|
|
|
async function readPsdSourceLayerBuffer(
|
|
layer: AgentPsdTemplate["layers"][number]
|
|
) {
|
|
if (!shouldHydrateLayerSourcesFromPsd) {
|
|
return null;
|
|
}
|
|
|
|
if (!psdSourceLayerBufferPromiseMap.has(layer.layerId)) {
|
|
psdSourceLayerBufferPromiseMap.set(
|
|
layer.layerId,
|
|
(async () => {
|
|
try {
|
|
const entryMap = await getPsdSourceLayerEntryMap();
|
|
const entry = entryMap?.get(layer.layerId);
|
|
if (!entry) {
|
|
return null;
|
|
}
|
|
|
|
const rgba = await entry.composite();
|
|
return sharp(Buffer.from(rgba), {
|
|
raw: {
|
|
width: Math.max(1, Math.round(entry.layer.width || layer.width)),
|
|
height: Math.max(1, Math.round(entry.layer.height || layer.height)),
|
|
channels: 4,
|
|
},
|
|
})
|
|
.png(PSD_RENDER_PNG_OPTIONS)
|
|
.toBuffer();
|
|
} catch (error) {
|
|
console.warn("[PSD Template] failed to hydrate layer from PSD source:", {
|
|
templateId: template.id,
|
|
layerId: layer.layerId,
|
|
error,
|
|
});
|
|
return null;
|
|
}
|
|
})()
|
|
);
|
|
}
|
|
|
|
return psdSourceLayerBufferPromiseMap.get(layer.layerId)!;
|
|
}
|
|
|
|
async function readReusableLayerBuffer(
|
|
layer: AgentPsdTemplate["layers"][number]
|
|
) {
|
|
const sourceUrl = getStoredLayerSourceUrl(layer);
|
|
if (sourceUrl) {
|
|
try {
|
|
return await readCachedBuffer(sourceUrl, "PSD 图层素材");
|
|
} catch (error) {
|
|
const hydratedBuffer = await readPsdSourceLayerBuffer(layer);
|
|
if (hydratedBuffer) {
|
|
return hydratedBuffer;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
return readPsdSourceLayerBuffer(layer);
|
|
}
|
|
|
|
async function appendBufferOverlay(params: {
|
|
input: Buffer;
|
|
left: number;
|
|
top: number;
|
|
width: number;
|
|
height: number;
|
|
}) {
|
|
const fittedOverlay = await fitOverlayBufferIntoCanvas({
|
|
input: params.input,
|
|
left: params.left,
|
|
top: params.top,
|
|
width: params.width,
|
|
height: params.height,
|
|
canvasWidth,
|
|
canvasHeight,
|
|
});
|
|
if (!fittedOverlay) {
|
|
return false;
|
|
}
|
|
|
|
compositeInputs.push(fittedOverlay);
|
|
return true;
|
|
}
|
|
|
|
async function appendStoredLayer(
|
|
layer: AgentPsdTemplate["layers"][number],
|
|
options: { optional?: boolean } = {}
|
|
) {
|
|
const sourceUrl = getStoredLayerSourceUrl(layer);
|
|
if ((!hasAgentPsdTemplateStoredLayerSource(layer) && !sourceUrl) || !sourceUrl) {
|
|
const hydratedBuffer = await readPsdSourceLayerBuffer(layer);
|
|
if (hydratedBuffer) {
|
|
const layerPositionOffset = getLayerPositionOffset(layer.id);
|
|
return appendBufferOverlay({
|
|
input: hydratedBuffer,
|
|
left: layer.x + layerPositionOffset.offsetX,
|
|
top: layer.y + layerPositionOffset.offsetY,
|
|
width: layer.width,
|
|
height: layer.height,
|
|
});
|
|
}
|
|
|
|
if (options.optional) {
|
|
return false;
|
|
}
|
|
throw new Error(
|
|
`PSD 模板图层“${layer.layerName}”缺少已打包资源,请在后台重新解析该模板`
|
|
);
|
|
}
|
|
|
|
const layerPositionOffset = getLayerPositionOffset(layer.id);
|
|
try {
|
|
const overlayBuffer = await readReusableLayerBuffer(layer);
|
|
if (!overlayBuffer) {
|
|
if (options.optional) {
|
|
return false;
|
|
}
|
|
throw new Error("Missing PSD layer asset");
|
|
}
|
|
const didAppend = await appendBufferOverlay({
|
|
input: overlayBuffer,
|
|
left: layer.x + layerPositionOffset.offsetX,
|
|
top: layer.y + layerPositionOffset.offsetY,
|
|
width: layer.width,
|
|
height: layer.height,
|
|
});
|
|
if (didAppend) {
|
|
return true;
|
|
}
|
|
return false;
|
|
} catch (error) {
|
|
console.warn(
|
|
"[PSD Template] failed to reuse stored layer asset during render:",
|
|
{
|
|
templateId: template.id,
|
|
layerId: layer.layerId,
|
|
error,
|
|
}
|
|
);
|
|
if (options.optional) {
|
|
return false;
|
|
}
|
|
throw new Error(
|
|
`PSD 模板图层“${layer.layerName}”资源加载失败,请稍后重试或在后台重新解析该模板`
|
|
);
|
|
}
|
|
}
|
|
|
|
await prefetchRenderAssets();
|
|
|
|
let didAppendRenderBase = false;
|
|
async function appendRenderBaseOverlay() {
|
|
if (!shouldUseRenderBaseFallback || didAppendRenderBase) {
|
|
return;
|
|
}
|
|
didAppendRenderBase = true;
|
|
const renderBaseBuffer = await readCachedBuffer(renderBaseUrl, "PSD 模板底图");
|
|
await appendBufferOverlay({
|
|
input: renderBaseBuffer,
|
|
left: 0,
|
|
top: 0,
|
|
width: canvasWidth,
|
|
height: canvasHeight,
|
|
});
|
|
}
|
|
|
|
const appendedRenderSegmentIds = new Set<string>();
|
|
async function appendRenderSegmentOverlay(
|
|
segment: (typeof renderSegments)[number] | undefined
|
|
) {
|
|
if (
|
|
!shouldUseRenderSegments ||
|
|
!segment ||
|
|
appendedRenderSegmentIds.has(segment.id)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
appendedRenderSegmentIds.add(segment.id);
|
|
const segmentBuffer = await readCachedBuffer(
|
|
segment.url,
|
|
"PSD 模板快速导出分层"
|
|
);
|
|
await appendBufferOverlay({
|
|
input: segmentBuffer,
|
|
left: 0,
|
|
top: 0,
|
|
width: canvasWidth,
|
|
height: canvasHeight,
|
|
});
|
|
}
|
|
|
|
async function appendRenderSegmentBeforeLayer(layerId: string) {
|
|
await appendRenderSegmentOverlay(renderSegmentByBeforeLayerId.get(layerId));
|
|
}
|
|
|
|
async function appendTopRenderSegments() {
|
|
for (const segment of topRenderSegments) {
|
|
await appendRenderSegmentOverlay(segment);
|
|
}
|
|
}
|
|
|
|
const appendedImageAssignmentLayerIds = new Set<string>();
|
|
async function appendAssignedImageLayer(layer: AgentPsdTemplateImageLayerSlot) {
|
|
if (appendedImageAssignmentLayerIds.has(layer.id)) {
|
|
return;
|
|
}
|
|
|
|
const isDefaultSourceImageLayer = layer.id === defaultSourceImageLayerId;
|
|
const assignmentUrl = String(imageAssignments[layer.id] || "").trim();
|
|
if (!assignmentUrl || hiddenLayerIdSet.has(layer.id)) {
|
|
return;
|
|
}
|
|
|
|
appendedImageAssignmentLayerIds.add(layer.id);
|
|
const sourceBuffer = await readCachedBuffer(assignmentUrl, "PSD 套版素材");
|
|
const clipMaskLayer =
|
|
layer.forceClipMask && layer.clipMaskLayerId
|
|
? layerMap.get(layer.clipMaskLayerId)
|
|
: null;
|
|
if (
|
|
clipMaskLayer &&
|
|
clipMaskLayer.id !== layer.id &&
|
|
hiddenLayerIdSet.has(clipMaskLayer.id)
|
|
) {
|
|
return;
|
|
}
|
|
const renderFrameLayer = clipMaskLayer || layer;
|
|
const maskSourceLayer = clipMaskLayer || (layer.forceClipMask ? layer : null);
|
|
const renderFrameContentBounds =
|
|
clipMaskLayer && isAgentPsdImageLayerSlot(clipMaskLayer)
|
|
? clipMaskLayer.contentBounds
|
|
: layer.contentBounds;
|
|
const sourceImage = sharp(sourceBuffer, { failOn: "none" }).rotate();
|
|
const metadata = await sourceImage.clone().metadata();
|
|
const placementFrame = resolveAgentPsdImagePlacementFrame({
|
|
containerWidth: renderFrameLayer.width,
|
|
containerHeight: renderFrameLayer.height,
|
|
imageWidth: Math.max(1, Number(metadata.width || 1)),
|
|
imageHeight: Math.max(1, Number(metadata.height || 1)),
|
|
replaceMode: layer.replaceMode,
|
|
contentBounds: renderFrameContentBounds,
|
|
placement: imagePlacements[layer.id],
|
|
});
|
|
|
|
const resizedOverlayBuffer = await sourceImage
|
|
.clone()
|
|
.resize({
|
|
width: Math.max(1, Math.round(placementFrame.width)),
|
|
height: Math.max(1, Math.round(placementFrame.height)),
|
|
fit:
|
|
layer.replaceMode === "contain"
|
|
? "contain"
|
|
: layer.replaceMode === "stretch"
|
|
? "fill"
|
|
: "cover",
|
|
background: {
|
|
r: 0,
|
|
g: 0,
|
|
b: 0,
|
|
alpha: 0,
|
|
},
|
|
})
|
|
.png(PSD_RENDER_PNG_OPTIONS)
|
|
.toBuffer();
|
|
|
|
const slotOverlay = await fitOverlayBufferIntoCanvas({
|
|
input: resizedOverlayBuffer,
|
|
left: placementFrame.left,
|
|
top: placementFrame.top,
|
|
width: placementFrame.width,
|
|
height: placementFrame.height,
|
|
canvasWidth: renderFrameLayer.width,
|
|
canvasHeight: renderFrameLayer.height,
|
|
});
|
|
|
|
if (!slotOverlay) {
|
|
return;
|
|
}
|
|
|
|
const layerPositionOffset = getLayerPositionOffset(renderFrameLayer.id);
|
|
const shouldApplyMask = Boolean(layer.maskUrl || maskSourceLayer);
|
|
if (!shouldApplyMask) {
|
|
if (isDefaultSourceImageLayer) {
|
|
await appendRenderBaseOverlay();
|
|
}
|
|
await appendBufferOverlay({
|
|
input: slotOverlay.input,
|
|
left: renderFrameLayer.x + layerPositionOffset.offsetX + slotOverlay.left,
|
|
top: renderFrameLayer.y + layerPositionOffset.offsetY + slotOverlay.top,
|
|
width: slotOverlay.width,
|
|
height: slotOverlay.height,
|
|
});
|
|
return;
|
|
}
|
|
|
|
let overlayBuffer = await sharp({
|
|
create: {
|
|
width: Math.max(1, Math.round(renderFrameLayer.width)),
|
|
height: Math.max(1, Math.round(renderFrameLayer.height)),
|
|
channels: 4,
|
|
background: {
|
|
r: 0,
|
|
g: 0,
|
|
b: 0,
|
|
alpha: 0,
|
|
},
|
|
},
|
|
})
|
|
.composite([slotOverlay])
|
|
.png(PSD_RENDER_PNG_OPTIONS)
|
|
.toBuffer();
|
|
|
|
try {
|
|
const rawMaskBuffer = layer.maskUrl
|
|
? await readCachedBuffer(layer.maskUrl, "PSD 蒙版资源")
|
|
: await readReusableLayerBuffer(maskSourceLayer!);
|
|
if (!rawMaskBuffer) {
|
|
throw new Error("Missing PSD layer mask source");
|
|
}
|
|
const maskBuffer = await sharp(rawMaskBuffer)
|
|
.resize({
|
|
width: Math.max(1, Math.round(renderFrameLayer.width)),
|
|
height: Math.max(1, Math.round(renderFrameLayer.height)),
|
|
fit: "fill",
|
|
background: {
|
|
r: 0,
|
|
g: 0,
|
|
b: 0,
|
|
alpha: 0,
|
|
},
|
|
})
|
|
.png(PSD_RENDER_PNG_OPTIONS)
|
|
.toBuffer();
|
|
overlayBuffer = await sharp(overlayBuffer)
|
|
.composite([
|
|
{
|
|
input: maskBuffer,
|
|
blend: "dest-in",
|
|
},
|
|
])
|
|
.png(PSD_RENDER_PNG_OPTIONS)
|
|
.toBuffer();
|
|
} catch (error) {
|
|
console.warn(
|
|
"[PSD Template] failed to apply layer mask during render:",
|
|
{
|
|
templateId: template.id,
|
|
layerId: layer.layerId,
|
|
error,
|
|
}
|
|
);
|
|
}
|
|
|
|
if (isDefaultSourceImageLayer) {
|
|
await appendRenderBaseOverlay();
|
|
}
|
|
|
|
await appendBufferOverlay({
|
|
input: overlayBuffer,
|
|
left: renderFrameLayer.x + layerPositionOffset.offsetX,
|
|
top: renderFrameLayer.y + layerPositionOffset.offsetY,
|
|
width: renderFrameLayer.width,
|
|
height: renderFrameLayer.height,
|
|
});
|
|
}
|
|
|
|
if (
|
|
shouldUseRenderBaseFallback &&
|
|
(!defaultSourceImageLayerId || hiddenLayerIdSet.has(defaultSourceImageLayerId))
|
|
) {
|
|
await appendRenderBaseOverlay();
|
|
}
|
|
|
|
for (const layer of [...orderedTemplateLayers].reverse()) {
|
|
if (shouldUseRenderSegments && isAgentPsdTemplateLayerUserEditable(layer)) {
|
|
await appendRenderSegmentBeforeLayer(layer.id);
|
|
}
|
|
|
|
if (hiddenLayerIdSet.has(layer.id)) {
|
|
continue;
|
|
}
|
|
|
|
if (!isAgentPsdTemplateLayerUserEditable(layer)) {
|
|
if (shouldUseRenderBaseFallback || shouldUseRenderSegments) {
|
|
continue;
|
|
}
|
|
await appendStoredLayer(layer, { optional: shouldUseRenderBaseFallback });
|
|
continue;
|
|
}
|
|
|
|
if (isAgentPsdImageLayerSlot(layer)) {
|
|
const isDefaultSourceImageLayer = layer.id === defaultSourceImageLayerId;
|
|
const assignmentUrl = String(imageAssignments[layer.id] || "").trim();
|
|
if (!assignmentUrl) {
|
|
await appendStoredLayer(layer, {
|
|
optional: shouldUseRenderBaseFallback,
|
|
});
|
|
if (isDefaultSourceImageLayer) {
|
|
await appendRenderBaseOverlay();
|
|
}
|
|
continue;
|
|
}
|
|
|
|
await appendAssignedImageLayer(layer);
|
|
continue;
|
|
}
|
|
|
|
if (isAgentPsdTextLayerSlot(layer)) {
|
|
const hasTextAssignment = Object.hasOwn(textAssignments, layer.id);
|
|
const defaultText = String(layer.defaultText || "").trim();
|
|
const text = String(
|
|
hasTextAssignment ? (textAssignments[layer.id] ?? "") : defaultText
|
|
).trim();
|
|
const textColor = normalizeHexColor(
|
|
textColorAssignments[layer.id] || layer.textColor,
|
|
layer.textColor
|
|
);
|
|
const layerPositionOffset = getLayerPositionOffset(layer.id);
|
|
const textPositionOffset = normalizeTextPositionOffset(
|
|
textPositionOffsets[layer.id]
|
|
);
|
|
if (!text) {
|
|
if (!hasTextAssignment) {
|
|
await appendStoredLayer(layer, {
|
|
optional: shouldUseRenderBaseFallback,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
const textAlign = resolveAgentPsdTextLayerEffectiveAlign(layer);
|
|
|
|
const font = layer.fontId ? fontMap.get(layer.fontId) : undefined;
|
|
const fontBuffer = font?.fileUrl
|
|
? await readCachedBuffer(font.fileUrl, "PSD 字体资源").catch(
|
|
(error) => {
|
|
console.warn(
|
|
"[PSD Template] failed to load render font, falling back to family stack:",
|
|
{
|
|
templateId: template.id,
|
|
layerId: layer.layerId,
|
|
fontId: font.id,
|
|
error,
|
|
}
|
|
);
|
|
return undefined;
|
|
}
|
|
)
|
|
: undefined;
|
|
const runtimeFont =
|
|
font && fontBuffer
|
|
? await ensurePsdFontAvailableForRender({
|
|
font,
|
|
fontBuffer,
|
|
preferredFamily: layer.fontFamily || font.family,
|
|
}).catch(() => null)
|
|
: null;
|
|
const overlayBuffer = await createTextOverlayBuffer({
|
|
width: layer.width,
|
|
height: layer.height,
|
|
text,
|
|
fontBuffer,
|
|
font,
|
|
fontFamily:
|
|
layer.fontFamily ||
|
|
layer.detectedFontNames?.[0] ||
|
|
layer.detectedFontName ||
|
|
font?.family ||
|
|
"sans-serif",
|
|
renderFontFamilies: runtimeFont
|
|
? [
|
|
runtimeFont.fontPostscriptName,
|
|
runtimeFont.fontFullName,
|
|
runtimeFont.fontFamily,
|
|
].filter((value): value is string =>
|
|
Boolean(String(value || "").trim())
|
|
)
|
|
: [
|
|
...(layer.detectedFontNames || []),
|
|
...(layer.detectedFontName ? [layer.detectedFontName] : []),
|
|
].filter((value): value is string =>
|
|
Boolean(String(value || "").trim())
|
|
),
|
|
renderFontFamily: runtimeFont?.fontFamily,
|
|
useExactFontFace: Boolean(
|
|
runtimeFont?.fontPostscriptName || runtimeFont?.fontFullName
|
|
),
|
|
fontSize: layer.fontSize,
|
|
fontWeight: layer.fontWeight,
|
|
lineHeight: layer.lineHeight,
|
|
letterSpacing: layer.letterSpacing,
|
|
textColor,
|
|
textAlign,
|
|
paddingX: layer.paddingX,
|
|
paddingY: layer.paddingY,
|
|
maxLines: layer.maxLines,
|
|
});
|
|
|
|
if (!overlayBuffer) continue;
|
|
const overlayLayout = resolveTextLayout({
|
|
text,
|
|
width: layer.width,
|
|
height: layer.height,
|
|
fontSize: layer.fontSize,
|
|
lineHeight: layer.lineHeight,
|
|
letterSpacing: layer.letterSpacing,
|
|
paddingX: layer.paddingX,
|
|
paddingY: layer.paddingY,
|
|
maxLines: layer.maxLines,
|
|
autoFit: false,
|
|
preferNoWrap: true,
|
|
});
|
|
const overlayFrame = resolveTextOverlayFrame({
|
|
layerWidth: layer.width,
|
|
layerHeight: layer.height,
|
|
contentWidth: overlayLayout.contentWidth,
|
|
contentHeight: overlayLayout.contentHeight,
|
|
textAlign,
|
|
topOffset: -overlayLayout.visualOffsetY,
|
|
});
|
|
|
|
const didAppend = await appendBufferOverlay({
|
|
input: overlayBuffer,
|
|
left:
|
|
layer.x +
|
|
layerPositionOffset.offsetX +
|
|
textPositionOffset.offsetX +
|
|
overlayFrame.leftOffset,
|
|
top:
|
|
layer.y +
|
|
layerPositionOffset.offsetY +
|
|
textPositionOffset.offsetY +
|
|
overlayFrame.topOffset,
|
|
width: overlayFrame.width,
|
|
height: overlayFrame.height,
|
|
});
|
|
if (!didAppend) continue;
|
|
}
|
|
}
|
|
|
|
await appendTopRenderSegments();
|
|
|
|
const transparentCanvas = await sharp({
|
|
create: {
|
|
width: canvasWidth,
|
|
height: canvasHeight,
|
|
channels: 4,
|
|
background: {
|
|
r: 0,
|
|
g: 0,
|
|
b: 0,
|
|
alpha: 0,
|
|
},
|
|
},
|
|
})
|
|
.png(PSD_RENDER_PNG_OPTIONS)
|
|
.toBuffer();
|
|
|
|
const resultBuffer = await sharp(transparentCanvas)
|
|
.composite(compositeInputs)
|
|
.png(PSD_RENDER_PNG_OPTIONS)
|
|
.toBuffer();
|
|
|
|
const uploaded = await uploadFile(
|
|
`agent-psd-render-${sanitizeFileSegment(template.id, "template")}-${Date.now()}.png`,
|
|
resultBuffer.buffer.slice(
|
|
resultBuffer.byteOffset,
|
|
resultBuffer.byteOffset + resultBuffer.byteLength
|
|
) as ArrayBuffer
|
|
);
|
|
|
|
return uploaded;
|
|
}
|