diff --git a/src/features/ecommerce/EcommerceVideoWorkspace.tsx b/src/features/ecommerce/EcommerceVideoWorkspace.tsx index 1174943..bb4b914 100644 --- a/src/features/ecommerce/EcommerceVideoWorkspace.tsx +++ b/src/features/ecommerce/EcommerceVideoWorkspace.tsx @@ -1,4 +1,4 @@ -import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import "../../styles/pages/ecommerce-video.css"; import { CloseOutlined, @@ -13,13 +13,10 @@ import { } from "@ant-design/icons"; import { runVideoPlan, - renderSceneImage, - renderScene, buildSceneTasks, saveVideoHistory, buildComplianceFailureMessage, } from "./ecommerceVideoService"; -import { waitForTask } from "../../api/taskSubscription"; import { PLAN_STEP_LABELS, PLAN_STEPS_DISPLAY, @@ -30,7 +27,6 @@ import { type PlanStep, } from "./ecommerceVideoTypes"; import type { AdVideoUserConfig } from "../../api/adVideoPlanClient"; -import { ServerRequestError } from "../../api/serverConnection"; import { saveToolResultToLocal, addToolResultToAssetLibrary } from "../workbench/toolResultActions"; import { useAppStore } from "../../stores"; import { useGenerationTasks } from "../../hooks/useGenerationTasks"; @@ -40,6 +36,7 @@ import { clearEcommerceVideoState, } from "./ecommerceVideoKeepalive"; import { saveUnifiedEcommerceGenerationRecord } from "./ecommerceGenerationPersistence"; +import { useVideoSceneRunner } from "./useVideoSceneRunner"; interface EcommerceVideoWorkspaceProps { isAuthenticated: boolean; @@ -138,8 +135,6 @@ export default function EcommerceVideoWorkspace({ const [actionNotice, setActionNotice] = useState(null); const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null); const [flowZoom, setFlowZoom] = useState(1); - const abortControllerRef = useRef(null); - const renderAbortRef = useRef({ current: false }); const actionNoticeTimerRef = useRef(null); const setView = useAppStore((s) => s.setView); const keepaliveRestoredFingerprintRef = useRef(null); @@ -151,6 +146,28 @@ export default function EcommerceVideoWorkspace({ [productImageDataUrls, requirement, platform, aspectRatio, durationSeconds, resolution], ); + const { + abortControllerRef, + renderAbortRef, + runImagePhase, + runVideoPhase, + resumePolling, + cancel, + retryScene, + } = useVideoSceneRunner({ + inputFingerprint, + planResult, + completedSteps, + sourceImageUrls, + aspectRatio, + resolution, + generation: generation as unknown as Parameters[0]["generation"], + sceneStoreIdMap, + onScenesChange: setScenes, + onStageChange: setStage, + onError: setError, + }); + // ── Keep-alive: restore saved state on mount ───────────── useEffect(() => { if (keepaliveRestoredFingerprintRef.current === inputFingerprint) return; @@ -181,11 +198,11 @@ export default function EcommerceVideoWorkspace({ setError(buildComplianceFailureMessage(planResult.compliance)); return; } - const timer = setTimeout(() => { void handleGenerateImages(); }, delay); + const timer = setTimeout(() => { void runImagePhase(scenes); }, delay); return () => clearTimeout(timer); } if (stage === "imaged" && scenes.every((s) => s.imageUrl)) { - const timer = setTimeout(() => { void handleRenderVideos(); }, delay); + const timer = setTimeout(() => { void runVideoPhase(scenes); }, delay); return () => clearTimeout(timer); } }, [stage, scenes, planResult]); @@ -302,80 +319,11 @@ export default function EcommerceVideoWorkspace({ useEffect(() => { if (keepalivePollingStartedRef.current) return; if (!scenes.length || stage === "idle" || stage === "cancelled" || stage === "completed") return; - const hasRunningScenes = scenes.some((s) => s.status === "running" || s.status === "pending"); if (!hasRunningScenes) return; keepalivePollingStartedRef.current = true; - - // Resume polling for image generation tasks - if (stage === "imaging") { - renderAbortRef.current = { current: false }; - void (async () => { - 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) => - setScenes((prev) => prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, progress: e.progress } : s))), - }); - if (resultUrl) { - setScenes((prev) => - prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "idle", progress: 100, imageUrl: resultUrl } : s)), - ); - } - } catch { - setScenes((prev) => - prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "idle", error: "恢复任务失败" } : s)), - ); - } - } - setScenes((current) => { - const allImaged = current.every((s) => s.imageUrl); - if (allImaged) setStage("imaged"); - return current; - }); - })(); - } - - // Resume polling for video rendering tasks - if (stage === "rendering") { - renderAbortRef.current = { current: false }; - void (async () => { - 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) => - setScenes((prev) => prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, progress: e.progress } : s))), - }); - if (resultUrl) { - setScenes((prev) => - prev.map((s) => - s.sceneId === scene.sceneId ? { ...s, status: "completed", progress: 100, resultUrl: resultUrl } : s, - ), - ); - } - } catch { - setScenes((prev) => - prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "failed", error: "恢复任务失败" } : s)), - ); - } - } - setScenes((current) => { - const hasFailed = current.some((s) => s.status === "failed"); - const allDone = current.every((s) => s.status === "completed" || s.status === "failed"); - if (allDone) setStage(hasFailed ? "partial_failed" : "completed"); - return current; - }); - })(); - } - }, [scenes, stage]); + void resumePolling(stage, scenes); + }, [scenes, stage, resumePolling]); // Note: keep-alive is NOT cleared on completion — results persist across page switches. // Only cleared when user explicitly starts a new plan via handlePlan. @@ -558,157 +506,9 @@ export default function EcommerceVideoWorkspace({ await runPlanFlow(planProgress); }; - // ── Phase 2: Image generation per scene ────────────────────── - const handleGenerateImages = async () => { - if (!planResult || !scenes.length) return; - if (!planAllowsVideoGeneration(planResult)) { - setError(buildComplianceFailureMessage(planResult.compliance)); - return; - } - setStage("imaging"); setError(null); - renderAbortRef.current = { current: false }; - const ratio = aspectRatio.includes("9:16") || aspectRatio.includes("9:16") ? "9:16" - : aspectRatio.includes("16:9") || aspectRatio.includes("16:9") ? "16:9" - : "1:1"; - let currentScenes = [...scenes]; - const persistScenes = (next: EcommerceVideoSceneTask[]) => { - currentScenes = next; - setScenes(next); - saveEcommerceVideoState({ inputFingerprint, stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls }); - }; - // Only redo scenes missing imageUrl — preserves successfully generated images on partial retry - const scenesToProcess = currentScenes.filter((s) => !s.imageUrl); - if (!scenesToProcess.length) { - setStage("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 = allHaveImages ? "imaged" as const : "partial_failed" as const; - setStage(finalStage); - saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls }); - }; - // ── Phase 3: Video rendering from generated images ────────── - const handleRenderVideos = async () => { - if (!scenes.length) return; - if (!planAllowsVideoGeneration(planResult)) { - setError(planResult ? buildComplianceFailureMessage(planResult.compliance) : "合规检查未通过,已停止生成。"); - return; - } - if (!scenes.some((s) => s.imageUrl)) { setError("请先生成分镜图片"); return; } - setStage("rendering"); setError(null); - renderAbortRef.current = { current: false }; - const quality = mapResolutionToQuality(resolution); - let currentScenes = [...scenes]; - const persistScenes = (next: EcommerceVideoSceneTask[]) => { - currentScenes = next; - setScenes(next); - saveEcommerceVideoState({ inputFingerprint, stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls }); - }; - // Only render scenes that haven't completed yet — preserves successful videos on partial retry - const scenesToProcess = currentScenes.filter((s) => s.imageUrl && s.status !== "completed"); - if (!scenesToProcess.length) { - const finalStage = currentScenes.every((s) => s.status === "completed") ? "completed" as const : "partial_failed" as const; - setStage(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) { setError("余额不足,请充值后再生成视频"); 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 = allDone ? (hasFailed ? "partial_failed" as const : "completed" as const) : "rendering" as const; - setScenes(currentScenes); - setStage(finalStage); - saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls }); - }; - - const handleCancel = () => { abortControllerRef.current?.abort(); renderAbortRef.current.current = true; setStage("cancelled"); }; - - const handleRetryScene = async (scene: EcommerceVideoSceneTask) => { - if (!scene.imageUrl) return; - setScenes((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) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)), - onSceneProgress: (id, progress) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, progress } : s)), - onSceneCompleted: (id, url) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s)), - onSceneFailed: (id, err2) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)), - }, - renderAbortRef.current, - ); - } catch (err) { - setScenes((prev) => prev.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "failed", error: (err as Error).message } : s)); - } - }; // ── Derived state ─────────────────────────────────────────── @@ -758,13 +558,13 @@ export default function EcommerceVideoWorkspace({ ) : null} {stage === "planned" || stage === "imaged" ? ( ) : null} {stage === "imaged" || (stage === "partial_failed" && imagedScenes.length > 0) ? ( ) : null} @@ -778,7 +578,7 @@ export default function EcommerceVideoWorkspace({ 生成视频中 ) : null} {stage === "planning" || stage === "imaging" || stage === "rendering" ? ( - ) : null} @@ -867,7 +667,7 @@ export default function EcommerceVideoWorkspace({ 分镜视频{scene.sceneId} {vidFailed ? ( diff --git a/src/features/ecommerce/useVideoSceneRunner.ts b/src/features/ecommerce/useVideoSceneRunner.ts new file mode 100644 index 0000000..0f4b6ba --- /dev/null +++ b/src/features/ecommerce/useVideoSceneRunner.ts @@ -0,0 +1,480 @@ +// 视频场景任务编排 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, + }; +}