fix: harden detail reference stitch loading
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-03-23 23:49:00 +08:00
parent 4afced3b8d
commit b40df45daa

View File

@ -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<HTMLImageElement>((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<string | null>(null);
const [warningText, setWarningText] = useState<string | null>(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 ? (
<div className="flex min-h-[420px] flex-col items-center justify-center gap-3 rounded-[24px] border border-dashed border-white/12 bg-black/20 text-center">
<Loader2 className="h-6 w-6 animate-spin text-emerald-300" />
<div className="text-sm text-white/75">正在拼合参考图...</div>
<div className="text-sm text-white/75">
{loadingProgressText || "正在拼合参考图..."}
</div>
<div className="text-xs text-white/42">会按当前参考图顺序从上到下拼成一张长图</div>
</div>
) : errorText ? (
@ -588,6 +707,11 @@ export function AgentDetailReferenceStitchDialog({
</div>
) : snapshot ? (
<div className="space-y-4">
{warningText ? (
<div className="rounded-[18px] border border-amber-400/20 bg-amber-500/[0.08] px-4 py-3 text-xs leading-6 text-amber-100/88">
{warningText}
</div>
) : null}
<div
ref={previewContainerRef}
className="relative overflow-hidden rounded-[24px] border border-white/10 bg-white"