ai-chatbot/lib/agents/psd-template-renderer.ts
Your Name 17dcfcd55d
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add PSD template font rendering workflow
2026-03-27 11:01:13 +08:00

343 lines
9.3 KiB
TypeScript

import "server-only";
import { uploadFile } from "@/lib/storage/service";
import {
isAgentPsdImageLayerSlot,
isAgentPsdTextLayerSlot,
type AgentPsdTemplate,
} from "@/lib/agents/psd-template-config";
import type { AgentPsdFont } from "@/lib/agents/psd-font-config";
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());
}
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 escapeXml(value: string) {
return value
.replace(/&/g, "&")
.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";
}
function estimateGlyphWidth(char: string, fontSize: number, letterSpacing: number) {
if (char === " ") return fontSize * 0.36 + letterSpacing;
if (/[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(char)) {
return fontSize + letterSpacing;
}
if (/[A-Z0-9]/.test(char)) return fontSize * 0.66 + letterSpacing;
return fontSize * 0.58 + letterSpacing;
}
function wrapTextByWidth(params: {
text: string;
maxWidth: number;
fontSize: number;
letterSpacing: number;
maxLines: number;
}) {
const { text, maxWidth, fontSize, letterSpacing, maxLines } = params;
const paragraphs = String(text || "")
.replace(/\r\n/g, "\n")
.split("\n");
const lines: string[] = [];
function pushLine(line: string) {
if (lines.length < maxLines) {
lines.push(line);
}
}
for (const paragraph of paragraphs) {
if (lines.length >= maxLines) break;
if (!paragraph) {
pushLine("");
continue;
}
let currentLine = "";
let currentWidth = 0;
for (const char of paragraph) {
const charWidth = estimateGlyphWidth(char, fontSize, letterSpacing);
if (currentLine && currentWidth + charWidth > maxWidth) {
pushLine(currentLine);
currentLine = char;
currentWidth = charWidth;
if (lines.length >= maxLines) break;
} else {
currentLine += char;
currentWidth += charWidth;
}
}
if (lines.length >= maxLines) break;
if (currentLine || paragraph.length === 0) {
pushLine(currentLine);
}
}
return lines.slice(0, maxLines);
}
async function createTextOverlayBuffer(params: {
width: number;
height: number;
text: string;
fontBuffer?: Buffer;
font: AgentPsdFont | undefined;
fontFamily: string;
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,
fontSize,
fontWeight,
lineHeight,
letterSpacing,
textColor,
textAlign,
paddingX,
paddingY,
maxLines,
} = params;
const availableWidth = Math.max(8, width - paddingX * 2);
const lineHeightPx = Math.max(fontSize, fontSize * lineHeight);
const heightLimitedMaxLines = Math.max(
1,
Math.floor((height - paddingY * 2 + fontSize * 0.2) / lineHeightPx)
);
const resolvedMaxLines = Math.max(1, Math.min(maxLines, heightLimitedMaxLines));
const lines = wrapTextByWidth({
text,
maxWidth: availableWidth,
fontSize,
letterSpacing,
maxLines: resolvedMaxLines,
});
if (lines.length === 0) {
return null;
}
const escapedFamily = escapeXml(fontFamily || "sans-serif");
const fontFaceCss = fontBuffer
? `@font-face { font-family: "${escapedFamily}"; src: url("data:${getFontMimeType(font, fontFamily)};base64,${fontBuffer.toString("base64")}"); }`
: "";
const clipId = `clip-${Math.random().toString(36).slice(2, 10)}`;
const anchor = textAlign === "center" ? "middle" : textAlign === "right" ? "end" : "start";
const x =
textAlign === "center"
? width / 2
: textAlign === "right"
? width - paddingX
: paddingX;
const tspans = lines
.map((line, index) => {
const y = paddingY + fontSize + index * lineHeightPx;
return `<tspan x="${x}" y="${y}">${escapeXml(line)}</tspan>`;
})
.join("");
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<defs>
<style>
${fontFaceCss}
.text { font-family: "${escapedFamily}", sans-serif; font-size: ${fontSize}px; font-weight: ${fontWeight}; fill: ${textColor}; letter-spacing: ${letterSpacing}px; }
</style>
<clipPath id="${clipId}">
<rect x="0" y="0" width="${width}" height="${height}" />
</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>;
textAssignments: Record<string, string>;
fonts: AgentPsdFont[];
}) {
const { template, imageAssignments, textAssignments, fonts } = params;
const sharp = await getSharp();
const baseBuffer = await fetchRemoteBuffer(template.renderBaseUrl || template.previewUrl);
const remoteBufferCache = new Map<string, Buffer>();
const fontMap = new Map(fonts.map((font) => [font.id, font]));
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)!;
}
const compositeInputs: Array<{ input: Buffer; left: number; top: number }> = [];
for (const layer of template.layers) {
if (!layer.isEditable) continue;
if (isAgentPsdImageLayerSlot(layer)) {
const assignmentUrl = String(imageAssignments[layer.id] || "").trim();
if (!assignmentUrl) continue;
const sourceBuffer = await readCachedBuffer(assignmentUrl);
const fit =
layer.replaceMode === "stretch"
? "fill"
: layer.replaceMode === "contain"
? "contain"
: "cover";
let overlayBuffer = await sharp(sourceBuffer)
.rotate()
.resize({
width: layer.width,
height: layer.height,
fit,
background: {
r: 0,
g: 0,
b: 0,
alpha: 0,
},
})
.png()
.toBuffer();
if (layer.maskUrl) {
const maskBuffer = await readCachedBuffer(layer.maskUrl);
overlayBuffer = await sharp(overlayBuffer)
.composite([
{
input: maskBuffer,
blend: "dest-in",
},
])
.png()
.toBuffer();
}
compositeInputs.push({
input: overlayBuffer,
left: layer.x,
top: layer.y,
});
continue;
}
if (isAgentPsdTextLayerSlot(layer)) {
const text = String(textAssignments[layer.id] || layer.defaultText || "").trim();
if (!text) continue;
const font = layer.fontId ? fontMap.get(layer.fontId) : undefined;
const fontBuffer = font?.fileUrl ? await readCachedBuffer(font.fileUrl) : undefined;
const overlayBuffer = await createTextOverlayBuffer({
width: layer.width,
height: layer.height,
text,
fontBuffer,
font,
fontFamily: layer.fontFamily || font?.family || "sans-serif",
fontSize: layer.fontSize,
fontWeight: layer.fontWeight,
lineHeight: layer.lineHeight,
letterSpacing: layer.letterSpacing,
textColor: layer.textColor,
textAlign: layer.textAlign,
paddingX: layer.paddingX,
paddingY: layer.paddingY,
maxLines: layer.maxLines,
});
if (!overlayBuffer) continue;
compositeInputs.push({
input: overlayBuffer,
left: layer.x,
top: layer.y,
});
}
}
const resultBuffer = await sharp(baseBuffer)
.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;
}