@@ -3,7 +3,7 @@ import { createPortal } from "react-dom";
import type { ChangeEvent , CSSProperties , PointerEvent as ReactPointerEvent } from "react" ;
import { ArrowLeft , ArrowRight , Check , ChevronRight , Image , Info , LayoutList , Play , RefreshCw , Route , Sparkles , Upload , UsersRound , X } from "lucide-react" ;
import { api , ApiError } from "../api" ;
import type { Asset , BillingSummary , ExportPoll , ModelConfig , Product , Project , StoryboardShot , Team , TimelineSavePayload , User } from "../types" ;
import type { Asset , BillingSummary , ExportPoll , ModelConfig , Product , Project , Team , TimelineSavePayload , User } from "../types" ;
import { isHiddenFromScriptPicker , publicModelDisplayName } from "../model-display" ;
import { isPublicGenerationError , presentGenerationError } from "../generation-error" ;
import type { Notice , Page } from "./route-config" ;
@@ -275,8 +275,7 @@ const PIPELINE_RAIL = [
{ n : "01" , title : "选择商品" , desc : "确定本次内容生产的商品主体" } ,
{ n : "02" , title : "脚本创建" , desc : "生成并确认镜头脚本" } ,
{ n : "03" , title : "资产选择" , desc : "准备商品、角色与场景资产" } ,
{ n : "04" , title : "故事板 " , desc : "生成并确认视频分镜画面 " } ,
{ n : "05" , title : "视频生成" , desc : "按故事板生成视频片段" } ,
{ n : "04" , title : "视频生成 " , desc : "按脚本的秒级分镜直接生成视频 " } ,
] ;
const OUTPUT_RATIOS = [
{ value : "9:16" , label : "9:16 竖屏" } ,
@@ -302,11 +301,10 @@ function modelResolutions(config: ModelConfig | undefined) {
}
const PIPELINE_HEAD : Record < number , { title : string ; desc : string ; status : string } > = {
1 : { title : "脚本创建" , desc : "围绕商品卖点组织镜头脚本,确认后进入下一步内容生产 " , status : "镜头脚本" } ,
2 : { title : "资产选择" , desc : "准备故事板所需的 商品、角色与场景资产,确保后续画面保持一致 " , status : "资产选择" } ,
3 : { title : "故事板 " , desc : "检查每个镜头的画面、台词、运镜和节奏,确认后进入视频生 成。" , status : "故事板 " } ,
4 : { title : "视频生成" , desc : "系统已按故事板场次自动开始 生成,各场视频会并行完成。" , status : "视频生成" } ,
5 : { title : "视频生成" , desc : "系统已按故事板场次自动开始生成,各场视频会并行完成。" , status : "视频生成" } ,
1 : { title : "脚本创建" , desc : "围绕商品卖点组织镜头脚本,画面按秒写清景别、机位、运镜, 确认后进入下一步" , status : "镜头脚本" } ,
2 : { title : "资产选择" , desc : "准备商品、角色与场景参考图,用来锁住出片时的脸、商品和场景 " , status : "资产选择" } ,
3 : { title : "视频生成 " , desc : "系统已按脚本的秒级分镜逐镜生成,各场视频会并行完 成。" , status : "视频生成 " } ,
4 : { title : "视频生成" , desc : "系统已按脚本的秒级分镜逐镜 生成,各场视频会并行完成。" , status : "视频生成" } ,
} ;
// 行34 · 行内「添加标签」:点 + 展开输入框,回车 / 失焦提交(空则收起)
@@ -557,12 +555,7 @@ export function PipelinePage(props: {
onGenerateTriview : ( portraitGroupId : string ) = > Promise < { id : string } | null > ;
// 流程步骤4 · 添加模特工作台命名:同步写回形象资产名称。
onRenameModel ? : ( assetId : string , name : string ) = > Promise < unknown > ;
onGenerateStoryboard : ( prompt : string ) = > void | Promise < unknown > ;
// 单场重跑(只重出该 shot 的分镜图)/ 采用某场某历史版本
onRerunStoryboardShot ? : ( shotId : string , prompt? : string ) = > Promise < unknown > ;
onAdoptStoryboardShotVersion ? : ( shotId : string , versionId : string ) = > void | Promise < unknown > ;
onPollStoryboardQuiet ? : ( ) = > void | Promise < void > ;
onSkipStoryboard : ( ) = > Promise < unknown > ;
onSubmitVideo : ( segmentId : string , prompt : string ) = > void | Promise < unknown > ;
onSubmitAllVideos : ( prompt : string ) = > void | Promise < unknown > ;
onPollVideosQuiet : ( ) = > void | Promise < void > ;
@@ -578,7 +571,7 @@ export function PipelinePage(props: {
const {
project , loading , navigate , products , assets , onNotify ,
textModels , videoModels , onAdoptScript , onUpdateShot , onAddShot , onDeleteShot , onRerunShot , onSaveProjectMeta , onAdoptVideoVersion , onGenerateVoiceover ,
onGenerateBaseAsset , onAdoptBaseAsset , onSetAdoptState , onDeleteBaseAsset , onAttachBaseAsset , onGenerateModel , onUploadModel , onGenerateTriview , onRenameModel , onGenerateStoryboard , onRerunStoryboardShot , onAdoptStoryboardShotVersion , onPollStoryboardQuiet , onSkipStoryboard ,
onGenerateBaseAsset , onAdoptBaseAsset , onSetAdoptState , onDeleteBaseAsset , onAttachBaseAsset , onGenerateModel , onUploadModel , onGenerateTriview , onRenameModel ,
onSubmitVideo , onSubmitAllVideos , onPollVideosQuiet , exportResult , onRefreshExport , onRefreshProject , onRefreshBilling ,
onUploadVideoSegment , onUploadBgm , onSaveTimeline , onSubmitExport
} = props ;
@@ -983,26 +976,13 @@ export function PipelinePage(props: {
setExtractErr ( err instanceof Error ? err . message : "提取失败,请重试" ) ;
}
}
// ── 流程步骤4/5 · 兜底弹窗拦截:生成故事板/ 视频前,查被引用的角色/场景是否都有「按名字采用」的参考图 ──
// ── 流程步骤3 · 兜底弹窗拦截:生成视频前,查被引用的角色/场景是否都有「按名字采用」的参考图 ──
// 缺了就弹窗(去补齐 / 仍要生成),不静默退化文生图、不让用户花冤枉钱。商品永远有主图、不算缺。
// reason:"noref" = 还没参考图(立绘/场景图);"notri" = 角色有立绘但缺三视图(故事板合成需多角度参考)。
type RefMiss = { name : string ; type : string ; reason : "noref" | "notri" } ;
// reason:"noref" = 还没参考图(立绘/场景图)。原来还拦「缺三视图」,那是故事板多图合成的要求;
// 故事板下线后视频只取立绘锁脸,不再拦三视图。
type RefMiss = { name : string ; type : string ; reason : "noref" } ;
const [ refGate , setRefGate ] = useState < { missing : RefMiss [ ] ; proceed : ( ) = > void } | null > ( null ) ;
useBodyScrollLock ( Boolean ( refGate ) ) ;
// 某角色(按名字)已采用的立绘有没有配套三视图:取该角色代表组的 adopted_asset → 查它的三视图组。
// 用与详情弹窗同款判定(triview 组有候选 / 资产 metadata 标记 / 模特库正面图)。
function personHasTriview ( name : string ) : boolean {
const ent = buildEntities ( "person" ) . find ( ( e ) = > ( e . name || "" ) . trim ( ) === ( name || "" ) . trim ( ) ) ;
const portrait = ent ? . group . adopted_asset ;
if ( ! portrait ) return false ;
const tri = triGroupForAsset ( portrait ) ;
if ( tri && ( tri . adopted_asset || ( tri . candidate_assets ? . length ? ? 0 ) > 0 ) ) return true ;
const m : Record < string , unknown > = byId . get ( portrait ) ? . metadata || { } ;
const has = m . tri_view ? ? m . triview ? ? m . has_triview ? ? m . three_view ? ? m . tri_views ;
if ( has === true || ( Array . isArray ( has ) && has . length > 0 ) || ( typeof has === "string" && ( has as string ) . trim ( ) ) ) return true ;
if ( m . view === "frontal" ) return true ; // 模特库正面图与三视图同批生成
return false ;
}
function missingRefsFor ( segs : Array < { entity_refs? : string [ ] } > ) : RefMiss [ ] {
const ents = project . metadata ? . script_entities ;
if ( ! Array . isArray ( ents ) || ! ents . length ) return [ ] ; // 没提取过实体就不拦(交给提取闸门)
@@ -1019,9 +999,7 @@ export function PipelinePage(props: {
const kind = ent . type === "character" ? "person" : "scene" ;
const name = ( ent . name || "" ) . trim ( ) ;
if ( ! name ) continue ;
if ( ! adopted [ kind ] . has ( name ) ) { miss . set ( name , { name , type : ent . type , reason : "noref" } ) ; continue ; }
// 已有立绘/场景图 → 角色再查三视图:故事板 @图 合成需正/侧/背多角度参考,缺则拦(ZWQ#3 防呆)
if ( kind === "person" && ! personHasTriview ( name ) ) miss . set ( name , { name , type : ent . type , reason : "notri" } ) ;
if ( ! adopted [ kind ] . has ( name ) ) miss . set ( name , { name , type : ent . type , reason : "noref" } ) ;
}
}
return [ . . . miss . values ( ) ] ;
@@ -1032,9 +1010,9 @@ export function PipelinePage(props: {
if ( missing . length ) setRefGate ( { missing , proceed : action } ) ;
else action ( ) ;
}
// ── 流程步骤5 · 生成视频前的「过审闸」:含真人脸的人物立绘/故事板分镜 必须先过审(火山要素材库已过审引用),
// 否则火山直接报 InputImageSensitiveContentDetected。未过审 → 弹窗指明哪一镜/哪个人物/哪张分镜 ,挡住不生成。
type ReviewBlocker = { video_segment_id : string ; sort_order : number ; scene_no : number ; kind : "person" | "storyboard" ; name : string ; asset_id : string ; review_status : string } ;
// ── 流程步骤3 · 生成视频前的「过审闸」:含真人脸的人物立绘必须先过审(火山要素材库已过审引用),
// 否则火山直接报 InputImageSensitiveContentDetected。未过审 → 弹窗指明哪一镜/哪个人物,挡住不生成。
type ReviewBlocker = { video_segment_id : string ; sort_order : number ; scene_no : number ; kind : "person" ; name : string ; asset_id : string ; review_status : string } ;
const [ reviewGate , setReviewGate ] = useState < { blockers : ReviewBlocker [ ] ; submitting : boolean } | null > ( null ) ;
useBodyScrollLock ( Boolean ( reviewGate ) ) ;
// 过审闸:有未过审项 → 弹窗(去送审/等过审),不放行;全过审 → 执行生成。segmentId 给定=只校验该段。
@@ -1136,63 +1114,12 @@ export function PipelinePage(props: {
} , [ adDetail ] ) ;
// 三视图改为「手动生成」(用户点详情弹窗里的「生成三视图」按钮),不再打开角色详情就自动出图、自动扣费。
// ── Stage 3:分镜制(对标视频段)—— 每镜一个 StoryboardShot,各自采用版图 + 历史版本,可单场重跑/切版 ──
const sbShots = [ . . . ( project . storyboard_shots ? ? [ ] ) ] . sort ( ( a , b ) = > a . sort_order - b . sort_order ) ;
const [ sbSelected , setSbSelected ] = useState ( 0 ) ;
const sbActiveShot = sbShots [ Math . min ( sbSelected , Math . max ( 0 , sbShots . length - 1 ) ) ] || null ;
// 「查看的版本」与「采用的版本」分离(对标视频详情弹窗):点历史缩略图只切预览(纯本地态),
// 绝不动后端状态 → 重跑在制时切版本不会把 RUNNING 状态冲掉、不丢任务;采用要点显式「采用此版本」。
const [ sbViewVerId , setSbViewVerId ] = useState < string | null > ( null ) ;
// 切换场 / 切项目 / 采用版变化(重跑落地或手动采用)时复位预览到「采用版」,这样重跑出片后自动看到新图
useEffect ( ( ) = > { setSbViewVerId ( null ) ; } , [ sbActiveShot ? . id , sbActiveShot ? . adopted_version ] ) ;
const sbAnyImage = sbShots . some ( ( s ) = > Boolean ( s . adopted_asset_url || s . adopted_asset ) ) ;
// 全部场都出片(有采用版)= 故事板完成,可进视频;在制中(queued/running)用于转圈veil
const sbAllDone = sbShots . length > 0 && sbShots . every ( ( s ) = > Boolean ( s . adopted_version ) ) ;
const sbAnyGenerating = sbShots . some ( ( s ) = > [ "queued" , "running" ] . includes ( s . status ) ) ;
// 故事板按「采用版脚本」逐镜出图 → 还没出图时,先按采用版镜头数铺等量空占位(场1…场N),
// 让用户一眼看出「这里本该有几张」,出图后逐个填真图。无采用版脚本则为 0(显示「暂无」)。
const sbAdoptedScript = scripts . find ( ( s ) = > s . is_adopted ) || null ;
const sbExpectedShots = sbAdoptedScript ? ( sbAdoptedScript . segments ? . length ? ? 0 ) : 0 ;
// 整张/全部生成中:不全屏遮挡,只在每张分镜占位上转个 spinner(样式同基础资产趴)
const [ sbGenerating , setSbGenerating ] = useState ( false ) ;
// 单场重跑的乐观态:点下立刻给该场转圈(撑到状态回传 queued/running 接管)
const [ sbBusyShots , setSbBusyShots ] = useState < Set < string > > ( ( ) = > new Set ( ) ) ;
const clearSbBusy = ( id : string ) = > setSbBusyShots ( ( s ) = > { if ( ! s . has ( id ) ) return s ; const n = new Set ( s ) ; n . delete ( id ) ; return n ; } ) ;
// 单场重跑:乐观置忙 + 调后端 → 由 poll 出图;状态进 queued/running 后由状态接管,失败/超时兜底解禁
function rerunStoryboardShotOptimistic ( shotId : string ) {
setSbBusyShots ( ( s ) = > new Set ( s ) . add ( shotId ) ) ;
Promise . resolve ( onRerunStoryboardShot ? . ( shotId , storyboardPrompt || SB_PROMPT_DEFAULT ) )
. then ( ( res ) = > { if ( res == null ) clearSbBusy ( shotId ) ; } )
. catch ( ( ) = > clearSbBusy ( shotId ) ) ;
window . setTimeout ( ( ) = > clearSbBusy ( shotId ) , 90000 ) ;
}
// 进入 succeeded/failed 终态的场,从乐观忙集合摘除(此后由真实状态驱动)
const sbStatusKey = sbShots . map ( ( s ) = > ` ${ s . id } : ${ s . status } ` ) . join ( "|" ) ;
useEffect ( ( ) = > {
setSbBusyShots ( ( prev ) = > {
if ( prev . size === 0 ) return prev ;
const next = new Set ( prev ) ;
for ( const s of sbShots ) if ( next . has ( s . id ) && [ "succeeded" , "failed" ] . includes ( s . status ) ) next . delete ( s . id ) ;
return next . size === prev . size ? prev : next ;
} ) ;
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ sbStatusKey ] ) ;
// 没生成故事板时点「确认故事板」→ 弹气泡提示先去生成(3.5s 自动收起)
const [ sbConfirmHint , setSbConfirmHint ] = useState ( false ) ;
useEffect ( ( ) = > {
if ( ! sbConfirmHint ) return ;
const t = window . setTimeout ( ( ) = > setSbConfirmHint ( false ) , 3500 ) ;
return ( ) = > window . clearTimeout ( t ) ;
} , [ sbConfirmHint ] ) ;
// ── Stage 4:视频片段(adopted_asset 缩略图 + 状态 pill + 时长)──
const segments = [ . . . ( project . video_segments ? ? [ ] ) ] . sort ( ( a , b ) = > a . sort_order - b . sort_order ) ;
const segDone = segments . filter ( ( s ) = > [ "succeeded" , "completed" , "done" ] . includes ( s . status ) ) . length ;
const segTotalSec = segments . reduce ( ( sum , s ) = > sum + ( s . target_duration_seconds || 0 ) , 0 ) ;
const videoAnyStarted = segments . some ( ( s ) = > [ "running" , "succeeded" , "queued" , "completed" , "done" ] . includes ( s . status ) ) ;
const [ chargeConfirm , setChargeConfirm ] = useState < "storyboard" | " video" | null > ( null ) ;
const sbChargeShots = shots . length || sbExpectedShots ;
const sbChargePoints = sbChargeShots * pts ( 20 ) ;
const [ chargeConfirm , setChargeConfirm ] = useState < "video" | null > ( null ) ;
const videoConfigs = videoModels ? ? [ ] ;
const defaultVideoModel = videoConfigs . find ( ( m ) = > m . status === "active" && m . name === DEFAULT_VIDEO_MODEL_NAME )
|| videoConfigs . find ( ( m ) = > m . status === "active" )
@@ -1300,9 +1227,6 @@ export function PipelinePage(props: {
( sum , duration ) = > sum + estimateCost ( outputModel , { ratio : outputAspect , resolution : outputResolution , duration , refs : [ ] } , billingRates ) . points ,
0 ,
) ;
const sbNextLabel = sbAnyImage || sbAnyGenerating
? "进入故事板"
: ` 生成故事板 · ${ sbChargeShots > 0 ? ` ${ sbChargePoints } 积分 ` : ` ${ pts ( 20 ) } 积分/镜 ` } ` ;
const videoNextLabel = videoAnyStarted
? "进入视频"
: videoChargePoints > 0
@@ -1381,7 +1305,7 @@ export function PipelinePage(props: {
// 步进器:对齐镜像 activateStage 逻辑。默认(无 hash)pane=脚本(1) 但步进器 active=项目真实阶段;
// 一旦导航(hash 或点击),active 跟随所看阶段,completed=max(项目阶段-1, 所看阶段-1)。
// V1 雪藏拼接导出:已完成项目落到第 4 阶段(视频),不落到被藏的第 5(V2 恢复改回 5)
const projectStage = project . status === "completed" ? 4 : Math.max ( 1 , ( stageOrder as readonly string [ ] ) . indexOf ( project . current_stage ) + 1 ) ;
const projectStage = project . status === "completed" ? 3 : Math.max ( 1 , ( stageOrder as readonly string [ ] ) . indexOf ( project . current_stage ) + 1 ) ;
const initHash = typeof location !== "undefined" ? location . hash . match ( /#stage-(\d)/ ) : null ;
// 进入项目默认落到「项目当前进行到的阶段」(视频在生成/已生成 → 直接到第4阶段),而不是恒显第1阶段(ZWQ#16)。
// 仍尊重地址栏 #stage-N(分享某阶段链接 / 浏览器前进后退)。
@@ -1409,7 +1333,7 @@ export function PipelinePage(props: {
// 在基础资产趴(stage 2)/ 故事板趴(stage 3)/ 视频趴(stage 4)定时轮询审核状态,刷新徽章
// (processing → active绿 / failed红)。stage4 也轮询:过审闸里「一键送审」后,要在视频阶段就能看到盾变绿再生成。
useEffect ( ( ) = > {
if ( viewStage !== 2 && viewStage !== 3 && viewStage !== 4 ) return ;
if ( viewStage !== 2 && viewStage !== 3 ) return ;
let alive = true ;
let timer = 0 ;
const tick = async ( ) = > {
@@ -1429,14 +1353,6 @@ export function PipelinePage(props: {
timer = window . setInterval ( tick , 8000 ) ;
return ( ) = > { alive = false ; window . clearInterval ( timer ) ; } ;
} , [ viewStage , project . id , genBusy , reviewWake , pendingGenKey ] ) ;
// 故事板有在制场(queued/running)时,后台每 5s 静默轮询驱动出图 + 刷新(对标视频段轮询)。
// 不占全局 loading → 单场重跑不会锁住其它场的按钮,可并行重跑多场。
useEffect ( ( ) = > {
if ( viewStage !== 3 || ! sbAnyGenerating ) return ;
const timer = window . setInterval ( ( ) = > { void onPollStoryboardQuiet ? . ( ) ; } , 5000 ) ;
void onPollStoryboardQuiet ? . ( ) ;
return ( ) = > window . clearInterval ( timer ) ;
} , [ viewStage , sbAnyGenerating , onPollStoryboardQuiet ] ) ;
// 外部 hash 变化(浏览器前进/后退、地址栏改 #stage-N)也要切阶段——镜像有 hashchange 监听,这里补齐
useEffect ( ( ) = > {
function onHashChange() {
@@ -1453,7 +1369,7 @@ export function PipelinePage(props: {
} , [ ] ) ;
const activeDot = navigated ? viewStage : projectStage ;
const [ chatText , setChatText ] = useState ( "" ) ;
// 媒体预览灯箱(视频片段播放 / 故事板分镜放大 )
// 媒体预览灯箱(视频片段播放)
const [ preview , setPreview ] = useState < { src : string ; kind : "image" | "video" ; name : string } | null > ( null ) ;
const [ chatMode , setChatMode ] = useState < "ai" | "manual" | "video" > ( "ai" ) ;
const [ chatAttachments , setChatAttachments ] = useState < Array < { name : string ; chars : number } > > ( [ ] ) ;
@@ -2073,20 +1989,9 @@ export function PipelinePage(props: {
document . body . style . userSelect = "" ;
} ;
} , [ gutterDragging ] ) ;
const SB_PROMPT_DEFAULT = "统一商品、人物、场景风格,生成完整、可直接指导视频的导演故事板" ;
// 整张风格提示词:项目级(原 StoryboardVersion.prompt 的去处),重跑时生效
const sbSavedPrompt = ( project . metadata as Record < string , unknown > | undefined ) ? . storyboard_prompt as string | undefined ;
const [ storyboardPrompt , setStoryboardPrompt ] = useState ( sbSavedPrompt || SB_PROMPT_DEFAULT ) ;
const startStoryboardGeneration = ( ) = > {
setSbGenerating ( true ) ;
void persistOutputSpec ( )
. catch ( ( ) = > undefined )
. then ( ( ) = > onGenerateStoryboard ( storyboardPrompt || SB_PROMPT_DEFAULT ) )
. finally ( ( ) = > setSbGenerating ( false ) ) ;
} ;
const canExport = project . video_segments . length > 0 && project . video_segments . every ( ( segment ) = > Boolean ( segment . adopted_version ) ) ;
// ── Stage 5 · 真实视频播放器:时间轴 clips 当作播放列表,逐段播真实视频文件 ──
// ── Stage 4 · 真实视频播放器:时间轴 clips 当作播放列表,逐段播真实视频文件 ──
const isVideoAsset = ( id : string | null | undefined ) : boolean = > {
const a = id ? byId . get ( id ) : null ;
if ( ! a ) return false ;
@@ -2481,7 +2386,7 @@ export function PipelinePage(props: {
if ( cur === "succeeded" && ( prev === "running" || prev === "queued" ) ) setPreviewFinal ( true ) ;
prevExportStatusRef . current = cur ;
} , [ exportResult ? . status ] ) ;
const showingFinal = viewStage === 5 && previewFinal && exportResult ? . status === "succeeded" && Boolean ( exportResult ? . output_url ) ;
const showingFinal = viewStage === 4 && previewFinal && exportResult ? . status === "succeeded" && Boolean ( exportResult ? . output_url ) ;
// ── 播放意图 effect:edPlaying 是唯一事实源,元素跟着意图走。
// 边界换 src 浏览器自动 pause 也不会丢意图——effect 在新片段上接着 play,跨段连续播放。
@@ -2697,7 +2602,7 @@ export function PipelinePage(props: {
const [ bgmPeaks , setBgmPeaks ] = useState < number [ ] | null > ( null ) ;
const videoAssetKey = edClips . filter ( ( c ) = > c . isVideo && c . assetId ) . map ( ( c ) = > c . assetId ) . join ( "," ) ;
useEffect ( ( ) = > {
if ( viewStage !== 5 || ! videoAssetKey ) return ;
if ( viewStage !== 4 || ! videoAssetKey ) return ;
let cancelled = false ;
( async ( ) = > {
for ( const id of Array . from ( new Set ( videoAssetKey . split ( "," ) ) ) ) {
@@ -2710,7 +2615,7 @@ export function PipelinePage(props: {
} , [ viewStage , videoAssetKey ] ) ;
const bgmAssetId = ( project . timeline ? . bgm_tracks ? ? [ ] ) [ 0 ] ? . asset || "" ;
useEffect ( ( ) = > {
if ( viewStage !== 5 || ! bgmAssetId ) { setBgmPeaks ( null ) ; return ; }
if ( viewStage !== 4 || ! bgmAssetId ) { setBgmPeaks ( null ) ; return ; }
let cancelled = false ;
void extractWavePeaks ( bgmAssetId ) . then ( ( peaks ) = > { if ( ! cancelled && peaks . length ) setBgmPeaks ( peaks ) ; } ) ;
return ( ) = > { cancelled = true ; } ;
@@ -2733,7 +2638,7 @@ export function PipelinePage(props: {
// 键盘:仅 stage5 编辑预览态生效(空格播放/暂停,←/→ 逐帧);看成片时交给原生 <video controls>
useEffect ( ( ) = > {
if ( viewStage !== 5 || showingFinal ) return ;
if ( viewStage !== 4 || showingFinal ) return ;
function onKey ( e : KeyboardEvent ) {
const tag = ( e . target as HTMLElement ) ? . tagName ;
if ( tag === "INPUT" || tag === "TEXTAREA" ) return ;
@@ -2756,7 +2661,7 @@ export function PipelinePage(props: {
// 进入视频 / 拼接阶段时回填已有成片(此前合成过就直接给出播放/下载入口)
useEffect ( ( ) = > {
if ( viewStage !== 4 && viewStage !== 5 ) return ;
if ( viewStage !== 3 && viewStage !== 4 ) return ;
onRefreshExport ( ) ;
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ viewStage ] ) ;
@@ -2765,7 +2670,7 @@ export function PipelinePage(props: {
// 兜底 —— 否则发起方的轮询循环随刷新丢掉,界面会卡在旧百分比、按钮一直禁用。
const exportRunning = exportResult ? . status === "queued" || exportResult ? . status === "running" ;
useEffect ( ( ) = > {
if ( ! exportRunning || ( viewStage !== 4 && viewStage !== 5 ) ) return ;
if ( ! exportRunning || ( viewStage !== 3 && viewStage !== 4 ) ) return ;
const timer = setInterval ( ( ) = > onRefreshExport ( ) , 3000 ) ;
return ( ) = > clearInterval ( timer ) ;
} , [ exportRunning , viewStage , onRefreshExport ] ) ;
@@ -2781,7 +2686,7 @@ export function PipelinePage(props: {
// ── Stage 5 编辑器:水合 + 片段操作 + 撤销/重做 + 保存负载 ──
const [ edZoom , setEdZoom ] = useState ( 100 ) ;
useEffect ( ( ) = > {
if ( viewStage !== 5 ) { edHydratedRef . current = false ; return ; }
if ( viewStage !== 4 ) { edHydratedRef . current = false ; return ; }
if ( edHydratedRef . current ) return ;
const hasData = Boolean ( project . timeline ? . clips ? . length ) || segments . some ( ( s ) = > s . adopted_asset ) ;
if ( ! hasData ) return ;
@@ -2901,7 +2806,7 @@ export function PipelinePage(props: {
}
// (旧的转场黑闪提示层已删:双缓冲槽位的 opacity 过渡就是真转场预览)
// Stage 4 / 5 文件上传
// Stage 3 / 4 文件上传
// 导出全部:把本项目所有已采用视频片段打成一个 zip 下载
const [ exporting , setExporting ] = useState ( false ) ;
const [ exportErr , setExportErr ] = useState ( "" ) ;
@@ -3033,8 +2938,8 @@ export function PipelinePage(props: {
const railMeta = productRecord
? ` ${ isLocalProduct ? "本地生活" : ( productRecord . category || "未分类" ) } · ${ ( productRecord . images ? . length || ( railCover ? 1 : 0 ) ) } 张参考图 `
: "尚未选择商品" ;
const workflowCurrent = Math . min ( 5 , viewStage + 1 ) ;
const unlockedWorkflow = Math . max ( 2 , Math . min ( 5 , projectStage + 1 ) , workflowCurrent ) ;
const workflowCurrent = Math . min ( 4 , viewStage + 1 ) ;
const unlockedWorkflow = Math . max ( 2 , Math . min ( 4 , projectStage + 1 ) , workflowCurrent ) ;
const plHead = PIPELINE_HEAD [ viewStage ] || PIPELINE_HEAD [ 1 ] ;
function goWorkflow ( step : number ) {
if ( step > unlockedWorkflow || step <= 1 ) return ;
@@ -3066,8 +2971,8 @@ export function PipelinePage(props: {
< / div >
< / header >
< div className = "pl-rail-summary" >
< span > 已 解 锁 { unlockedWorkflow } / 5 · 当 前 第 { workflowCurrent } 步 < / span >
< div className = "pl-rail-track" > < span style = { { width : ` ${ ( unlockedWorkflow / 5 ) * 100 } % ` } } / > < / div >
< span > 已 解 锁 { unlockedWorkflow } / 4 · 当 前 第 { workflowCurrent } 步 < / span >
< div className = "pl-rail-track" > < span style = { { width : ` ${ ( unlockedWorkflow / 4 ) * 100 } % ` } } / > < / div >
< / div >
< div className = "pl-rail-steps" >
{ PIPELINE_RAIL . map ( ( step , index ) = > {
@@ -3830,9 +3735,9 @@ export function PipelinePage(props: {
} ) }
< / div >
< footer className = "as-action-bar" >
< span > < Info / > 确 认 后 将 使 用 以 上 资 产 创 建 故 事 板 , 后 续 仍 可 替 换 。 提 取 { pts ( 10 ) } / 人 物 { pts ( 20 ) } / 场 景 { pts ( 20 ) } · 失 败 不 扣 < / span >
< span > < Info / > 确 认 后 将 用 以 上 资 产 直 接 生 成 视 频 , 后 续 仍 可 替 换 。 提 取 { pts ( 10 ) } / 人 物 { pts ( 20 ) } / 场 景 { pts ( 20 ) } · 失 败 不 扣 < / span >
< div >
{ sbAnyImage || sbAnyGenerating ? (
{ videoAnyStarted ? (
< span className = "pill pill-l2 neutral" title = "后续生成视频将使用此设置" >
< span className = "dot" > < / span > 当 前 成 片 · { outputSpecSummary }
< / span >
@@ -3841,16 +3746,15 @@ export function PipelinePage(props: {
< button
className = "pl-next"
type = "button"
disabled = { sbGenerating && ! sbAnyImage && ! sbAnyGenerating }
onClick = { ( ) = > {
if ( sbAnyImage || sbAnyGenerating ) {
if ( videoAnyStarted ) {
goStage ( 3 ) ;
return ;
}
guardGen ( shots , ( ) = > setChargeConfirm ( "storyboard " ) ) ;
guardVideoGen ( shots , null , ( ) = > setChargeConfirm ( "video " ) ) ;
} }
>
< span > { sbNextLabel } < / span >
< span > { videoNextLabel } < / span >
< ArrowRight / >
< / button >
< / div >
@@ -3860,191 +3764,8 @@ export function PipelinePage(props: {
< / section >
) ;
} ) ( ) }
{ /* ============= STAGE 3 · 故事板(采用版的 frames,真图 + 镜头提示词)============= */ }
{ viewStage === 3 && ( ( ) = > {
// 每场时间区间(累加脚本镜时长 → 「0~5s」)
let cum = 0 ;
const sceneTimes = shots . map ( ( s ) = > { const st = cum ; cum += shotSeconds ( s ) ; return ` ${ st } ~ ${ cum } s ` ; } ) ;
const shotImg = ( s? : StoryboardShot | null ) = > frameUrl ( s ? { asset : s.adopted_asset ? ? "" , asset_url : s.adopted_asset_url } : null ) ;
const shotBusy = ( s : StoryboardShot ) = > sbGenerating || sbBusyShots . has ( s . id ) || [ "queued" , "running" ] . includes ( s . status ) ;
const cardCount = sbShots . length || sbExpectedShots ;
const activeBusy = sbActiveShot ? shotBusy ( sbActiveShot ) : sbGenerating ;
const activeVers = [ . . . ( sbActiveShot ? . versions ? ? [ ] ) ] ;
// 当前「查看」的版本(纯预览,默认=采用版);切换它只动本地预览,不动后端、不丢重跑
const sbViewedVer = activeVers . find ( ( v ) = > v . id === sbViewVerId )
|| activeVers . find ( ( v ) = > v . is_adopted || v . id === sbActiveShot ? . adopted_version ) || null ;
const sbViewedIsAdopted = ! sbViewedVer || sbViewedVer . is_adopted || sbViewedVer . id === sbActiveShot ? . adopted_version ;
const mainUrl = sbViewedVer ? ( sbViewedVer . asset_url || assetUrl ( sbViewedVer . asset ) ) : shotImg ( sbActiveShot ) ;
const mainAid = sbViewedVer ? . asset || sbActiveShot ? . adopted_asset || "" ;
return (
< section className = "stage active" data-stage-pane = "3" >
< div className = "stage-storyboard" >
< div className = "sb-canvas" >
< div className = "sb-scenes-col" id = "sb-scenes-row" >
{ sbShots . length ? sbShots . map ( ( shot , idx ) = > {
const url = shotImg ( shot ) ;
const aid = shot . adopted_asset || "" ;
return (
< div className = { ` sb-scene-thumb ${ idx === sbSelected ? " selected" : "" } ` } key = { shot . id } data-sid = { shot . id } onClick = { ( ) = > setSbSelected ( idx ) } >
< div className = { ` placeholder ${ url ? " has-mock-media" : "" } ` } style = { url ? mediaStyle ( url ) : undefined } >
< span className = "ph-frame" > 场 { idx + 1 } < / span >
{ shotBusy ( shot ) && < span className = "sb-gen-veil" aria-hidden = "true" > < span className = "spinner" / > < / span > }
{ /* 失败帧:角标红字「失败」,hover 看友好提示;不再灰着没反应(点该场到右侧看完整原因+重跑) */ }
{ ! shotBusy ( shot ) && shot . status === "failed" && (
< span title = { shot . error_message || "生成失败,点开本场查看原因并重跑" } style = { { position : "absolute" , left : 6 , top : 6 , display : "inline-flex" , alignItems : "center" , gap : 3 , padding : "2px 6px" , borderRadius : 6 , background : "var(--err, #d33)" , color : "#fff" , fontSize : 11 , fontWeight : 600 , zIndex : 2 } } >
< svg width = "11" height = "11" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2.2" strokeLinecap = "round" strokeLinejoin = "round" > < path d = "M12 9v4M12 17h.01" / > < path d = "M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" / > < / svg > 失 败
< / span >
) }
{ aid && < span className = "sb-frame-rv" data - stop onClick = { ( e ) = > e . stopPropagation ( ) } > < ReviewBadge compact status = { ( reviews [ aid ] || shot . review_status || "" ) as ReviewStatus } error = { shot . review_error } onSubmit = { ( ) = > void submitAssetReview ( aid ) } busy = { reviewBusyId === aid } / > < / span > }
< / div >
< div className = "nm" > 场 { idx + 1 } < / div >
< div className = "sub" > { sceneTimes [ idx ] || ` # ${ shot . sort_order + 1 } ` } < / div >
< / div >
) ;
} ) : sbExpectedShots ? (
/* 还没出图:按采用版脚本镜头数铺等量空占位,标「待生成」,让用户看出本该有几张 */
Array . from ( { length : sbExpectedShots } , ( _ , idx ) = > (
< div className = { ` sb-scene-thumb ${ idx === sbSelected ? " selected" : "" } ` } key = { ` ph- ${ idx } ` } onClick = { ( ) = > setSbSelected ( idx ) } >
< div className = "placeholder" > < span className = "ph-frame" > 场 { idx + 1 } < / span > { sbGenerating && < span className = "sb-gen-veil" aria-hidden = "true" > < span className = "spinner" / > < / span > } < / div >
< div className = "nm" > 场 { idx + 1 } < / div >
< div className = "sub" > 待 生 成 < / div >
< / div >
) )
) : < div className = "placeholder" style = { { aspectRatio : "1" } } > < span className = "ph-frame" > 暂 无 < / span > < / div > }
< / div >
{ ( ( ) = > {
const url = mainUrl ;
const aid = mainAid ;
return (
< div className = { ` placeholder sb-main-img ${ url ? " has-mock-media" : "" } ` } id = "sb-main-img" style = { url ? { . . . mediaStyle ( url ) , cursor : "zoom-in" } : undefined } role = { url ? "button" : undefined } tabIndex = { url ? 0 : undefined } title = { url ? "点击放大" : undefined } onClick = { url ? ( ) = > setPreview ( { src : url , kind : "image" , name : ` 场 ${ sbSelected + 1 } ` } ) : undefined } onKeyDown = { url ? ( event ) = > { if ( event . key === "Enter" || event . key === " " ) { event . preventDefault ( ) ; setPreview ( { src : url , kind : "image" , name : ` 场 ${ sbSelected + 1 } ` } ) ; } } : undefined } >
< span className = "ph-frame" > { url ? ` 场 ${ sbSelected + 1 } ${ sbViewedIsAdopted ? "" : " · 预览历史版" } ` : sbExpectedShots ? ` 场 ${ sbSelected + 1 } · 待生成 ` : "故事板未生成" } < / span >
{ activeBusy && < span className = "sb-gen-veil" aria-hidden = "true" > < span className = "spinner" / > < / span > }
{ aid && < span className = "sb-main-rv" data - stop onClick = { ( e ) = > e . stopPropagation ( ) } > < ReviewBadge compact status = { ( reviews [ aid ] || sbViewedVer ? . review_status || sbActiveShot ? . review_status || "" ) as ReviewStatus } error = { sbViewedVer ? . review_error || sbActiveShot ? . review_error } onSubmit = { ( ) = > void submitAssetReview ( aid ) } busy = { reviewBusyId === aid } / > < / span > }
< / div >
) ;
} ) ( ) }
< / div >
< div className = "sb-side" >
< div className = "pane" style = { { padding : "18px" } } >
< div className = "hstack" style = { { marginBottom : "10px" } } >
< strong style = { { fontSize : "14px" } } > 故 事 板 · < span id = "sb-side-scene" > { sbShots . length ? ` 场 ${ sbSelected + 1 } ` : "—" } < / span > < / strong >
< span className = "spacer" > < / span >
{ sbActiveShot
? ( activeBusy
? < span className = "pill neutral" > < span className = "dot" > < / span > 生 成 中 < / span >
: sbActiveShot . status === "failed"
? < span className = "pill bad" > < span className = "dot" > < / span > 生 成 失 败 < / span >
: sbActiveShot . adopted_version
? < span className = "pill ok" > < span className = "dot" > < / span > 已 出 片 < / span >
: < span className = "pill neutral" > < span className = "dot" > < / span > 待 生 成 < / span > )
: < span className = "pill neutral" > < span className = "dot" > < / span > 未 生 成 < / span > }
< / div >
{ /* 失败原因·友好提示(后端已把 yunqi 真因翻成中文,如内容审核拦截 → 提示改措辞) */ }
{ sbActiveShot ? . status === "failed" && ! activeBusy && sbActiveShot . error_message && (
< div style = { { display : "flex" , gap : 6 , alignItems : "flex-start" , color : "var(--err, #d33)" , fontSize : "12.5px" , lineHeight : 1.55 , margin : "2px 0 10px" , padding : "8px 10px" , background : "rgba(211,51,51,.07)" , borderRadius : 8 } } >
< svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" style = { { flexShrink : 0 , marginTop : 1 } } > < path d = "M12 9v4M12 17h.01" / > < path d = "M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" / > < / svg >
< span > { sbActiveShot . error_message } < / span >
< / div >
) }
< div className = "muted-2" style = { { fontSize : "12px" , lineHeight : 1.55 , marginBottom : "10px" } } > 每 个 场 由 image - 2 直 接 生 成 一 张 完 整 导 演 故 事 板 图 片 ; 可 单 独 「 重 跑 本 场 」 只 重 出 这 一 张 , 每 场 各 自 留 历 史 版 本 , 互 不 影 响 。 < / div >
< div className = "muted mono" style = { { fontSize : "12px" , fontWeight : 500 , marginBottom : "6px" , letterSpacing : ".04em" } } > 整 张 风 格 提 示 词 ( 重 跑 时 生 效 , 可 编 辑 ) < / div >
< PromptBox
className = "prompt-edit"
id = "sb-prompt-edit"
stop = { false }
value = { storyboardPrompt }
onChange = { ( v ) = > setStoryboardPrompt ( v . trim ( ) ) }
/ >
< div className = "sb-stage-actions" >
{ sbActiveShot && (
< button className = "btn btn-sm" type = "button" disabled = { activeBusy } title = "只重出当前这一场(新增一条该场历史版本)" onClick = { ( ) = > guardGen ( shots . filter ( ( s ) = > s . sort_order === sbActiveShot . sort_order ) , ( ) = > rerunStoryboardShotOptimistic ( sbActiveShot . id ) ) } >
{ activeBusy ? < > < span className = "spinner btn-spin" aria-hidden = "true" / > 生 成 中 … < / > : ` ↻ 重跑本场 ` }
< / button >
) }
< span className = "spacer" > < / span >
< span className = "muted-2 mono" style = { { fontSize : "12px" , alignSelf : "center" } } > { pts ( 20 ) } 积 分 / 镜 < / span >
< / div >
< div className = "sb-history" >
< div className = "sb-history-h" > 本 场 历 史 版 本 ( < span id = "sb-history-ct" > { activeVers . length } < / span > ) · 点 击 预 览 < / div >
< div className = "sb-history-row" id = "sb-history-row" >
{ activeVers . length ? activeVers . map ( ( ver ) = > {
const cover = frameUrl ( { asset : ver.asset ? ? "" , asset_url : ver.asset_url } ) ;
const isAdopted = ver . is_adopted || ver . id === sbActiveShot ? . adopted_version ;
const isViewed = sbViewedVer ? . id === ver . id ;
// 点击只切「预览」(本地态),不动后端 → 重跑在制时切版本不丢任务。采用要点下方按钮。
return (
< div className = { ` sb-history-thumb ${ isViewed ? " current" : "" } ` } key = { ver . id } data-vi = { ver . id } role = "button" tabIndex = { 0 } title = { isAdopted ? "当前采用版(点击预览)" : "点击预览此版本" } onClick = { ( ) = > setSbViewVerId ( ver . id ) } onKeyDown = { ( event ) = > { if ( event . key === "Enter" || event . key === " " ) { event . preventDefault ( ) ; setSbViewVerId ( ver . id ) ; } } } >
< div className = { ` placeholder ${ cover ? " has-mock-media" : "" } ` } style = { cover ? mediaStyle ( cover ) : undefined } > < span className = "ph-frame" > { isAdopted ? "采用" : "历史" } < / span > < / div >
< div className = "ts" > { formatShanghaiClock ( ver . created_at ) } < / div >
< / div >
) ;
} ) : < span className = "muted-2 mono" style = { { fontSize : "12px" } } > 本 场 暂 无 历 史 < / span > }
< / div >
{ /* 预览的是非采用版 → 显式「采用此版本」(对标视频详情弹窗的采用按钮);采用不影响在制重跑 */ }
{ sbViewedVer && ! sbViewedIsAdopted && sbActiveShot && (
< button className = "btn btn-sm btn-primary" type = "button" style = { { marginTop : "8px" } } onClick = { ( ) = > void onAdoptStoryboardShotVersion ? . ( sbActiveShot . id , sbViewedVer . id ) } > 采 用 此 版 本 < / button >
) }
< / div >
< div className = "divider" style = { { marginTop : "16px" } } > < / div >
< div className = "muted mono" style = { { fontSize : "12px" , fontWeight : 500 , marginBottom : "8px" , letterSpacing : ".04em" } } > 绑 定 的 资 产 < / div >
< div style = { { display : "flex" , gap : "6px" , flexWrap : "wrap" } } id = "sb-bound-assets" >
{ ( ( ) = > {
const bound = [
. . . groupsByKind ( "product" ) . filter ( ( g ) = > g . adopted_asset ) . map ( ( g ) = > ( { id : g.id , name : ( g . metadata ? . label || "" ) . trim ( ) || KIND_LABEL . product , kind : "product" as const , url : groupMainUrl ( g ) } ) ) ,
. . . buildEntities ( "person" ) . filter ( ( e ) = > e . group . adopted_asset ) . map ( ( e ) = > ( { id : e.key , name : e.name , kind : "person" as const , url : groupMainUrl ( e . group ) } ) ) ,
. . . buildEntities ( "scene" ) . filter ( ( e ) = > e . group . adopted_asset ) . map ( ( e ) = > ( { id : e.key , name : e.name , kind : "scene" as const , url : groupMainUrl ( e . group ) } ) ) ,
] ;
return bound . length ? bound . map ( ( b ) = > (
< span className = "asset-tag" key = { b . id } > { b . url ? < span className = "asset-thumb" style = { { backgroundImage : ` url( ${ b . url } ) ` } } / > : < span className = "dotc" > < / span > } { b . name } ( { KIND_LABEL [ b . kind ] } ) < / span >
) ) : < span className = "muted-2 mono" style = { { fontSize : "12px" } } > 暂 无 绑 定 资 产 < / span > ;
} ) ( ) }
< / div >
< / div >
< / div >
< / div >
< div className = "stage-foot" >
< div className = "info" > < span className = "mono" > [ image - 2 逐 场 输 出 · { cardCount ? ` ${ cardCount } 场 ` : "0 场" } · 单 场 可 重 跑 , 失 败 不 扣 ] < / span > < / div >
< div className = "hstack" >
< span className = "pill pill-l2 neutral" title = "后续生成视频将使用此设置" >
< span className = "dot" > < / span > 当 前 成 片 · { outputSpecSummary }
< / span >
< button className = "btn" type = "button" onClick = { ( ) = > goStage ( 2 ) } > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "1.5" strokeLinecap = "round" strokeLinejoin = "round" > < path d = "M19 12H5M12 19l-7-7 7-7" / > < / svg > 返 回 资 产 < / button >
< div className = "sb-confirm-wrap" >
{ sbConfirmHint && (
< div className = "sb-confirm-pop" role = "tooltip" >
< span className = "pop-h" >
< svg width = "11" height = "11" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "1.5" strokeLinecap = "round" strokeLinejoin = "round" > < path d = "M12 9v4M12 17h.01" / > < path d = "M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" / > < / svg >
故 事 板 还 没 出 齐
< / span >
< span className = "pop-body" > 请 先 等 每 场 故 事 板 都 出 片 , 再 点 < b > 生 成 视 频 < / b > 。 < / span >
< / div >
) }
< button
className = "btn btn-primary btn-lg"
type = "button"
disabled = { loading }
onClick = { ( ) = > {
if ( ! sbAllDone ) { setSbConfirmHint ( true ) ; return ; }
if ( videoAnyStarted ) {
goStage ( 4 ) ;
return ;
}
guardVideoGen ( shots , null , ( ) = > setChargeConfirm ( "video" ) ) ;
} }
>
{ videoNextLabel }
< svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "1.5" strokeLinecap = "round" strokeLinejoin = "round" > < path d = "M5 12h14M12 5l7 7-7 7" / > < / svg >
< / button >
< / div >
< / div >
< / div >
< / section >
) ;
} ) ( ) }
{ /* ============= STAGE 4 · 视频(video_segments,adopted_asset 缩略 + 状态 + 时长)============= */ }
{ viewStage === 4 && ( ( ) = > {
{ viewStage === 3 && ( ( ) = > {
const pct = segments . length ? Math . round ( ( segDone / segments . length ) * 100 ) : 0 ;
const segSeconds = segments . map ( ( s ) = > s . target_duration_seconds ) . filter ( ( n ) = > n > 0 ) ;
const segMin = segSeconds . length ? Math . min ( . . . segSeconds ) : 0 ;
@@ -4069,7 +3790,7 @@ export function PipelinePage(props: {
const merging = exportResult ? . status === "queued" || exportResult ? . status === "running" ;
const mergeProgress = exportResult ? . progress ? ? 0 ;
return (
< section className = "stage active" data-stage-pane = "4 " >
< section className = "stage active" data-stage-pane = "3 " >
< div className = "queue-bar" >
< div >
< div style = { { fontSize : "14px" , fontWeight : 600 } } > 视 频 生 成 · { segDone } / { segments . length } 完 成 < / div >
@@ -4155,7 +3876,7 @@ export function PipelinePage(props: {
) }
< / div >
< div className = "hstack" >
< button className = "btn" type = "button" onClick = { ( ) = > goStage ( 3 ) } > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "1.5" strokeLinecap = "round" strokeLinejoin = "round" > < path d = "M19 12H5M12 19l-7-7 7-7" / > < / svg > 返 回 故 事 板 < / button >
< button className = "btn" type = "button" onClick = { ( ) = > goStage ( 2 ) } > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "1.5" strokeLinecap = "round" strokeLinejoin = "round" > < path d = "M19 12H5M12 19l-7-7 7-7" / > < / svg > 返 回 资 产 < / button >
{ /* 合成成片:各场视频按顺序拼成一条完整视频(合成完才有下面的播放/下载) */ }
{ mergedUrl && (
< >
@@ -4183,7 +3904,7 @@ export function PipelinePage(props: {
) ;
} ) ( ) }
{ /* ============= STAGE 5 · 拼接导出(timeline.clips / subtitle_tracks / bgm_tracks 真实定位)============= */ }
{ viewStage === 5 && ( ( ) = > {
{ viewStage === 4 && ( ( ) = > {
const previewUrl = edCur ? . url || assetUrl ( tlClips [ 0 ] ? . asset ) || segUrl ( segments . find ( ( s ) = > s . adopted_asset ) ) ;
const aspect = timeline ? . aspect_ratio || "9:16" ;
const resolution = timeline ? . resolution || "1080× 1920" ;
@@ -4211,7 +3932,7 @@ export function PipelinePage(props: {
const serverBgmName = serverBgm ? . asset_name || ( serverBgm ? "背景音乐" : "" ) ;
const subVisible = edState . subtitleEnabled ;
return (
< section className = "stage active" data-stage-pane = "5 " >
< section className = "stage active" data-stage-pane = "4 " >
< div className = "editor" >
< div className = "editor-preview" >
< div className = { ` canvas ${ ! showVideo && ! showFinal && previewUrl ? " has-mock-media" : "" } ` } id = "ed-canvas" style = { ! showVideo && ! showFinal && previewUrl ? mediaStyle ( previewUrl ) : undefined } >
@@ -4534,7 +4255,7 @@ export function PipelinePage(props: {
{ ! canExport && ! finalUrl && ! exporting && < span className = "mono" style = { { marginLeft : 10 , color : "var(--black-alpha-48)" } } > 待 全 部 视 频 片 段 生 成 完 成 后 可 导 出 < / span > }
< / div >
< div className = "hstack" >
< button className = "btn" type = "button" onClick = { ( ) = > goStage ( 4 ) } > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "1.5" strokeLinecap = "round" strokeLinejoin = "round" > < path d = "M19 12H5M12 19l-7-7 7-7" / > < / svg > 返 回 片 段 < / button >
< button className = "btn" type = "button" onClick = { ( ) = > goStage ( 3 ) } > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "1.5" strokeLinecap = "round" strokeLinejoin = "round" > < path d = "M19 12H5M12 19l-7-7 7-7" / > < / svg > 返 回 片 段 < / button >
< button className = "btn" type = "button" disabled = { loading } onClick = { ( ) = > onSaveTimeline ( buildSavePayload ( ) ) } > 保 存 草 稿 < / button >
{ finalUrl && (
< a className = "btn" href = { finalUrl } target = "_blank" rel = "noreferrer" download >
@@ -4631,11 +4352,10 @@ export function PipelinePage(props: {
document . body
) ;
} ) ( ) }
{ /* ── 流程步骤4/5 · 兜底弹窗拦截:参考图不齐时拦住生成,让用户去补 / 仍要生成(随他便) ── */ }
{ /* ── 流程步骤3 · 兜底弹窗拦截:参考图不齐时拦住生成,让用户去补 / 仍要生成(随他便) ── */ }
{ refGate && ( ( ) = > {
// 拆两类: noref=连参考图都没有;notri=有立绘但缺三视图 。文案分别 告诉用户「去哪里点什么」。
// noref=连参考图都没有。文案告诉用户「去哪里点什么」。
const noRef = refGate . missing . filter ( ( m ) = > m . reason === "noref" ) ;
const noTri = refGate . missing . filter ( ( m ) = > m . reason === "notri" ) ;
return createPortal (
< div className = "ref-gate-mask" onClick = { ( ) = > setRefGate ( null ) } >
< div className = "ref-gate-modal" role = "dialog" aria-modal = "true" onClick = { ( e ) = > e . stopPropagation ( ) } >
@@ -4643,7 +4363,7 @@ export function PipelinePage(props: {
{ noRef . length > 0 && (
< >
< div className = "rg-body" >
下 面 这 些 角 色 / 场 景 < strong > 还 没 生 成 参 考 图 < / strong > 。 请 回 「 基 础 资 产 」 页 , 点 对 应 卡 片 进 详 情 后 < strong > 「 生 成 立 绘 / 场 景 图 」 < / strong > 并 采 用 , 否 则 故 事 板 会 变 成 纯 文 生 图 、 跟 你 的 设 定 对 不 上 ( 白 花 钱 ) :
下 面 这 些 角 色 / 场 景 < strong > 还 没 生 成 参 考 图 < / strong > 。 请 回 「 基 础 资 产 」 页 , 点 对 应 卡 片 进 详 情 后 < strong > 「 生 成 立 绘 / 场 景 图 」 < / strong > 并 采 用 , 否 则 视 频 会 变 成 纯 文 生 视 频 、 跟 你 的 设 定 对 不 上 ( 白 花 钱 ) :
< / div >
< div className = "rg-list" >
{ noRef . map ( ( m ) = > (
@@ -4655,21 +4375,6 @@ export function PipelinePage(props: {
< / div >
< / >
) }
{ noTri . length > 0 && (
< >
< div className = "rg-body" style = { { marginTop : noRef.length > 0 ? 14 : 0 } } >
下 面 这 些 角 色 已 有 立 绘 , 但 < strong > 还 没 生 成 三 视 图 < / strong > 。 故 事 板 @图 合 成 要 靠 正 / 侧 / 背 多 角 度 锁 人 物 , 缺 三 视 图 角 色 容 易 跑 形 。 请 回 「 基 础 资 产 」 页 , 点 角 色 卡 进 详 情 后 点 < strong > 「 AI 生 成 三 视 图 」 < / strong > :
< / div >
< div className = "rg-list" >
{ noTri . map ( ( m ) = > (
< span className = "rg-chip" key = { ` notri: ${ m . type } : ${ m . name } ` } >
< span className = "rg-kind rg-kind-person" > 角 色 < / span >
{ m . name } < span className = "rg-warn" > 缺 三 视 图 < / span >
< / span >
) ) }
< / div >
< / >
) }
< div className = "rg-actions" >
< button type = "button" className = "btn btn-primary" onClick = { ( ) = > { setRefGate ( null ) ; goStage ( 2 ) ; } } > 去 基 础 资 产 页 补 齐 < / button >
< button type = "button" className = "btn btn-ghost rg-force" onClick = { ( ) = > { const go = refGate . proceed ; setRefGate ( null ) ; go ( ) ; } } >
@@ -4682,13 +4387,13 @@ export function PipelinePage(props: {
document . body
) ;
} ) ( ) }
{ /* ── 流程步骤5 · 过审闸弹窗:含真人脸的人物/分镜 未过审 → 列出哪一镜/哪个,先过审再生成(火山合规要求) ── */ }
{ /* ── 流程步骤3 · 过审闸弹窗:含真人脸的人物未过审 → 列出哪一镜/哪个,先过审再生成(火山合规要求) ── */ }
{ reviewGate && createPortal (
< div className = "ref-gate-mask" onClick = { ( ) = > setReviewGate ( null ) } >
< div className = "ref-gate-modal" role = "dialog" aria-modal = "true" onClick = { ( e ) = > e . stopPropagation ( ) } >
< div className = "rg-title" > 这 些 素 材 还 没 过 审 , 先 过 审 再 生 成 视 频 < / div >
< div className = "rg-body" >
含 真 人 脸 的 < strong > 人 物 形 象 < / strong > 和 < strong > 故 事 板 分 镜 < / strong > 必 须 先 通 过 火 山 合 规 审 核 ( 变 < span style = { { color : "var(--ok, #16a34a)" } } > 绿 盾 · 已 过 审 < / span > ) ,
含 真 人 脸 的 < strong > 人 物 形 象 < / strong > 必 须 先 通 过 火 山 合 规 审 核 ( 变 < span style = { { color : "var(--ok, #16a34a)" } } > 绿 盾 · 已 过 审 < / span > ) ,
否 则 视 频 生 成 会 被 火 山 以 「 疑 似 真 人 」 拒 绝 。 下 面 这 些 还 没 过 审 :
< / div >
< div className = "rg-list" >
@@ -5008,38 +4713,22 @@ export function PipelinePage(props: {
< / TeamModal >
< ConfirmModal
open = { chargeConfirm !== null }
title = { chargeConfirm === "video" ? "确认生成视频" : "确认生成故事板" }
title = "确认生成视频"
subtitle = "// 失败不扣 · 成功后结算"
icon = { < Sparkles size = { 16 } / > }
detail = { chargeConfirm === "video"
? (
< >
将 按 故 事 板 生 成 < b > { videoChargeShots || "多" } 段 < / b > 视 频 ( { outputSpecSummary } ) , 预 估 扣 除 < b > { videoChargePoints > 0 ? ` ${ videoChargePoints } 积分 ` : "按实际用量结算" } < / b >
{ videoChargeShots > 0 && videoChargePoints > 0 ? ` (约 ${ Math . round ( videoChargePoints / videoChargeShots ) } 积分/段) ` : "" } 。
确 认 后 进 入 视 频 页 并 开 始 生 成 。
< / >
)
: (
< >
将 按 脚 本 生 成 < b > { sbChargeShots || "多" } 场 < / b > 分 镜 图 ( { outputAspect } ) , 预 估 扣 除 < b > { sbChargePoints > 0 ? ` ${ sbChargePoints } 积分 ` : "按实际用量结算" } < / b >
{ sbChargeShots > 0 ? ` ( ${ pts ( 20 ) } 积分/镜 × ${ sbChargeShots } 场) ` : "" } 。
确 认 后 进 入 故 事 板 并 开 始 生 成 。
< / >
) }
confirmText = { chargeConfirm === "video"
? ( videoChargePoints > 0 ? ` 确认生成 · ${ videoChargePoints } 积分 ` : "确认生成" )
: ( sbChargePoints > 0 ? ` 确认生成 · ${ sbChargePoints } 积分 ` : "确认生成" ) }
detail = { (
< >
将 按 脚 本 的 秒 级 分 镜 生 成 < b > { videoChargeShots || "多" } 段 < / b > 视 频 ( { outputSpecSummary } ) , 预 估 扣 除 < b > { videoChargePoints > 0 ? ` ${ videoChargePoints } 积分 ` : "按实际用量结算" } < / b >
{ videoChargeShots > 0 && videoChargePoints > 0 ? ` (约 ${ Math . round ( videoChargePoints / videoChargeShots ) } 积分/段) ` : "" } 。
确 认 后 进 入 视 频 页 并 开 始 生 成 。
< / >
) }
confirmText = { videoChargePoints > 0 ? ` 确认生成 · ${ videoChargePoints } 积分 ` : "确认生成" }
onCancel = { ( ) = > setChargeConfirm ( null ) }
onConfirm = { ( ) = > {
const kind = chargeConfirm ;
setChargeConfirm ( null ) ;
if ( kind === "storyboard" ) {
goStage ( 3 ) ;
startStoryboardGeneration ( ) ;
} else if ( kind === "video" ) {
goStage ( 4 ) ;
submitAllVideosOptimistic ( ) ;
}
goStage ( 3 ) ;
submitAllVideosOptimistic ( ) ;
} }
/ >
< / section >