diff --git a/app/api/psd-templates/render/route.ts b/app/api/psd-templates/render/route.ts index cc356d3e9..b6dd6e86a 100644 --- a/app/api/psd-templates/render/route.ts +++ b/app/api/psd-templates/render/route.ts @@ -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 diff --git a/lib/agents/psd-remote-asset.ts b/lib/agents/psd-remote-asset.ts new file mode 100644 index 000000000..3629fac25 --- /dev/null +++ b/lib/agents/psd-remote-asset.ts @@ -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})`); +} diff --git a/lib/agents/psd-template-parser.ts b/lib/agents/psd-template-parser.ts index 98a82e93d..10f1d1a1d 100644 --- a/lib/agents/psd-template-parser.ts +++ b/lib/agents/psd-template-parser.ts @@ -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( diff --git a/lib/agents/psd-template-renderer.ts b/lib/agents/psd-template-renderer.ts index 27e7493c2..0a25bb423 100644 --- a/lib/agents/psd-template-renderer.ts +++ b/lib/agents/psd-template-renderer.ts @@ -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(); const fontMap = new Map(fonts.map((font) => [font.id, font])); + const shouldUseDedicatedRenderBase = hasDedicatedRenderBase(template); let sourceLayerMapPromise: | Promise>["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,