481 lines
17 KiB
TypeScript
481 lines
17 KiB
TypeScript
// 视频场景任务编排 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("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<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,
|
||
};
|
||
}
|