2026-06-09 12:02:30 +08:00
|
|
|
import { type Dispatch, type SetStateAction, 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>());
|
|
|
|
|
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 }));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
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) => {
|
|
|
|
|
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(() => {
|
|
|
|
|
removeCanvasGenKeepalive(entry.taskId);
|
|
|
|
|
setImageGenerationStatus(entry.nodeId, { status: "error", message: "图片生成失败" });
|
|
|
|
|
}).finally(() => {
|
|
|
|
|
imageGenerationInFlightRef.current.delete(entry.nodeId);
|
|
|
|
|
});
|
|
|
|
|
} else if (entry.nodeKind === "video") {
|
2026-06-08 20:57:40 +08:00
|
|
|
videoGenerationInFlightRef.current.add(entry.nodeId);
|
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 });
|
|
|
|
|
}).then(async (outputUrl) => {
|
|
|
|
|
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(() => {
|
|
|
|
|
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-02 12:38:01 +08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const resetGenerationState = () => {
|
|
|
|
|
textGenerationAbortControllersRef.current.forEach((c) => c.abort());
|
|
|
|
|
textGenerationAbortControllersRef.current.clear();
|
|
|
|
|
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-08 20:57:40 +08:00
|
|
|
useEffect(() => {
|
|
|
|
|
const handlePageHide = () => {
|
|
|
|
|
cancelCanvasGenKeepaliveOnUnload();
|
|
|
|
|
textGenerationAbortControllersRef.current.forEach((controller) => controller.abort());
|
|
|
|
|
textGenerationAbortControllersRef.current.clear();
|
|
|
|
|
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-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,
|
|
|
|
|
canvasGenKeepaliveRestoredRef,
|
|
|
|
|
setTextGenerationStatus,
|
|
|
|
|
setImageGenerationStatus,
|
|
|
|
|
setVideoGenerationStatus,
|
|
|
|
|
restoreKeepaliveTasks,
|
|
|
|
|
resetGenerationState,
|
|
|
|
|
};
|
|
|
|
|
}
|