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

738 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
buildSceneTasks,
saveVideoHistory,
buildComplianceFailureMessage,
} 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 { 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";
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 imageSignature = input.productImageDataUrls
.map((source) => `${source.length}:${hashString(source)}`)
.join("|");
return hashString([
imageSignature,
input.requirement.trim(),
input.platform,
input.aspectRatio,
input.durationSeconds,
input.resolution,
].join("::"));
}
function planAllowsVideoGeneration(plan: EcommerceVideoPlanResult | null): boolean {
return plan?.compliance.allow_video_generation !== false;
}
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 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,
});
// ── 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) {
if (!planAllowsVideoGeneration(planResult)) {
setError(buildComplianceFailureMessage(planResult.compliance));
return;
}
const timer = setTimeout(() => { void runImagePhase(scenes); }, delay);
return () => clearTimeout(timer);
}
if (stage === "imaged" && scenes.every((s) => s.imageUrl)) {
const timer = setTimeout(() => { void runVideoPhase(scenes); }, delay);
return () => clearTimeout(timer);
}
}, [stage, scenes, planResult]);
// ── External trigger: start plan from parent ────────────────
const triggerPlanPrevRef = useRef(0);
useEffect(() => {
if (typeof triggerPlan === "number" && triggerPlan > 0 && 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(() => {});
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]);
// ── 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]);
// ── 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]);
// 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))
: [];
let liveCurrentStep: PlanStep | null = null;
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) => {
liveCurrentStep = 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 || liveCurrentStep);
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) {
setError("请先上传商品图片"); return;
}
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}>
{/* ── 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 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 runVideoPhase(scenes)} 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={cancel} 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 retryScene(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>
);
}