@@ -1,6 +1,6 @@
"use client"
import { type MouseEvent as ReactMouseEvent , type ReactNode , type RefObject , useEffect , useMemo , useRef , useState } from "react"
import { type DragEvent as ReactDragEvent , type MouseEvent as ReactMouseEvent , type ReactNode , type RefObject , useEffect , useMemo , useRef , useState } from "react"
import { createPortal } from "react-dom"
import {
AlertTriangle , BookOpen , Check , ChevronDown , Circle , Film , FileText , Image as ImageIcon , Info , Link2 , Loader2 ,
@@ -66,6 +66,7 @@ import {
subjectTemplateImageUrl ,
updateElement ,
updateStoryboard ,
uploadReferenceFrame ,
uploadStoryboardAsset ,
translateText ,
videoUrl ,
@@ -2688,7 +2689,7 @@ function AudioIntakePanel({
selectedFrames : Set < number >
onToggleFrame : ( idx : number ) = > void
onJobUpdate : ( job : Job ) = > void
onAddFrame ? : ( jobId : string , t : number ) = > Promise < void > | void
onAddFrame ? : ( jobId : string , t : number ) = > Promise < Job | void > | Job | void
onDeleteFrame ? : ( jobId : string , idx : number ) = > Promise < void > | void
runtimeModels? : RuntimeModels
} ) {
@@ -2839,17 +2840,21 @@ function AudioIntakePanel({
}
const addFilmstripFrame = async ( time : number ) = > {
if ( ! job || ! onAddFrame ) return
if ( ! job || ! onAddFrame ) return null
const next = clampNumber ( time , 0 , timelineDuration )
const duplicate = frames . find ( ( frame ) = > Math . abs ( frame . timestamp - next ) < 0.45 )
if ( duplicate ) {
toast . warning ( ` 附近已有关键帧: ${ duplicate . timestamp . toFixed ( 1 ) } s ` )
return
return duplicate
}
setFilmstripBusyTime ( next )
try {
await onAddFrame ( job . id , next )
const known = new Set ( frames . map ( ( frame ) = > frame . index ) )
const updated = await onAddFrame ( job . id , next )
toast . success ( ` 已加入关键帧: ${ next . toFixed ( 1 ) } s ` )
const updatedJob = updated && typeof updated === "object" && "frames" in updated ? updated : null
const added = updatedJob ? . frames . find ( ( frame ) = > ! known . has ( frame . index ) && Math . abs ( frame . timestamp - next ) < 0.45 ) ? ? null
return added
} finally {
setFilmstripBusyTime ( null )
setFilmstripDragTime ( null )
@@ -2999,7 +3004,7 @@ function AudioIntakePanel({
onJobUpdate = { onJobUpdate }
runtimeModels = { runtimeModels }
filmstripDragging = { filmstripDragTime !== null }
onDropFilmstripFrame = { ( time ) = > void addFilmstripFrame ( time ) }
onDropFilmstripFrame = { ( time ) = > addFilmstripFrame ( time ) }
/ >
< / div >
< / div >
@@ -3305,10 +3310,12 @@ function SourceSubjectPipeline({
onJobUpdate : ( job : Job ) = > void
runtimeModels? : RuntimeModels
filmstripDragging? : boolean
onDropFilmstripFrame ? : ( time : number ) = > void
onDropFilmstripFrame ? : ( time : number ) = > Promise < KeyFrame | null > | KeyFrame | null | void
} ) {
const [ referenceDropActive , setReferenceDropActive ] = useState ( false )
const [ agentDropActive , setAgentDropActive ] = useState ( false )
const [ referenceFrameDragging , setReferenceFrameDragging ] = useState ( false )
const [ agentReferenceUploadBusy , setAgentReferenceUploadBusy ] = useState ( false )
const [ reconstructionDirections , setReconstructionDirections ] = useState < Record < SubjectReconstructionMode , string > > ( ( ) = > ( { . . . DEFAULT_RECONSTRUCTION_DIRECTIONS } ) )
const [ subjectModelBundle , setSubjectModelBundle ] = useState < SubjectModelBundle > ( ( ) = > job . subject_agent ? . model_bundle ? ? "gpt" )
const [ agentReferenceFrameIndices , setAgentReferenceFrameIndices ] = useState < number [ ] > ( ( ) = > job . subject_agent ? . source_frame_indices ? ? [ ] )
@@ -3636,15 +3643,30 @@ function SourceSubjectPipeline({
}
}
const mergeAgentReferenceIndices = ( current : number [ ] , incoming : number [ ] ) = > {
let replaced = false
const next = [ . . . current ]
for ( const index of incoming ) {
const numericIndex = Number ( index )
if ( ! Number . isFinite ( numericIndex ) || next . includes ( numericIndex ) ) continue
next . push ( numericIndex )
while ( next . length > RECONSTRUCTION_FRAME_LIMIT ) {
next . shift ( )
replaced = true
}
}
return { next , replaced }
}
const addAgentReferenceFrame = ( frame : KeyFrame ) = > {
setAgentReferenceFrameIndices ( ( current ) = > {
if ( current . includes ( frame . index ) ) {
toast . info ( "这张参考帧已经在转换层里。" )
return current
}
const next = current . length >= RECONSTRUCTION_FRAME_LIMIT ? [ . . . current . slice ( 1 ) , frame . index ] : [ . . . current , frame . index ]
if ( current . length >= RECONSTRUCTION_FRAME_LIMIT ) {
toast . warning ( ` 最多保留 ${ RECONSTRUCTION_FRAME_LIMIT } 张参考帧 ,已替换为最近拖入的组合。 ` )
const { next , replaced } = mergeAgentReferenceIndices ( current , [ frame . index ] )
if ( replaced ) {
toast . warning ( ` 最多保留 ${ RECONSTRUCTION_FRAME_LIMIT } 张参考图 ,已替换为最近拖入的组合。 ` )
} else {
toast . info ( ` 已加入转换层: ${ frame . timestamp . toFixed ( 1 ) } s。 ` )
}
@@ -3652,10 +3674,109 @@ function SourceSubjectPipeline({
} )
}
const addAgentReferenceIndices = ( indices : number [ ] , notice = "已加入转换层" ) = > {
if ( ! indices . length ) return
setAgentReferenceFrameIndices ( ( current ) = > {
const { next , replaced } = mergeAgentReferenceIndices ( current , indices )
if ( next . length === current . length && next . every ( ( item , idx ) = > item === current [ idx ] ) ) {
toast . info ( "这些参考图已经在转换层里。" )
return current
}
if ( replaced ) {
toast . warning ( ` 最多保留 ${ RECONSTRUCTION_FRAME_LIMIT } 张参考图,已保留最近加入的组合。 ` )
} else {
toast . success ( ` ${ notice } : ${ indices . length } 张。 ` )
}
return next
} )
}
const removeAgentReferenceFrame = ( frameIndex : number ) = > {
setAgentReferenceFrameIndices ( ( current ) = > current . filter ( ( index ) = > index !== frameIndex ) )
}
const transferHasAgentReference = ( transfer : DataTransfer ) = > {
const types = Array . from ( transfer . types || [ ] )
return (
types . includes ( SOURCE_KEYFRAME_DRAG_TYPE ) ||
types . includes ( FILMSTRIP_DRAG_TYPE ) ||
types . includes ( "Files" )
)
}
const handleAgentReferenceDragEnter = ( event : ReactDragEvent < HTMLElement > ) = > {
if ( ! transferHasAgentReference ( event . dataTransfer ) ) return
event . preventDefault ( )
setAgentDropActive ( true )
}
const handleAgentReferenceDragOver = ( event : ReactDragEvent < HTMLElement > ) = > {
if ( ! transferHasAgentReference ( event . dataTransfer ) ) return
event . preventDefault ( )
event . dataTransfer . dropEffect = "copy"
setAgentDropActive ( true )
}
const handleAgentReferenceDragLeave = ( event : ReactDragEvent < HTMLElement > ) = > {
const next = event . relatedTarget as Node | null
if ( next && event . currentTarget . contains ( next ) ) return
setAgentDropActive ( false )
}
const uploadAgentReferenceFiles = async ( files : File [ ] ) = > {
const imageFiles = files . filter ( ( file ) = > {
const name = file . name . toLowerCase ( )
return file . type . startsWith ( "image/" ) || /\.(jpe?g|png|webp|bmp)$/i . test ( name )
} ) . slice ( 0 , RECONSTRUCTION_FRAME_LIMIT )
if ( ! imageFiles . length ) {
toast . warning ( "只支持拖入图片文件。" )
return
}
setAgentReferenceUploadBusy ( true )
try {
let workingJob = job
const known = new Set ( job . frames . map ( ( frame ) = > frame . index ) )
const addedIndices : number [ ] = [ ]
for ( const file of imageFiles ) {
const updated = await uploadReferenceFrame ( workingJob . id , file )
workingJob = updated
onJobUpdate ( updated )
const added = updated . frames . filter ( ( frame ) = > ! known . has ( frame . index ) )
added . forEach ( ( frame ) = > {
known . add ( frame . index )
addedIndices . push ( frame . index )
} )
}
addAgentReferenceIndices ( addedIndices , "已上传并加入转换层" )
} catch ( e ) {
toast . error ( "参考图上传失败:" + ( e instanceof Error ? e.message : String ( e ) ) )
} finally {
setAgentReferenceUploadBusy ( false )
}
}
const handleAgentReferenceDrop = async ( event : ReactDragEvent < HTMLElement > ) = > {
if ( ! transferHasAgentReference ( event . dataTransfer ) ) return
event . preventDefault ( )
setAgentDropActive ( false )
const files = Array . from ( event . dataTransfer . files || [ ] )
if ( files . length ) {
await uploadAgentReferenceFiles ( files )
return
}
const frameIndex = Number ( event . dataTransfer . getData ( SOURCE_KEYFRAME_DRAG_TYPE ) )
if ( Number . isFinite ( frameIndex ) ) {
const frame = frames . find ( ( item ) = > item . index === frameIndex )
if ( frame ) addAgentReferenceFrame ( frame )
return
}
const filmstripTime = Number ( event . dataTransfer . getData ( FILMSTRIP_DRAG_TYPE ) )
if ( Number . isFinite ( filmstripTime ) && onDropFilmstripFrame ) {
const addedFrame = await onDropFilmstripFrame ( filmstripTime )
if ( addedFrame ) addAgentReferenceFrame ( addedFrame )
}
}
const runSubjectAgentAnalyze = async ( ) = > {
if ( ! agentReferenceFrameIndices . length ) {
toast . warning ( "先从左侧拖入 1-3 张参考帧,再开始分析。" )
@@ -3748,7 +3869,7 @@ function SourceSubjectPipeline({
return (
< >
< div className = "grid gap-2 xl:grid-cols-[150px_minmax(21 0px,0.75 fr)_minmax(0,1.25 fr)] 2xl:grid-cols-[170px_minmax(240 px,0.8 fr)_minmax(0,1.3 fr)]" >
< div className = "grid gap-2 xl:grid-cols-[150px_minmax(26 0px,0.9 fr)_minmax(0,1.1 fr)] 2xl:grid-cols-[170px_minmax(285 px,0.95 fr)_minmax(0,1.05 fr)]" >
< div className = "min-w-0" >
< div className = "mb-2 flex items-center justify-between gap-2" >
< SectionTitle icon = { < ImageIcon className = "h-4 w-4" / > } title = "参考帧池" / >
@@ -3805,7 +3926,15 @@ function SourceSubjectPipeline({
return (
< div
key = { frame . index }
className = "relative"
draggable
onDragStart = { ( event ) = > {
event . dataTransfer . setData ( SOURCE_KEYFRAME_DRAG_TYPE , String ( frame . index ) )
event . dataTransfer . effectAllowed = "copy"
setReferenceFrameDragging ( true )
} }
onDragEnd = { ( ) = > setReferenceFrameDragging ( false ) }
className = "relative cursor-grab active:cursor-grabbing"
title = "拖到转换层作为生图参考"
>
< MediaAssetTile
src = { effectiveFrameUrl ( job . id , frame ) }
@@ -3856,7 +3985,7 @@ function SourceSubjectPipeline({
{ agentReferenceFrames . length ? ` ${ agentReferenceFrames . length } / ${ RECONSTRUCTION_FRAME_LIMIT } 图 ` : "待选图" }
< / span >
< / div >
< div className = "min-h-[410px] rounded-md border border-white/10 bg-black/24 p-2 2xl:min-h-[500px]" >
< div className = "flex min-h-[410px] flex-col rounded-md border border-white/10 bg-black/24 p-2 2xl:min-h-[500px]" >
< div className = "mb-2 grid grid-cols-2 gap-1.5" >
{ SUBJECT_MODEL_BUNDLE_OPTIONS . map ( ( option ) = > (
< button
@@ -3876,10 +4005,20 @@ function SourceSubjectPipeline({
) ) }
< / div >
< div className = "rounded-md border border-white/10 bg-black/22 p-2" >
< div
className = { ` rounded-md border p-2 transition ${
agentDropActive || referenceFrameDragging || filmstripDragging || agentReferenceUploadBusy
? "border-cyan-200/65 bg-cyan-300/[0.08] ring-1 ring-cyan-200/25"
: "border-white/10 bg-black/22"
} ` }
onDragEnter = { handleAgentReferenceDragEnter }
onDragOver = { handleAgentReferenceDragOver }
onDragLeave = { handleAgentReferenceDragLeave }
onDrop = { ( event ) = > void handleAgentReferenceDrop ( event ) }
>
< div className = "mb-1.5 flex items-center justify-between gap-2" >
< span className = "text-[10px] font-semibold text-white/72" > 参 考 图 < / span >
< span className = "text-[9px] text-white/34" > 最 多 { RECONSTRUCTION_FRAME_LIMIT } 张 < / span >
< span className = "text-[10px] font-semibold text-white/72" > 参 考 输 入 < / span >
< span className = "text-[9px] text-white/34" > { agentReferenceFrames . length } / { RECONSTRUCTION_FRAME_LIMIT } < / span >
< / div >
{ agentReferenceFrames . length ? (
< div className = "grid grid-cols-3 gap-1.5" >
@@ -3902,19 +4041,26 @@ function SourceSubjectPipeline({
) ) }
< / div >
) : (
< div className = "flex h-24 items-center justify-center rounded border border-dashed border-white/12 px-2 text-center text-[10px] leading-snug text-white/32 " >
从 左 侧 参 考 帧 点 + 加 入 。
< div className = "flex h-[116px] flex-col items-center justify-center rounded border border-dashed border-white/15 px-3 text-center text-[10px] leading-snug text-white/34 " >
{ agentReferenceUploadBusy ? < Loader2 className = "mb-1.5 h-4 w-4 animate-spin text-cyan-100/80" / > : < Upload className = "mb-1.5 h-4 w-4 text-cyan-100/55" / > }
< span className = "font-semibold text-white/50" > 拖 入 参 考 帧 或 本 地 图 片 < / span >
< span className = "mt-0.5 text-white/28" > 也 可 点 左 侧 缩 略 图 上 的 + < / span >
< / div >
) }
< button
type = " button"
onClick = { ( ) = > void runSubjectAgentAnalyze ( ) }
disabled = { ! agentReferenceFrames . length || ! ! subjectAgentBusy }
className = "skg-secondary-action mt-2 inline-flex h-7 w-full items-center justify-center gap-1.5 px-2 text-[10px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
>
{ subjectAgentBusy === "analyze" ? < Loader2 className = "h-3.5 w-3.5 animate-spin" / > : < Sparkles className = "h-3.5 w-3.5" / > }
分 析 参 考 图
< / button >
< div className = "mt-2 flex items-center gap-1.5" >
< button
type = "button"
onClick = { ( ) = > void runSubjectAgentAnalyze ( ) }
disabled = { ! agentReferenceFrames . length || ! ! subjectAgentBusy || agentReferenceUploadBusy }
className = "skg-secondary-action inline-flex h-7 flex-1 items-center justify-center gap-1.5 px-2 text-[10px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
>
{ subjectAgentBusy === "analyze" || agentReferenceUploadBusy ? < Loader2 className = "h-3.5 w-3.5 animate-spin" / > : < Sparkles className = "h-3.5 w-3.5" / > }
分 析 参 考 图
< / button >
< span className = "shrink-0 rounded border border-white/10 bg-black/24 px-1.5 py-1 text-[9px] text-white/35" >
图 片 区
< / span >
< / div >
< / div >
{ agentAnalysis ? (
@@ -3945,14 +4091,17 @@ function SourceSubjectPipeline({
< / div >
) : null }
< div className = "mt-2 rounded-md border border-white/10 bg-black/22 p-2" >
< div className = "mt-2 flex min-h-0 flex-1 flex-col rounded-md border border-white/10 bg-black/22 p-2" >
< div className = "mb-1.5 flex items-center justify-between gap-2" >
< span className = "text-[10px] font-semibold text-white/72" > 生 图 对 话 < / span >
< span className = "inline-flex items-center gap-1 text-[10px] font-semibold text-white/72" >
< MessageSquare className = "h-3.5 w-3.5 text-cyan-100/55" / >
生 图 对 话
< / span >
< span className = "text-[9px] text-white/34" >
{ reconstructionModeConfig ( effectiveAgentMode ) . label } · { effectiveAgentQuantity } 张
< / span >
< / div >
< div className = "max -h-28 space-y-1.5 overflow-auto rounded border border-white/8 bg-black/20 p-1.5" >
< div className = "min -h-[86px] flex-1 space-y-1.5 overflow-auto rounded border border-white/8 bg-black/20 p-1.5" >
{ agentMessages . length ? agentMessages . slice ( - 5 ) . map ( ( message , index ) = > (
< div
key = { ` ${ message . created_at } - ${ index } ` }
@@ -3965,8 +4114,8 @@ function SourceSubjectPipeline({
{ message . content }
< / div >
) ) : (
< div className = "flex h-14 items-center justify-center text-center text-[10px] leading-snug text-white/30" >
分 析 后 , 直接 写 你 要 复 刻 、 创 新 、 卡 通 、 数 量 和 画 面 要 求 。
< div className = "flex h-full min-h-[74px] items-center justify-center px-2 text-center text-[10px] leading-snug text-white/30" >
直 接 发 送 复 刻 、 创 新 、 卡 通 、 数 量 和 画 面 要 求 。
< / div >
) }
< / div >
@@ -3982,21 +4131,23 @@ function SourceSubjectPipeline({
< / button >
) ) }
< / div >
< textarea
value = { agentInput }
onChange = { ( event ) = > setAgentInput ( event . target . value ) }
placeholder = "例如: 保留透明骨架和蓝色头带, 但人物更大, 服装统一, 生成6张。"
className = "mt-2 h-20 w-full resize-none rounded-md border border-white/10 bg-black/35 px-2 py-1.5 text-[10.5px] leading-snug text-white outline-none transition placeholder:text-white/24 focus:border-cyan-200/55 "
/ >
< button
type = " button"
onClick = { ( ) = > void sendSubjectAgentRequirement ( ) }
disabled = { ! ! subjectAgentBusy || ( ! agentInput . trim ( ) && ! agentRequirement . trim ( ) ) }
className = "skg-primary-action mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 px-2 text-[10.5px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
>
{ subjectAgentBusy === "message" ? < Loader2 className = "h-3.5 w-3.5 animate-spin" / > : < Send className = "h-3.5 w-3.5" / > }
生 成 提 示 词
< / button >
< div className = "mt-2 rounded-md border border-white/10 bg-black/35 p-1.5" >
< textarea
value = { agentInput }
onChange = { ( event ) = > setAgentInput ( event . target . value ) }
placeholder = "例如: 保留透明骨架和蓝色头带, 但人物更大, 服装统一, 生成6张。 "
className = "h-[72px] w-full resize-none rounded border border-transparent bg-transparent px-1 py-1 text-[10.5px] leading-snug text-white outline-none transition placeholder:text-white/24 focus:border-cyan-200/45"
/ >
< button
type = "button"
onClick = { ( ) = > void sendSubjectAgentRequirement ( ) }
disabled = { ! ! subjectAgentBusy || ( ! agentInput . trim ( ) && ! agentRequirement . trim ( ) ) }
className = "skg-primary-action mt-1 inline-flex h-8 w-full items-center justify-center gap-1.5 px-2 text-[10.5px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
>
{ subjectAgentBusy === "message" ? < Loader2 className = "h-3.5 w-3.5 animate-spin" / > : < Send className = "h-3.5 w-3.5" / > }
发 送 消 息
< / button >
< / div >
< / div >
{ effectivePrompt ? (