Files
omniai-web/src/features/canvas/useCanvasGeneration.ts
T

254 lines
11 KiB
TypeScript
Raw Normal View History

2026-06-10 14:12:14 +08:00
import { type Dispatch, type SetStateAction, useCallback, useEffect, useRef, useState } from "react";
2026-06-02 12:38:01 +08:00
import type {
CanvasImageGenerationState,
CanvasImageNode,
CanvasTextGenerationState,
CanvasVideoGenerationState,
CanvasVideoNode,
} from "./canvasTypes";
2026-06-08 20:57:40 +08:00
import { aiGenerationClient } from "../../api/aiGenerationClient";
2026-06-02 12:38:01 +08:00
import { createCanvasAssetRefFromGeneratedResult, persistCanvasGeneratedResultAsset } from "./canvasAssetPersistence";
import { waitForImageTaskResult, waitForVideoTaskResult } from "./canvasUtils";
const CANVAS_GEN_KEEPALIVE_KEY = "omniai:canvas-gen-tasks";
interface CanvasGenKeepaliveEntry {
taskId: string;
nodeId: string;
nodeKind: "image" | "video";
projectId: string;
}
function loadCanvasGenKeepalive(): CanvasGenKeepaliveEntry[] {
try {
const raw = window.localStorage.getItem(CANVAS_GEN_KEEPALIVE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
function saveCanvasGenKeepalive(entries: CanvasGenKeepaliveEntry[]): void {
window.localStorage.setItem(CANVAS_GEN_KEEPALIVE_KEY, JSON.stringify(entries));
}
export function addCanvasGenKeepalive(taskId: string, nodeId: string, nodeKind: "image" | "video", projectId: string): void {
const entries = loadCanvasGenKeepalive().filter((e) => e.taskId !== taskId);
entries.push({ taskId, nodeId, nodeKind, projectId });
saveCanvasGenKeepalive(entries);
}
export function removeCanvasGenKeepalive(taskId: string): void {
saveCanvasGenKeepalive(loadCanvasGenKeepalive().filter((e) => e.taskId !== taskId));
}
2026-06-08 20:57:40 +08:00
export function cancelCanvasGenKeepaliveOnUnload(): void {
const entries = loadCanvasGenKeepalive();
if (!entries.length) return;
entries.forEach((entry) => aiGenerationClient.cancelTaskOnUnload(entry.taskId));
saveCanvasGenKeepalive([]);
}
2026-06-02 12:38:01 +08:00
export interface UseCanvasGenerationParams {
setImageNodes: Dispatch<SetStateAction<CanvasImageNode[]>>;
setVideoNodes: Dispatch<SetStateAction<CanvasVideoNode[]>>;
}
export function useCanvasGeneration(params: UseCanvasGenerationParams) {
const { setImageNodes, setVideoNodes } = params;
const [textGenerationState, setTextGenerationState] = useState<Record<string, CanvasTextGenerationState>>({});
const [imageGenerationState, setImageGenerationState] = useState<Record<string, CanvasImageGenerationState>>({});
const [videoGenerationState, setVideoGenerationState] = useState<Record<string, CanvasVideoGenerationState>>({});
const [generationToast, setGenerationToast] = useState<string | null>(null);
const imageGenerationInFlightRef = useRef(new Set<string>());
2026-06-08 20:57:40 +08:00
const videoGenerationInFlightRef = useRef(new Set<string>());
2026-06-02 12:38:01 +08:00
const textGenerationInFlightRef = useRef(new Set<string>());
const textGenerationAbortControllersRef = useRef(new Map<string, AbortController>());
2026-06-10 14:12:14 +08:00
const imageGenerationAbortRef = useRef(new Map<string, { current: boolean }>());
const videoGenerationAbortRef = useRef(new Map<string, { current: boolean }>());
2026-06-02 12:38:01 +08:00
const canvasGenKeepaliveRestoredRef = useRef(false);
const setTextGenerationStatus = (nodeId: string, state: CanvasTextGenerationState) => {
setTextGenerationState((current) => ({ ...current, [nodeId]: state }));
};
const setImageGenerationStatus = (nodeId: string, state: CanvasImageGenerationState) => {
setImageGenerationState((current) => ({ ...current, [nodeId]: state }));
};
const setVideoGenerationStatus = (nodeId: string, state: CanvasVideoGenerationState) => {
setVideoGenerationState((current) => ({ ...current, [nodeId]: state }));
};
2026-06-10 14:12:14 +08:00
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();
}, []);
2026-06-02 12:38:01 +08:00
// Toast auto-dismiss
useEffect(() => {
if (!generationToast) return undefined;
const timer = window.setTimeout(() => setGenerationToast(null), 1800);
return () => window.clearTimeout(timer);
}, [generationToast]);
const restoreKeepaliveTasks = (
projectId: string,
imageNodesList: CanvasImageNode[],
videoNodesList: CanvasVideoNode[],
) => {
if (canvasGenKeepaliveRestoredRef.current) return;
canvasGenKeepaliveRestoredRef.current = true;
const storedTasks = loadCanvasGenKeepalive().filter((e) => e.projectId === projectId);
for (const entry of storedTasks) {
const nodeExists = [...imageNodesList, ...videoNodesList].some((n) => n.id === entry.nodeId);
if (!nodeExists) {
removeCanvasGenKeepalive(entry.taskId);
continue;
}
if (entry.nodeKind === "image") {
imageGenerationInFlightRef.current.add(entry.nodeId);
2026-06-10 14:12:14 +08:00
const abortRef = { current: false };
imageGenerationAbortRef.current.set(entry.nodeId, abortRef);
2026-06-02 12:38:01 +08:00
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 });
2026-06-10 14:12:14 +08:00
}, abortRef).then(async (outputUrl) => {
if (abortRef.current || !outputUrl) return;
2026-06-02 12:38:01 +08:00
removeCanvasGenKeepalive(entry.taskId);
setImageGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 });
const ref = createCanvasAssetRefFromGeneratedResult({
url: outputUrl, mediaType: "image/png", resultType: "image", taskId: entry.taskId, originalUrl: outputUrl,
});
setImageNodes((current) => current.map((n) =>
n.id === entry.nodeId ? { ...n, imageUrl: outputUrl, assetRef: ref, taskRef: { taskId: entry.taskId, status: "completed", resultUrl: outputUrl, updatedAt: new Date().toISOString() } } : n
));
let durableRef = ref;
try {
durableRef = await persistCanvasGeneratedResultAsset({
title: "image-node", url: outputUrl, mediaType: "image/png", resultType: "image", taskId: entry.taskId, originalUrl: outputUrl,
});
} catch { /* keep temp URL on failure */ }
if (durableRef.url !== outputUrl || durableRef.ossKey) {
setImageNodes((current) => current.map((n) =>
n.id === entry.nodeId ? { ...n, imageUrl: durableRef.url, assetRef: durableRef, taskRef: { taskId: entry.taskId, status: "completed", resultUrl: durableRef.url, updatedAt: new Date().toISOString() } } : n
));
}
}).catch(() => {
2026-06-10 14:12:14 +08:00
if (abortRef.current) return;
2026-06-02 12:38:01 +08:00
removeCanvasGenKeepalive(entry.taskId);
setImageGenerationStatus(entry.nodeId, { status: "error", message: "图片生成失败" });
}).finally(() => {
imageGenerationInFlightRef.current.delete(entry.nodeId);
2026-06-10 14:12:14 +08:00
imageGenerationAbortRef.current.delete(entry.nodeId);
2026-06-02 12:38:01 +08:00
});
} else if (entry.nodeKind === "video") {
2026-06-08 20:57:40 +08:00
videoGenerationInFlightRef.current.add(entry.nodeId);
2026-06-10 14:12:14 +08:00
const abortRef = { current: false };
videoGenerationAbortRef.current.set(entry.nodeId, abortRef);
2026-06-02 12:38:01 +08:00
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 });
2026-06-10 14:12:14 +08:00
}, abortRef).then(async (outputUrl) => {
if (abortRef.current || !outputUrl) return;
2026-06-02 12:38:01 +08:00
removeCanvasGenKeepalive(entry.taskId);
setVideoGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 });
const ref = createCanvasAssetRefFromGeneratedResult({
url: outputUrl, mediaType: "video/mp4", resultType: "video", taskId: entry.taskId, originalUrl: outputUrl,
});
setVideoNodes((current) => current.map((n) =>
n.id === entry.nodeId ? { ...n, videoUrl: outputUrl, assetRef: ref, taskRef: { taskId: entry.taskId, status: "completed", resultUrl: outputUrl, updatedAt: new Date().toISOString() } } : n
));
let durableRef = ref;
try {
durableRef = await persistCanvasGeneratedResultAsset({
title: "video-node", url: outputUrl, mediaType: "video/mp4", resultType: "video", taskId: entry.taskId, originalUrl: outputUrl,
});
} catch { /* keep temp URL on failure */ }
if (durableRef.url !== outputUrl || durableRef.ossKey) {
setVideoNodes((current) => current.map((n) =>
n.id === entry.nodeId ? { ...n, videoUrl: durableRef.url, assetRef: durableRef, taskRef: { taskId: entry.taskId, status: "completed", resultUrl: durableRef.url, updatedAt: new Date().toISOString() } } : n
));
}
}).catch(() => {
2026-06-10 14:12:14 +08:00
if (abortRef.current) return;
2026-06-02 12:38:01 +08:00
removeCanvasGenKeepalive(entry.taskId);
setVideoGenerationStatus(entry.nodeId, { status: "error", message: "视频生成失败" });
}).finally(() => {
2026-06-08 20:57:40 +08:00
videoGenerationInFlightRef.current.delete(entry.nodeId);
2026-06-10 14:12:14 +08:00
videoGenerationAbortRef.current.delete(entry.nodeId);
2026-06-02 12:38:01 +08:00
});
}
}
};
const resetGenerationState = () => {
2026-06-10 14:12:14 +08:00
abortAllGenerationPollers();
2026-06-02 12:38:01 +08:00
textGenerationInFlightRef.current.clear();
imageGenerationInFlightRef.current.clear();
2026-06-08 20:57:40 +08:00
videoGenerationInFlightRef.current.clear();
2026-06-02 12:38:01 +08:00
setTextGenerationState({});
setImageGenerationState({});
setVideoGenerationState({});
};
2026-06-10 14:12:14 +08:00
// 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]);
2026-06-08 20:57:40 +08:00
useEffect(() => {
const handlePageHide = () => {
cancelCanvasGenKeepaliveOnUnload();
2026-06-10 14:12:14 +08:00
abortAllGenerationPollers();
2026-06-08 20:57:40 +08:00
textGenerationInFlightRef.current.clear();
imageGenerationInFlightRef.current.clear();
videoGenerationInFlightRef.current.clear();
setTextGenerationState({});
setImageGenerationState({});
setVideoGenerationState({});
};
const handleOnline = () => {
aiGenerationClient.flushPendingTaskCancellations();
};
window.addEventListener("pagehide", handlePageHide);
window.addEventListener("online", handleOnline);
aiGenerationClient.flushPendingTaskCancellations();
return () => {
window.removeEventListener("pagehide", handlePageHide);
window.removeEventListener("online", handleOnline);
};
2026-06-10 14:12:14 +08:00
}, [abortAllGenerationPollers]);
2026-06-08 20:57:40 +08:00
2026-06-02 12:38:01 +08:00
return {
textGenerationState,
imageGenerationState,
videoGenerationState,
generationToast,
setGenerationToast,
imageGenerationInFlightRef,
2026-06-08 20:57:40 +08:00
videoGenerationInFlightRef,
2026-06-02 12:38:01 +08:00
textGenerationInFlightRef,
textGenerationAbortControllersRef,
2026-06-10 14:12:14 +08:00
imageGenerationAbortRef,
videoGenerationAbortRef,
2026-06-02 12:38:01 +08:00
canvasGenKeepaliveRestoredRef,
setTextGenerationStatus,
setImageGenerationStatus,
setVideoGenerationStatus,
restoreKeepaliveTasks,
resetGenerationState,
};
}