Files
omniai-ds-code-package/src/features/ecommerce/EcommerceVideoWorkspace.tsx
T

738 lines
34 KiB
TypeScript
Raw Normal View History

import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
2026-06-10 14:06:16 +08:00
import "../../styles/pages/ecommerce-video.css";
import {
CloseOutlined,
CopyOutlined,
DownloadOutlined,
FolderAddOutlined,
HistoryOutlined,
LoadingOutlined,
ReloadOutlined,
SendOutlined,
StopOutlined,
} from "@ant-design/icons";
2026-06-12 17:25:30 +08:00
import {
runVideoPlan,
buildSceneTasks,
saveVideoHistory,
buildComplianceFailureMessage,
} from "./ecommerceVideoService";
2026-06-10 14:06:16 +08:00
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 { saveToolResultToLocal, addToolResultToAssetLibrary } from "../workbench/toolResultActions";
import { useAppStore } from "../../stores";
import { useGenerationTasks } from "../../hooks/useGenerationTasks";
import {
saveEcommerceVideoState,
loadEcommerceVideoState,
clearEcommerceVideoState,
} from "./ecommerceVideoKeepalive";
import { saveUnifiedEcommerceGenerationRecord } from "./ecommerceGenerationPersistence";
import { useVideoSceneRunner } from "./useVideoSceneRunner";
2026-06-10 14:06:16 +08:00
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 };
2026-06-10 14:06:16 +08:00
}
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 {
2026-06-12 17:25:30 +08:00
const imageSignature = input.productImageDataUrls
.map((source) => `${source.length}:${hashString(source)}`)
.join("|");
2026-06-10 14:06:16 +08:00
return hashString([
2026-06-12 17:25:30 +08:00
imageSignature,
2026-06-10 14:06:16 +08:00
input.requirement.trim(),
input.platform,
input.aspectRatio,
input.durationSeconds,
input.resolution,
].join("::"));
}
2026-06-12 17:25:30 +08:00
function planAllowsVideoGeneration(plan: EcommerceVideoPlanResult | null): boolean {
return plan?.compliance.allow_video_generation !== false;
}
2026-06-10 14:06:16 +08:00
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,
2026-06-10 14:06:16 +08:00
}: 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 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],
);
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,
});
2026-06-10 14:06:16 +08:00
// ── 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) {
2026-06-12 17:25:30 +08:00
if (!planAllowsVideoGeneration(planResult)) {
setError(buildComplianceFailureMessage(planResult.compliance));
return;
}
const timer = setTimeout(() => { void runImagePhase(scenes); }, delay);
2026-06-10 14:06:16 +08:00
return () => clearTimeout(timer);
}
if (stage === "imaged" && scenes.every((s) => s.imageUrl)) {
const timer = setTimeout(() => { void runVideoPhase(scenes); }, delay);
2026-06-10 14:06:16 +08:00
return () => clearTimeout(timer);
}
}, [stage, scenes, planResult]);
// ── External trigger: start plan from parent ────────────────
const triggerPlanPrevRef = useRef(0);
2026-06-10 14:06:16 +08:00
useEffect(() => {
if (typeof triggerPlan === "number" && triggerPlan > 0 && triggerPlan !== triggerPlanPrevRef.current) {
2026-06-10 14:06:16 +08:00
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 || "电商短视频";
2026-06-10 14:06:16 +08:00
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(() => {});
void saveUnifiedEcommerceGenerationRecord({
clientRecordId: `ecommerce-video-${inputFingerprint}-${Date.now()}`,
title,
mode: "short-video",
prompt: requirement,
sourceImages: sourceImageUrls.map((url, index) => ({ url, label: `source-${index + 1}` })),
results: scenes
.filter((scene) => Boolean(scene.resultUrl))
.map((scene) => ({
url: scene.resultUrl!,
label: `scene-${scene.sceneId}`,
mediaType: "video",
taskId: scene.taskId,
})),
taskIds: scenes.map((scene) => scene.taskId).filter((taskId): taskId is string => Boolean(taskId)),
config: { platform, aspectRatio, durationSeconds, resolution },
result: {
plan: planResult as unknown as Record<string, unknown>,
scenes: scenes.map((scene) => ({
sceneId: scene.sceneId,
prompt: scene.prompt,
imageUrl: scene.imageUrl,
videoUrl: scene.resultUrl,
status: scene.status,
})),
},
metadata: { inputFingerprint },
});
}, [stage, planResult, scenes, sourceImageUrls, platform, aspectRatio, durationSeconds, resolution, inputFingerprint, requirement]);
2026-06-10 14:06:16 +08:00
// ── 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(() => {});
void saveUnifiedEcommerceGenerationRecord({
clientRecordId: `ecommerce-video-manual-${inputFingerprint}-${Date.now()}`,
title,
mode: "short-video",
prompt: requirement,
sourceImages: currentSources.map((url, index) => ({ url, label: `source-${index + 1}` })),
results: currentScenes
.filter((scene) => Boolean(scene.resultUrl))
.map((scene) => ({
url: scene.resultUrl!,
label: `scene-${scene.sceneId}`,
mediaType: "video",
taskId: scene.taskId,
})),
taskIds: currentScenes.map((scene) => scene.taskId).filter((taskId): taskId is string => Boolean(taskId)),
config: { platform, aspectRatio, durationSeconds, resolution },
result: {
plan: currentPlan as unknown as Record<string, unknown>,
scenes: currentScenes.map((scene) => ({
sceneId: scene.sceneId,
prompt: scene.prompt,
imageUrl: scene.imageUrl,
videoUrl: scene.resultUrl,
status: scene.status,
})),
},
metadata: { inputFingerprint, manual: true },
});
};
}, [saveRef, platform, aspectRatio, durationSeconds, resolution, inputFingerprint, requirement]);
2026-06-10 14:06:16 +08:00
// ── 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;
void resumePolling(stage, scenes);
}, [scenes, stage, resumePolling]);
2026-06-10 14:06:16 +08:00
// 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: ["电商", "短视频"],
2026-06-10 14:06:16 +08:00
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: ["电商", "短视频"],
2026-06-10 14:06:16 +08:00
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: "电商短视频 - 导入画布",
2026-06-10 14:06:16 +08:00
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))
: [];
2026-06-12 17:25:30 +08:00
let liveCurrentStep: PlanStep | null = null;
2026-06-10 14:06:16 +08:00
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(),
{
2026-06-12 17:25:30 +08:00
onStepStart: (step) => {
liveCurrentStep = step;
setCurrentStep(step);
},
2026-06-10 14:06:16 +08:00
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
2026-06-12 17:25:30 +08:00
setFailedStep((prev) => prev || liveCurrentStep);
2026-06-10 14:06:16 +08:00
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; }
2026-06-12 17:25:30 +08:00
if (!productImageDataUrls.length) {
setError("请先上传商品图片"); return;
2026-06-10 14:06:16 +08:00
}
await runPlanFlow(null);
};
const handleResumePlan = async () => {
if (!isAuthenticated) { onRequestLogin?.(); return; }
if (!planProgress) { void handlePlan(); return; }
await runPlanFlow(planProgress);
};
// ── 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}>
2026-06-11 23:56:27 +08:00
{/* ── 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>
2026-06-10 14:06:16 +08:00
</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 runImagePhase(scenes)} title={stage === "imaged" ? "重新生成分镜图" : "生成图片"}>
2026-06-10 14:06:16 +08:00
{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 runVideoPhase(scenes)} title={stage === "partial_failed" ? "重新生成失败的视频" : "生成视频"}>
2026-06-10 14:06:16 +08:00
<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={cancel} title="终止">
2026-06-10 14:06:16 +08:00
<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 retryScene(scene); }}
2026-06-10 14:06:16 +08:00
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>
);
}