844 lines
42 KiB
TypeScript
844 lines
42 KiB
TypeScript
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import "../../styles/pages/ecommerce-video.css";
|
||
import {
|
||
CloseOutlined,
|
||
CopyOutlined,
|
||
DownloadOutlined,
|
||
FolderAddOutlined,
|
||
HistoryOutlined,
|
||
LoadingOutlined,
|
||
ReloadOutlined,
|
||
SendOutlined,
|
||
StopOutlined,
|
||
} from "@ant-design/icons";
|
||
import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks, saveVideoHistory } from "./ecommerceVideoService";
|
||
import {
|
||
PLAN_STEP_LABELS,
|
||
PLAN_STEPS_DISPLAY,
|
||
type EcommerceVideoStage,
|
||
type EcommerceVideoSceneTask,
|
||
type EcommerceVideoPlanProgress,
|
||
type EcommerceVideoPlanResult,
|
||
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";
|
||
import {
|
||
saveEcommerceVideoState,
|
||
loadEcommerceVideoState,
|
||
clearEcommerceVideoState,
|
||
} from "./ecommerceVideoKeepalive";
|
||
|
||
interface EcommerceVideoWorkspaceProps {
|
||
isAuthenticated: boolean;
|
||
productImageDataUrls: string[];
|
||
productImageFiles?: Array<File | undefined>;
|
||
requirement: string;
|
||
platform: string;
|
||
aspectRatio: string;
|
||
durationSeconds: number;
|
||
resolution: string;
|
||
onRequestLogin?: () => void;
|
||
onOpenHistory?: () => void;
|
||
triggerPlan?: number;
|
||
saveRef?: { current: (() => void) | null };
|
||
}
|
||
|
||
const ALL_STEPS: PlanStep[] = [
|
||
"upload", "analyze", "summary", "selling",
|
||
"creative", "storyboard", "prompts", "compliance",
|
||
];
|
||
|
||
function hashString(value: string): string {
|
||
let hash = 2166136261;
|
||
for (let index = 0; index < value.length; index += 1) {
|
||
hash ^= value.charCodeAt(index);
|
||
hash = Math.imul(hash, 16777619);
|
||
}
|
||
return (hash >>> 0).toString(36);
|
||
}
|
||
|
||
function buildInputFingerprint(input: {
|
||
productImageDataUrls: string[];
|
||
requirement: string;
|
||
platform: string;
|
||
aspectRatio: string;
|
||
durationSeconds: number;
|
||
resolution: string;
|
||
}): string {
|
||
const imageCount = input.productImageDataUrls.length;
|
||
return hashString([
|
||
String(imageCount),
|
||
input.requirement.trim(),
|
||
input.platform,
|
||
input.aspectRatio,
|
||
input.durationSeconds,
|
||
input.resolution,
|
||
].join("::"));
|
||
}
|
||
|
||
function mapResolutionToQuality(res: string): "720P" | "1080P" {
|
||
return res.includes("720") ? "720P" : "1080P";
|
||
}
|
||
|
||
function stepCompletedFromProgress(step: PlanStep, p: EcommerceVideoPlanProgress): boolean {
|
||
switch (step) {
|
||
case "upload": return Boolean(p.imageUrls?.length);
|
||
case "analyze": return p.imageDescription !== undefined;
|
||
case "summary": return Boolean(p.summary);
|
||
case "selling": return Boolean(p.selling);
|
||
case "creative": return Boolean(p.creatives?.length);
|
||
case "storyboard": return Boolean(p.storyboard);
|
||
case "prompts": return Boolean(p.videoPrompts);
|
||
case "compliance": return Boolean(p.compliance);
|
||
}
|
||
}
|
||
|
||
export default function EcommerceVideoWorkspace({
|
||
isAuthenticated,
|
||
productImageDataUrls,
|
||
productImageFiles = [],
|
||
requirement,
|
||
platform,
|
||
aspectRatio,
|
||
durationSeconds,
|
||
resolution,
|
||
onRequestLogin,
|
||
onOpenHistory,
|
||
triggerPlan,
|
||
saveRef,
|
||
}: EcommerceVideoWorkspaceProps) {
|
||
const [stage, setStage] = useState<EcommerceVideoStage>("idle");
|
||
const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null);
|
||
const [planProgress, setPlanProgress] = useState<EcommerceVideoPlanProgress | null>(null);
|
||
const [scenes, setScenes] = useState<EcommerceVideoSceneTask[]>([]);
|
||
const [completedSteps, setCompletedSteps] = useState<PlanStep[]>([]);
|
||
const [sourceImageUrls, setSourceImageUrls] = useState<string[]>([]);
|
||
const [currentStep, setCurrentStep] = useState<PlanStep | null>(null);
|
||
const [failedStep, setFailedStep] = useState<PlanStep | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
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);
|
||
const keepalivePollingStartedRef = useRef(false);
|
||
const generation = useGenerationTasks({ sourceView: "ecommerce" });
|
||
const sceneStoreIdMap = useRef<Map<number, string>>(new Map());
|
||
const inputFingerprint = useMemo(
|
||
() => buildInputFingerprint({ productImageDataUrls, requirement, platform, aspectRatio, durationSeconds, resolution }),
|
||
[productImageDataUrls, requirement, platform, aspectRatio, durationSeconds, resolution],
|
||
);
|
||
|
||
// ── Keep-alive: restore saved state on mount ─────────────
|
||
useEffect(() => {
|
||
if (keepaliveRestoredFingerprintRef.current === inputFingerprint) return;
|
||
keepaliveRestoredFingerprintRef.current = inputFingerprint;
|
||
const saved = loadEcommerceVideoState(inputFingerprint);
|
||
if (!saved) return;
|
||
if (saved.stage === "idle" || saved.stage === "cancelled") return;
|
||
// Restore completed / in-progress states — results persist across page switches
|
||
setStage(saved.stage);
|
||
setCompletedSteps(saved.completedSteps || []);
|
||
setPlanResult(saved.planResult);
|
||
setPlanProgress((saved as { planProgress?: EcommerceVideoPlanProgress | null }).planProgress || null);
|
||
setScenes(saved.scenes || []);
|
||
setSourceImageUrls(saved.sourceImageUrls || saved.planResult?.imageUrls || []);
|
||
}, [inputFingerprint]);
|
||
|
||
// ── Keep-alive: save state on changes ───────────────────
|
||
useEffect(() => {
|
||
if (stage === "idle" || stage === "cancelled") return;
|
||
saveEcommerceVideoState({ inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls });
|
||
}, [inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]);
|
||
|
||
// ── Auto-advance: automatically run the full pipeline ─────────
|
||
useEffect(() => {
|
||
const delay = 600;
|
||
if (stage === "planned" && planResult && scenes.length > 0) {
|
||
const timer = setTimeout(() => { void handleGenerateImages(); }, delay);
|
||
return () => clearTimeout(timer);
|
||
}
|
||
if (stage === "imaged" && scenes.every((s) => s.imageUrl)) {
|
||
const timer = setTimeout(() => { void handleRenderVideos(); }, delay);
|
||
return () => clearTimeout(timer);
|
||
}
|
||
}, [stage, scenes, planResult]);
|
||
|
||
// ── External trigger: start plan from parent ────────────────
|
||
const triggerPlanPrevRef = useRef(triggerPlan);
|
||
useEffect(() => {
|
||
if (triggerPlan != null && triggerPlan !== triggerPlanPrevRef.current) {
|
||
triggerPlanPrevRef.current = triggerPlan;
|
||
void handlePlan();
|
||
}
|
||
}, [triggerPlan]);
|
||
|
||
// ── Auto-save: persist completed results to server ──────────
|
||
const historySavedRef = useRef(false);
|
||
useEffect(() => {
|
||
if (stage !== "completed") { historySavedRef.current = false; return; }
|
||
if (historySavedRef.current) return;
|
||
if (!planResult || !scenes.length) return;
|
||
historySavedRef.current = true;
|
||
const title = planResult.storyboard?.video_title || planResult.summary?.product_name || "电商广告视频";
|
||
saveVideoHistory({
|
||
title,
|
||
config: { platform, aspectRatio, durationSeconds, resolution },
|
||
plan: planResult as unknown as Record<string, unknown>,
|
||
scenes: scenes.map((s) => ({ sceneId: s.sceneId, prompt: s.prompt, imageUrl: s.imageUrl, videoUrl: s.resultUrl })),
|
||
sourceImageUrls,
|
||
}).catch(() => {});
|
||
}, [stage, planResult, scenes, sourceImageUrls, platform, aspectRatio, durationSeconds, resolution]);
|
||
|
||
// ── Expose manual save via ref ──────────────────────────
|
||
const planResultRef = useRef(planResult);
|
||
planResultRef.current = planResult;
|
||
const scenesRef = useRef(scenes);
|
||
scenesRef.current = scenes;
|
||
const sourceImageUrlsRef = useRef(sourceImageUrls);
|
||
sourceImageUrlsRef.current = sourceImageUrls;
|
||
|
||
useEffect(() => {
|
||
if (!saveRef) return;
|
||
saveRef.current = () => {
|
||
const currentPlan = planResultRef.current;
|
||
const currentScenes = scenesRef.current;
|
||
const currentSources = sourceImageUrlsRef.current;
|
||
if (!currentPlan || !currentScenes.length) return;
|
||
const title = currentPlan.storyboard?.video_title || currentPlan.summary?.product_name || "电商广告视频";
|
||
saveVideoHistory({
|
||
title,
|
||
config: { platform, aspectRatio, durationSeconds, resolution },
|
||
plan: currentPlan as unknown as Record<string, unknown>,
|
||
scenes: currentScenes.map((s) => ({ sceneId: s.sceneId, prompt: s.prompt, imageUrl: s.imageUrl, videoUrl: s.resultUrl })),
|
||
sourceImageUrls: currentSources,
|
||
}).catch(() => {});
|
||
};
|
||
}, [saveRef, platform, aspectRatio, durationSeconds, resolution]);
|
||
|
||
// ── Keep-alive: resume polling for running tasks ──────────
|
||
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 { waitForTask } = await import("../../api/taskSubscription");
|
||
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 { waitForTask } = await import("../../api/taskSubscription");
|
||
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.
|
||
// Only cleared when user explicitly starts a new plan via handlePlan.
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (actionNoticeTimerRef.current !== null) {
|
||
window.clearTimeout(actionNoticeTimerRef.current);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
const showNotice = (msg: string) => {
|
||
setActionNotice(msg);
|
||
if (actionNoticeTimerRef.current !== null) {
|
||
window.clearTimeout(actionNoticeTimerRef.current);
|
||
}
|
||
actionNoticeTimerRef.current = window.setTimeout(() => {
|
||
actionNoticeTimerRef.current = null;
|
||
setActionNotice(null);
|
||
}, 3000);
|
||
};
|
||
|
||
const handleDownload = async (url: string) => {
|
||
try {
|
||
await saveToolResultToLocal({
|
||
url, name: `ecommerce-video-${Date.now()}`, type: "video",
|
||
isVideo: true, tags: ["电商", "短视频", "生成视频"],
|
||
});
|
||
showNotice("下载完成");
|
||
} catch {
|
||
const a = document.createElement("a"); a.href = url; a.download = "ecommerce-video.mp4"; a.click();
|
||
}
|
||
};
|
||
|
||
const handleSaveAsset = async (url: string) => {
|
||
try {
|
||
const result = await addToolResultToAssetLibrary({
|
||
url, name: `电商短视频-${Date.now()}.mp4`, description: "电商广告视频生成结果",
|
||
type: "video", isVideo: true, tags: ["电商", "短视频", "广告视频"],
|
||
metadata: { source: "ecommerce-video", platform },
|
||
});
|
||
showNotice(result === "server" ? "已保存到资产库" : "已保存到本地资产库");
|
||
} catch { showNotice("保存失败"); }
|
||
};
|
||
|
||
const handleSaveAllAssets = async () => {
|
||
if (!completedScenes.length) return;
|
||
let saved = 0;
|
||
for (const scene of completedScenes) {
|
||
try {
|
||
await addToolResultToAssetLibrary({
|
||
url: scene.resultUrl!, name: `电商短视频-镜头${scene.sceneId}-${Date.now()}.mp4`,
|
||
description: `电商广告视频 - 镜头${scene.sceneId}`,
|
||
type: "video", isVideo: true, tags: ["电商", "短视频", "广告视频"],
|
||
metadata: { source: "ecommerce-video", platform, sceneId: scene.sceneId },
|
||
});
|
||
saved++;
|
||
} catch { /* continue */ }
|
||
}
|
||
showNotice(saved > 0 ? `已保存 ${saved}/${completedScenes.length} 个视频到资产库` : "保存失败");
|
||
};
|
||
|
||
const handleDownloadAll = async () => {
|
||
for (const scene of completedScenes) {
|
||
await new Promise((r) => setTimeout(r, 300));
|
||
const a = document.createElement("a");
|
||
a.href = scene.resultUrl!;
|
||
a.download = `ecommerce-video-scene-${scene.sceneId}.mp4`;
|
||
a.click();
|
||
}
|
||
showNotice(`正在下载 ${completedScenes.length} 个视频`);
|
||
};
|
||
|
||
const handleImportToCanvas = async (url: string) => {
|
||
try {
|
||
await addToolResultToAssetLibrary({
|
||
url, name: `电商短视频-${Date.now()}.mp4`, description: "电商广告视频 - 导入画布",
|
||
type: "video", isVideo: true, tags: ["电商", "短视频", "画布导入"],
|
||
metadata: { source: "ecommerce-video", platform },
|
||
});
|
||
setView("canvas");
|
||
showNotice("已保存资产并跳转画布");
|
||
} catch { showNotice("导入失败"); }
|
||
};
|
||
|
||
const buildConfig = useCallback((): AdVideoUserConfig => ({
|
||
platform, aspectRatio, durationSeconds,
|
||
style: "痛点解决", language: "中文", market: "中国",
|
||
needVoiceover: true, needSubtitle: true, conversionFocus: "conversion",
|
||
}), [platform, aspectRatio, durationSeconds]);
|
||
|
||
// ── Phase 1: Planning ──────────────────────────────────────
|
||
|
||
const runPlanFlow = async (resume: EcommerceVideoPlanProgress | null) => {
|
||
abortControllerRef.current?.abort();
|
||
const controller = new AbortController();
|
||
abortControllerRef.current = controller;
|
||
setStage("planning"); setError(null); setFailedStep(null);
|
||
if (!resume) {
|
||
setCompletedSteps([]); setPlanResult(null); setScenes([]); setSourceImageUrls([]); setPlanProgress(null);
|
||
}
|
||
setCurrentStep(null);
|
||
// Mutable snapshot — async handlers must persist to localStorage directly since the component may unmount
|
||
let livePlanProgress: EcommerceVideoPlanProgress = resume ? { ...resume } : {};
|
||
let liveCompletedSteps: PlanStep[] = resume
|
||
? ALL_STEPS.filter((s) => stepCompletedFromProgress(s, resume))
|
||
: [];
|
||
const persist = (stageNow: EcommerceVideoStage) => {
|
||
saveEcommerceVideoState({
|
||
inputFingerprint,
|
||
stage: stageNow,
|
||
completedSteps: liveCompletedSteps,
|
||
planResult: null,
|
||
planProgress: livePlanProgress,
|
||
scenes: [],
|
||
sourceImageUrls: livePlanProgress.imageUrls || [],
|
||
});
|
||
};
|
||
try {
|
||
const productImageSources = productImageDataUrls.map((url, index) => productImageFiles[index] ?? url);
|
||
const result = await runVideoPlan(
|
||
productImageSources, requirement, buildConfig(),
|
||
{
|
||
onStepStart: (step) => setCurrentStep(step),
|
||
onStepDone: (step) => {
|
||
liveCompletedSteps = [...liveCompletedSteps, step];
|
||
setCompletedSteps((prev) => [...prev, step]);
|
||
},
|
||
onImagesUploaded: (urls) => {
|
||
setSourceImageUrls(urls);
|
||
livePlanProgress = { ...livePlanProgress, imageUrls: urls };
|
||
persist("planning");
|
||
},
|
||
onUploadRejected: (messages) => {
|
||
if (messages.length) showNotice(`已跳过 ${messages.length} 张上传失败的图片`);
|
||
},
|
||
onPartialProgress: (progress) => {
|
||
livePlanProgress = progress;
|
||
setPlanProgress(progress);
|
||
persist("planning");
|
||
},
|
||
resumeFrom: resume || undefined,
|
||
signal: controller.signal,
|
||
},
|
||
);
|
||
const builtScenes = buildSceneTasks(result);
|
||
setPlanResult(result);
|
||
setPlanProgress(null);
|
||
setScenes(builtScenes);
|
||
setStage("planned");
|
||
saveEcommerceVideoState({ inputFingerprint, stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, planProgress: null, scenes: builtScenes, sourceImageUrls: result.imageUrls });
|
||
} catch (err) {
|
||
if ((err as Error).name === "AbortError" && controller.signal.aborted) return;
|
||
const message = err instanceof Error ? err.message : "策划失败";
|
||
setError(message);
|
||
// Mark the step that was in-progress as failed so user can resume
|
||
setFailedStep((prev) => prev || currentStep);
|
||
setStage("idle");
|
||
// Persist partial progress so the user can resume after a page switch
|
||
persist("idle");
|
||
} finally { setCurrentStep(null); }
|
||
};
|
||
|
||
const handlePlan = async () => {
|
||
if (!isAuthenticated) { onRequestLogin?.(); return; }
|
||
if (!productImageDataUrls.length && !requirement.trim()) {
|
||
setError("请先上传产品图片或填写商品说明"); return;
|
||
}
|
||
await runPlanFlow(null);
|
||
};
|
||
|
||
const handleResumePlan = async () => {
|
||
if (!isAuthenticated) { onRequestLogin?.(); return; }
|
||
if (!planProgress) { void handlePlan(); return; }
|
||
await runPlanFlow(planProgress);
|
||
};
|
||
|
||
// ── Phase 2: Image generation per scene ──────────────────────
|
||
|
||
const handleGenerateImages = async () => {
|
||
if (!planResult || !scenes.length) 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"); 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 (!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) { setStage(currentScenes.every((s) => s.status === "completed") ? "completed" : "partial_failed"); 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 ───────────────────────────────────────────
|
||
|
||
const completedScenes = scenes.filter((s) => s.status === "completed" && s.resultUrl);
|
||
const imagedScenes = scenes.filter((s) => s.imageUrl);
|
||
const primaryVideo = completedScenes[0]?.resultUrl;
|
||
const sourceImage = sourceImageUrls[0] || planResult?.imageUrls[0] || productImageDataUrls[0] || "";
|
||
const flowMeta = `${platform} / ${aspectRatio} / ${durationSeconds}s / ${resolution}`;
|
||
const hasImaging = stage === "imaging" || stage === "imaged" || stage === "rendering" || stage === "completed" || stage === "partial_failed";
|
||
const hasRendering = stage === "rendering" || stage === "completed" || stage === "partial_failed";
|
||
const visiblePlanSteps = PLAN_STEPS_DISPLAY.filter((s) => completedSteps.includes(s));
|
||
|
||
return (
|
||
<div className="ecom-video-workspace" data-stage={stage}>
|
||
{/* ── Preview header ─────────────────────────────── */}
|
||
<header className="ecom-video-flowbar ecom-video-preview-head" title={flowMeta}>
|
||
<div className="ecom-video-preview-copy">
|
||
<h1>预览</h1>
|
||
<p>上传商品图,AI 即刻生成 <span>符合多电商平台规范</span> 的高转化率短视频素材。</p>
|
||
<div className="ecom-video-flowbar__zoom">
|
||
<button type="button" onClick={() => setFlowZoom((z) => Math.max(0.25, z - 0.1))} disabled={flowZoom <= 0.25} aria-label="缩小">−</button>
|
||
<span>{Math.round(flowZoom * 100)}%</span>
|
||
<button type="button" onClick={() => setFlowZoom((z) => Math.min(2, z + 0.1))} disabled={flowZoom >= 2} aria-label="放大">+</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="ecom-video-step-dots" aria-label="策划进度">
|
||
{ALL_STEPS.map((step) => {
|
||
const isDone = completedSteps.includes(step);
|
||
const isActive = currentStep === step;
|
||
return <span key={step} className={`ecom-video-step-dot ${isDone ? "is-done" : isActive ? "is-active" : ""}`} title={PLAN_STEP_LABELS[step]} />;
|
||
})}
|
||
</div>
|
||
|
||
<div className="ecom-video-flowbar__actions">
|
||
{onOpenHistory ? (
|
||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost" onClick={onOpenHistory} title="生成记录">
|
||
<HistoryOutlined />
|
||
</button>
|
||
) : null}
|
||
{error ? <span className="ecom-video-flowbar__error" role="alert">{error}</span> : null}
|
||
{stage === "idle" && planProgress && (planProgress.summary || planProgress.creatives || planProgress.storyboard) ? (
|
||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
||
onClick={() => void handleResumePlan()} title={`从「${failedStep ? PLAN_STEP_LABELS[failedStep] : "已中断处"}」继续策划`}>
|
||
<ReloadOutlined /> 继续
|
||
</button>
|
||
) : null}
|
||
{stage === "planned" || stage === "imaged" ? (
|
||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
||
onClick={() => void handleGenerateImages()} 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" ? "重新生成失败的视频" : "生成视频"}>
|
||
<SendOutlined />
|
||
</button>
|
||
) : null}
|
||
{stage === "planning" ? (
|
||
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> {currentStep ? PLAN_STEP_LABELS[currentStep] : "策划中"}</span>
|
||
) : null}
|
||
{stage === "imaging" ? (
|
||
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> 生成图片中</span>
|
||
) : null}
|
||
{stage === "rendering" ? (
|
||
<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="终止">
|
||
<StopOutlined />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</header>
|
||
|
||
{/* ── Flow canvas ──────────────────────────────────── */}
|
||
<section className="ecom-video-flow-canvas" aria-label="视频分镜流程图">
|
||
<div style={{ zoom: flowZoom, flexShrink: 0, display: "flex", alignItems: "flex-start", justifyContent: "center", minWidth: "max-content" }}>
|
||
{!sourceImage ? (
|
||
<div className="ecom-video-empty">
|
||
<span>上传商品图并点击"一键策划"开始</span>
|
||
</div>
|
||
) : (
|
||
<div className="ecom-video-tree">
|
||
{/* Source Node — 附件原图 */}
|
||
<div className="ecom-video-tree__source">
|
||
<article className="ecom-video-tree-node ecom-video-tree-node--source">
|
||
<img src={sourceImage} alt="商品原图" />
|
||
</article>
|
||
<span className="ecom-video-tree-node__label">附件原图</span>
|
||
</div>
|
||
|
||
{/* Branch Connector — 分支连接线 */}
|
||
<div className="ecom-video-tree__trunk" aria-hidden="true">
|
||
<div className="ecom-video-tree__trunk-line" />
|
||
<div className="ecom-video-tree__branches-line">
|
||
{scenes.length > 0 ? scenes.map((s) => (
|
||
<div key={`trunk-${s.sceneId}`} className="ecom-video-tree__branch-tap" />
|
||
)) : (
|
||
<div className="ecom-video-tree__branch-tap" />
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Branches — 每个场景一条分支 */}
|
||
<div className="ecom-video-tree__rows">
|
||
{scenes.length > 0 ? scenes.map((scene, idx) => {
|
||
const planDone = completedSteps.length >= ALL_STEPS.length;
|
||
const imgReady = !!scene.imageUrl;
|
||
const imgRunning = stage === "imaging" && (scene.status === "running" || scene.status === "pending") && !scene.imageUrl;
|
||
const vidReady = scene.status === "completed" && scene.resultUrl;
|
||
const vidRunning = stage === "rendering" && (scene.status === "running" || scene.status === "pending");
|
||
const vidFailed = scene.status === "failed";
|
||
|
||
return (
|
||
<div key={scene.sceneId} className="ecom-video-tree__row" style={{ animationDelay: `${idx * 120}ms` }}>
|
||
<article className={`ecom-video-tree-node ecom-video-tree-node--text${planDone ? " is-completed" : currentStep ? " is-active" : ""}`}>
|
||
<div className="ecom-video-tree-node__inner">
|
||
<span className="ecom-video-tree-node__title">分镜文本{scene.sceneId}</span>
|
||
<span className="ecom-video-tree-node__desc">
|
||
{planDone ? "已完成" : stage === "planning" ? "策划中..." : "等待策划"}
|
||
</span>
|
||
</div>
|
||
</article>
|
||
|
||
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
||
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||
</div>
|
||
|
||
<article className={`ecom-video-tree-node ecom-video-tree-node--image${imgReady ? " is-completed" : imgRunning ? " is-active" : ""}`} onClick={imgReady ? () => setPreviewMedia({ url: scene.imageUrl!, type: "image" }) : undefined} style={imgReady ? { cursor: "pointer" } : undefined}>
|
||
{imgReady ? (
|
||
<img src={scene.imageUrl!} alt={`分镜${scene.sceneId}`} />
|
||
) : (
|
||
<div className="ecom-video-tree-node__placeholder">
|
||
{imgRunning ? <LoadingOutlined /> : <span>待生成</span>}
|
||
</div>
|
||
)}
|
||
{imgRunning ? <span className="ecom-video-tree-node__progress">{scene.progress || 0}%</span> : null}
|
||
<span className="ecom-video-tree-node__tag">分镜图{scene.sceneId}</span>
|
||
</article>
|
||
|
||
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
||
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||
</div>
|
||
|
||
<article className={`ecom-video-tree-node ecom-video-tree-node--video${vidReady ? " is-completed" : vidRunning ? " is-active" : vidFailed ? " is-failed" : ""}`} onClick={vidReady ? () => setPreviewMedia({ url: scene.resultUrl!, type: "video" }) : undefined} style={vidReady ? { cursor: "pointer" } : undefined}>
|
||
{vidReady ? (
|
||
<video src={scene.resultUrl!} muted playsInline loop autoPlay />
|
||
) : (
|
||
<div className="ecom-video-tree-node__placeholder">
|
||
{vidRunning ? <LoadingOutlined /> : vidFailed ? <span>失败</span> : <span>待生成</span>}
|
||
</div>
|
||
)}
|
||
{vidRunning ? <span className="ecom-video-tree-node__progress">{scene.progress || 0}%</span> : null}
|
||
<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); }}
|
||
title="重试此镜头">
|
||
<ReloadOutlined />
|
||
</button>
|
||
) : null}
|
||
</article>
|
||
</div>
|
||
);
|
||
}) : (
|
||
<div className={`ecom-video-tree__row ecom-video-tree__row--empty${stage === "planning" ? " is-planning" : ""}`}>
|
||
<article className="ecom-video-tree-node ecom-video-tree-node--text">
|
||
<div className="ecom-video-tree-node__inner">
|
||
<span className="ecom-video-tree-node__title">分镜策划</span>
|
||
<span className="ecom-video-tree-node__desc">{stage === "planning" ? "策划中..." : "点击一键策划开始"}</span>
|
||
</div>
|
||
</article>
|
||
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
||
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||
</div>
|
||
<article className="ecom-video-tree-node ecom-video-tree-node--image">
|
||
<div className="ecom-video-tree-node__placeholder">
|
||
{stage === "planning" ? <LoadingOutlined /> : <span>待生成</span>}
|
||
</div>
|
||
<span className="ecom-video-tree-node__tag">分镜图</span>
|
||
</article>
|
||
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
||
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||
</div>
|
||
<article className="ecom-video-tree-node ecom-video-tree-node--video">
|
||
<div className="ecom-video-tree-node__placeholder">
|
||
{stage === "planning" ? <LoadingOutlined /> : <span>待生成</span>}
|
||
</div>
|
||
<span className="ecom-video-tree-node__tag">分镜视频</span>
|
||
</article>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Delivery dock ────────────────────────────── */}
|
||
{primaryVideo ? (
|
||
<div className="ecom-video-flow-dock" aria-label="视频交付操作">
|
||
<button type="button" onClick={() => void handleDownloadAll()} title={`下载全部 ${completedScenes.length} 个视频`}><DownloadOutlined /></button>
|
||
<button type="button" onClick={() => void handleSaveAllAssets()} title={`保存全部 ${completedScenes.length} 个视频到资产库`}><FolderAddOutlined /></button>
|
||
{primaryVideo ? <button type="button" onClick={() => void handleImportToCanvas(primaryVideo)} title="导入画布"><SendOutlined /></button> : null}
|
||
{primaryVideo ? <button type="button" onClick={() => void navigator.clipboard.writeText(primaryVideo)} title="复制链接"><CopyOutlined /></button> : null}
|
||
</div>
|
||
) : null}
|
||
{actionNotice ? <div className="ecom-video-flow-notice">{actionNotice}</div> : null}
|
||
</section>
|
||
|
||
{previewMedia ? (
|
||
<div className="ecom-video-preview-overlay" onClick={() => setPreviewMedia(null)}>
|
||
<button type="button" className="ecom-video-preview-overlay__close" onClick={() => setPreviewMedia(null)}>
|
||
<CloseOutlined />
|
||
</button>
|
||
{previewMedia.type === "image" ? (
|
||
<img src={previewMedia.url} alt="预览" onClick={(e) => e.stopPropagation()} />
|
||
) : (
|
||
<video src={previewMedia.url} controls autoPlay onClick={(e) => e.stopPropagation()} />
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|