// 视频场景任务编排 hook。 // 从 EcommerceVideoWorkspace.tsx 抽出,封装"分镜图片生成 / 视频渲染 / 恢复轮询 / 取消" // 四类场景任务的执行逻辑,消除组件内 persistScenes 闭包的重复。 // // 运行时行为与原组件逻辑等价(setScenes/setStage/saveEcommerceVideoState 的调用顺序和参数不变); // 抽离目的是建立逻辑边界,让 resume 与正常执行共享同一套遍历。 import { useCallback, useRef } from "react"; import type { MutableRefObject } from "react"; import { renderSceneImage, renderScene, } from "./ecommerceVideoService"; import { waitForTask } from "../../api/taskSubscription"; import { ServerRequestError } from "../../api/serverConnection"; import { ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy"; import { resolveVideoRequestModel } from "../../utils/resolveVideoModel"; import { saveEcommerceVideoState, } from "./ecommerceVideoKeepalive"; import type { EcommerceVideoSceneTask, EcommerceVideoStage, EcommerceVideoPlanResult, PlanStep, } from "./ecommerceVideoTypes"; type SetStateAction = T | ((prev: T) => T); export interface VideoSceneRunnerContext { inputFingerprint: string; planResult: EcommerceVideoPlanResult | null; completedSteps: PlanStep[]; sourceImageUrls: string[]; aspectRatio: string; resolution: string; /** useGenerationTasks 实例,用于 submitTask/markCompleted/markFailed */ generation: { submitTask: (task: Record & { taskId: string }) => string; markCompleted: (id: string, resultUrl?: string) => void; markFailed: (id: string, error?: string) => void; }; sceneStoreIdMap: MutableRefObject>; onScenesChange: (updater: SetStateAction) => void; onStageChange: (stage: EcommerceVideoStage) => void; onError?: (message: string) => void; } function mapResolutionToQuality(res: string): "720P" | "1080P" { return res.includes("720") ? "720P" : "1080P"; } function deriveAspectRatioToken(aspectRatio: string): string { if (aspectRatio.includes("9:16") || aspectRatio.includes("9:16")) return "9:16"; if (aspectRatio.includes("16:9") || aspectRatio.includes("16:9")) return "16:9"; return "1:1"; } export function useVideoSceneRunner(context: VideoSceneRunnerContext) { const { inputFingerprint, planResult, completedSteps, sourceImageUrls, aspectRatio, resolution, generation, sceneStoreIdMap, onScenesChange, onStageChange, onError, } = context; const abortControllerRef = useRef(null); const renderAbortRef = useRef({ current: false }); // ── Image phase: generate per-scene images ────────────────── const runImagePhase = useCallback( async (scenes: EcommerceVideoSceneTask[]): Promise => { if (!planResult || !scenes.length) return; const ratio = deriveAspectRatioToken(aspectRatio); let currentScenes = [...scenes]; const persistScenes = (next: EcommerceVideoSceneTask[]) => { currentScenes = next; onScenesChange(next); saveEcommerceVideoState({ inputFingerprint, stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls, }); }; const scenesToProcess = currentScenes.filter((s) => !s.imageUrl); if (!scenesToProcess.length) { onStageChange("imaged"); saveEcommerceVideoState({ inputFingerprint, stage: "imaged", completedSteps, planResult, scenes: currentScenes, sourceImageUrls, }); return; } for (const scene of scenesToProcess) { if (renderAbortRef.current.current) break; persistScenes( currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s, ), ); try { await renderSceneImage( { sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio, productImageUrls: sourceImageUrls, }, { onSceneImageSubmitted: (id, taskId) => { persistScenes( currentScenes.map((s) => (s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s)), ); const storeId = generation.submitTask({ title: `分镜${id}图片`, type: "image", status: "running", progress: 0, prompt: scene.prompt, sourceView: "ecommerce", taskId, params: { sceneId: id, phase: "imaging" }, }); sceneStoreIdMap.current.set(id, storeId); }, onSceneImageProgress: (id, progress) => persistScenes(currentScenes.map((s) => (s.sceneId === id ? { ...s, progress } : s))), onSceneImageCompleted: (id, url) => { persistScenes( currentScenes.map((s) => (s.sceneId === id ? { ...s, status: "idle", progress: 100, imageUrl: url } : s)), ); const sid = sceneStoreIdMap.current.get(id); if (sid) generation.markCompleted(sid, url); }, onSceneImageFailed: (id, err2) => { persistScenes( currentScenes.map((s) => (s.sceneId === id ? { ...s, status: "idle", error: err2 } : s)), ); const sid = sceneStoreIdMap.current.get(id); if (sid) generation.markFailed(sid, err2); }, }, renderAbortRef.current, ); } catch (err) { const message = err instanceof Error ? err.message : "图片生成失败"; persistScenes( currentScenes.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "idle", error: message } : s)), ); } } const allHaveImages = currentScenes.every((s) => s.imageUrl); const finalStage: EcommerceVideoStage = allHaveImages ? "imaged" : "partial_failed"; onStageChange(finalStage); saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls, }); }, [ planResult, aspectRatio, inputFingerprint, completedSteps, sourceImageUrls, generation, sceneStoreIdMap, onScenesChange, onStageChange, ], ); // ── Video phase: render per-scene videos ──────────────────── const runVideoPhase = useCallback( async (scenes: EcommerceVideoSceneTask[]): Promise => { if (!scenes.length) return; const quality = mapResolutionToQuality(resolution); let currentScenes = [...scenes]; const persistScenes = (next: EcommerceVideoSceneTask[]) => { currentScenes = next; onScenesChange(next); saveEcommerceVideoState({ inputFingerprint, stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls, }); }; const scenesToProcess = currentScenes.filter((s) => s.imageUrl && s.status !== "completed"); if (!scenesToProcess.length) { const finalStage: EcommerceVideoStage = currentScenes.every((s) => s.status === "completed") ? "completed" : "partial_failed"; onStageChange(finalStage); saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls, }); return; } for (const scene of scenesToProcess) { if (renderAbortRef.current.current) break; if (!scene.imageUrl) continue; persistScenes( currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s, ), ); try { await renderScene( { sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, productImageUrls: sourceImageUrls, aspectRatio, resolution: quality, }, { onSceneSubmitted: (id, taskId) => { persistScenes( currentScenes.map((s) => (s.sceneId === id ? { ...s, taskId, status: "running" } : s)), ); const storeId = generation.submitTask({ title: `分镜${id}视频`, type: "video", status: "running", progress: 0, prompt: scene.prompt, sourceView: "ecommerce", taskId, params: { sceneId: id, phase: "rendering" }, }); sceneStoreIdMap.current.set(id, storeId); }, onSceneProgress: (id, progress) => persistScenes(currentScenes.map((s) => (s.sceneId === id ? { ...s, progress } : s))), onSceneCompleted: (id, url) => { persistScenes( currentScenes.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s, ), ); const sid = sceneStoreIdMap.current.get(id); if (sid) generation.markCompleted(sid, url); }, onSceneFailed: (id, err2) => { persistScenes( currentScenes.map((s) => (s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)), ); const sid = sceneStoreIdMap.current.get(id); if (sid) generation.markFailed(sid, err2); }, }, renderAbortRef.current, ); } catch (err) { const msg = err instanceof Error ? err.message : "生成失败"; const isPayment = err instanceof ServerRequestError && err.status === 402; persistScenes( currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "failed", error: isPayment ? "余额不足,请充值后继续" : msg } : s, ), ); if (isPayment) { onError?.("余额不足,请充值后再生成视频"); renderAbortRef.current.current = true; break; } } } const hasFailed = currentScenes.some((s) => s.status === "failed"); const allDone = currentScenes.every((s) => s.status === "completed" || s.status === "failed"); const finalStage: EcommerceVideoStage = allDone ? hasFailed ? "partial_failed" : "completed" : "rendering"; onScenesChange(currentScenes); onStageChange(finalStage); saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls, }); }, [ resolution, inputFingerprint, completedSteps, planResult, sourceImageUrls, aspectRatio, generation, sceneStoreIdMap, onScenesChange, onStageChange, onError, ], ); // ── Resume polling: re-attach waitForTask to running scenes ─ // Used when the page is restored from keep-alive. Differs from runImagePhase/runVideoPhase // in that it does NOT create new tasks — it only polls existing imageTaskId/taskId. const resumePolling = useCallback( async (stage: EcommerceVideoStage, scenes: EcommerceVideoSceneTask[]): Promise => { renderAbortRef.current = { current: false }; if (stage === "imaging") { for (const scene of scenes) { if (renderAbortRef.current.current) break; if (scene.status !== "running" && scene.status !== "pending") continue; if (!scene.imageTaskId) continue; try { const resultUrl = await waitForTask(scene.imageTaskId, { abortRef: renderAbortRef.current, onProgress: (e) => onScenesChange((prev) => prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, progress: e.progress } : s)), ), }); if (resultUrl) { onScenesChange((prev) => prev.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "idle", progress: 100, imageUrl: resultUrl } : s, ), ); } } catch { onScenesChange((prev) => prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "idle", error: "恢复任务失败" } : s)), ); } } onScenesChange((current) => { const allImaged = current.every((s) => s.imageUrl); if (allImaged) onStageChange("imaged"); return current; }); } if (stage === "rendering") { for (const scene of scenes) { if (renderAbortRef.current.current) break; if (scene.status !== "running" && scene.status !== "pending") continue; if (!scene.taskId) continue; try { const resultUrl = await waitForTask(scene.taskId, { abortRef: renderAbortRef.current, onProgress: (e) => onScenesChange((prev) => prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, progress: e.progress } : s)), ), }); if (resultUrl) { onScenesChange((prev) => prev.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "completed", progress: 100, resultUrl: resultUrl } : s, ), ); } } catch { onScenesChange((prev) => prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "failed", error: "恢复任务失败" } : s)), ); } } onScenesChange((current) => { const hasFailed = current.some((s) => s.status === "failed"); const allDone = current.every((s) => s.status === "completed" || s.status === "failed"); if (allDone) onStageChange(hasFailed ? "partial_failed" : "completed"); return current; }); } }, [onScenesChange, onStageChange], ); // ── Cancel: abort planning + scene rendering ──────────────── const cancel = useCallback(() => { abortControllerRef.current?.abort(); renderAbortRef.current.current = true; onStageChange("cancelled"); }, [onStageChange]); // ── Retry a single scene's video ──────────────────────────── const retryScene = useCallback( async (scene: EcommerceVideoSceneTask): Promise => { if (!scene.imageUrl) return; onScenesChange((prev) => prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s)), ); try { await renderScene( { sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, productImageUrls: sourceImageUrls, aspectRatio, resolution: mapResolutionToQuality(resolution), }, { onSceneSubmitted: (id, taskId) => onScenesChange((prev) => prev.map((s) => (s.sceneId === id ? { ...s, taskId, status: "running" } : s))), onSceneProgress: (id, progress) => onScenesChange((prev) => prev.map((s) => (s.sceneId === id ? { ...s, progress } : s))), onSceneCompleted: (id, url) => onScenesChange((prev) => prev.map((s) => (s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s)), ), onSceneFailed: (id, err2) => onScenesChange((prev) => prev.map((s) => (s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)), ), }, renderAbortRef.current, ); } catch (err) { onScenesChange((prev) => prev.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "failed", error: (err as Error).message } : s, ), ); } }, [sourceImageUrls, aspectRatio, resolution, onScenesChange], ); return { abortControllerRef, renderAbortRef, runImagePhase, runVideoPhase, resumePolling, cancel, retryScene, }; }