refactor(video): 抽出 useVideoSceneRunner hook,视频场景任务编排与 UI 分离(#3)
This commit is contained in:
@@ -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<string | null>(null);
|
||||
const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null);
|
||||
const [flowZoom, setFlowZoom] = useState(1);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const renderAbortRef = useRef({ current: false });
|
||||
const actionNoticeTimerRef = useRef<number | null>(null);
|
||||
const setView = useAppStore((s) => s.setView);
|
||||
const keepaliveRestoredFingerprintRef = useRef<string | null>(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<typeof useVideoSceneRunner>[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" ? (
|
||||
<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 />}
|
||||
</button>
|
||||
) : null}
|
||||
{stage === "imaged" || (stage === "partial_failed" && imagedScenes.length > 0) ? (
|
||||
<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 />
|
||||
</button>
|
||||
) : null}
|
||||
@@ -778,7 +578,7 @@ export default function EcommerceVideoWorkspace({
|
||||
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> 生成视频中</span>
|
||||
) : null}
|
||||
{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 />
|
||||
</button>
|
||||
) : null}
|
||||
@@ -867,7 +667,7 @@ export default function EcommerceVideoWorkspace({
|
||||
<span className="ecom-video-tree-node__tag">分镜视频{scene.sceneId}</span>
|
||||
{vidFailed ? (
|
||||
<button type="button" className="ecom-video-tree-node__retry"
|
||||
onClick={(e) => { e.stopPropagation(); void handleRetryScene(scene); }}
|
||||
onClick={(e) => { e.stopPropagation(); void retryScene(scene); }}
|
||||
title="重试此镜头">
|
||||
<ReloadOutlined />
|
||||
</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("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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user