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; 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("idle"); const [planResult, setPlanResult] = useState(null); const [planProgress, setPlanProgress] = useState(null); const [scenes, setScenes] = useState([]); const [completedSteps, setCompletedSteps] = useState([]); const [sourceImageUrls, setSourceImageUrls] = useState([]); const [currentStep, setCurrentStep] = useState(null); const [failedStep, setFailedStep] = useState(null); const [error, setError] = useState(null); const [actionNotice, setActionNotice] = useState(null); const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null); const [flowZoom, setFlowZoom] = useState(1); const abortControllerRef = useRef(null); const renderAbortRef = useRef({ current: false }); const actionNoticeTimerRef = useRef(null); const setView = useAppStore((s) => s.setView); const keepaliveRestoredFingerprintRef = useRef(null); const keepalivePollingStartedRef = useRef(false); const generation = useGenerationTasks({ sourceView: "ecommerce" }); const sceneStoreIdMap = useRef>(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, 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, 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 (
{/* ── Preview header ─────────────────────────────── */}

预览

上传商品图,AI 即刻生成 符合多电商平台规范 的高转化率短视频素材。

{Math.round(flowZoom * 100)}%
{ALL_STEPS.map((step) => { const isDone = completedSteps.includes(step); const isActive = currentStep === step; return ; })}
{onOpenHistory ? ( ) : null} {error ? {error} : null} {stage === "idle" && planProgress && (planProgress.summary || planProgress.creatives || planProgress.storyboard) ? ( ) : null} {stage === "planned" || stage === "imaged" ? ( ) : null} {stage === "imaged" || (stage === "partial_failed" && imagedScenes.length > 0) ? ( ) : null} {stage === "planning" ? ( {currentStep ? PLAN_STEP_LABELS[currentStep] : "策划中"} ) : null} {stage === "imaging" ? ( 生成图片中 ) : null} {stage === "rendering" ? ( 生成视频中 ) : null} {stage === "planning" || stage === "imaging" || stage === "rendering" ? ( ) : null}
{/* ── Flow canvas ──────────────────────────────────── */}
{!sourceImage ? (
上传商品图并点击"一键策划"开始
) : (
{/* Source Node — 附件原图 */}
商品原图
附件原图
{/* Branch Connector — 分支连接线 */}
{previewMedia ? (
setPreviewMedia(null)}> {previewMedia.type === "image" ? ( 预览 e.stopPropagation()} /> ) : (
) : null}
); }