ai-chatbot/lib/agents/psd-template-renderer.ts
Your Name 01748fe501
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: harden psd text workflow compatibility
2026-04-02 00:08:53 +08:00

652 lines
19 KiB
TypeScript

import "server-only";
import { uploadFile } from "@/lib/storage/service";
import { loadPsdTemplateSourceFromUrl } from "@/lib/agents/psd-template-parser";
import {
isAgentPsdImageLayerSlot,
isAgentPsdTextLayerSlot,
type AgentPsdTemplate,
} 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";
async function getSharp() {
const sharp = await import("sharp");
return sharp.default;
}
async function fetchRemoteBuffer(url: string) {
const response = await fetch(url, {
cache: "no-store",
signal: AbortSignal.timeout(25_000),
});
if (!response.ok) {
throw new Error(`Resource download failed: ${response.status}`);
}
return Buffer.from(await response.arrayBuffer());
}
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 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 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;
}
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().toBuffer();
}
export async function renderAgentPsdTemplate(params: {
template: AgentPsdTemplate;
imageAssignments: Record<string, string>;
imagePlacements: Record<string, ReturnType<typeof normalizeAgentPsdImagePlacement>>;
textAssignments: Record<string, string>;
textColorAssignments?: Record<string, string>;
fonts: AgentPsdFont[];
}) {
const {
template,
imageAssignments,
imagePlacements,
textAssignments,
textColorAssignments = {},
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]));
let sourceLayerMapPromise:
| Promise<Map<string, Awaited<ReturnType<typeof loadPsdTemplateSourceFromUrl>>["layers"][number]>>
| null = null;
async function readCachedBuffer(url: string) {
const normalizedUrl = String(url || "").trim();
if (!normalizedUrl) {
throw new Error("Missing remote asset URL");
}
if (!remoteBufferCache.has(normalizedUrl)) {
remoteBufferCache.set(normalizedUrl, await fetchRemoteBuffer(normalizedUrl));
}
return remoteBufferCache.get(normalizedUrl)!;
}
async function getSourceLayerMap() {
if (!sourceLayerMapPromise) {
sourceLayerMapPromise = loadPsdTemplateSourceFromUrl(template.fileUrl).then(
(source) => new Map(source.layers.map((entry) => [entry.layer.layerId, entry]))
);
}
return sourceLayerMapPromise;
}
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 appendOriginalLayer(layerId: string) {
const sourceLayerMap = await getSourceLayerMap();
const sourceLayer = sourceLayerMap.get(layerId);
if (!sourceLayer) return;
const rgba = await sourceLayer.composite();
const overlayBuffer = await rgbaToPngBuffer(
rgba,
sourceLayer.layer.width,
sourceLayer.layer.height
);
await appendBufferOverlay({
input: overlayBuffer,
left: sourceLayer.layer.x,
top: sourceLayer.layer.y,
width: sourceLayer.layer.width,
height: sourceLayer.layer.height,
});
}
async function appendStoredLayer(layer: AgentPsdTemplate["layers"][number]) {
const sourceUrl = String(layer.sourceUrl || "").trim();
if (!sourceUrl) {
await appendOriginalLayer(layer.layerId);
return;
}
try {
const overlayBuffer = await readCachedBuffer(sourceUrl);
const didAppend = await appendBufferOverlay({
input: overlayBuffer,
left: layer.x,
top: layer.y,
width: layer.width,
height: layer.height,
});
if (didAppend) {
return;
}
} catch (error) {
console.warn("[PSD Template] failed to reuse stored layer asset during render:", {
templateId: template.id,
layerId: layer.layerId,
error,
});
}
await appendOriginalLayer(layer.layerId);
}
for (const layer of [...template.layers].reverse()) {
if (!layer.isEditable) {
await appendStoredLayer(layer);
continue;
}
if (isAgentPsdImageLayerSlot(layer)) {
const assignmentUrl = String(imageAssignments[layer.id] || "").trim();
if (!assignmentUrl) {
await appendStoredLayer(layer);
continue;
}
const sourceBuffer = await readCachedBuffer(assignmentUrl);
const metadata = await sharp(sourceBuffer).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,
placement: imagePlacements[layer.id],
});
let overlayBuffer = await sharp(sourceBuffer)
.rotate()
.resize({
width: Math.max(1, Math.round(placementFrame.width)),
height: Math.max(1, Math.round(placementFrame.height)),
fit: "fill",
background: {
r: 0,
g: 0,
b: 0,
alpha: 0,
},
})
.png()
.toBuffer();
if (layer.maskUrl) {
try {
const maskBuffer = await readCachedBuffer(layer.maskUrl);
overlayBuffer = await sharp(overlayBuffer)
.composite([
{
input: maskBuffer,
blend: "dest-in",
},
])
.png()
.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 + placementFrame.left,
top: layer.y + placementFrame.top,
width: placementFrame.width,
height: placementFrame.height,
});
if (!didAppend) continue;
continue;
}
if (isAgentPsdTextLayerSlot(layer)) {
const text = String(textAssignments[layer.id] || layer.defaultText || "").trim();
const defaultText = String(layer.defaultText || "").trim();
const textColor = normalizeHexColor(
textColorAssignments[layer.id] || layer.textColor,
layer.textColor
);
const hasColorChange = normalizeHexColor(textColor, layer.textColor) !== normalizeHexColor(layer.textColor);
if (!text || (text === defaultText && !hasColorChange)) {
await appendStoredLayer(layer);
continue;
}
const font = layer.fontId ? fontMap.get(layer.fontId) : undefined;
const fontBuffer = font?.fileUrl
? await readCachedBuffer(font.fileUrl).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: layer.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: layer.textAlign,
topOffset: -overlayLayout.visualOffsetY,
});
const didAppend = await appendBufferOverlay({
input: overlayBuffer,
left: layer.x + overlayFrame.leftOffset,
top: layer.y + 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()
.toBuffer();
const resultBuffer = await sharp(transparentCanvas)
.composite(compositeInputs)
.png()
.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;
}