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
@@ -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,
};
}