fix: stabilize psd template export rendering
This commit is contained in:
parent
b6e1a6c657
commit
02f13a045a
@ -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
|
||||
|
||||
115
lib/agents/psd-remote-asset.ts
Normal file
115
lib/agents/psd-remote-asset.ts
Normal 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})`);
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user