471 lines
13 KiB
TypeScript
471 lines
13 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 { 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 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(fontFamily: string, text: string) {
|
|
const familyTokens = new Set<string>();
|
|
const primaryFamily = String(fontFamily || "").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;
|
|
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 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,
|
|
});
|
|
const adjustedFontSize = resolvedLayout.fontSize;
|
|
const lineHeightPx = resolvedLayout.lineHeightPx;
|
|
const lines = resolvedLayout.lines;
|
|
|
|
if (lines.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const escapedFamily = escapeXml(fontFamily || "sans-serif");
|
|
const fontFamilyStack = buildSvgFontFamilyStack(fontFamily, normalizedText);
|
|
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;
|
|
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="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
<defs>
|
|
<style>
|
|
${fontFaceCss}
|
|
.text { font-family: ${fontFamilyStack}; font-size: ${adjustedFontSize}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 source = await loadPsdTemplateSourceFromUrl(template.fileUrl);
|
|
const canvasWidth = Math.max(1, Math.round(Number(source.width || template.width || 1)));
|
|
const canvasHeight = Math.max(1, Math.round(Number(source.height || template.height || 1)));
|
|
const remoteBufferCache = new Map<string, Buffer>();
|
|
const fontMap = new Map(fonts.map((font) => [font.id, font]));
|
|
const sourceLayerMap = new Map(source.layers.map((entry) => [entry.layer.layerId, entry]));
|
|
|
|
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 }> = [];
|
|
|
|
async function appendOriginalLayer(layerId: string) {
|
|
const sourceLayer = sourceLayerMap.get(layerId);
|
|
if (!sourceLayer) return;
|
|
const rgba = await sourceLayer.composite();
|
|
const overlayBuffer = await rgbaToPngBuffer(
|
|
rgba,
|
|
sourceLayer.layer.width,
|
|
sourceLayer.layer.height
|
|
);
|
|
const fittedOverlay = await fitOverlayBufferIntoCanvas({
|
|
input: overlayBuffer,
|
|
left: sourceLayer.layer.x,
|
|
top: sourceLayer.layer.y,
|
|
width: sourceLayer.layer.width,
|
|
height: sourceLayer.layer.height,
|
|
canvasWidth,
|
|
canvasHeight,
|
|
});
|
|
if (!fittedOverlay) return;
|
|
compositeInputs.push(fittedOverlay);
|
|
}
|
|
|
|
for (const layer of [...template.layers].reverse()) {
|
|
if (!layer.isEditable) {
|
|
await appendOriginalLayer(layer.layerId);
|
|
continue;
|
|
}
|
|
|
|
if (isAgentPsdImageLayerSlot(layer)) {
|
|
const assignmentUrl = String(imageAssignments[layer.id] || "").trim();
|
|
if (!assignmentUrl) {
|
|
await appendOriginalLayer(layer.layerId);
|
|
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();
|
|
}
|
|
|
|
const fittedOverlay = await fitOverlayBufferIntoCanvas({
|
|
input: overlayBuffer,
|
|
left: layer.x,
|
|
top: layer.y,
|
|
width: layer.width,
|
|
height: layer.height,
|
|
canvasWidth,
|
|
canvasHeight,
|
|
});
|
|
if (!fittedOverlay) continue;
|
|
|
|
compositeInputs.push(fittedOverlay);
|
|
continue;
|
|
}
|
|
|
|
if (isAgentPsdTextLayerSlot(layer)) {
|
|
const text = String(textAssignments[layer.id] || layer.defaultText || "").trim();
|
|
const defaultText = String(layer.defaultText || "").trim();
|
|
if (!text || text === defaultText) {
|
|
await appendOriginalLayer(layer.layerId);
|
|
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;
|
|
|
|
const fittedOverlay = await fitOverlayBufferIntoCanvas({
|
|
input: overlayBuffer,
|
|
left: layer.x,
|
|
top: layer.y,
|
|
width: layer.width,
|
|
height: layer.height,
|
|
canvasWidth,
|
|
canvasHeight,
|
|
});
|
|
if (!fittedOverlay) continue;
|
|
|
|
compositeInputs.push(fittedOverlay);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|