ai-chatbot/lib/agents/psd-template-renderer.ts
Your Name 667ab76e3d
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: preserve psd render base in exports
2026-05-22 11:25:00 +08:00

1035 lines
29 KiB
TypeScript

import "server-only";
import { uploadFile } from "@/lib/storage/service";
import { fetchRemoteAssetBuffer } from "@/lib/agents/psd-remote-asset";
import {
hasAgentPsdTemplateStoredLayerSource,
isAgentPsdImageLayerSlot,
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 =>
isAgentPsdTemplateLayerUserEditable(layer) &&
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
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 defaultSourceImageLayerId =
getDefaultSourceImageLayer(orderedTemplateLayers)?.id || "";
const hasHiddenStaticLayer = orderedTemplateLayers.some(
(layer) =>
!isAgentPsdTemplateLayerUserEditable(layer) &&
hiddenLayerIdSet.has(layer.id)
);
const shouldUseRenderBaseFallback =
hasDedicatedRenderBase &&
!hasHiddenStaticLayer;
function getLayerPositionOffset(layerId: string) {
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 模板底图");
}
for (const layer of orderedTemplateLayers) {
if (hiddenLayerIdSet.has(layer.id)) {
continue;
}
const storedLayerSourceUrl = getStoredLayerSourceUrl(layer);
if (!isAgentPsdTemplateLayerUserEditable(layer)) {
if (shouldUseRenderBaseFallback) {
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);
} 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 }> =
[];
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
) {
if (options.optional) {
return false;
}
throw new Error(
`PSD 模板图层“${layer.layerName}”缺少已打包资源,请在后台重新解析该模板`
);
}
const layerPositionOffset = getLayerPositionOffset(layer.id);
try {
const overlayBuffer = await readCachedBuffer(sourceUrl, "PSD 图层素材");
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,
});
}
if (
shouldUseRenderBaseFallback &&
(!defaultSourceImageLayerId || hiddenLayerIdSet.has(defaultSourceImageLayerId))
) {
await appendRenderBaseOverlay();
}
for (const layer of [...orderedTemplateLayers].reverse()) {
if (hiddenLayerIdSet.has(layer.id)) {
continue;
}
if (!isAgentPsdTemplateLayerUserEditable(layer)) {
if (shouldUseRenderBaseFallback) {
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;
}
const sourceBuffer = await readCachedBuffer(
assignmentUrl,
"PSD 套版素材"
);
const sourceImage = sharp(sourceBuffer, { failOn: "none" }).rotate();
const metadata = await sourceImage.clone().metadata();
const placementFrame = resolveAgentPsdImagePlacementFrame({
containerWidth: layer.width,
containerHeight: layer.height,
imageWidth: Math.max(1, Number(metadata.width || 1)),
imageHeight: Math.max(1, Number(metadata.height || 1)),
replaceMode: layer.replaceMode,
contentBounds: layer.contentBounds,
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: layer.width,
canvasHeight: layer.height,
});
if (!slotOverlay) {
continue;
}
const layerPositionOffset = getLayerPositionOffset(layer.id);
if (!layer.maskUrl) {
const didAppend = await appendBufferOverlay({
input: slotOverlay.input,
left:
layer.x +
layerPositionOffset.offsetX +
slotOverlay.left,
top:
layer.y +
layerPositionOffset.offsetY +
slotOverlay.top,
width: slotOverlay.width,
height: slotOverlay.height,
});
if (isDefaultSourceImageLayer) {
await appendRenderBaseOverlay();
}
if (!didAppend) continue;
continue;
}
let overlayBuffer = await sharp({
create: {
width: Math.max(1, Math.round(layer.width)),
height: Math.max(1, Math.round(layer.height)),
channels: 4,
background: {
r: 0,
g: 0,
b: 0,
alpha: 0,
},
},
})
.composite([slotOverlay])
.png(PSD_RENDER_PNG_OPTIONS)
.toBuffer();
if (layer.maskUrl) {
try {
const maskBuffer = await sharp(
await readCachedBuffer(layer.maskUrl, "PSD 蒙版资源")
)
.resize({
width: Math.max(1, Math.round(layer.width)),
height: Math.max(1, Math.round(layer.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,
}
);
}
}
const didAppend = await appendBufferOverlay({
input: overlayBuffer,
left: layer.x + layerPositionOffset.offsetX,
top: layer.y + layerPositionOffset.offsetY,
width: layer.width,
height: layer.height,
});
if (isDefaultSourceImageLayer) {
await appendRenderBaseOverlay();
}
if (!didAppend) continue;
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;
}
}
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;
}