@@ -1,4 +1,4 @@
import { Fragment , useCallback , useRef , useState } from "react" ;
import { Fragment , useCallback , useEffect , useRef , useState } from "react" ;
import {
CopyOutlined ,
DownloadOutlined ,
@@ -9,9 +9,10 @@ import {
SendOutlined ,
StopOutlined ,
} from "@ant-design/icons" ;
import { runVideoPlan , renderScene , buildSceneTasks } from "./ecommerceVideoService" ;
import { runVideoPlan , renderSceneImage , renderScene , buildSceneTasks } from "./ecommerceVideoService" ;
import {
PLAN_STEP_LABELS ,
PLAN_STEPS_DISPLAY ,
type EcommerceVideoStage ,
type EcommerceVideoSceneTask ,
type EcommerceVideoPlanResult ,
@@ -21,6 +22,11 @@ import type { AdVideoUserConfig } from "../../api/adVideoPlanClient";
import { ServerRequestError } from "../../api/serverConnection" ;
import { saveToolResultToLocal , addToolResultToAssetLibrary } from "../workbench/toolResultActions" ;
import { useAppStore } from "../../stores" ;
import {
saveEcommerceVideoState ,
loadEcommerceVideoState ,
clearEcommerceVideoState ,
} from "./ecommerceVideoKeepalive" ;
interface EcommerceVideoWorkspaceProps {
isAuthenticated : boolean ;
@@ -56,12 +62,120 @@ export default function EcommerceVideoWorkspace({
const [ planResult , setPlanResult ] = useState < EcommerceVideoPlanResult | 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 [ error , setError ] = useState < string | null > ( null ) ;
const [ actionNotice , setActionNotice ] = useState < string | null > ( null ) ;
const abortControllerRef = useRef < AbortController | null > ( null ) ;
const renderAbortRef = useRef ( { current : false } ) ;
const setView = useAppStore ( ( s ) = > s . setView ) ;
const keepaliveRestoredRef = useRef ( false ) ;
const keepalivePollingStartedRef = useRef ( false ) ;
// ── Keep-alive: restore saved state on mount ─────────────
useEffect ( ( ) = > {
if ( keepaliveRestoredRef . current ) return ;
keepaliveRestoredRef . current = true ;
const saved = loadEcommerceVideoState ( ) ;
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 ) ;
setScenes ( saved . scenes || [ ] ) ;
setSourceImageUrls ( saved . sourceImageUrls || saved . planResult ? . imageUrls || [ ] ) ;
} , [ ] ) ;
// ── Keep-alive: save state on changes ───────────────────
useEffect ( ( ) = > {
if ( stage === "idle" || stage === "cancelled" ) return ;
saveEcommerceVideoState ( { stage , completedSteps , planResult , scenes , sourceImageUrls } ) ;
} , [ stage , completedSteps , planResult , scenes , sourceImageUrls ] ) ;
// ── 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.
const showNotice = ( msg : string ) = > {
setActionNotice ( msg ) ;
@@ -71,166 +185,229 @@ export default function EcommerceVideoWorkspace({
const handleDownload = async ( url : string ) = > {
try {
await saveToolResultToLocal ( {
url ,
name : ` ecommerce-video- ${ Date . now ( ) } ` ,
type : "video" ,
isVideo : true ,
tags : [ "电商" , "短视频" , "生成视频" ] ,
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 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 : [ "电商" , "短视频" , "广告视频" ] ,
url , name : ` 电商短视频- ${ Date . now ( ) } .mp4 ` , description : "电商广告视频生成结果" ,
type : "video" , isVideo : true , tags : [ "电商" , "短视频" , "广告视频" ] ,
metadata : { source : "ecommerce-video" , platform } ,
} ) ;
showNotice ( result === "server" ? "已保存到资产库" : "已保存到本地资产库" ) ;
} catch {
showNotice ( "保存失败" ) ;
} 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 : [ "电商" , "短视频" , "画布导入" ] ,
url , name : ` 电商短视频- ${ Date . now ( ) } .mp4 ` , description : "电商广告视频 - 导入画布" ,
type : "video" , isVideo : true , tags : [ "电商" , "短视频" , "画布导入" ] ,
metadata : { source : "ecommerce-video" , platform } ,
} ) ;
setView ( "canvas" ) ;
showNotice ( "已保存资产并跳转画布" ) ;
} catch {
showNotice ( "导入失败" ) ;
}
} catch { showNotice ( "导入失败" ) ; }
} ;
const buildConfig = useCallback ( ( ) : AdVideoUserConfig = > ( {
platform ,
aspectRatio ,
durationSeconds ,
style : "痛点解决" ,
language : "中文" ,
market : "中国" ,
needVoiceover : true ,
needSubtitle : true ,
conversionFocus : "conversion" ,
platform , aspectRatio , durationSeconds ,
style : "痛点解决" , language : "中文" , market : "中国" ,
needVoiceover : true , needSubtitle : true , conversionFocus : "conversion" ,
} ) , [ platform , aspectRatio , durationSeconds ] ) ;
// ── Phase 1: Planning ──────────────────────────────────────
const handlePlan = async ( ) = > {
if ( ! isAuthenticated ) { onRequestLogin ? . ( ) ; return ; }
if ( ! productImageDataUrls . length && ! requirement . trim ( ) ) {
setError ( "请先上传产品图片或填写商品说明" ) ;
return ;
setError ( "请先上传产品图片或填写商品说明" ) ; return ;
}
abortControllerRef . current ? . abort ( ) ;
const controller = new AbortController ( ) ;
abortControllerRef . current = controller ;
setStage ( "planning" ) ;
setError ( null ) ;
setCompletedSteps ( [ ] ) ;
setCurrentStep ( null ) ;
setPlanResult ( null ) ;
setScenes ( [ ] ) ;
setStage ( "planning" ) ; setError ( null ) ;
setCompletedSteps ( [ ] ) ; setCurrentStep ( null ) ;
setPlanResult ( null ) ; setScenes ( [ ] ) ; setSourceImageUrls ( [ ] ) ;
try {
const result = await runVideoPlan (
productImageDataUrls , requirement , buildConfig ( ) ,
{
onStepStart : ( step ) = > setCurrentStep ( step ) ,
onStepDone : ( step ) = > setCompletedSteps ( ( prev ) = > [ . . . prev , step ] ) ,
onImagesUploaded : ( urls ) = > { setSourceImageUrls ( urls ) ; saveEcommerceVideoState ( { stage : "planning" , completedSteps : [ "upload" ] , planResult : null , scenes : [ ] , sourceImageUrls : urls } ) ; } ,
signal : controller.signal ,
} ,
) ;
const builtScenes = buildSceneTasks ( result ) ;
setPlanResult ( result ) ;
setScenes ( buildSceneTasks ( result ) ) ;
setScenes ( builtScenes ) ;
setStage ( "planned" ) ;
// Persist immediately — component may be unmounted by the time React re-renders
saveEcommerceVideoState ( { stage : "planned" , completedSteps : [ . . . ALL_STEPS ] , planResult : result , scenes : builtScenes , sourceImageUrls : result.imageUrls } ) ;
} catch ( err ) {
if ( ( err as Error ) . name === "AbortError" ) return ;
setError ( err instanceof Error ? err . message : "策划失败" ) ;
setStage ( "idle" ) ;
} finally {
setCurrentStep ( null ) ;
}
} finally { setCurrentStep ( null ) ; }
} ;
const handleRender = async ( ) = > {
if ( ! planResult || ! scenes . length ) return ;
const imageUrl = planResult . imageUrls [ 0 ] || "" ;
setStage ( "rendering" ) ;
setError ( null ) ;
renderAbortRef . current = { current : false } ;
const quality = mapResolutionToQuality ( resolution ) ;
// ── Phase 2: Image generation per scene ──────────────────────
for ( const scene of scenes ) {
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 ( { stage : "imaging" , completedSteps , planResult , scenes : next , sourceImageUrls } ) ;
} ;
for ( const scene of currentScenes ) {
if ( renderAbortRef . current . current ) break ;
setScenes ( ( prev ) = > prev . map ( ( s ) = >
s . sceneId === scene . sceneId ? { . . . s , status : "pending" } : s ) ) ;
persistScenes ( currentScenes . map ( ( s ) = > s . sceneId === scene . sceneId ? { . . . s , status : "pending" } : s ) ) ;
try {
await renderScene (
{ sceneId : scene.sceneId , prompt : scene.prompt , durationSeconds : scene.durationSeconds , imageUrl , aspectRatio , resolution : quality } ,
await renderSceneImage (
{ sceneId : scene.sceneId , prompt : scene.prompt , aspectRatio : ratio } ,
{
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 : "fa iled " , error : err2 } : s ) ) ,
onSceneImageSubmitted : ( id , taskId ) = > persistScenes ( currentScenes . map ( ( s ) = > s . sceneId === id ? { . . . s , imageTaskId : taskId , status : "running" } : s ) ) ,
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 ) ) ,
onSceneImageFailed : ( id , err2 ) = > persistScenes ( currentScenes . map ( ( s ) = > s . sceneId === id ? { . . . s , status : "id le" , error : err2 } : s ) ) ,
} ,
renderAbortRef . current ,
) ;
} catch ( err ) {
const message = err instanceof Error ? err . message : "生成失败" ;
const isPaymentError = err instanceof ServerRequestError && err . status === 402 ;
setScenes ( ( prev ) = > prev . map ( ( s ) = >
s . sceneId === scene . sceneId ? { . . . s , status : "failed" , error : isPaymentError ? "余额不足,请充值后继续" : message } : s ) ) ;
if ( isPaymentError ) {
setError ( "余额不足,请充值后再生成视频" ) ;
renderAbortRef . current . current = true ;
break ;
}
const message = err instanceof Error ? err . message : "图片 生成失败" ;
persistScenes ( currentScenes . map ( ( s ) = > s . sceneId === scene . sceneId ? { . . . s , status : "idle" , error : message } : 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 ;
} ) ;
const allHaveImages = currentScenes . every ( ( s ) = > s . imageUrl ) ;
const finalStage = allHaveImages ? "imaged" as const : "partial_ failed" as const ;
setStage ( finalStage ) ;
saveEcommerceVideoState ( { stage : finalStage , completedSteps , planResult , scenes : currentScenes , sourceImageUrls } ) ;
} ;
const handleCancel = ( ) = > {
abortControllerRef . current ? . abort ( ) ;
renderAbortRef . current . current = true ;
setStage ( "cancelled" ) ;
// ── Phase 3: Video rendering from generated images ──────────
const handleRenderVideos = async ( ) = > {
if ( ! scenes . length ) return ;
const firstImage = scenes [ 0 ] ? . imageUrl ;
if ( ! firstImage ) { 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 ( { stage : "rendering" , completedSteps , planResult , scenes : next , sourceImageUrls } ) ;
} ;
for ( const scene of currentScenes ) {
if ( renderAbortRef . current . current ) break ;
if ( ! scene . imageUrl ) continue ;
persistScenes ( currentScenes . map ( ( s ) = > s . sceneId === scene . sceneId ? { . . . s , status : "pending" } : s ) ) ;
try {
await renderScene (
{ sceneId : scene.sceneId , prompt : scene.prompt , durationSeconds : scene.durationSeconds , imageUrl : scene.imageUrl , aspectRatio , resolution : quality } ,
{
onSceneSubmitted : ( id , taskId ) = > persistScenes ( currentScenes . map ( ( s ) = > s . sceneId === id ? { . . . s , taskId , status : "running" } : s ) ) ,
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 ) ) ,
onSceneFailed : ( id , err2 ) = > persistScenes ( currentScenes . map ( ( s ) = > s . sceneId === id ? { . . . s , status : "failed" , error : err2 } : s ) ) ,
} ,
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 ( { 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 ! , 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 canRender = planResult ? . compliance . allow_video_generation && stage === "planned ";
const sourceImage = planResult ? . imageUrls [ 0 ] || productImageDataUrls [ 0 ] || "" ;
const flowHasStarted = stage !== "idle" || completedSteps . length > 0 || scenes . length > 0 ;
const sourceImage = sourceImageUrls [ 0 ] || planResult ? . imageUrls [ 0 ] || productImageDataUrls [ 0 ] || " ";
const flowHasStarted = stage !== "idle" || completedSteps . length > 0 ;
const flowMeta = ` ${ platform } / ${ aspectRatio } / ${ durationSeconds } s / ${ resolution } ` ;
const planActionLabel = stage === "planning"
? "策划中"
: ( stage === "planned" || stage === "completed" || stage === "partial_failed" ) ? "重新策划" : "一键策划" ;
const renderActionLabel = stage === "rendering" ? "生成中" : "确认生成" ;
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 } >
{ /* ── Flow bar ──────────────────────────────────── */ }
< header className = "ecom-video-flowbar" >
< div className = "ecom-video-flowbar__title" aria-label = { ` 短视频分镜流, ${ flowMeta } ` } title = { flowMeta } >
< span className = { ` ecom-video-flowbar__pulse ${ flowHasStarted ? " is-active" : "" } ` } aria-hidden = "true" / >
@@ -241,111 +418,178 @@ export default function EcommerceVideoWorkspace({
{ ALL_STEPS . map ( ( step ) = > {
const isDone = completedSteps . includes ( step ) ;
const isActive = currentStep === step ;
const cls = isDone ? "is-done" : isActive ? "is-active" : "" ;
return (
< span
key = { step }
className = { ` ecom-video-step-dot ${ cls } ` }
title = { PLAN_STEP_LABELS [ step ] }
aria-label = { PLAN_STEP_LABELS [ 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" >
{ error ? < span className = "ecom-video-flowbar__error" role = "alert" > { error } < / span > : null }
< button
type = "button"
className = "ecom-video-flow-action"
disabled = { stage === "planning" || stage === "rendering" }
onClick = { ( ) = > void handlePlan ( ) }
aria-label = { planActionLabel }
title = { planActionLabel }
>
{ stage === "planning" ? < LoadingOutlined / > : ( stage === "planned" || stage === "completed" || stage === "partial_failed" ) ? < ReloadOutlined / > : < PlayCircleOutlined / > }
< / button >
{ ( stage === "rendering" || stage === "planned" ) ? (
< button
type = "button"
className = "ecom-video-flow-action ecom-video-flow-action--ghost"
disabled = { ! canRender }
onClick = { ( ) = > void handleRender ( ) }
aria-label = { renderActionLabel }
title = { renderActionLabel }
>
{ stage === "rendering" ? < LoadingOutlined / > : < SendOutlined / > }
{ stage !== "planning" && stage !== "imaging" && stage !== "rendering" ? (
< button type = "button" className = "ecom-video-flow-action"
onClick = { ( ) = > void handlePlan ( ) } title = "一键策划" >
< PlayCircleOutlined / >
< / button >
) : null }
{ stage === "planned" ? (
< button type = "button" className = "ecom-video-flow-action ecom-video-flow-action--ghost"
onClick = { ( ) = > void handleGenerateImages ( ) } title = "生成图片" >
< SendOutlined / >
< / button >
) : null }
{ stage === "imaged" ? (
< button type = "button" className = "ecom-video-flow-action ecom-video-flow-action--ghost"
onClick = { ( ) = > void handleRenderVideos ( ) } title = "生成视频" >
< SendOutlined / >
< / button >
) : null }
{ stage === "planning" ? (
< span className = "ecom-video-flowbar__stage-label" > < LoadingOutlined / > 策 划 中 < / span >
) : null }
{ stage === "imaging" ? (
< span className = "ecom-video-flowbar__stage-label" > < LoadingOutlined / > 生 成 图 片 中 < / span >
) : null }
{ stage === "rendering" ? (
< button type = "button" className = "ecom-video-flow-action ecom-video-flow-action--danger" onClick = { handleCancel } aria-label = "取消生成" title = "取消生成" >
< span className = "ecom-video-flowbar__stage-label" > < LoadingOutlined / > 生 成 视 频 中 < / span >
) : null }
{ stage === "planning" || stage === "imaging" || stage === "rendering" ? (
< button type = "button" className = "ecom-video-flow-action ecom-video-flow-action--danger" onClick = { handleCancel } title = "终止" >
< StopOutlined / >
< / button >
) : null }
< / div >
< / header >
{ /* ── Flow canvas ──────────────────────────────────── */ }
< section className = "ecom-video-flow-canvas" aria-label = "视频分镜流程图" >
{ completedScenes . length === 0 && ! sourceImage ? (
< div style = { { display : "flex" , alignItems : "center" , justifyContent : "center" , height : "100%" , color : "#697486" , fontSize : 13 } } >
{ ! sourceImage ? (
< div className = "ecom-video-empty" >
< span > 上 传 商 品 图 并 点 击 "一键策划" 开 始 < / span >
< / div >
) : (
< div className = "ecom-video-flow-map" >
{ sourceImage ? (
< article className = "ecom-video-flow-node ecom-video-flow-node--source is-ready" aria-label = "商品图节点" >
< div className = "ecom-video-flow-node__media" >
< img src = { sourceImage } alt = "商品图" / >
< / div >
< span className = "ecom-video-flow-node__status-orb" aria-hidden = "true" / >
< / article >
) : null }
{ /* Source image node */ }
< article className = "ecom-video-flow-node ecom-video-flow-node--source is-ready" aria-label = "商品图节点" >
< div className = "ecom-video-flow-node__media" >
< img src = { sourceImage } alt = "商品图" / >
< / div >
< span className = "ecom-video-flow-node__label" > 商 品 原 图 < / span >
< span className = "ecom-video-flow-node__status-orb" aria-hidden = "true" / >
< / article >
{ sourceImage && completedScenes . length > 0 ? (
{ /* Connector: source → plan text nodes */ }
{ visiblePlanSteps . length > 0 ? (
< div className = "ecom-video-flow-connector is-active" aria-hidden = "true" > < i / > < / div >
) : null }
< div className = "ecom-video-scene-strip" aria-label = "已完成分镜节点" >
{ completedScenes . map ( ( scene , index ) = > (
< Fragment key = { scene . sceneId } >
< article
className = "ecom-video-flow-node ecom-video-flow-node--scene is-completed"
aria-label = { ` 镜头 ${ scene . sceneId } ,完成 ` }
title = { ` 镜头 ${ scene . sceneId } ` }
>
< div className = "ecom-video-flow-node__media" >
< video src = { scene . resultUrl ! } muted playsInline loop autoPlay / >
< / div >
< span className = "ecom-video-flow-node__status-orb" aria-hidden = "true" / >
< / article >
{ index < completedScenes . length - 1 ? (
< div className = "ecom-video-scene-link is-active" aria-hidden = "true" > < i / > < / div >
) : null }
< / Fragment >
) ) }
< / div >
{ /* Plan text nodes — side by side */ }
{ visiblePlanSteps . length > 0 ? (
< div className = "ecom-video-scene-strip ecom-video-scene-strip--text" aria-label = "策划节点" >
{ visiblePlanSteps . map ( ( step , idx ) = > (
< Fragment key = { step } >
< article className = { ` ecom-video-flow-node ecom-video-flow-node--text is-completed ${ currentStep === step ? " is-pulsing" : "" } ` }
aria-label = { PLAN_STEP_LABELS [ step ] } title = { PLAN_STEP_LABELS [ step ] } >
< span className = "ecom-video-flow-node__text-icon" >
{ currentStep === step ? < LoadingOutlined / > : "✓" }
< / span >
< span className = "ecom-video-flow-node__label" > { PLAN_STEP_LABELS [ step ] } < / span >
< / article >
{ idx < visiblePlanSteps . length - 1 ? (
< div className = "ecom-video-scene-link is-active" aria-hidden = "true" > < i / > < / div >
) : null }
< / Fragment >
) ) }
< / div >
) : null }
{ completedScenes . length > 0 && primaryVideo ? (
{ /* Connector: plan → images */ }
{ hasImaging ? (
< div className = "ecom-video-flow-connector is-active" aria-hidden = "true" > < i / > < / div >
) : null }
{ primaryVideo ? (
< article className = "ecom-video-flow-node ecom-video-flow-node--final is-completed" aria-label = "成片节点,已完成" >
< div className = "ecom-video-flow-node__media " >
< video src = { primaryVideo } muted playsInline loop autoPlay / >
< / div >
< span className = "ecom-video-flow-node__status-orb" aria-hidden = "true" / >
< / article >
{ /* Storyboard image nodes — side by side per scene */ }
{ hasImaging ? (
< div className = "ecom-video-scene-strip" aria-label = "分镜图片节点 ">
{ scenes . map ( ( scene , idx ) = > {
const imgReady = ! ! scene . imageUrl ;
const imgRunning = stage === "imaging" && ( scene . status === "running" || scene . status === "pending" ) && ! scene . imageUrl ;
const cls = imgReady ? "is-completed" : imgRunning ? "is-active" : "" ;
return (
< Fragment key = { ` img- ${ scene . sceneId } ` } >
< article className = { ` ecom-video-flow-node ecom-video-flow-node--image ${ cls } ` }
aria-label = { ` 分镜 ${ scene . sceneId } ` } title = { ` 分镜 ${ scene . sceneId } ` } >
< div className = "ecom-video-flow-node__media" >
{ imgReady ? < img src = { scene . imageUrl ! } alt = { ` 分镜 ${ scene . sceneId } ` } / >
: imgRunning ? < div className = "ecom-video-flow-node__placeholder" > < LoadingOutlined / > < / div >
: < div className = "ecom-video-flow-node__placeholder" > 待 生 成 < / div > }
< / div >
{ imgRunning ? < span className = "ecom-video-flow-node__progress" > { scene . progress || 0 } % < / span > : null }
< span className = "ecom-video-flow-node__label" > 分 镜 { scene . sceneId } < / span >
< span className = "ecom-video-flow-node__status-orb" aria-hidden = "true" / >
< / article >
{ idx < scenes . length - 1 ? (
< div className = "ecom-video-scene-link is-active" aria-hidden = "true" > < i / > < / div >
) : null }
< / Fragment >
) ;
} ) }
< / div >
) : null }
{ /* Connector: images → videos */ }
{ hasRendering ? (
< div className = "ecom-video-flow-connector is-active" aria-hidden = "true" > < i / > < / div >
) : null }
{ /* Video nodes — side by side per scene */ }
{ hasRendering ? (
< div className = "ecom-video-scene-strip" aria-label = "视频分镜节点" >
{ scenes . map ( ( scene , idx ) = > {
const vidReady = scene . status === "completed" && scene . resultUrl ;
const vidRunning = stage === "rendering" && ( scene . status === "running" || scene . status === "pending" ) ;
const vidFailed = scene . status === "failed" ;
const cls = vidReady ? "is-completed" : vidRunning ? "is-active" : vidFailed ? "is-failed" : "" ;
return (
< Fragment key = { ` vid- ${ scene . sceneId } ` } >
< article className = { ` ecom-video-flow-node ecom-video-flow-node--video ${ cls } ` }
aria-label = { ` 镜头 ${ scene . sceneId } ` } title = { ` 镜头 ${ scene . sceneId } ` } >
< div className = "ecom-video-flow-node__media" >
{ vidReady ? < video src = { scene . resultUrl ! } muted playsInline loop autoPlay / >
: vidRunning ? < div className = "ecom-video-flow-node__placeholder" > < LoadingOutlined / > < / div >
: vidFailed ? < div className = "ecom-video-flow-node__placeholder" > 失 败 < / div >
: < div className = "ecom-video-flow-node__placeholder" > 待 生 成 < / div > }
< / div >
{ vidRunning ? < span className = "ecom-video-flow-node__progress" > { scene . progress || 0 } % < / span > : null }
< span className = "ecom-video-flow-node__label" > 镜 头 { scene . sceneId } < / span >
{ vidFailed ? (
< button type = "button" className = "ecom-video-flow-node__retry"
onClick = { ( e ) = > { e . stopPropagation ( ) ; void handleRetryScene ( scene ) ; } }
title = "重试此镜头" >
< ReloadOutlined / >
< / button >
) : null }
{ vidFailed && scene . error ? (
< span className = "ecom-video-flow-node__error" title = { scene . error } > { scene . error . slice ( 0 , 20 ) } < / span >
) : null }
< span className = "ecom-video-flow-node__status-orb" aria-hidden = "true" / >
< / article >
{ idx < scenes . length - 1 ? (
< div className = "ecom-video-scene-link is-active" aria-hidden = "true" > < i / > < / div >
) : null }
< / Fragment >
) ;
} ) }
< / div >
) : null }
< / div >
) }
{ /* ── Delivery dock ────────────────────────────── */ }
{ primaryVideo ? (
< div className = "ecom-video-flow-dock" aria-label = "视频交付操作" >
< button type = "button" aria-label = "下载当前视频" title = "下载当前视频" onClick = { ( ) = > void handleDownload ( primaryVideo ) } > < DownloadOutlined / > < / button >
< button type = "button" aria-label = "保存到资产库" title = "保存到资产库" onClick = { ( ) = > void handleSaveAsset ( primaryVideo ) } > < FolderAddOutlined / > < / button >
< button type = "button" aria-label = "导入画布" title = "导入画布" onClick = { ( ) = > void handleImportToCanvas ( primaryVideo ) } > < SendOutlined / > < / button >
< button type = "button" aria-label = "复制视频链接" title = "复制视频链接" onClick = { ( ) = > void navigator . clipboard . writeText ( primaryVideo ) } > < CopyOutlined / > < / button >
< 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 }