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; 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("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 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], ); const { abortControllerRef, renderAbortRef, runImagePhase, runVideoPhase, resumePolling, cancel, retryScene, } = useVideoSceneRunner({ inputFingerprint, planResult, completedSteps, sourceImageUrls, aspectRatio, resolution, generation: generation as unknown as Parameters[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, 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, 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, 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, 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 (
{/* ── 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}
); }