refactor(video): 抽出 useVideoSceneRunner hook,视频场景任务编排与 UI 分离(#3)

This commit is contained in:
2026-06-15 20:18:26 +08:00
parent 643595bede
commit 003c41ddcc
2 changed files with 512 additions and 232 deletions
@@ -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 "../../styles/pages/ecommerce-video.css";
import { import {
CloseOutlined, CloseOutlined,
@@ -13,13 +13,10 @@ import {
} from "@ant-design/icons"; } from "@ant-design/icons";
import { import {
runVideoPlan, runVideoPlan,
renderSceneImage,
renderScene,
buildSceneTasks, buildSceneTasks,
saveVideoHistory, saveVideoHistory,
buildComplianceFailureMessage, buildComplianceFailureMessage,
} from "./ecommerceVideoService"; } from "./ecommerceVideoService";
import { waitForTask } from "../../api/taskSubscription";
import { import {
PLAN_STEP_LABELS, PLAN_STEP_LABELS,
PLAN_STEPS_DISPLAY, PLAN_STEPS_DISPLAY,
@@ -30,7 +27,6 @@ import {
type PlanStep, type PlanStep,
} from "./ecommerceVideoTypes"; } from "./ecommerceVideoTypes";
import type { AdVideoUserConfig } from "../../api/adVideoPlanClient"; import type { AdVideoUserConfig } from "../../api/adVideoPlanClient";
import { ServerRequestError } from "../../api/serverConnection";
import { saveToolResultToLocal, addToolResultToAssetLibrary } from "../workbench/toolResultActions"; import { saveToolResultToLocal, addToolResultToAssetLibrary } from "../workbench/toolResultActions";
import { useAppStore } from "../../stores"; import { useAppStore } from "../../stores";
import { useGenerationTasks } from "../../hooks/useGenerationTasks"; import { useGenerationTasks } from "../../hooks/useGenerationTasks";
@@ -40,6 +36,7 @@ import {
clearEcommerceVideoState, clearEcommerceVideoState,
} from "./ecommerceVideoKeepalive"; } from "./ecommerceVideoKeepalive";
import { saveUnifiedEcommerceGenerationRecord } from "./ecommerceGenerationPersistence"; import { saveUnifiedEcommerceGenerationRecord } from "./ecommerceGenerationPersistence";
import { useVideoSceneRunner } from "./useVideoSceneRunner";
interface EcommerceVideoWorkspaceProps { interface EcommerceVideoWorkspaceProps {
isAuthenticated: boolean; isAuthenticated: boolean;
@@ -138,8 +135,6 @@ export default function EcommerceVideoWorkspace({
const [actionNotice, setActionNotice] = useState<string | null>(null); const [actionNotice, setActionNotice] = useState<string | null>(null);
const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null); const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null);
const [flowZoom, setFlowZoom] = useState(1); const [flowZoom, setFlowZoom] = useState(1);
const abortControllerRef = useRef<AbortController | null>(null);
const renderAbortRef = useRef({ current: false });
const actionNoticeTimerRef = useRef<number | null>(null); const actionNoticeTimerRef = useRef<number | null>(null);
const setView = useAppStore((s) => s.setView); const setView = useAppStore((s) => s.setView);
const keepaliveRestoredFingerprintRef = useRef<string | null>(null); const keepaliveRestoredFingerprintRef = useRef<string | null>(null);
@@ -151,6 +146,28 @@ export default function EcommerceVideoWorkspace({
[productImageDataUrls, requirement, platform, aspectRatio, durationSeconds, resolution], [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<typeof useVideoSceneRunner>[0]["generation"],
sceneStoreIdMap,
onScenesChange: setScenes,
onStageChange: setStage,
onError: setError,
});
// ── Keep-alive: restore saved state on mount ───────────── // ── Keep-alive: restore saved state on mount ─────────────
useEffect(() => { useEffect(() => {
if (keepaliveRestoredFingerprintRef.current === inputFingerprint) return; if (keepaliveRestoredFingerprintRef.current === inputFingerprint) return;
@@ -181,11 +198,11 @@ export default function EcommerceVideoWorkspace({
setError(buildComplianceFailureMessage(planResult.compliance)); setError(buildComplianceFailureMessage(planResult.compliance));
return; return;
} }
const timer = setTimeout(() => { void handleGenerateImages(); }, delay); const timer = setTimeout(() => { void runImagePhase(scenes); }, delay);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
if (stage === "imaged" && scenes.every((s) => s.imageUrl)) { if (stage === "imaged" && scenes.every((s) => s.imageUrl)) {
const timer = setTimeout(() => { void handleRenderVideos(); }, delay); const timer = setTimeout(() => { void runVideoPhase(scenes); }, delay);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [stage, scenes, planResult]); }, [stage, scenes, planResult]);
@@ -302,80 +319,11 @@ export default function EcommerceVideoWorkspace({
useEffect(() => { useEffect(() => {
if (keepalivePollingStartedRef.current) return; if (keepalivePollingStartedRef.current) return;
if (!scenes.length || stage === "idle" || stage === "cancelled" || stage === "completed") return; if (!scenes.length || stage === "idle" || stage === "cancelled" || stage === "completed") return;
const hasRunningScenes = scenes.some((s) => s.status === "running" || s.status === "pending"); const hasRunningScenes = scenes.some((s) => s.status === "running" || s.status === "pending");
if (!hasRunningScenes) return; if (!hasRunningScenes) return;
keepalivePollingStartedRef.current = true; keepalivePollingStartedRef.current = true;
void resumePolling(stage, scenes);
// Resume polling for image generation tasks }, [scenes, stage, resumePolling]);
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]);
// Note: keep-alive is NOT cleared on completion — results persist across page switches. // Note: keep-alive is NOT cleared on completion — results persist across page switches.
// Only cleared when user explicitly starts a new plan via handlePlan. // Only cleared when user explicitly starts a new plan via handlePlan.
@@ -558,157 +506,9 @@ export default function EcommerceVideoWorkspace({
await runPlanFlow(planProgress); 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("916") ? "9:16"
: aspectRatio.includes("16:9") || aspectRatio.includes("169") ? "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 ─────────────────────────────────────────── // ── Derived state ───────────────────────────────────────────
@@ -758,13 +558,13 @@ export default function EcommerceVideoWorkspace({
) : null} ) : null}
{stage === "planned" || stage === "imaged" ? ( {stage === "planned" || stage === "imaged" ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost" <button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
onClick={() => void handleGenerateImages()} title={stage === "imaged" ? "重新生成分镜图" : "生成图片"}> onClick={() => void runImagePhase(scenes)} title={stage === "imaged" ? "重新生成分镜图" : "生成图片"}>
{stage === "imaged" ? <ReloadOutlined /> : <SendOutlined />} {stage === "imaged" ? <ReloadOutlined /> : <SendOutlined />}
</button> </button>
) : null} ) : null}
{stage === "imaged" || (stage === "partial_failed" && imagedScenes.length > 0) ? ( {stage === "imaged" || (stage === "partial_failed" && imagedScenes.length > 0) ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost" <button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
onClick={() => void handleRenderVideos()} title={stage === "partial_failed" ? "重新生成失败的视频" : "生成视频"}> onClick={() => void runVideoPhase(scenes)} title={stage === "partial_failed" ? "重新生成失败的视频" : "生成视频"}>
<SendOutlined /> <SendOutlined />
</button> </button>
) : null} ) : null}
@@ -778,7 +578,7 @@ export default function EcommerceVideoWorkspace({
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> </span> <span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> </span>
) : null} ) : null}
{stage === "planning" || stage === "imaging" || stage === "rendering" ? ( {stage === "planning" || stage === "imaging" || stage === "rendering" ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--danger" onClick={handleCancel} title="终止"> <button type="button" className="ecom-video-flow-action ecom-video-flow-action--danger" onClick={cancel} title="终止">
<StopOutlined /> <StopOutlined />
</button> </button>
) : null} ) : null}
@@ -867,7 +667,7 @@ export default function EcommerceVideoWorkspace({
<span className="ecom-video-tree-node__tag">{scene.sceneId}</span> <span className="ecom-video-tree-node__tag">{scene.sceneId}</span>
{vidFailed ? ( {vidFailed ? (
<button type="button" className="ecom-video-tree-node__retry" <button type="button" className="ecom-video-tree-node__retry"
onClick={(e) => { e.stopPropagation(); void handleRetryScene(scene); }} onClick={(e) => { e.stopPropagation(); void retryScene(scene); }}
title="重试此镜头"> title="重试此镜头">
<ReloadOutlined /> <ReloadOutlined />
</button> </button>
@@ -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> = 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<string, unknown> & { taskId: string }) => string;
markCompleted: (id: string, resultUrl?: string) => void;
markFailed: (id: string, error?: string) => void;
};
sceneStoreIdMap: MutableRefObject<Map<number, string>>;
onScenesChange: (updater: SetStateAction<EcommerceVideoSceneTask[]>) => 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("916")) return "9:16";
if (aspectRatio.includes("16:9") || aspectRatio.includes("169")) 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<AbortController | null>(null);
const renderAbortRef = useRef({ current: false });
// ── Image phase: generate per-scene images ──────────────────
const runImagePhase = useCallback(
async (scenes: EcommerceVideoSceneTask[]): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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,
};
}