@@ -58,16 +58,45 @@
< / div >
< div >
< label class = "mb-2 block text-sm font-medium text-zinc-300" > Motion & scene prompt < / label >
< div class = "mb-2 flex flex-wrap items-center justify-between gap-2" >
< label class = "text-sm font-medium text-zinc-300" > Motion & scene prompt < / label >
< label class = "flex cursor-pointer items-center gap-2 text-xs text-zinc-400" >
< span class = "relative inline-flex h-5 w-9 shrink-0 items-center" >
< input v-model = "shotScriptMode" type="checkbox" class="peer sr-only" role="switch" >
< span class = "absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" / >
< span class = "absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white transition peer-checked:translate-x-4" / >
< / span >
Shot script
< / label >
< / div >
< p v-if = "shotScriptMode" class="mb-2 text-xs text-zinc-500" >
Paste a block. Text before the first marker is shot 1. Write < span class = "font-medium text-zinc-300" > shot 2 < / span > , < span class = "font-medium text-zinc-300" > shot 3 < / span > , and so on to queue extensions . Every shot uses the duration slider .
< / p >
< textarea
v-model = "prompt"
rows = "6"
: rows = "shotScriptMode ? 12 : 6"
class = "w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
placeholder = "Camera slowly pushes in. Soft daylight, fabric ripples, dust motes in the air. Audio: quiet room tone, distant birds."
: placeholder = "shotScriptMode
? 'A woman walks into the village.\n\nshot 2\nShe crosses the stone bridge at dusk.\n\nshot 3\nShe opens the shop door and steps inside.'
: 'Camera slowly pushes in. Soft daylight, fabric ripples, dust motes in the air. Audio: quiet room tone, distant birds.'"
/ >
< div v-if = "shotScriptMode" class="mt-3 space-y-2" >
< p v-if = "!parsedShots.length" class="text-xs text-zinc-500">Add a first shot, then <span class="text-zinc-300" > shot 2 < / span > for an extension. < / p >
< div
v-for = "(shot, index) in parsedShots"
: key = "shot.n + '-' + index"
class = "rounded-xl border border-white/10 bg-zinc-950/50 px-3 py-2"
>
< p class = "text-xs font-medium text-amber-100" >
{ { index === 0 ? 'Initial' : ` Extension ${ index } ` } }
< span class = "font-normal text-zinc-500" > · shot { { shot . n } } · { { formatClipDuration ( duration ) } } < / span >
< / p >
< p class = "mt-1 whitespace-pre-wrap text-xs text-zinc-300" > { { shot . prompt } } < / p >
< / div >
< / div >
< / div >
< div class = "space-y-3" >
< div v-if = "!shotScriptMode" class=" space-y-3" >
< div
v-for = "(item, index) in extensionQueue"
:key = "item.id"
@@ -234,7 +263,9 @@
< DurationField
v-model = "duration"
label = "Duration"
hint = "Type any length, use the slider, or drag up/down in the box."
: hint = "shotScriptMode
? 'This length applies to the initial shot and every extension in the script.'
: 'Type any length, use the slider, or drag up/down in the box.'"
/ >
< / div >
< label class = "text-sm" >
@@ -322,6 +353,19 @@
Step {{ chainStep }} / {{ chainTotal }}{{ chainLabel ? ` : $ { chainLabel } ` : ' ' }}
< template v-if = "overallProgress" > · {{ overallProgress }} % overall < / template >
< / p >
< ol v-if = "busy && activeChainPlan.length > 1" class="mb-3 space-y-1" >
< li
v-for = "(part, index) in activeChainPlan"
:key = "index"
class = "flex items-center gap-2 rounded-lg px-2 py-1 text-xs"
:class = "chainPartClass(index)"
>
< span class = "h-2 w-2 shrink-0 rounded-full" :class = "chainPartDot(index)" / >
< span class = "shrink-0 font-medium" > { { part . label } } < / span >
< span class = "min-w-0 truncate text-zinc-400" > { { part . prompt } } < / span >
< span class = "shrink-0 text-zinc-500" > { { formatClipDuration ( part . duration ) } } < / span >
< / li >
< / ol >
< div class = "h-2 overflow-hidden rounded-full bg-zinc-800" >
< div class = "h-full rounded-full bg-amber-400 progress-glow transition-all duration-300" : style = "{ width: progress + '%' }" / >
< / div >
@@ -458,76 +502,127 @@
< / button >
< / div >
< p v-if = "!visibleCli ps.length" class="mt-4 text-sm text-zinc-500" > Nothing in this folder yet. Generate into it from the left. < / p >
< p v-if = "!libraryGrou ps.length" class="mt-4 text-sm text-zinc-500" > Nothing in this folder yet. Generate into it from the left. < / p >
< ul class = "mt-4 space-y-3" >
< li v-for = "item in visibleClips" :key="item.id" class="flex gap-3 rounded-2xl border border-white/5 bg-zinc-950/40 p-3" >
< button type = "button" class = "relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-zinc-900" @click ="openClip(item)" >
< span v-if = "item.hideThumbnail" class="sensitive-glow block h-full w-full" / >
< img v-else-if = "item.hasThumbnail" :src="`/api/library/clips/${item.id}/thumb`" alt="" class="h-full w-full object-cover" >
< video
v-else
:src = "`/api/library/clips/${item.id}/video#t=0.1`"
class = "h-full w-full object-cover"
muted
playsinline
preload = "metadata"
/ >
< / button >
< div class = "min-w-0 flex-1" >
< div class = "flex items-start gap-2" >
< input
v-if = "renamingId === item.id"
ref = "renameInput"
v-model = "renameDraft"
class = "min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
maxlength = "80"
@keydown.enter.prevent ="saveRename(item) "
@keydown.escape.prevent ="cancelRename"
@blur ="saveRename(item) "
>
< p v-else class = "min-w-0 flex-1 truncate text-sm font-medium" > { { item . name || item . prompt } } < / p >
< div class = "group relative shrink-0" >
< button
type = "button"
class = "flex h-6 w-6 items-center justify-center rounded-full text-zinc-500 hover:text-amber-200"
: aria -label = " ' Copy prompt for ' + ( item.name | | ' clip ' ) "
title = "Copy prompt"
@click.stop ="copyPrompt(item.prompt)"
< li
v-for = "group in libraryGroups"
:key = "group.key"
class = "rounded-2xl border border-white/5 bg-zinc-950/40 p-3"
>
< div class = "flex gap-3" >
< button type = "button" class = "relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-zinc-900" @click ="openClip(group.head)" >
< span v-if = "group.head.hideThumbnail" class="sensitive-glow block h-full w-full" / >
< img v-else-if = "group.head.hasThumbnail" :src="`/api/library/clips/${group.head.id}/thumb`" alt="" class="h-full w-full object-cover" >
< video
v-else
:src = "`/api/library/clips/${group.head.id}/video#t=0.1`"
class = "h-full w-full object-cover"
muted
playsinline
preload = "metadata"
/ >
< / button >
< div class = "min-w-0 flex-1" >
< div class = "flex items-start gap-2" >
< input
v-if = "renamingId === group.head.id "
ref = "renameInput"
v-model = "renameDraft "
class = "min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
maxlength = "80"
@keydown.enter.prevent ="saveRename(group.head)"
@keydown.escape.prevent ="cancelRename"
@blur ="saveRename(group.head)"
>
< svg xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke -width = " 2 " class = "h-4 w-4" >
< circle cx = "12" cy = "12" r = "10" / >
< path d = "M12 16v-4M12 8h.01" stroke -linecap = " round " / >
< / svg >
< / button >
< div class = "pointer-events-none invisible absolute right-0 top-7 z-20 w-64 whitespace-pre-wrap rounded-xl border border-white/10 bg-zinc-950 p-3 text-xs leading-relaxed text-zinc-300 shadow-2xl group-hover:visible group-focus-within:visible" >
{ { item . prompt } }
< p v-else class = "min-w-0 flex-1 truncate text-sm font-medium" > { { group . head . name || group . head . prompt } } < / p >
< div class = "group relative shrink-0" >
< button
type = "button"
class = "flex h-6 w-6 items-center justify-center rounded-full text-zinc-500 hover:text-amber-200"
: aria -label = " ' Copy prompt for ' + ( group.head.name | | ' clip ' ) "
title = "Copy prompt"
@click.stop ="copyPrompt(group.head.prompt)"
>
< svg xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke -width = " 2 " class = "h-4 w-4" >
< circle cx = "12" cy = "12" r = "10" / >
< path d = "M12 16v-4M12 8h.01" stroke -linecap = " round " / >
< / svg >
< / button >
< div class = "pointer-events-none invisible absolute right-0 top-7 z-20 w-64 whitespace-pre-wrap rounded-xl border border-white/10 bg-zinc-950 p-3 text-xs leading-relaxed text-zinc-300 shadow-2xl group-hover:visible group-focus-within:visible" >
{ { group . head . prompt } }
< / div >
< / div >
< / div >
< / div >
< p class = "text-xs text-zinc-500" > { { item . width && item . height ? ` ${ item . width } × ${ item . height } ` : 'size unknown' } } · { { item . steps || '—' } } steps · seed { { item . seed || '—' } } < / p >
< div class = "mt-2 flex flex-wrap items-center gap-3 ">
< button type = "button" class = "text-xs text-amber-300" @click ="openClip(item)" > Open < / button >
< button type = "button" class = "text-xs text-amber-300" @click ="openExtend(item)" > Extend < / button >
< button type = "button" class = "text-xs text-amber-300" @click ="startRename(item )" > Rename < / button >
< button type = "button" class = "text-xs text-amber-300" @click ="rerun(item )" > Rerun < / button >
< select
v-if = "folders.length > 1"
class = "max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-xs text-amber-300"
:value = "''"
@change ="onMoveClip(item, $event)"
>
< option disabled value = "" > Move to … < / option >
< option
v-for = "folder in folders.filter(folder => folder.id !== item.folderId)"
:key = "folder.id"
:value = "folder.id"
< p class = "text-xs text-zinc-500" > { { clipMetaLine ( group . head ) } } < / p >
< div class = "mt-2 flex flex-wrap items-center gap-3" >
< button v-if = "group.parts.length > 1" type="button" class="text-xs text-amber-200" @click="toggleFamily(group.key) ">
{{ expandedFamilies [ group.key ] ? ' Collapse ' : ` Expand $ { group.parts.length } parts ` }}
< / button >
< button type = "button" class = "text-xs text-amber-300" @click ="openClip(group.head )" > Open < / button >
< button type = "button" class = "text-xs text-amber-300" @click ="openExtend(group.head )" > Extend < / button >
< button type = "button" class = "text-xs text-amber-300" @click ="startRename(group.head)" > Rename < / button >
< button type = "button" class = "text-xs text-amber-300" @click ="rerun(group.head)" > Rerun < / button >
< select
v-if = "folders.length > 1"
class = "max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-xs text-amber-300"
:value = "''"
@change ="onMoveClip(group.head, $event)"
>
{ { folder . name } } { { folder . protected && ! folder . unlocked ? ' · locked' : '' } }
< / option >
< / select >
< button type = "button" class = "text-xs text-red-300" @click ="removeClip(item)" > Delete < / button >
< option disabled value = "" > Move to … < / option >
< option
v-for = "folder in folders.filter(folder => folder.id !== group.head.folderId)"
:key = "folder.id"
:value = "folder.id"
>
{ { folder . name } } { { folder . protected && ! folder . unlocked ? ' · locked' : '' } }
< / option >
< / select >
< button type = "button" class = "text-xs text-red-300" @click ="removeClip(group.head)" > Delete < / button >
< / div >
< / div >
< / div >
< ul v-if = "expandedFamilies[group.key] && group.parts.length > 1" class="mt-3 space-y-2 border-t border-white/5 pt-3" >
< li
v-for = "item in group.parts.slice(1)"
:key = "item.id"
class = "flex gap-3 rounded-xl border border-white/5 bg-black/20 p-2"
>
< button type = "button" class = "relative h-12 w-12 shrink-0 overflow-hidden rounded-lg bg-zinc-900" @click ="openClip(item)" >
< span v-if = "item.hideThumbnail" class="sensitive-glow block h-full w-full" / >
< img v-else-if = "item.hasThumbnail" :src="`/api/library/clips/${item.id}/thumb`" alt="" class="h-full w-full object-cover" >
< video
v-else
:src = "`/api/library/clips/${item.id}/video#t=0.1`"
class = "h-full w-full object-cover"
muted
playsinline
preload = "metadata"
/ >
< / button >
< div class = "min-w-0 flex-1" >
< div class = "flex items-start gap-2" >
< input
v-if = "renamingId === item.id"
v-model = "renameDraft"
class = "min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
maxlength = "80"
@keydown.enter.prevent ="saveRename(item)"
@keydown.escape.prevent ="cancelRename"
@blur ="saveRename(item)"
>
< p v-else class = "min-w-0 flex-1 truncate text-sm font-medium" > { { item . name || item . prompt } } < / p >
< / div >
< p class = "text-xs text-zinc-500" > { { clipMetaLine ( item ) } } < / p >
< div class = "mt-1.5 flex flex-wrap items-center gap-3" >
< button type = "button" class = "text-xs text-amber-300" @click ="openClip(item)" > Open < / button >
< button type = "button" class = "text-xs text-amber-300" @click ="openExtend(item)" > Extend < / button >
< button type = "button" class = "text-xs text-amber-300" @click ="startRename(item)" > Rename < / button >
< button type = "button" class = "text-xs text-amber-300" @click ="rerun(item)" > Rerun < / button >
< button type = "button" class = "text-xs text-red-300" @click ="removeClip(item)" > Delete < / button >
< / div >
< / div >
< / li >
< / ul >
< / li >
< / ul >
< / template >
@@ -612,6 +707,10 @@ interface LibraryClip {
fps ? : number
samplerName ? : string
scheduler ? : string
duration ? : number
familyId ? : string
parentClipId ? : string
chainIndex ? : number
}
interface RetryDraft {
@@ -664,6 +763,7 @@ const file = ref<File | null>(null)
const preview = ref ( '' )
const dragging = ref ( false )
const prompt = ref ( '' )
const shotScriptMode = ref ( false )
const extensionQueue = ref < QueuedExtension [ ] > ( [ ] )
const clipName = ref ( '' )
const outputDownloadName = ref ( 'aigen' )
@@ -698,6 +798,9 @@ const chainStep = ref(1)
const chainTotal = ref ( 1 )
const chainLabel = ref ( '' )
const overallProgress = ref ( 0 )
const completedChainStep = ref ( 0 )
const activeChainPlan = ref < { label : string ; prompt : string ; duration : number } [ ] > ( [ ] )
const expandedFamilies = ref < Record < string , boolean > > ( { } )
const elapsedMs = ref ( 0 )
const videoUrl = ref ( '' )
const jobId = ref ( '' )
@@ -818,15 +921,51 @@ const canExtend = computed(() => Boolean(
&& ( currentClipId . value || clipIdFromPlayer ( ) )
) )
const extendSaveName = computed ( ( ) => nextClipPartName ( currentClip . value ? . name || currentClip . value ? . prompt || '' ) )
const queueReady = computed ( ( ) => extensionQueue . value . every ( item => item . prompt . trim ( ) ) )
const queueReady = computed ( ( ) => {
if ( shotScriptMode . value ) return parsedShots . value . length > 0 && parsedShots . value . every ( shot => shot . prompt . trim ( ) )
return extensionQueue . value . every ( item => item . prompt . trim ( ) )
} )
const generateLabel = computed ( ( ) => {
const n = extensionQueue . value . length
const n = shotScriptMode . value ? Math . max ( 0 , parsedShots . value . length - 1 ) : extensionQueue . value . length
if ( busy . value ) return 'Generating…'
if ( ! n ) return 'Generate Video'
return n === 1 ? 'Generate Initial + 1 Extension' : ` Generate Initial + ${ n } Extensions `
} )
const parsedShots = computed ( ( ) => parseShotScript ( prompt . value ) )
const plannedChain = computed ( ( ) => {
if ( shotScriptMode . value ) {
return parsedShots . value . map ( ( shot , index ) => ( {
label : index === 0 ? 'Initial' : ` Extension ${ index } ` ,
prompt : shot . prompt ,
duration : duration . value
} ) )
}
return [
{ label : 'Initial' , prompt : prompt . value , duration : duration . value } ,
... extensionQueue . value . map ( ( item , index ) => ( {
label : ` Extension ${ index + 1 } ` ,
prompt : item . prompt ,
duration : item . duration
} ) )
]
} )
const visibleClips = computed ( ( ) => clips . value . filter ( clip => clip . folderId === browseFolderId . value ) )
const libraryGroups = computed ( ( ) => {
const groups = new Map < string , LibraryClip [ ] > ( )
for ( const clip of visibleClips . value ) {
const key = clipFamilyKey ( clip )
const list = groups . get ( key ) || [ ]
list . push ( clip )
groups . set ( key , list )
}
return [ ... groups . entries ( ) ]
. map ( ( [ key , parts ] ) => {
const sorted = [ ... parts ] . sort ( sortClipParts )
return { key , head : sorted [ 0 ] , parts : sorted }
} )
. sort ( ( a , b ) => b . head . createdAt - a . head . createdAt )
} )
const currentFolder = computed ( ( ) => folders . value . find ( folder => folder . id === folderId . value ) )
const browseFolder = computed ( ( ) => folders . value . find ( folder => folder . id === browseFolderId . value ) )
@@ -834,6 +973,59 @@ function clipsIn(id: string) {
return clips . value . filter ( clip => clip . folderId === id )
}
function clipFamilyKey ( clip : LibraryClip ) {
if ( clip . familyId ) return clip . familyId
const base = String ( clip . name || '' ) . trim ( ) . replace ( /\s+p\d+$/i , '' )
if ( base ) return ` name: ${ clip . folderId } : ${ base . toLowerCase ( ) } `
return ` solo: ${ clip . id } `
}
function sortClipParts ( a : LibraryClip , b : LibraryClip ) {
const durationDelta = ( Number ( b . duration ) || 0 ) - ( Number ( a . duration ) || 0 )
if ( durationDelta ) return durationDelta
const chainDelta = ( b . chainIndex || 0 ) - ( a . chainIndex || 0 )
if ( chainDelta ) return chainDelta
return b . createdAt - a . createdAt
}
function formatClipDuration ( value ? : number ) {
if ( typeof value !== 'number' || ! Number . isFinite ( value ) || value <= 0 ) return ''
const rounded = Math . round ( value * 10 ) / 10
return Number . isInteger ( rounded ) ? ` ${ rounded } s ` : ` ${ rounded . toFixed ( 1 ) } s `
}
function clipMetaLine ( item : LibraryClip ) {
const res = item . width && item . height ? ` ${ item . width } × ${ item . height } ` : 'size unknown'
const duration = formatClipDuration ( item . duration )
const steps = ` ${ item . steps || '—' } steps `
const seed = ` seed ${ item . seed || '—' } `
return duration ? ` ${ res } · ${ duration } · ${ steps } · ${ seed } ` : ` ${ res } · ${ steps } · ${ seed } `
}
function toggleFamily ( key : string ) {
expandedFamilies . value = { ... expandedFamilies . value , [ key ] : ! expandedFamilies . value [ key ] }
}
function chainPartDone ( index : number ) {
return index < completedChainStep . value
}
function chainPartCurrent ( index : number ) {
return busy . value && index === completedChainStep . value
}
function chainPartClass ( index : number ) {
if ( chainPartDone ( index ) ) return 'border border-emerald-400/20 bg-emerald-400/10 text-emerald-100'
if ( chainPartCurrent ( index ) ) return 'border border-amber-300/40 bg-amber-400/10 text-amber-100'
return 'border border-white/5 text-zinc-400'
}
function chainPartDot ( index : number ) {
if ( chainPartDone ( index ) ) return 'bg-emerald-400'
if ( chainPartCurrent ( index ) ) return 'bg-amber-300 animate-pulse'
return 'bg-zinc-600'
}
function applyThumbnailDefault ( ) {
if ( ! showPrivacyToggles . value ) {
hideThumbnail . value = false
@@ -1300,6 +1492,8 @@ async function generateExtension() {
chainTotal . value = 1
chainLabel . value = ''
overallProgress . value = 0
completedChainStep . value = 0
activeChainPlan . value = [ ]
statusMessage . value = 'Extracting last frame & stitching extension...'
startTimer ( )
try {
@@ -1451,13 +1645,18 @@ function removeQueuedExtension(id: string) {
async function generate ( ) {
if ( ! file . value || ! prompt . value . trim ( ) ) return
if ( ! queueReady . value ) {
toast ( 'Each queued extension needs a prompt.' )
toast ( shotScriptMode . value ? 'Each shot in the script needs a prompt.' : 'Each queued extension needs a prompt.' )
return
}
if ( ! folderId . value ) {
toast ( 'Choose a library folder before generating.' )
return
}
const shots = shotScriptMode . value ? parsedShots . value : null
const initialPrompt = ( shots ? . [ 0 ] ? . prompt || prompt . value ) . trim ( )
const queued = shots
? shots . slice ( 1 ) . map ( shot => ( { prompt : shot . prompt . trim ( ) , duration : duration . value } ) )
: extensionQueue . value . map ( item => ( { prompt : item . prompt . trim ( ) , duration : item . duration } ) )
statusBusy . value = false
busy . value = true
listenGen += 1
@@ -1474,14 +1673,16 @@ async function generate() {
playerSize . value = outputSize . value
statusMessage . value = 'Checking ComfyUI...'
chainStep . value = 1
chainTotal . value = 1 + extensionQueue . value . length
chainLabel . value = extensionQueue . value . length ? 'Initial' : ''
chainTotal . value = 1 + queued . length
chainLabel . value = queued . length ? 'Initial' : ''
overallProgress . value = 0
completedChainStep . value = 0
activeChainPlan . value = plannedChain . value . map ( part => ( { ... part } ) )
startTimer ( )
try {
const body = new FormData ( )
body . append ( 'image' , file . value )
body . append ( 'prompt' , prompt . value . trim ( ) )
body . append ( 'prompt' , initialPrompt )
body . append ( 'aspect' , aspect . value )
body . append ( 'width' , String ( outputSize . value . width ) )
body . append ( 'height' , String ( outputSize . value . height ) )
@@ -1499,13 +1700,10 @@ async function generate() {
body . append ( 'sound' , String ( withSound . value ) )
body . append ( 'name' , clipName . value . trim ( ) )
if ( activeDraftId ) body . append ( 'draftId' , activeDraftId )
if ( extensionQueue . value . length ) {
body . append ( 'extensions' , JSON . stringify ( extensionQueue . value . map ( item => ( {
prompt : item . prompt . trim ( ) ,
duration : item . duration
} ) ) ) )
if ( queued . length ) {
body . append ( 'extensions' , JSON . stringify ( queued ) )
}
outputDownloadName . value = clipFileName ( clipName . value . trim ( ) || prompt . value . trim ( ) . split ( /[.!?\n]/ ) [ 0 ] || 'aigen' )
outputDownloadName . value = clipFileName ( clipName . value . trim ( ) || initialPrompt . split ( /[.!?\n]/ ) [ 0 ] || 'aigen' )
const started = await $fetch < { jobId : string ; seed : number ; width : number ; height : number ; steps : number ; turbo : boolean ; hideThumbnail ? : boolean ; folderLocked ? : boolean } > ( '/api/generate' , {
method : 'POST' ,
body
@@ -1557,6 +1755,7 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLock
if ( settledUi ) return
if ( payload . type === 'checkpoint' && payload . clipId ) {
completedChainStep . value = Math . max ( completedChainStep . value , Number ( payload . chainStep || chainStep . value ) || 0 )
loadLibrary ( ) . catch ( ( ) => null )
const locked = folderLocked || payload . folderLocked === true
if ( ! watchingLibrary . value && ! locked ) {
@@ -1640,6 +1839,7 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLock
stopTimer ( )
stopListen ( )
clearActiveJob ( )
loadLibrary ( ) . catch ( ( ) => null )
void pollComfyHealth ( )
toast ( payload . error || payload . message || 'Generation failed' )
}