From bfb70bab261d8807ed7432dfd44f417a073fc7af Mon Sep 17 00:00:00 2001 From: Stringadmin Date: Wed, 10 Jun 2026 14:12:14 +0800 Subject: [PATCH] Fix canvas generation cleanup --- src/features/canvas/CanvasPage.tsx | 38 ++++++++++++++---- src/features/canvas/canvasUtils.ts | 16 +++++++- src/features/canvas/useCanvasGeneration.ts | 45 ++++++++++++++++++---- 3 files changed, 81 insertions(+), 18 deletions(-) diff --git a/src/features/canvas/CanvasPage.tsx b/src/features/canvas/CanvasPage.tsx index c424ba6..8ba9351 100644 --- a/src/features/canvas/CanvasPage.tsx +++ b/src/features/canvas/CanvasPage.tsx @@ -371,12 +371,19 @@ function CanvasPage({ const textNodeIdRef = useRef(9); const imageNodeIdRef = useRef(1); const videoNodeIdRef = useRef(1); + const objectUrlsRef = useRef(new Set()); + const trackObjectUrl = (file: Blob) => { + const url = URL.createObjectURL(file); + objectUrlsRef.current.add(url); + return url; + }; const { pushSnapshot, undo, redo } = useCanvasHistory(); const { textGenerationState, imageGenerationState, videoGenerationState, generationToast, setGenerationToast, imageGenerationInFlightRef, videoGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef, + imageGenerationAbortRef, videoGenerationAbortRef, canvasGenKeepaliveRestoredRef, setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus, restoreKeepaliveTasks, resetGenerationState, @@ -527,6 +534,7 @@ function CanvasPage({ const autoSaveStatusTimerRef = useRef(null); useEffect(() => { + const objectUrls = objectUrlsRef.current; return () => { if (canvasAutoSaveTimerRef.current !== null) window.clearTimeout(canvasAutoSaveTimerRef.current); if (canvasAutoSaveRetryTimerRef.current !== null) window.clearTimeout(canvasAutoSaveRetryTimerRef.current); @@ -535,6 +543,8 @@ function CanvasPage({ if (canvasAutoSaveIdleHandleRef.current !== null && "cancelIdleCallback" in window) { window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current); } + objectUrls.forEach((url) => URL.revokeObjectURL(url)); + objectUrls.clear(); }; }, []); @@ -1691,12 +1701,15 @@ function CanvasPage({ const quality = resolveImageQuality(model, imageNode.imageSize || ""); imageGenerationInFlightRef.current.add(nodeId); + const abortRef = { current: false }; + imageGenerationAbortRef.current.set(nodeId, abortRef); setImageGenerationStatus(nodeId, { status: "submitting", message: "正在提交生成", progress: 8 }); setGenerationToast("图片正在生成"); let task: Awaited> | null = null; try { const referenceUrls = await resolveConnectedImageReferenceUrls("image", nodeId, imageNode); + if (abortRef.current) return; const taskInput: CreatePreviewTaskInput = { title: imageNode.title || "图片节点生成", type: "image", @@ -1732,7 +1745,8 @@ function CanvasPage({ ? "图片生成完成" : "图片生成失败"; setImageGenerationStatus(nodeId, { status: "running", message: statusLabel, progress }); - })); + }, abortRef)); + if (abortRef.current || !outputUrl) return; setImageGenerationStatus(nodeId, { status: "success", message: "生成完成", progress: 100 }); removeCanvasGenKeepalive(task.id); const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({ @@ -1794,13 +1808,15 @@ function CanvasPage({ ); } } catch (error) { + if (abortRef.current) return; setImageGenerationStatus(nodeId, { status: "error", message: error instanceof Error ? error.message : "图片生成失败", }); } finally { imageGenerationInFlightRef.current.delete(nodeId); - if (task?.id) removeCanvasGenKeepalive(task.id); + imageGenerationAbortRef.current.delete(nodeId); + if (task?.id && !abortRef.current) removeCanvasGenKeepalive(task.id); } }; @@ -1843,12 +1859,15 @@ function CanvasPage({ const duration = Number(videoNode.duration) || 4; videoGenerationInFlightRef.current.add(nodeId); + const abortRef = { current: false }; + videoGenerationAbortRef.current.set(nodeId, abortRef); setVideoGenerationStatus(nodeId, { status: "submitting", message: "正在提交视频生成", progress: 8 }); setGenerationToast("视频正在生成"); let task: Awaited> | null = null; try { const referenceUrls = await resolveConnectedImageReferenceUrls("video", nodeId); + if (abortRef.current) return; if (videoNode.videoMode === "img2video" && referenceUrls.length === 0) { throw new Error("图生视频需要先连接至少一个可用的图片节点"); } @@ -1892,7 +1911,8 @@ function CanvasPage({ ? "视频生成完成" : "视频生成失败"; setVideoGenerationStatus(nodeId, { status: "running", message: statusLabel, progress }); - })); + }, abortRef)); + if (abortRef.current || !outputUrl) return; setVideoGenerationStatus(nodeId, { status: "success", message: "视频生成完成", progress: 100 }); removeCanvasGenKeepalive(taskId); const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({ @@ -1948,13 +1968,15 @@ function CanvasPage({ ); } } catch (error) { + if (abortRef.current) return; setVideoGenerationStatus(nodeId, { status: "error", message: error instanceof Error ? error.message : "视频生成失败", }); } finally { videoGenerationInFlightRef.current.delete(nodeId); - if (task?.id) removeCanvasGenKeepalive(task.id); + videoGenerationAbortRef.current.delete(nodeId); + if (task?.id && !abortRef.current) removeCanvasGenKeepalive(task.id); } }; @@ -1965,7 +1987,7 @@ function CanvasPage({ const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; - const imageUrl = URL.createObjectURL(file); + const imageUrl = trackObjectUrl(file); if (pendingImageToImageNodeId) { const sourceNode = imageNodes.find((node) => node.id === pendingImageToImageNodeId); if (sourceNode) { @@ -2047,7 +2069,7 @@ function CanvasPage({ let offsetX = 0; let offsetY = 0; for (const file of files) { - const imageUrl = URL.createObjectURL(file); + const imageUrl = trackObjectUrl(file); addImageNode(imageUrl, file.name, { x: dropPosition.x + offsetX, y: dropPosition.y + offsetY, @@ -2103,7 +2125,7 @@ function CanvasPage({ let offsetX = 0; let offsetY = 0; for (const file of files) { - const imageUrl = URL.createObjectURL(file); + const imageUrl = trackObjectUrl(file); addImageNode(imageUrl, file.name, { x: sourceNode.position.x + sourceNode.size.width + 40 + offsetX, y: sourceNode.position.y + offsetY, @@ -5279,7 +5301,7 @@ function CanvasPage({ onChange={(event) => { const file = event.target.files?.[0]; if (!file) return; - setAssetCoverUrl(URL.createObjectURL(file)); + setAssetCoverUrl(trackObjectUrl(file)); setCoverSourceOpen(false); }} /> diff --git a/src/features/canvas/canvasUtils.ts b/src/features/canvas/canvasUtils.ts index 08a2ab6..72fc37c 100644 --- a/src/features/canvas/canvasUtils.ts +++ b/src/features/canvas/canvasUtils.ts @@ -252,28 +252,40 @@ export function blobToDataUrl(blob: Blob) { }); } -export async function waitForImageTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) { +export async function waitForImageTaskResult( + taskId: string, + onStatus?: (status: AiTaskStatus) => void, + abortRef?: { current: boolean }, +) { const resultUrl = await waitForTask(taskId, { kind: "image", + abortRef, onProgress: (e) => { if (onStatus) { onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus); } }, }); + if (abortRef?.current) return ""; if (!resultUrl) throw new Error("生成任务已完成,但服务器没有返回结果地址,请稍后重试"); return resultUrl; } -export async function waitForVideoTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) { +export async function waitForVideoTaskResult( + taskId: string, + onStatus?: (status: AiTaskStatus) => void, + abortRef?: { current: boolean }, +) { const resultUrl = await waitForTask(taskId, { kind: "video", + abortRef, onProgress: (e) => { if (onStatus) { onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus); } }, }); + if (abortRef?.current) return ""; if (!resultUrl) throw new Error("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试"); return resultUrl; } diff --git a/src/features/canvas/useCanvasGeneration.ts b/src/features/canvas/useCanvasGeneration.ts index 5f5b135..222ffb0 100644 --- a/src/features/canvas/useCanvasGeneration.ts +++ b/src/features/canvas/useCanvasGeneration.ts @@ -1,4 +1,4 @@ -import { type Dispatch, type SetStateAction, useEffect, useRef, useState } from "react"; +import { type Dispatch, type SetStateAction, useCallback, useEffect, useRef, useState } from "react"; import type { CanvasImageGenerationState, CanvasImageNode, @@ -66,6 +66,8 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) { const videoGenerationInFlightRef = useRef(new Set()); const textGenerationInFlightRef = useRef(new Set()); const textGenerationAbortControllersRef = useRef(new Map()); + const imageGenerationAbortRef = useRef(new Map()); + const videoGenerationAbortRef = useRef(new Map()); const canvasGenKeepaliveRestoredRef = useRef(false); const setTextGenerationStatus = (nodeId: string, state: CanvasTextGenerationState) => { @@ -80,6 +82,15 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) { setVideoGenerationState((current) => ({ ...current, [nodeId]: state })); }; + const abortAllGenerationPollers = useCallback(() => { + textGenerationAbortControllersRef.current.forEach((c) => c.abort()); + textGenerationAbortControllersRef.current.clear(); + imageGenerationAbortRef.current.forEach((ref) => { ref.current = true; }); + imageGenerationAbortRef.current.clear(); + videoGenerationAbortRef.current.forEach((ref) => { ref.current = true; }); + videoGenerationAbortRef.current.clear(); + }, []); + // Toast auto-dismiss useEffect(() => { if (!generationToast) return undefined; @@ -103,11 +114,14 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) { } if (entry.nodeKind === "image") { imageGenerationInFlightRef.current.add(entry.nodeId); + const abortRef = { current: false }; + imageGenerationAbortRef.current.set(entry.nodeId, abortRef); setImageGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复图片生成", progress: 20 }); void waitForImageTaskResult(entry.taskId, (status) => { const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0))); setImageGenerationStatus(entry.nodeId, { status: "running", message: "图片生成中", progress }); - }).then(async (outputUrl) => { + }, abortRef).then(async (outputUrl) => { + if (abortRef.current || !outputUrl) return; removeCanvasGenKeepalive(entry.taskId); setImageGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 }); const ref = createCanvasAssetRefFromGeneratedResult({ @@ -128,18 +142,23 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) { )); } }).catch(() => { + if (abortRef.current) return; removeCanvasGenKeepalive(entry.taskId); setImageGenerationStatus(entry.nodeId, { status: "error", message: "图片生成失败" }); }).finally(() => { imageGenerationInFlightRef.current.delete(entry.nodeId); + imageGenerationAbortRef.current.delete(entry.nodeId); }); } else if (entry.nodeKind === "video") { videoGenerationInFlightRef.current.add(entry.nodeId); + const abortRef = { current: false }; + videoGenerationAbortRef.current.set(entry.nodeId, abortRef); setVideoGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复视频生成", progress: 20 }); void waitForVideoTaskResult(entry.taskId, (status) => { const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0))); setVideoGenerationStatus(entry.nodeId, { status: "running", message: "视频生成中", progress }); - }).then(async (outputUrl) => { + }, abortRef).then(async (outputUrl) => { + if (abortRef.current || !outputUrl) return; removeCanvasGenKeepalive(entry.taskId); setVideoGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 }); const ref = createCanvasAssetRefFromGeneratedResult({ @@ -160,18 +179,19 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) { )); } }).catch(() => { + if (abortRef.current) return; removeCanvasGenKeepalive(entry.taskId); setVideoGenerationStatus(entry.nodeId, { status: "error", message: "视频生成失败" }); }).finally(() => { videoGenerationInFlightRef.current.delete(entry.nodeId); + videoGenerationAbortRef.current.delete(entry.nodeId); }); } } }; const resetGenerationState = () => { - textGenerationAbortControllersRef.current.forEach((c) => c.abort()); - textGenerationAbortControllersRef.current.clear(); + abortAllGenerationPollers(); textGenerationInFlightRef.current.clear(); imageGenerationInFlightRef.current.clear(); videoGenerationInFlightRef.current.clear(); @@ -180,11 +200,18 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) { setVideoGenerationState({}); }; + // Stop all in-flight front-end polling/setState when the canvas unmounts (route change). + // Keepalive records are intentionally preserved so restoreKeepaliveTasks can resume on return. + useEffect(() => { + return () => { + abortAllGenerationPollers(); + }; + }, [abortAllGenerationPollers]); + useEffect(() => { const handlePageHide = () => { cancelCanvasGenKeepaliveOnUnload(); - textGenerationAbortControllersRef.current.forEach((controller) => controller.abort()); - textGenerationAbortControllersRef.current.clear(); + abortAllGenerationPollers(); textGenerationInFlightRef.current.clear(); imageGenerationInFlightRef.current.clear(); videoGenerationInFlightRef.current.clear(); @@ -202,7 +229,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) { window.removeEventListener("pagehide", handlePageHide); window.removeEventListener("online", handleOnline); }; - }, []); + }, [abortAllGenerationPollers]); return { textGenerationState, @@ -214,6 +241,8 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) { videoGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef, + imageGenerationAbortRef, + videoGenerationAbortRef, canvasGenKeepaliveRestoredRef, setTextGenerationStatus, setImageGenerationStatus,