Fix canvas generation cleanup

This commit is contained in:
2026-06-10 14:12:14 +08:00
parent 77ffd01a50
commit bfb70bab26
3 changed files with 81 additions and 18 deletions
+30 -8
View File
@@ -371,12 +371,19 @@ function CanvasPage({
const textNodeIdRef = useRef(9); const textNodeIdRef = useRef(9);
const imageNodeIdRef = useRef(1); const imageNodeIdRef = useRef(1);
const videoNodeIdRef = useRef(1); const videoNodeIdRef = useRef(1);
const objectUrlsRef = useRef(new Set<string>());
const trackObjectUrl = (file: Blob) => {
const url = URL.createObjectURL(file);
objectUrlsRef.current.add(url);
return url;
};
const { pushSnapshot, undo, redo } = useCanvasHistory(); const { pushSnapshot, undo, redo } = useCanvasHistory();
const { const {
textGenerationState, imageGenerationState, videoGenerationState, textGenerationState, imageGenerationState, videoGenerationState,
generationToast, setGenerationToast, generationToast, setGenerationToast,
imageGenerationInFlightRef, videoGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef, imageGenerationInFlightRef, videoGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef,
imageGenerationAbortRef, videoGenerationAbortRef,
canvasGenKeepaliveRestoredRef, canvasGenKeepaliveRestoredRef,
setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus, setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus,
restoreKeepaliveTasks, resetGenerationState, restoreKeepaliveTasks, resetGenerationState,
@@ -527,6 +534,7 @@ function CanvasPage({
const autoSaveStatusTimerRef = useRef<number | null>(null); const autoSaveStatusTimerRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
const objectUrls = objectUrlsRef.current;
return () => { return () => {
if (canvasAutoSaveTimerRef.current !== null) window.clearTimeout(canvasAutoSaveTimerRef.current); if (canvasAutoSaveTimerRef.current !== null) window.clearTimeout(canvasAutoSaveTimerRef.current);
if (canvasAutoSaveRetryTimerRef.current !== null) window.clearTimeout(canvasAutoSaveRetryTimerRef.current); if (canvasAutoSaveRetryTimerRef.current !== null) window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
@@ -535,6 +543,8 @@ function CanvasPage({
if (canvasAutoSaveIdleHandleRef.current !== null && "cancelIdleCallback" in window) { if (canvasAutoSaveIdleHandleRef.current !== null && "cancelIdleCallback" in window) {
window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current); window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current);
} }
objectUrls.forEach((url) => URL.revokeObjectURL(url));
objectUrls.clear();
}; };
}, []); }, []);
@@ -1691,12 +1701,15 @@ function CanvasPage({
const quality = resolveImageQuality(model, imageNode.imageSize || ""); const quality = resolveImageQuality(model, imageNode.imageSize || "");
imageGenerationInFlightRef.current.add(nodeId); imageGenerationInFlightRef.current.add(nodeId);
const abortRef = { current: false };
imageGenerationAbortRef.current.set(nodeId, abortRef);
setImageGenerationStatus(nodeId, { status: "submitting", message: "正在提交生成", progress: 8 }); setImageGenerationStatus(nodeId, { status: "submitting", message: "正在提交生成", progress: 8 });
setGenerationToast("图片正在生成"); setGenerationToast("图片正在生成");
let task: Awaited<ReturnType<typeof onCreateTask>> | null = null; let task: Awaited<ReturnType<typeof onCreateTask>> | null = null;
try { try {
const referenceUrls = await resolveConnectedImageReferenceUrls("image", nodeId, imageNode); const referenceUrls = await resolveConnectedImageReferenceUrls("image", nodeId, imageNode);
if (abortRef.current) return;
const taskInput: CreatePreviewTaskInput = { const taskInput: CreatePreviewTaskInput = {
title: imageNode.title || "图片节点生成", title: imageNode.title || "图片节点生成",
type: "image", type: "image",
@@ -1732,7 +1745,8 @@ function CanvasPage({
? "图片生成完成" ? "图片生成完成"
: "图片生成失败"; : "图片生成失败";
setImageGenerationStatus(nodeId, { status: "running", message: statusLabel, progress }); setImageGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
})); }, abortRef));
if (abortRef.current || !outputUrl) return;
setImageGenerationStatus(nodeId, { status: "success", message: "生成完成", progress: 100 }); setImageGenerationStatus(nodeId, { status: "success", message: "生成完成", progress: 100 });
removeCanvasGenKeepalive(task.id); removeCanvasGenKeepalive(task.id);
const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({ const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({
@@ -1794,13 +1808,15 @@ function CanvasPage({
); );
} }
} catch (error) { } catch (error) {
if (abortRef.current) return;
setImageGenerationStatus(nodeId, { setImageGenerationStatus(nodeId, {
status: "error", status: "error",
message: error instanceof Error ? error.message : "图片生成失败", message: error instanceof Error ? error.message : "图片生成失败",
}); });
} finally { } finally {
imageGenerationInFlightRef.current.delete(nodeId); 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; const duration = Number(videoNode.duration) || 4;
videoGenerationInFlightRef.current.add(nodeId); videoGenerationInFlightRef.current.add(nodeId);
const abortRef = { current: false };
videoGenerationAbortRef.current.set(nodeId, abortRef);
setVideoGenerationStatus(nodeId, { status: "submitting", message: "正在提交视频生成", progress: 8 }); setVideoGenerationStatus(nodeId, { status: "submitting", message: "正在提交视频生成", progress: 8 });
setGenerationToast("视频正在生成"); setGenerationToast("视频正在生成");
let task: Awaited<ReturnType<typeof onCreateTask>> | null = null; let task: Awaited<ReturnType<typeof onCreateTask>> | null = null;
try { try {
const referenceUrls = await resolveConnectedImageReferenceUrls("video", nodeId); const referenceUrls = await resolveConnectedImageReferenceUrls("video", nodeId);
if (abortRef.current) return;
if (videoNode.videoMode === "img2video" && referenceUrls.length === 0) { if (videoNode.videoMode === "img2video" && referenceUrls.length === 0) {
throw new Error("图生视频需要先连接至少一个可用的图片节点"); throw new Error("图生视频需要先连接至少一个可用的图片节点");
} }
@@ -1892,7 +1911,8 @@ function CanvasPage({
? "视频生成完成" ? "视频生成完成"
: "视频生成失败"; : "视频生成失败";
setVideoGenerationStatus(nodeId, { status: "running", message: statusLabel, progress }); setVideoGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
})); }, abortRef));
if (abortRef.current || !outputUrl) return;
setVideoGenerationStatus(nodeId, { status: "success", message: "视频生成完成", progress: 100 }); setVideoGenerationStatus(nodeId, { status: "success", message: "视频生成完成", progress: 100 });
removeCanvasGenKeepalive(taskId); removeCanvasGenKeepalive(taskId);
const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({ const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({
@@ -1948,13 +1968,15 @@ function CanvasPage({
); );
} }
} catch (error) { } catch (error) {
if (abortRef.current) return;
setVideoGenerationStatus(nodeId, { setVideoGenerationStatus(nodeId, {
status: "error", status: "error",
message: error instanceof Error ? error.message : "视频生成失败", message: error instanceof Error ? error.message : "视频生成失败",
}); });
} finally { } finally {
videoGenerationInFlightRef.current.delete(nodeId); 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]; const file = event.target.files?.[0];
event.target.value = ""; event.target.value = "";
if (!file) return; if (!file) return;
const imageUrl = URL.createObjectURL(file); const imageUrl = trackObjectUrl(file);
if (pendingImageToImageNodeId) { if (pendingImageToImageNodeId) {
const sourceNode = imageNodes.find((node) => node.id === pendingImageToImageNodeId); const sourceNode = imageNodes.find((node) => node.id === pendingImageToImageNodeId);
if (sourceNode) { if (sourceNode) {
@@ -2047,7 +2069,7 @@ function CanvasPage({
let offsetX = 0; let offsetX = 0;
let offsetY = 0; let offsetY = 0;
for (const file of files) { for (const file of files) {
const imageUrl = URL.createObjectURL(file); const imageUrl = trackObjectUrl(file);
addImageNode(imageUrl, file.name, { addImageNode(imageUrl, file.name, {
x: dropPosition.x + offsetX, x: dropPosition.x + offsetX,
y: dropPosition.y + offsetY, y: dropPosition.y + offsetY,
@@ -2103,7 +2125,7 @@ function CanvasPage({
let offsetX = 0; let offsetX = 0;
let offsetY = 0; let offsetY = 0;
for (const file of files) { for (const file of files) {
const imageUrl = URL.createObjectURL(file); const imageUrl = trackObjectUrl(file);
addImageNode(imageUrl, file.name, { addImageNode(imageUrl, file.name, {
x: sourceNode.position.x + sourceNode.size.width + 40 + offsetX, x: sourceNode.position.x + sourceNode.size.width + 40 + offsetX,
y: sourceNode.position.y + offsetY, y: sourceNode.position.y + offsetY,
@@ -5279,7 +5301,7 @@ function CanvasPage({
onChange={(event) => { onChange={(event) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
setAssetCoverUrl(URL.createObjectURL(file)); setAssetCoverUrl(trackObjectUrl(file));
setCoverSourceOpen(false); setCoverSourceOpen(false);
}} }}
/> />
+14 -2
View File
@@ -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, { const resultUrl = await waitForTask(taskId, {
kind: "image", kind: "image",
abortRef,
onProgress: (e) => { onProgress: (e) => {
if (onStatus) { if (onStatus) {
onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus); 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("生成任务已完成,但服务器没有返回结果地址,请稍后重试"); if (!resultUrl) throw new Error("生成任务已完成,但服务器没有返回结果地址,请稍后重试");
return resultUrl; 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, { const resultUrl = await waitForTask(taskId, {
kind: "video", kind: "video",
abortRef,
onProgress: (e) => { onProgress: (e) => {
if (onStatus) { if (onStatus) {
onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus); 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("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试"); if (!resultUrl) throw new Error("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试");
return resultUrl; return resultUrl;
} }
+37 -8
View File
@@ -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 { import type {
CanvasImageGenerationState, CanvasImageGenerationState,
CanvasImageNode, CanvasImageNode,
@@ -66,6 +66,8 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
const videoGenerationInFlightRef = useRef(new Set<string>()); const videoGenerationInFlightRef = useRef(new Set<string>());
const textGenerationInFlightRef = useRef(new Set<string>()); const textGenerationInFlightRef = useRef(new Set<string>());
const textGenerationAbortControllersRef = useRef(new Map<string, AbortController>()); const textGenerationAbortControllersRef = useRef(new Map<string, AbortController>());
const imageGenerationAbortRef = useRef(new Map<string, { current: boolean }>());
const videoGenerationAbortRef = useRef(new Map<string, { current: boolean }>());
const canvasGenKeepaliveRestoredRef = useRef(false); const canvasGenKeepaliveRestoredRef = useRef(false);
const setTextGenerationStatus = (nodeId: string, state: CanvasTextGenerationState) => { const setTextGenerationStatus = (nodeId: string, state: CanvasTextGenerationState) => {
@@ -80,6 +82,15 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
setVideoGenerationState((current) => ({ ...current, [nodeId]: state })); 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 // Toast auto-dismiss
useEffect(() => { useEffect(() => {
if (!generationToast) return undefined; if (!generationToast) return undefined;
@@ -103,11 +114,14 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
} }
if (entry.nodeKind === "image") { if (entry.nodeKind === "image") {
imageGenerationInFlightRef.current.add(entry.nodeId); imageGenerationInFlightRef.current.add(entry.nodeId);
const abortRef = { current: false };
imageGenerationAbortRef.current.set(entry.nodeId, abortRef);
setImageGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复图片生成", progress: 20 }); setImageGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复图片生成", progress: 20 });
void waitForImageTaskResult(entry.taskId, (status) => { void waitForImageTaskResult(entry.taskId, (status) => {
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0))); const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
setImageGenerationStatus(entry.nodeId, { status: "running", message: "图片生成中", progress }); setImageGenerationStatus(entry.nodeId, { status: "running", message: "图片生成中", progress });
}).then(async (outputUrl) => { }, abortRef).then(async (outputUrl) => {
if (abortRef.current || !outputUrl) return;
removeCanvasGenKeepalive(entry.taskId); removeCanvasGenKeepalive(entry.taskId);
setImageGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 }); setImageGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 });
const ref = createCanvasAssetRefFromGeneratedResult({ const ref = createCanvasAssetRefFromGeneratedResult({
@@ -128,18 +142,23 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
)); ));
} }
}).catch(() => { }).catch(() => {
if (abortRef.current) return;
removeCanvasGenKeepalive(entry.taskId); removeCanvasGenKeepalive(entry.taskId);
setImageGenerationStatus(entry.nodeId, { status: "error", message: "图片生成失败" }); setImageGenerationStatus(entry.nodeId, { status: "error", message: "图片生成失败" });
}).finally(() => { }).finally(() => {
imageGenerationInFlightRef.current.delete(entry.nodeId); imageGenerationInFlightRef.current.delete(entry.nodeId);
imageGenerationAbortRef.current.delete(entry.nodeId);
}); });
} else if (entry.nodeKind === "video") { } else if (entry.nodeKind === "video") {
videoGenerationInFlightRef.current.add(entry.nodeId); videoGenerationInFlightRef.current.add(entry.nodeId);
const abortRef = { current: false };
videoGenerationAbortRef.current.set(entry.nodeId, abortRef);
setVideoGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复视频生成", progress: 20 }); setVideoGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复视频生成", progress: 20 });
void waitForVideoTaskResult(entry.taskId, (status) => { void waitForVideoTaskResult(entry.taskId, (status) => {
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0))); const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
setVideoGenerationStatus(entry.nodeId, { status: "running", message: "视频生成中", progress }); setVideoGenerationStatus(entry.nodeId, { status: "running", message: "视频生成中", progress });
}).then(async (outputUrl) => { }, abortRef).then(async (outputUrl) => {
if (abortRef.current || !outputUrl) return;
removeCanvasGenKeepalive(entry.taskId); removeCanvasGenKeepalive(entry.taskId);
setVideoGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 }); setVideoGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 });
const ref = createCanvasAssetRefFromGeneratedResult({ const ref = createCanvasAssetRefFromGeneratedResult({
@@ -160,18 +179,19 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
)); ));
} }
}).catch(() => { }).catch(() => {
if (abortRef.current) return;
removeCanvasGenKeepalive(entry.taskId); removeCanvasGenKeepalive(entry.taskId);
setVideoGenerationStatus(entry.nodeId, { status: "error", message: "视频生成失败" }); setVideoGenerationStatus(entry.nodeId, { status: "error", message: "视频生成失败" });
}).finally(() => { }).finally(() => {
videoGenerationInFlightRef.current.delete(entry.nodeId); videoGenerationInFlightRef.current.delete(entry.nodeId);
videoGenerationAbortRef.current.delete(entry.nodeId);
}); });
} }
} }
}; };
const resetGenerationState = () => { const resetGenerationState = () => {
textGenerationAbortControllersRef.current.forEach((c) => c.abort()); abortAllGenerationPollers();
textGenerationAbortControllersRef.current.clear();
textGenerationInFlightRef.current.clear(); textGenerationInFlightRef.current.clear();
imageGenerationInFlightRef.current.clear(); imageGenerationInFlightRef.current.clear();
videoGenerationInFlightRef.current.clear(); videoGenerationInFlightRef.current.clear();
@@ -180,11 +200,18 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
setVideoGenerationState({}); 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(() => { useEffect(() => {
const handlePageHide = () => { const handlePageHide = () => {
cancelCanvasGenKeepaliveOnUnload(); cancelCanvasGenKeepaliveOnUnload();
textGenerationAbortControllersRef.current.forEach((controller) => controller.abort()); abortAllGenerationPollers();
textGenerationAbortControllersRef.current.clear();
textGenerationInFlightRef.current.clear(); textGenerationInFlightRef.current.clear();
imageGenerationInFlightRef.current.clear(); imageGenerationInFlightRef.current.clear();
videoGenerationInFlightRef.current.clear(); videoGenerationInFlightRef.current.clear();
@@ -202,7 +229,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
window.removeEventListener("pagehide", handlePageHide); window.removeEventListener("pagehide", handlePageHide);
window.removeEventListener("online", handleOnline); window.removeEventListener("online", handleOnline);
}; };
}, []); }, [abortAllGenerationPollers]);
return { return {
textGenerationState, textGenerationState,
@@ -214,6 +241,8 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
videoGenerationInFlightRef, videoGenerationInFlightRef,
textGenerationInFlightRef, textGenerationInFlightRef,
textGenerationAbortControllersRef, textGenerationAbortControllersRef,
imageGenerationAbortRef,
videoGenerationAbortRef,
canvasGenKeepaliveRestoredRef, canvasGenKeepaliveRestoredRef,
setTextGenerationStatus, setTextGenerationStatus,
setImageGenerationStatus, setImageGenerationStatus,