@@ -3,7 +3,7 @@
import { type MouseEvent as ReactMouseEvent , type ReactNode , type RefObject , useEffect , useMemo , useRef , useState } from "react"
import { createPortal } from "react-dom"
import {
AlertTriangle , Check , ChevronDown , Circle , Film , FileText , Image as ImageIcon , Link2 , Loader2 ,
AlertTriangle , Check , ChevronDown , Circle , Film , FileText , Image as ImageIcon , Info , Link2 , Loader2 ,
Mic , Package , PanelRight , Play , Plus , Scissors , Sparkles , Trash2 , Upload , Wand2 ,
} from "lucide-react"
import { toast } from "sonner"
@@ -18,6 +18,7 @@ import {
type KeyFrame ,
type ProductViewAnalysisItem ,
type ProductRefStateItem ,
type RuntimeModels ,
type StoryboardScriptRewriteSegment ,
type StoryboardScene ,
type SubjectAsset ,
@@ -31,6 +32,7 @@ import {
generateProductAngleAsset ,
generateSubjectAssets ,
generatedImageUrl ,
getRuntimeHealth ,
hasCutout ,
representativeCutoutUrl ,
resolveImageRefUrl ,
@@ -95,6 +97,13 @@ type AudioStoryboardRow = {
type ProductRefItem = ProductRefStateItem
type SubjectStyleMode = "transparent_human" | "source_actor"
type ModelTraceSpec = {
title : string
model : string
chain : string [ ]
note? : string
}
const PRODUCT_VIEW_SLOTS = [
{ value : "front" , label : "正面/外侧" , hint : "整体 U 形轮廓、开口宽度、外壳主外观" } ,
{ value : "left_45" , label : "佩戴者左 45" , hint : "戴在脖子上时佩戴者左肩一侧的弧度、按钮/结构差异" } ,
@@ -285,6 +294,89 @@ function subjectAssetUrl(job: Job, asset: SubjectAsset) {
return apiAssetUrl ( asset . url ) || resolveImageRefUrl ( job . id , { kind : "asset" , frame_idx : 0 , element_id : asset.id } )
}
function modelValue ( value? : string ) {
return value ? . trim ( ) || "待配置"
}
function modelList ( values : Array < string | undefined > ) {
return values . map ( modelValue ) . filter ( ( value , index , list ) = > value && list . indexOf ( value ) === index ) . join ( " / " )
}
function imageModelChain ( models? : RuntimeModels ) {
return modelList ( models ? . image_fallbacks ? . length ? models . image_fallbacks : [ models ? . image , "gemini-3.1-flash-image-preview" , "gemini-2.5-flash-image" ] )
}
function resolveVideoModelLabel ( models : RuntimeModels | undefined , model : string ) {
const concrete = models ? . video_aliases ? . [ model ] || ( model === models ? . video ? models . video : "" )
return concrete && concrete !== model ? ` ${ model } -> ${ concrete } ` : modelValue ( concrete || model )
}
function audioModelTrace ( models? : RuntimeModels ) : ModelTraceSpec {
return {
title : "音频解析" ,
model : modelList ( [ models ? . asr , models ? . translate , models ? . asr_fallback ] ) ,
chain : [
` ASR 转写:优先 ${ modelValue ( models ? . asr ) } ,失败后尝试本机 ${ modelValue ( models ? . local_asr ) } ,再回退 ${ modelValue ( models ? . asr_fallback ) } ` ,
` 字幕翻译: ${ modelValue ( models ? . translate ) } 输出中文逐句时间轴 ` ,
` 讲话人 / 节奏 / 背景音: ${ modelValue ( models ? . asr_fallback ) } 读取 audio.wav 做多模态音频分析 ` ,
] ,
note : "点击“解析音频”后触发;开始任务下载完成后也会自动走这条链路。" ,
}
}
function productModelTrace ( models? : RuntimeModels ) : ModelTraceSpec {
return {
title : "产品视角识别 / 补图" ,
model : modelList ( [ models ? . vision , models ? . image ] ) ,
chain : [
` 批量视角识别: ${ modelValue ( models ? . vision ) } 一次读取同一产品多张图,标注视角、左右、上下、用途和风险 ` ,
` 缺角度补图: ${ imageModelChain ( models ) } 按同一肩颈按摩仪结构补齐缺失视角 ` ,
"前端只保存标注和 AI 补图结果;后续生成视频时每条最多挑 6 张相关产品图" ,
] ,
note : "上传产品图、重新识别、缺视角重试都会使用这组模型链路。" ,
}
}
function similarSubjectModelTrace ( models : RuntimeModels | undefined , subjectStyle : SubjectStyleMode ) : ModelTraceSpec {
return {
title : subjectStyle === "transparent_human" ? "相似透明骨架主体" : "相似普通真人主体" ,
model : imageModelChain ( models ) ,
chain : [
"参考帧策略:未勾选时使用全部关键帧,勾选后只使用已选关键帧" ,
` 主体类型: ${ subjectStyle === "transparent_human" ? "透明/半透明皮肤包裹可见白色骨架" : "普通商业广告真人" } ` ,
` 图像生成: ${ imageModelChain ( models ) } 逐张生成正、背、左、右、左前 45、右前 45 ` ,
"身份锁定:六张必须是同一个主体,性别表现、年龄段、体型、材质和风格保持一致" ,
] ,
note : "这是生成类似主体,不是复制、抠出或复刻源视频人物身份。" ,
}
}
function scriptRewriteModelTrace ( models? : RuntimeModels ) : ModelTraceSpec {
return {
title : "新口播文案改写" ,
model : modelList ( [ models ? . audio_rewrite , models ? . asr_fallback , models ? . translate ] ) ,
chain : [
` 主改写: ${ modelValue ( models ? . audio_rewrite ) } 根据原文案、当前分镜、作者想法生成新口播 ` ,
` 失败回退:依次尝试 ${ modelValue ( models ? . asr_fallback ) } 和 ${ modelValue ( models ? . translate ) } ` ,
"返回结果只写入当前分镜文案编辑框;生成视频时再把当前文案写入分镜 action" ,
] ,
}
}
function videoModelTrace ( models : RuntimeModels | undefined , model : string ) : ModelTraceSpec {
return {
title : "视频生成" ,
model : resolveVideoModelLabel ( models , model ) ,
chain : [
` 前端选择: ${ model } ` ,
` 后端解析: ${ resolveVideoModelLabel ( models , model ) } ` ,
` 服务商: ${ modelValue ( models ? . video_provider ) } · ${ modelValue ( models ? . video_base_url ) } ` ,
"输入:当前分镜文案、参考帧、产品素材、产品方向标注和画面规划" ,
"输出:异步候选视频,完成后回填到对应分镜行" ,
] ,
}
}
function buildFallbackScene ( job : Job , frame : KeyFrame , order : number ) : StoryboardScene {
const frames = [ . . . job . frames ] . sort ( ( a , b ) = > a . timestamp - b . timestamp )
const nextFrame = frames . find ( ( item ) = > item . timestamp > frame . timestamp ) ? ? null
@@ -632,6 +724,7 @@ export function AdRecreationBoard({
const [ elementBusyFrame , setElementBusyFrame ] = useState < number | null > ( null )
const [ sixViewBusyKey , setSixViewBusyKey ] = useState < string | null > ( null )
const [ generatingAll , setGeneratingAll ] = useState ( false )
const [ runtimeModels , setRuntimeModels ] = useState < RuntimeModels | undefined > ( )
const fileRef = useRef < HTMLInputElement | null > ( null )
const selectedFrames = job
? job . frames . filter ( ( frame ) = > data . selectedFrames . has ( frame . index ) ) . sort ( ( a , b ) = > a . timestamp - b . timestamp )
@@ -648,6 +741,20 @@ export function AdRecreationBoard({
setSelectedVideoIds ( new Set ( ) )
} , [ activeJobId ] )
useEffect ( ( ) = > {
let cancelled = false
getRuntimeHealth ( )
. then ( ( health ) = > {
if ( ! cancelled ) setRuntimeModels ( health . models )
} )
. catch ( ( error ) = > {
console . warn ( "模型配置读取失败" , error )
} )
return ( ) = > {
cancelled = true
}
} , [ ] )
const submitUrl = ( ) = > {
const trimmed = url . trim ( )
if ( ! trimmed ) return
@@ -847,10 +954,13 @@ export function AdRecreationBoard({
{ job ? . message || "下载源视频后解析音频,再抽参考帧并生成相似主体。" }
< / div >
< / div >
< ActionButton disabled = { ! job ? . video_url || job . status === "transcribing" } onClick = { ( ) = > data . onTranscribeAudio ? . ( job ? . id ) } >
< Mic className = "h-3.5 w-3.5" / >
解 析 音 频
< / ActionButton >
< div className = "flex shrink-0 items-center gap-2" >
< ModelTrace trace = { audioModelTrace ( runtimeModels ) } compact / >
< ActionButton disabled = { ! job ? . video_url || job . status === "transcribing" } onClick = { ( ) = > data . onTranscribeAudio ? . ( job ? . id ) } >
< Mic className = "h-3.5 w-3.5" / >
解 析 音 频
< / ActionButton >
< / div >
< / div >
< div className = "mt-2 grid grid-cols-1 gap-2 xl:grid-cols-[minmax(0,1fr)_360px]" >
@@ -890,6 +1000,7 @@ export function AdRecreationBoard({
selectedFrames = { data . selectedFrames }
onJobUpdate = { data . onJobUpdate }
onGenerateVideo = { onGenerateVideo }
runtimeModels = { runtimeModels }
/ >
< / div >
< / section >
@@ -1258,6 +1369,7 @@ function AudioIntakePanel({
onToggleFrame = { onToggleFrame }
onJobUpdate = { onJobUpdate }
onDeleteFrame = { onDeleteFrame }
runtimeModels = { runtimeModels }
/ >
< / div >
< / div >
@@ -1271,12 +1383,14 @@ function SourceReferenceBuildPanel({
onToggleFrame ,
onJobUpdate ,
onDeleteFrame ,
runtimeModels ,
} : {
job : Job
selectedFrames : Set < number >
onToggleFrame : ( idx : number ) = > void
onJobUpdate : ( job : Job ) = > void
onDeleteFrame ? : ( jobId : string , idx : number ) = > Promise < void > | void
runtimeModels? : RuntimeModels
} ) {
const [ extracting , setExtracting ] = useState ( false )
const [ subjectBusy , setSubjectBusy ] = useState ( false )
@@ -1498,7 +1612,10 @@ function SourceReferenceBuildPanel({
< div className = "mt-2 border-t border-white/8 pt-2" >
< div className = "mb-1.5 flex flex-wrap items-center justify-between gap-2 text-[10px] text-white/36" >
< span > 相 似 主 体 白 底 视 图 < / span >
< div className = "flex items-center gap-2" >
< span > 相 似 主 体 白 底 视 图 < / span >
< ModelTrace trace = { similarSubjectModelTrace ( runtimeModels , subjectStyle ) } compact / >
< / div >
< div className = "flex min-w-0 flex-wrap items-center justify-end gap-2" >
< div className = "flex rounded-md border border-white/10 bg-black/28 p-0.5" >
{ [
@@ -1566,11 +1683,13 @@ function AudioStoryboardPlanPanel({
selectedFrames ,
onJobUpdate ,
onGenerateVideo ,
runtimeModels ,
} : {
job : Job | null
selectedFrames : Set < number >
onJobUpdate ? : ( job : Job ) = > void
onGenerateVideo ? : ( frameIdx : number , scene : StoryboardScene , model : string ) = > Promise < void > | void
runtimeModels? : RuntimeModels
} ) {
const [ videoBusyRow , setVideoBusyRow ] = useState < number | null > ( null )
const [ productItems , setProductItems ] = useState < ProductRefItem [ ] > ( [ ] )
@@ -1881,6 +2000,7 @@ function AudioStoryboardPlanPanel({
< div className = "min-w-0" >
< div className = "flex items-center gap-2" >
< SectionTitle icon = { < Package className = "h-4 w-4" / > } title = "同一产品素材池 / 视角标注" / >
< ModelTrace trace = { productModelTrace ( runtimeModels ) } compact / >
< span className = "rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[10px] text-white/42" > { productItems . length ? ` ${ productItems . length } 张素材 ` : "素材池不限量" } < / span >
{ ( productAnalyzing || productAngleBusy ) && (
< span className = "inline-flex items-center gap-1 rounded-md border border-cyan-300/20 bg-cyan-300/[0.08] px-2 py-0.5 text-[10px] text-cyan-100/75" >
@@ -1959,6 +2079,7 @@ function AudioStoryboardPlanPanel({
className = "min-h-[42px] resize-y rounded-md border border-white/10 bg-black/35 px-2.5 py-2 text-[11px] leading-snug text-white outline-none placeholder:text-white/25 focus:border-cyan-300/50"
/ >
< div className = "flex items-center justify-end gap-2" >
< ModelTrace trace = { scriptRewriteModelTrace ( runtimeModels ) } compact / >
< button
type = "button"
onClick = { ( ) = > void rewriteAllRows ( ) }
@@ -2030,6 +2151,10 @@ function AudioStoryboardPlanPanel({
< div className = "mt-1 truncate text-[10px] text-white/34" title = { referenceFrame ? ` 参考 ${ referenceFrame . timestamp . toFixed ( 1 ) } s ` : row . referencePlan } >
{ referenceFrame ? ` 参考 ${ referenceFrame . timestamp . toFixed ( 1 ) } s · 可多次生成候选 ` : "先在关键帧区自动抽帧 12 张" }
< / div >
< div className = "mt-1 flex items-center justify-between gap-2" >
< span className = "text-[10px] text-white/34" > 生 视 频 模 型 < / span >
< ModelTrace trace = { videoModelTrace ( runtimeModels , "seedance" ) } compact / >
< / div >
< button
type = "button"
onClick = { ( ) = > generateRowVideo ( row , referenceFrame ) }
@@ -2037,7 +2162,7 @@ function AudioStoryboardPlanPanel({
className = "mt-1.5 inline-flex h-8 w-full items-center justify-center gap-1 rounded-md bg-white px-2 text-[11px] font-semibold text-black transition hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-40"
>
{ generating ? < Loader2 className = "h-3.5 w-3.5 animate-spin" / > : < Play className = "h-3.5 w-3.5" / > }
生 成 本 条
生 成 本 条 · Seedance
< / button >
< / StoryboardPlanCell >
< / article >
@@ -2366,6 +2491,79 @@ function ProfileTile({ label, value, running }: { label: string; value?: string;
)
}
function ModelTrace ( { trace , compact = false } : { trace : ModelTraceSpec ; compact? : boolean } ) {
const [ position , setPosition ] = useState < { left : number ; top : number } | null > ( null )
const buttonRef = useRef < HTMLButtonElement | null > ( null )
const toggle = ( ) = > {
if ( position ) {
setPosition ( null )
return
}
const rect = buttonRef . current ? . getBoundingClientRect ( )
if ( ! rect ) return
const width = Math . min ( 380 , window . innerWidth - 32 )
const height = 260
let left = rect . right - width
let top = rect . bottom + 8
if ( left < 16 ) left = 16
if ( left + width > window . innerWidth - 16 ) left = window . innerWidth - width - 16
if ( top + height > window . innerHeight - 16 ) top = Math . max ( 16 , rect . top - height - 8 )
setPosition ( { left , top } )
}
const popover = position && typeof document !== "undefined"
? createPortal (
< div
className = "fixed z-[10000] w-[min(380px,calc(100vw-32px))] rounded-xl border border-white/15 bg-black/95 p-3 text-white shadow-[0_24px_80px_rgba(0,0,0,0.75)]"
style = { { left : position.left , top : position.top } }
>
< div className = "mb-2 flex items-start justify-between gap-3" >
< div className = "min-w-0" >
< div className = "text-[12px] font-semibold text-white" > { trace . title } < / div >
< div className = "mt-1 truncate font-mono text-[11px] text-cyan-100/80" title = { trace . model } > { trace . model } < / div >
< / div >
< button
type = "button"
onClick = { ( ) = > setPosition ( null ) }
className = "h-6 w-6 rounded-md border border-white/10 text-white/45 transition hover:border-white/25 hover:text-white"
aria-label = "关闭模型链路"
>
×
< / button >
< / div >
< ol className = "space-y-1.5" >
{ trace . chain . map ( ( item , index ) = > (
< li key = { ` ${ trace . title } - ${ index } ` } className = "grid grid-cols-[18px_minmax(0,1fr)] gap-2 text-[11px] leading-snug text-white/66" >
< span className = "flex h-[18px] w-[18px] items-center justify-center rounded-full border border-white/10 bg-white/[0.04] font-mono text-[9px] text-white/42" > { index + 1 } < / span >
< span > { item } < / span >
< / li >
) ) }
< / ol >
{ trace . note ? < div className = "mt-2 rounded-md border border-white/10 bg-white/[0.035] px-2 py-1.5 text-[10.5px] leading-snug text-white/42" > { trace . note } < / div > : null }
< / div > ,
document . body ,
)
: null
return (
< >
< button
ref = { buttonRef }
type = "button"
onClick = { toggle }
className = { ` inline-flex min-w-0 items-center justify-center gap-1 rounded-md border border-cyan-300/18 bg-cyan-300/[0.065] text-cyan-50/76 transition hover:border-cyan-200/45 hover:bg-cyan-300/[0.12] ${ compact ? "h-7 max-w-[260px] px-2 text-[10px]" : "h-8 max-w-[320px] px-2.5 text-[11px]" } ` }
title = { ` ${ trace . title } · ${ trace . model } ` }
>
< Info className = "h-3.5 w-3.5 shrink-0" / >
< span className = "shrink-0" > 模 型 < / span >
< span className = "min-w-0 truncate font-mono" > { trace . model } < / span >
< / button >
{ popover }
< / >
)
}
function FrameExtractControls ( {
job ,
data ,