Files
omniai-ds-code-package/src/features/ecommerce/useVideoSceneRunner.ts
T

481 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 视频场景任务编排 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,
};
}