fix: stabilize psd template export rendering
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-10 14:28:19 +08:00
parent b6e1a6c657
commit 02f13a045a
4 changed files with 199 additions and 29 deletions

View File

@ -12,6 +12,7 @@ import {
} from "@/lib/agents/psd-template-config";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import { normalizeAgentPsdImagePlacement } from "@/lib/agents/psd-image-placement";
import { describeRemoteAssetLocation } from "@/lib/agents/psd-remote-asset";
import { repairAgentPsdTemplateAssetsIfNeeded } from "@/lib/agents/psd-template-repair";
import { renderAgentPsdTemplate } from "@/lib/agents/psd-template-renderer";
import { resolveAgentPsdTemplateTextStyles } from "@/lib/agents/psd-template-style-resolver";
@ -166,7 +167,14 @@ export async function POST(request: NextRequest) {
},
} satisfies AgentPsdTemplateRenderResponse);
} catch (error) {
console.error("[PSD Templates] render failed:", error);
console.error("[PSD Templates] render failed:", {
templateId: body.templateId,
sourceImage: body.sourceImageUrl
? describeRemoteAssetLocation(body.sourceImageUrl)
: null,
assignedImageCount: Object.keys(body.imageAssignments || {}).length,
error,
});
const message =
error instanceof Error && String(error.message || "").trim()
? error.message

View File

@ -0,0 +1,115 @@
import "server-only";
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_RETRY_COUNT = 3;
const RETRY_DELAY_MS = 1_000;
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function describeRemoteAssetLocation(url: string) {
const normalizedUrl = String(url || "").trim();
if (!normalizedUrl) {
return "未知地址";
}
try {
const parsed = new URL(normalizedUrl);
return parsed.host || parsed.hostname || normalizedUrl;
} catch {
return normalizedUrl.length > 80 ? `${normalizedUrl.slice(0, 77)}...` : normalizedUrl;
}
}
function isRetryableStatus(status: number) {
return status === 408 || status === 425 || status === 429 || status >= 500;
}
function isRetryableFetchError(error: unknown) {
if (!(error instanceof Error)) {
return true;
}
const message = `${error.name} ${error.message}`.toLowerCase();
return (
error.name === "AbortError" ||
message.includes("fetch failed") ||
message.includes("timeout") ||
message.includes("timed out") ||
message.includes("econnreset") ||
message.includes("socket") ||
message.includes("network") ||
message.includes("terminated") ||
message.includes("und_err")
);
}
export async function fetchRemoteAssetBuffer(
url: string,
options?: {
label?: string;
timeoutMs?: number;
retries?: number;
}
) {
const normalizedUrl = String(url || "").trim();
const label = String(options?.label || "远程资源").trim() || "远程资源";
if (!normalizedUrl) {
throw new Error(`${label}地址为空`);
}
const attempts = Math.max(1, Math.round(Number(options?.retries || DEFAULT_RETRY_COUNT)));
const timeoutMs = Math.max(5_000, Math.round(Number(options?.timeoutMs || DEFAULT_TIMEOUT_MS)));
const location = describeRemoteAssetLocation(normalizedUrl);
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const response = await fetch(normalizedUrl, {
cache: "no-store",
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) {
const statusError = new Error(`${label}下载失败(${response.status})`);
if (attempt < attempts && isRetryableStatus(response.status)) {
console.warn("[PSD Remote Asset] transient status, retrying download:", {
label,
location,
status: response.status,
attempt,
attempts,
});
await sleep(RETRY_DELAY_MS * attempt);
continue;
}
throw statusError;
}
return Buffer.from(await response.arrayBuffer());
} catch (error) {
if (attempt < attempts && isRetryableFetchError(error)) {
console.warn("[PSD Remote Asset] transient fetch error, retrying download:", {
label,
location,
attempt,
attempts,
error,
});
await sleep(RETRY_DELAY_MS * attempt);
continue;
}
console.error("[PSD Remote Asset] failed to download asset:", {
label,
location,
attemptsTried: attempt,
error,
});
throw new Error(`${label}下载失败,请稍后重试或检查资源是否可访问(${location})`);
}
}
throw new Error(`${label}下载失败,请稍后重试或检查资源是否可访问(${location})`);
}

View File

@ -2,6 +2,7 @@ import "server-only";
import { uploadFile } from "@/lib/storage/service";
import { normalizeDetectedFontNames } from "@/lib/agents/psd-font-matching";
import { fetchRemoteAssetBuffer } from "@/lib/agents/psd-remote-asset";
import { analyzeTextLayerPixels, normalizeHexColor } from "@/lib/agents/psd-text-layout";
import {
isAgentPsdImageLayerSlot,
@ -397,16 +398,15 @@ async function parsePsdBuffer(buffer: ArrayBuffer) {
}
async function fetchRemoteBuffer(url: string) {
const response = await fetch(url, {
cache: "no-store",
signal: AbortSignal.timeout(20_000),
const buffer = await fetchRemoteAssetBuffer(url, {
label: "PSD 模板源文件",
timeoutMs: 40_000,
retries: 3,
});
if (!response.ok) {
throw new Error(`PSD file download failed: ${response.status}`);
}
return await response.arrayBuffer();
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
}
export async function parsePsdTemplateFile(

View File

@ -1,6 +1,7 @@
import "server-only";
import { uploadFile } from "@/lib/storage/service";
import { fetchRemoteAssetBuffer } from "@/lib/agents/psd-remote-asset";
import { loadPsdTemplateSourceFromUrl } from "@/lib/agents/psd-template-parser";
import {
isAgentPsdImageLayerSlot,
@ -17,19 +18,6 @@ async function getSharp() {
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), {
@ -119,6 +107,15 @@ function sanitizeFileSegment(value: string, fallback: string) {
return normalized || fallback;
}
function hasDedicatedRenderBase(template: AgentPsdTemplate) {
const renderBaseUrl = String(template.renderBaseUrl || "").trim();
const previewUrl = String(template.previewUrl || "").trim();
return Boolean(String(template.renderBasePath || "").trim()) || (
Boolean(renderBaseUrl) &&
renderBaseUrl !== previewUrl
);
}
function resolveTextOverlayFrame(params: {
layerWidth: number;
layerHeight: number;
@ -343,18 +340,26 @@ export async function renderAgentPsdTemplate(params: {
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 shouldUseDedicatedRenderBase = hasDedicatedRenderBase(template);
let sourceLayerMapPromise:
| Promise<Map<string, Awaited<ReturnType<typeof loadPsdTemplateSourceFromUrl>>["layers"][number]>>
| null = null;
async function readCachedBuffer(url: string) {
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 fetchRemoteBuffer(normalizedUrl));
remoteBufferCache.set(
normalizedUrl,
await fetchRemoteAssetBuffer(normalizedUrl, {
label,
timeoutMs: 35_000,
retries: 3,
})
);
}
return remoteBufferCache.get(normalizedUrl)!;
@ -371,6 +376,7 @@ export async function renderAgentPsdTemplate(params: {
}
const compositeInputs: Array<{ input: Buffer; left: number; top: number }> = [];
let didAppendDedicatedRenderBase = false;
async function appendBufferOverlay(params: {
input: Buffer;
@ -423,7 +429,7 @@ export async function renderAgentPsdTemplate(params: {
}
try {
const overlayBuffer = await readCachedBuffer(sourceUrl);
const overlayBuffer = await readCachedBuffer(sourceUrl, "PSD 图层素材");
const didAppend = await appendBufferOverlay({
input: overlayBuffer,
left: layer.x,
@ -445,8 +451,47 @@ export async function renderAgentPsdTemplate(params: {
await appendOriginalLayer(layer.layerId);
}
if (shouldUseDedicatedRenderBase && template.renderBaseUrl) {
try {
const renderBaseBuffer = await readCachedBuffer(
template.renderBaseUrl,
"PSD 模板底图"
);
const normalizedRenderBaseBuffer = await sharp(renderBaseBuffer)
.rotate()
.resize({
width: canvasWidth,
height: canvasHeight,
fit: "fill",
background: {
r: 0,
g: 0,
b: 0,
alpha: 0,
},
})
.png()
.toBuffer();
didAppendDedicatedRenderBase = await appendBufferOverlay({
input: normalizedRenderBaseBuffer,
left: 0,
top: 0,
width: canvasWidth,
height: canvasHeight,
});
} catch (error) {
console.warn("[PSD Template] failed to load dedicated render base during render:", {
templateId: template.id,
error,
});
}
}
for (const layer of [...template.layers].reverse()) {
if (!layer.isEditable) {
if (didAppendDedicatedRenderBase) {
continue;
}
await appendStoredLayer(layer);
continue;
}
@ -458,7 +503,7 @@ export async function renderAgentPsdTemplate(params: {
continue;
}
const sourceBuffer = await readCachedBuffer(assignmentUrl);
const sourceBuffer = await readCachedBuffer(assignmentUrl, "PSD 套版素材");
const normalizedSourceBuffer = await sharp(sourceBuffer).rotate().png().toBuffer();
const metadata = await sharp(normalizedSourceBuffer).metadata();
const placementFrame = resolveAgentPsdImagePlacementFrame({
@ -520,7 +565,9 @@ export async function renderAgentPsdTemplate(params: {
if (layer.maskUrl) {
try {
const maskBuffer = await sharp(await readCachedBuffer(layer.maskUrl))
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)),
@ -578,7 +625,7 @@ export async function renderAgentPsdTemplate(params: {
const font = layer.fontId ? fontMap.get(layer.fontId) : undefined;
const fontBuffer = font?.fileUrl
? await readCachedBuffer(font.fileUrl).catch((error) => {
? 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,