From b40df45daa71570eb74b124e8e6138240f9f7642 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 23 Mar 2026 23:49:00 +0800 Subject: [PATCH] fix: harden detail reference stitch loading --- .../AgentDetailReferenceStitchDialog.tsx | 162 ++++++++++++++++-- 1 file changed, 143 insertions(+), 19 deletions(-) diff --git a/components/agents/AgentDetailReferenceStitchDialog.tsx b/components/agents/AgentDetailReferenceStitchDialog.tsx index c33e35a24..e34e6762d 100644 --- a/components/agents/AgentDetailReferenceStitchDialog.tsx +++ b/components/agents/AgentDetailReferenceStitchDialog.tsx @@ -33,6 +33,7 @@ type LoadedReferenceImage = { width: number; height: number; scaledHeight: number; + revokeObjectUrl?: (() => void) | null; }; type MergedSnapshot = { @@ -46,20 +47,97 @@ function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); } -function loadImage(url: string) { +function createImageElement(src: string, useCrossOrigin = true) { return new Promise((resolve, reject) => { const image = new Image(); - image.crossOrigin = "anonymous"; + if (useCrossOrigin) { + image.crossOrigin = "anonymous"; + } image.onload = () => resolve(image); image.onerror = () => reject(new Error("图片加载失败")); - image.src = buildImageProxyUrl(url, { - width: MERGED_IMAGE_MAX_WIDTH, - quality: 92, - format: "png", - }); + image.src = src; }); } +async function loadImage(url: string) { + const normalizedSource = String(url || "").trim(); + if (!normalizedSource) { + throw new Error("图片地址为空"); + } + + const candidateUrls = Array.from( + new Set( + [ + buildImageProxyUrl(normalizedSource, { + width: MERGED_IMAGE_MAX_WIDTH, + quality: 92, + format: "png", + }), + buildImageProxyUrl(normalizedSource, { + width: Math.min(MERGED_IMAGE_MAX_WIDTH, 960), + quality: 86, + format: "webp", + }), + normalizedSource, + ].filter((item): item is string => Boolean(item)) + ) + ); + + const errorMessages: string[] = []; + + for (const candidateUrl of candidateUrls) { + try { + const response = await fetch(candidateUrl, { + cache: "force-cache", + credentials: "same-origin", + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const blob = await response.blob(); + if (!blob.type.startsWith("image/")) { + throw new Error("返回内容不是图片"); + } + + const objectUrl = URL.createObjectURL(blob); + try { + const image = await createImageElement(objectUrl, false); + return { + image, + revokeObjectUrl: () => URL.revokeObjectURL(objectUrl), + }; + } catch (error) { + URL.revokeObjectURL(objectUrl); + throw error; + } + } catch (error) { + errorMessages.push( + error instanceof Error ? error.message : `无法加载图片资源: ${candidateUrl}` + ); + } + } + + for (const candidateUrl of candidateUrls) { + try { + const image = await createImageElement( + candidateUrl, + !candidateUrl.startsWith("data:") && !candidateUrl.startsWith("blob:") + ); + return { + image, + revokeObjectUrl: null, + }; + } catch (error) { + errorMessages.push( + error instanceof Error ? error.message : `无法直接加载图片资源: ${candidateUrl}` + ); + } + } + + throw new Error(errorMessages[0] || "图片加载失败"); +} + function buildMergedSnapshot(images: LoadedReferenceImage[]) { const baseWidth = Math.max( 1, @@ -155,7 +233,9 @@ export function AgentDetailReferenceStitchDialog({ const [isLoading, setIsLoading] = useState(false); const [isApplying, setIsApplying] = useState(false); const [applyProgressText, setApplyProgressText] = useState(""); + const [loadingProgressText, setLoadingProgressText] = useState(""); const [errorText, setErrorText] = useState(null); + const [warningText, setWarningText] = useState(null); const validCards = useMemo( () => @@ -210,6 +290,8 @@ export function AgentDetailReferenceStitchDialog({ setDraggingLineIndex(null); setIsAddingCutLine(false); setPreviewCutLineY(null); + setLoadingProgressText(""); + setWarningText(null); return; } @@ -223,29 +305,54 @@ export function AgentDetailReferenceStitchDialog({ let cancelled = false; const setup = async () => { + let loadedImages: LoadedReferenceImage[] = []; + let snapshotCommitted = false; try { setIsLoading(true); setErrorText(null); + setWarningText(null); - const loadedImages = await Promise.all( - validCards.map(async (card) => { - const image = await loadImage(card.imageUrl); - return { + const failedCards: string[] = []; + + for (const [index, card] of validCards.entries()) { + setLoadingProgressText(`正在加载参考图 ${index + 1}/${validCards.length}`); + try { + const loaded = await loadImage(card.imageUrl); + loadedImages.push({ card, - image, - width: image.naturalWidth, - height: image.naturalHeight, - scaledHeight: image.naturalHeight, - } satisfies LoadedReferenceImage; - }) - ); + image: loaded.image, + width: loaded.image.naturalWidth, + height: loaded.image.naturalHeight, + scaledHeight: loaded.image.naturalHeight, + revokeObjectUrl: loaded.revokeObjectUrl, + } satisfies LoadedReferenceImage); + } catch (error) { + failedCards.push(card.label || `第 ${index + 1} 张参考图`); + console.error("[AgentDetailReferenceStitchDialog] Failed to load reference image:", { + cardId: card.id, + label: card.label, + imageUrl: card.imageUrl, + error, + }); + } + } if (cancelled) return; + if (loadedImages.length === 0) { + throw new Error("当前参考图加载失败,请稍后重试"); + } + const next = buildMergedSnapshot(loadedImages); mergedCanvasRef.current = next.canvas; setSnapshot(next.snapshot); setCutLines(next.snapshot.defaultCutLines); + snapshotCommitted = true; + setWarningText( + failedCards.length > 0 + ? `有 ${failedCards.length} 张参考图加载失败,已自动跳过异常图片。` + : null + ); } catch (error) { if (cancelled) return; console.error("[AgentDetailReferenceStitchDialog] Failed to prepare merged image:", error); @@ -253,8 +360,12 @@ export function AgentDetailReferenceStitchDialog({ setCutLines([]); setErrorText(error instanceof Error ? error.message : "拼合参考图失败"); } finally { + if (!snapshotCommitted) { + loadedImages.forEach((item) => item.revokeObjectUrl?.()); + } if (!cancelled) { setIsLoading(false); + setLoadingProgressText(""); } } }; @@ -266,6 +377,12 @@ export function AgentDetailReferenceStitchDialog({ }; }, [open, sourceLabel, validCards]); + useEffect(() => { + return () => { + snapshot?.loadedImages.forEach((item) => item.revokeObjectUrl?.()); + }; + }, [snapshot]); + const resolveInsertCutLineY = useCallback( (rawY: number) => { if (!snapshot) return null; @@ -576,7 +693,9 @@ export function AgentDetailReferenceStitchDialog({ {isLoading ? (
-
正在拼合参考图...
+
+ {loadingProgressText || "正在拼合参考图..."} +
会按当前参考图顺序从上到下拼成一张长图
) : errorText ? ( @@ -588,6 +707,11 @@ export function AgentDetailReferenceStitchDialog({ ) : snapshot ? (
+ {warningText ? ( +
+ {warningText} +
+ ) : null}