From c9c78f20d4668de72e8711abc178736691e70c56 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 10 Apr 2026 23:45:52 +0800 Subject: [PATCH] fix: stabilize PSD render asset downloads --- .../agents/AgentPsdTemplateEditorDialog.tsx | 194 +++++++++++++++++- 1 file changed, 192 insertions(+), 2 deletions(-) diff --git a/components/agents/AgentPsdTemplateEditorDialog.tsx b/components/agents/AgentPsdTemplateEditorDialog.tsx index b92584225..f1cc4ece6 100644 --- a/components/agents/AgentPsdTemplateEditorDialog.tsx +++ b/components/agents/AgentPsdTemplateEditorDialog.tsx @@ -84,6 +84,7 @@ import { } from "@/lib/agents/psd-text-layout"; import { requestLongTaskResult } from "@/lib/client/long-task"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; +import { buildImageProxyUrl } from "@/lib/image-delivery"; import { getFriendlyRateLimitMessage, getUserFacingRequestErrorMessage } from "@/lib/request-error"; function buildTemplateListRequestUrl(params?: { @@ -242,6 +243,7 @@ const PREVIEW_STAGE_TARGET_HEIGHT = 760; const MIN_IMAGE_SCALE = 0.4; const MAX_IMAGE_SCALE = 3; const UNASSIGNED_BRAND_LABEL = "未分类"; +const IMAGE_EXTENSION_REGEX = /\.([a-z0-9]{2,5})$/i; function buildTemplatePreviewThumbUrl( previewUrl: string, @@ -265,6 +267,76 @@ function buildTemplatePreviewThumbUrl( return `/api/image-proxy?${searchParams.toString()}`; } +function isLikelyStableRenderImageUrl(url: string) { + const normalized = String(url || "").trim(); + if (!normalized) { + return true; + } + + if ( + normalized.startsWith("data:") || + normalized.startsWith("blob:") || + normalized.startsWith("/api/image-proxy") + ) { + return true; + } + + if (typeof window === "undefined") { + return false; + } + + try { + const parsed = new URL(normalized, window.location.origin); + if (parsed.origin === window.location.origin) { + return true; + } + + const hostname = parsed.hostname.toLowerCase(); + return ( + hostname.includes("aliyuncs.com") || + hostname.includes("oss-cn-") || + hostname.includes("cloudfront.net") || + hostname.includes("r2.dev") + ); + } catch { + return false; + } +} + +function inferImageExtension(url: string, contentType: string) { + const normalizedContentType = String(contentType || "").toLowerCase(); + if (normalizedContentType.includes("png")) { + return "png"; + } + if (normalizedContentType.includes("webp")) { + return "webp"; + } + if ( + normalizedContentType.includes("jpeg") || + normalizedContentType.includes("jpg") + ) { + return "jpg"; + } + if (normalizedContentType.includes("gif")) { + return "gif"; + } + if (normalizedContentType.includes("avif")) { + return "avif"; + } + + try { + const pathname = new URL(url, "https://placeholder.invalid").pathname; + const matched = pathname.match(IMAGE_EXTENSION_REGEX); + if (matched?.[1]) { + return matched[1].toLowerCase(); + } + } catch { + // Ignore malformed URLs and fall back to png. + } + + return "png"; +} + function normalizeManagedBrandName(value?: string | null) { const normalized = String(value || "").trim(); if (!normalized || normalized === "未分配品牌") { @@ -1256,6 +1328,7 @@ export function AgentPsdTemplateEditorDialog({ const imagePlacementsRef = useRef(imagePlacements); const textAssignmentsRef = useRef(textAssignments); const textColorAssignmentsRef = useRef(textColorAssignments); + const archivedRenderImageUrlCacheRef = useRef>({}); const selectedLayerIdRef = useRef(selectedLayerId); imageAssignmentsRef.current = imageAssignments; @@ -1264,6 +1337,12 @@ export function AgentPsdTemplateEditorDialog({ textColorAssignmentsRef.current = textColorAssignments; selectedLayerIdRef.current = selectedLayerId; + useEffect(() => { + if (!open) { + archivedRenderImageUrlCacheRef.current = {}; + } + }, [open]); + const selectedTemplateSummary = useMemo( () => templates.find((template) => template.id === selectedTemplateId) || null, @@ -2179,6 +2258,101 @@ export function AgentPsdTemplateEditorDialog({ })); } + async function archiveRenderImageUrl( + rawUrl: string, + label: string + ): Promise { + const normalizedUrl = String(rawUrl || "").trim(); + if (!normalizedUrl || isLikelyStableRenderImageUrl(normalizedUrl)) { + return normalizedUrl; + } + + const cachedUrl = archivedRenderImageUrlCacheRef.current[normalizedUrl]; + if (cachedUrl) { + return cachedUrl; + } + + const fetchCandidates = Array.from( + new Set([normalizedUrl, buildImageProxyUrl(normalizedUrl)].filter(Boolean)) + ); + + let lastError: unknown = null; + for (const candidateUrl of fetchCandidates) { + try { + const response = await fetch(candidateUrl, { + method: "GET", + cache: "no-store", + }); + if (!response.ok) { + throw new Error(`${label}读取失败(${response.status})`); + } + + const blob = await response.blob(); + if (!blob.size) { + throw new Error(`${label}读取结果为空`); + } + + const extension = inferImageExtension( + normalizedUrl, + blob.type || response.headers.get("content-type") || "" + ); + const formData = new FormData(); + formData.set( + "file", + new File([blob], `psd-render-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${extension}`, { + type: blob.type || "image/png", + }) + ); + + const uploadResponse = await fetchUploadWithRetry(formData); + const uploadPayload = await parseActionJsonResponse<{ url?: string }>( + uploadResponse, + `${label}转存失败`, + getFriendlyRateLimitMessage("当前上传过于频繁,请稍后再试") + ); + const uploadedUrl = String(uploadPayload?.url || "").trim(); + if (!uploadedUrl) { + throw new Error(`${label}转存失败`); + } + + archivedRenderImageUrlCacheRef.current[normalizedUrl] = uploadedUrl; + return uploadedUrl; + } catch (error) { + lastError = error; + } + } + + throw new Error( + getUserFacingRequestErrorMessage( + lastError, + `${label}无法转存到本站存储,请重新上传图片后再试` + ) + ); + } + + async function normalizeRenderImageInputs() { + const nextSourceImageUrl = await archiveRenderImageUrl( + sourceImageUrl || "", + "PSD 套版主图" + ); + const nextImageAssignments = { ...effectiveImageAssignments }; + const layerNameById = new Map( + editableImageLayers.map((layer) => [layer.id, layer.layerName]) + ); + + for (const [layerId, layerUrl] of Object.entries(nextImageAssignments)) { + nextImageAssignments[layerId] = await archiveRenderImageUrl( + layerUrl, + `PSD 图层素材「${layerNameById.get(layerId) || layerId}」` + ); + } + + return { + sourceImageUrl: nextSourceImageUrl, + imageAssignments: nextImageAssignments, + }; + } + function handleImageDragStart( layer: AgentPsdTemplateImageLayerSlot, event: ReactPointerEvent @@ -2251,15 +2425,31 @@ export function AgentPsdTemplateEditorDialog({ try { setIsRendering(true); setRenderedResultUrl(""); + const normalizedRenderInputs = await normalizeRenderImageInputs(); + if (normalizedRenderInputs.sourceImageUrl !== (sourceImageUrl || "")) { + const defaultSourceLayer = editableImageLayers.find( + (layer) => layer.isDefaultResultSource + ); + if (defaultSourceLayer) { + setImageAssignments((current) => ({ + ...current, + [defaultSourceLayer.id]: normalizedRenderInputs.sourceImageUrl, + })); + } + } + setImageAssignments((current) => ({ + ...current, + ...normalizedRenderInputs.imageAssignments, + })); const payload = (await requestLongTaskResult({ endpoint: "/api/psd-templates/render", timeoutMs: 8 * 60 * 1000, maxPollFailures: 8, payload: { templateId: selectedTemplate.id, - sourceImageUrl: sourceImageUrl || "", + sourceImageUrl: normalizedRenderInputs.sourceImageUrl, hiddenLayerIds, - imageAssignments: effectiveImageAssignments, + imageAssignments: normalizedRenderInputs.imageAssignments, imagePlacements: effectiveImagePlacements, textAssignments: effectiveTextAssignments, textColorAssignments: effectiveTextColorAssignments,