Tick off saved shots in studio and Queue so an interrupted run shows where it stopped and which scene to continue.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-27 14:57:50 -05:00
co-authored by Cursor
parent 73c4356e08
commit 2bb30395d8
2 changed files with 248 additions and 45 deletions
+108 -5
View File
@@ -616,19 +616,23 @@
Step {{ editChainStep }}/{{ editChainTotal }}{{ editChainLabel ? `: ${editChainLabel}` : '' }} Step {{ editChainStep }}/{{ editChainTotal }}{{ editChainLabel ? `: ${editChainLabel}` : '' }}
<template v-if="editOverallProgress"> · {{ editOverallProgress }}% overall</template> <template v-if="editOverallProgress"> · {{ editOverallProgress }}% overall</template>
</p> </p>
<ol v-if="outputProgressKind === 'video' && videoBusy && activeChainPlan.length > 1" class="mb-3 space-y-1"> <ol v-if="outputProgressKind === 'video' && shotChecklist.length > 1 && showShotChecklist" class="mb-3 space-y-1">
<li <li
v-for="(part, index) in activeChainPlan" v-for="(part, index) in shotChecklist"
:key="index" :key="index"
class="flex items-center gap-2 rounded-lg px-2 py-1 text-xs" class="flex items-center gap-2 rounded-lg px-2 py-1 text-xs"
:class="chainPartClass(index)" :class="shotCheckClass(part)"
> >
<span class="h-2 w-2 shrink-0 rounded-full" :class="chainPartDot(index)" /> <span class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[10px] font-bold" :class="shotCheckMarkClass(part)">{{ shotCheckMark(part) }}</span>
<span class="shrink-0 font-medium">{{ part.label }}</span> <span class="shrink-0 font-medium">{{ part.label }}</span>
<span class="min-w-0 truncate text-zinc-400">{{ part.prompt }}</span> <span class="min-w-0 truncate text-zinc-400">{{ part.prompt }}</span>
<span class="shrink-0 text-zinc-500">{{ formatClipDuration(part.duration) }}</span> <span class="shrink-0 text-zinc-500">{{ formatClipDuration(part.duration) }}</span>
</li> </li>
</ol> </ol>
<p v-if="shotChecklistResume" class="mb-3 text-xs text-amber-100">
{{ shotChecklistResume }}
<NuxtLink to="/queue" class="ml-1 underline decoration-amber-300/50 underline-offset-2 hover:text-white">Open Queue</NuxtLink>
</p>
<ol v-else-if="outputProgressKind === 'edit' && editBusy && editActiveChainPlan.length > 1" class="mb-3 space-y-1"> <ol v-else-if="outputProgressKind === 'edit' && editBusy && editActiveChainPlan.length > 1" class="mb-3 space-y-1">
<li <li
v-for="(part, index) in editActiveChainPlan" v-for="(part, index) in editActiveChainPlan"
@@ -1346,6 +1350,7 @@ const hideInputPreview = ref(false)
const autoplayEnabled = ref(false) const autoplayEnabled = ref(false)
const JOB_STORE = 'aigen-active-job' const JOB_STORE = 'aigen-active-job'
const EDIT_JOB_STORE = 'aigen-active-edit-job' const EDIT_JOB_STORE = 'aigen-active-edit-job'
const QUEUE_STORE = 'aigen-active-queue'
const videoBusy = ref(false) const videoBusy = ref(false)
const editBusy = ref(false) const editBusy = ref(false)
const studioMode = ref('video') const studioMode = ref('video')
@@ -1433,6 +1438,15 @@ const unlockPassword = ref('')
const settingsOpen = ref(false) const settingsOpen = ref(false)
const queueAutoRun = ref(false) const queueAutoRun = ref(false)
const queuePendingCount = ref(0) const queuePendingCount = ref(0)
const activeQueueId = ref('')
const liveQueue = ref<{
id: string
status: string
pendingCount: number
completedCount: number
totalCount: number
segments: Array<{ index: number; prompt: string; duration: number; status: string; clipId?: string }>
} | null>(null)
const pickerOpen = ref(false) const pickerOpen = ref(false)
const pickerSlot = ref<'main' | 'editRef' | number>('main') const pickerSlot = ref<'main' | 'editRef' | number>('main')
const libraryFilter = ref<'all' | 'images' | 'videos'>('all') const libraryFilter = ref<'all' | 'images' | 'videos'>('all')
@@ -1663,6 +1677,36 @@ const plannedChain = computed(() => {
})) }))
] ]
}) })
const shotChecklist = computed(() => {
if (liveQueue.value?.segments?.length) {
return liveQueue.value.segments.map(segment => ({
label: `Shot ${segment.index + 1}`,
prompt: segment.prompt,
duration: segment.duration,
status: segment.status,
clipId: segment.clipId
}))
}
return activeChainPlan.value.map((part, index) => ({
label: part.label,
prompt: part.prompt,
duration: part.duration,
status: index < completedChainStep.value
? 'complete'
: (videoBusy.value && index === completedChainStep.value ? 'running' : (index === completedChainStep.value ? 'pending' : 'pending')),
clipId: undefined as string | undefined
}))
})
const showShotChecklist = computed(() => (
shotChecklist.value.length > 1 && (videoBusy.value || shotChecklist.value.some(part => part.status !== 'complete'))
))
const shotChecklistResume = computed(() => {
if (videoBusy.value) return ''
const next = shotChecklist.value.find(part => part.status === 'pending' || part.status === 'error' || part.status === 'running')
const done = shotChecklist.value.filter(part => part.status === 'complete').length
if (!next || !done) return ''
return `Left off after shot ${done} of ${shotChecklist.value.length}. Continue with ${next.label}.`
})
const visibleClips = computed(() => clips.value.filter(clip => clip.folderId === browseFolderId.value && clip.hasVideo !== false)) const visibleClips = computed(() => clips.value.filter(clip => clip.folderId === browseFolderId.value && clip.hasVideo !== false))
const visibleOutputStills = computed(() => stills.value.filter(still => still.folderId === browseFolderId.value && still.role === 'output')) const visibleOutputStills = computed(() => stills.value.filter(still => still.folderId === browseFolderId.value && still.role === 'output'))
@@ -1862,6 +1906,27 @@ function chainPartDot(index: number) {
return 'bg-zinc-600' return 'bg-zinc-600'
} }
function shotCheckClass(part: { status: string }) {
if (part.status === 'complete') return 'border border-emerald-400/20 bg-emerald-400/10 text-emerald-100'
if (part.status === 'running') return 'border border-amber-300/40 bg-amber-400/10 text-amber-100'
if (part.status === 'error') return 'border border-red-400/30 bg-red-400/10 text-red-200'
return 'border border-white/5 text-zinc-400'
}
function shotCheckMarkClass(part: { status: string }) {
if (part.status === 'complete') return 'bg-emerald-400 text-zinc-950'
if (part.status === 'running') return 'bg-amber-400 text-zinc-950'
if (part.status === 'error') return 'bg-red-400 text-zinc-950'
return 'border border-white/20 text-zinc-500'
}
function shotCheckMark(part: { status: string; label: string }) {
if (part.status === 'complete') return '✓'
if (part.status === 'running') return '▶'
if (part.status === 'error') return '!'
return part.label.replace(/\D/g, '') || '•'
}
function editChainPartDone(index: number) { function editChainPartDone(index: number) {
return index < editCompletedChainStep.value return index < editCompletedChainStep.value
} }
@@ -1917,6 +1982,7 @@ async function loadLibrary() {
]) ])
applyLibrary(data) applyLibrary(data)
queuePendingCount.value = queued.pendingCount || 0 queuePendingCount.value = queued.pendingCount || 0
await refreshLiveQueue()
} }
function applyComfyHealth(health: { function applyComfyHealth(health: {
@@ -1957,6 +2023,7 @@ onMounted(async () => {
autoplayEnabled.value = localStorage.getItem('aigen-autoplay') === 'true' autoplayEnabled.value = localStorage.getItem('aigen-autoplay') === 'true'
videoWorkflow.value = localStorage.getItem('aigen-video-workflow') === 'v2' ? 'v2' : 'v1' videoWorkflow.value = localStorage.getItem('aigen-video-workflow') === 'v2' ? 'v2' : 'v1'
useIdentityRefs.value = localStorage.getItem('aigen-use-identity-refs') === 'true' useIdentityRefs.value = localStorage.getItem('aigen-use-identity-refs') === 'true'
activeQueueId.value = localStorage.getItem(QUEUE_STORE) || ''
} catch { /* ignore */ } } catch { /* ignore */ }
applyInstancePrivacy() applyInstancePrivacy()
window.addEventListener('pointerdown', onSitePointerDown, true) window.addEventListener('pointerdown', onSitePointerDown, true)
@@ -1973,8 +2040,10 @@ onMounted(async () => {
applyInstancePrivacy() applyInstancePrivacy()
healthTimer = setInterval(() => { healthTimer = setInterval(() => {
void pollComfyHealth() void pollComfyHealth()
if (activeQueueId.value) void refreshLiveQueue()
}, 5000) }, 5000)
await resumeActiveJob() await resumeActiveJob()
await refreshLiveQueue()
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -2228,6 +2297,33 @@ function persistActiveJob(kind: 'video' | 'edit', id: string, hidden: boolean, f
} catch { /* ignore */ } } catch { /* ignore */ }
} }
function persistActiveQueue(id: string) {
activeQueueId.value = id
try {
localStorage.setItem(QUEUE_STORE, id)
} catch { /* ignore */ }
}
async function refreshLiveQueue() {
const id = activeQueueId.value
if (!id) return
const queue = await $fetch<{
id: string
status: string
pendingCount: number
completedCount: number
totalCount: number
segments: Array<{ index: number; prompt: string; duration: number; status: string; clipId?: string }>
}>(`/api/queues/${id}`).catch(() => null)
if (!queue) return
liveQueue.value = queue
queuePendingCount.value = queue.status === 'complete' ? queuePendingCount.value : queue.pendingCount
completedChainStep.value = queue.completedCount
if (queue.status === 'complete' && !videoBusy.value) {
try { localStorage.removeItem(QUEUE_STORE) } catch { /* ignore */ }
}
}
function clearActiveJob(kind?: 'video' | 'edit') { function clearActiveJob(kind?: 'video' | 'edit') {
try { try {
if (!kind || kind === 'video') localStorage.removeItem(JOB_STORE) if (!kind || kind === 'video') localStorage.removeItem(JOB_STORE)
@@ -3142,7 +3238,7 @@ async function generate() {
body.append('queueAutoRun', String(queueAutoRun.value)) body.append('queueAutoRun', String(queueAutoRun.value))
} }
videoDownloadName.value = clipFileName(clipName.value.trim() || initialPrompt.split(/[.!?\n]/)[0] || 'aigen') videoDownloadName.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', { const started = await $fetch<{ jobId: string; seed: number; width: number; height: number; steps: number; turbo: boolean; hideThumbnail?: boolean; folderLocked?: boolean; queueId?: string }>('/api/generate', {
method: 'POST', method: 'POST',
body body
}) })
@@ -3151,6 +3247,10 @@ async function generate() {
playerSize.value = { width: started.width, height: started.height } playerSize.value = { width: started.width, height: started.height }
videoSettledUi = false videoSettledUi = false
persistActiveJob('video', started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true) persistActiveJob('video', started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true)
if (started.queueId) {
persistActiveQueue(started.queueId)
void refreshLiveQueue()
}
listen('video', started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true) listen('video', started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true)
void pollComfyHealth() void pollComfyHealth()
} catch (error: any) { } catch (error: any) {
@@ -3278,6 +3378,7 @@ function applyEditEvent(payload: Record<string, any>, hidden: boolean, folderLoc
} }
function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLocked: boolean) { function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLocked: boolean) {
if (payload.queueId) persistActiveQueue(String(payload.queueId))
if (payload.message) statusMessage.value = payload.message if (payload.message) statusMessage.value = payload.message
statusBusy.value = payload.type === 'busy' || payload.busy === true statusBusy.value = payload.type === 'busy' || payload.busy === true
if (typeof payload.progress === 'number') progress.value = payload.progress if (typeof payload.progress === 'number') progress.value = payload.progress
@@ -3297,6 +3398,7 @@ function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLo
if (payload.type === 'checkpoint' && payload.clipId) { if (payload.type === 'checkpoint' && payload.clipId) {
completedChainStep.value = Math.max(completedChainStep.value, Number(payload.chainStep || chainStep.value) || 0) completedChainStep.value = Math.max(completedChainStep.value, Number(payload.chainStep || chainStep.value) || 0)
loadLibrary().catch(() => null) loadLibrary().catch(() => null)
void refreshLiveQueue()
const locked = folderLocked || payload.folderLocked === true const locked = folderLocked || payload.folderLocked === true
if (!watchingLibrary.value && !locked) { if (!watchingLibrary.value && !locked) {
currentClipId.value = payload.clipId currentClipId.value = payload.clipId
@@ -3340,6 +3442,7 @@ function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLo
progress.value = Number(payload.progress || progress.value || 100) progress.value = Number(payload.progress || progress.value || 100)
toast(`Shot ${step} of ${total} saved. ${left} shot${left === 1 ? '' : 's'} waiting on Queue — edit prompts or process next.`) toast(`Shot ${step} of ${total} saved. ${left} shot${left === 1 ? '' : 's'} waiting on Queue — edit prompts or process next.`)
queuePendingCount.value = left queuePendingCount.value = left
void refreshLiveQueue()
videoBusy.value = false videoBusy.value = false
statusBusy.value = false statusBusy.value = false
stopTimer('video') stopTimer('video')
+125 -25
View File
@@ -23,7 +23,7 @@
<div> <div>
<h2 class="font-display text-2xl font-bold">Queue</h2> <h2 class="font-display text-2xl font-bold">Queue</h2>
<p class="mt-1 max-w-2xl text-sm text-zinc-400"> <p class="mt-1 max-w-2xl text-sm text-zinc-400">
Multi-shot batches wait here after the first clip. Edit remaining prompts, process the next shot, the next few, or run the rest overnight. Each shot ticks off when it saves. If a run stops, continue from the next unchecked scene.
</p> </p>
</div> </div>
<p v-if="pendingCount" class="text-sm text-amber-100">{{ pendingCount }} shot{{ pendingCount === 1 ? '' : 's' }} waiting</p> <p v-if="pendingCount" class="text-sm text-amber-100">{{ pendingCount }} shot{{ pendingCount === 1 ? '' : 's' }} waiting</p>
@@ -42,12 +42,8 @@
<div class="flex flex-wrap items-start justify-between gap-3"> <div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0"> <div class="min-w-0">
<h3 class="truncate font-display text-lg font-semibold text-zinc-100">{{ queue.name }}</h3> <h3 class="truncate font-display text-lg font-semibold text-zinc-100">{{ queue.name }}</h3>
<p class="mt-1 text-xs text-zinc-500"> <p class="mt-1 text-sm text-zinc-300">
Shot {{ queue.completedCount }} of {{ queue.totalCount }} {{ progressLine(queue) }}
<span v-if="queue.status === 'running'"> · processing</span>
<span v-else-if="queue.status === 'paused'"> · paused</span>
<span v-else-if="queue.status === 'complete'"> · finished</span>
<span v-else-if="queue.status === 'error'"> · needs attention</span>
</p> </p>
<p v-if="liveMessage[queue.id]" class="mt-1 text-xs text-amber-100">{{ liveMessage[queue.id] }}</p> <p v-if="liveMessage[queue.id]" class="mt-1 text-xs text-amber-100">{{ liveMessage[queue.id] }}</p>
<p v-if="queue.lastError" class="mt-1 text-xs text-red-300">{{ queue.lastError }}</p> <p v-if="queue.lastError" class="mt-1 text-xs text-red-300">{{ queue.lastError }}</p>
@@ -59,7 +55,7 @@
:disabled="busyId === queue.id || queue.status === 'running' || queue.status === 'complete' || queue.pendingCount === 0" :disabled="busyId === queue.id || queue.status === 'running' || queue.status === 'complete' || queue.pendingCount === 0"
@click="process(queue, 1)" @click="process(queue, 1)"
> >
Process next {{ continueLabel(queue) }}
</button> </button>
<button <button
type="button" type="button"
@@ -96,25 +92,37 @@
</div> </div>
</div> </div>
<div class="space-y-3"> <ol class="space-y-2">
<article <li
v-for="segment in queue.segments" v-for="segment in queue.segments"
:key="segment.index" :key="segment.index"
class="rounded-2xl border px-4 py-3" class="rounded-2xl border px-4 py-3"
:class="segmentClass(segment.status)" :class="segmentClass(queue, segment)"
> >
<div class="mb-2 flex flex-wrap items-center justify-between gap-2"> <div class="flex items-start gap-3">
<p class="text-sm font-medium text-zinc-200"> <span
{{ segment.index === 0 ? 'Shot 1 · initial' : `Shot ${segment.index + 1}` }} class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold"
<span class="font-normal text-zinc-500"> · {{ segment.status }}</span> :class="markClass(queue, segment)"
>{{ markLabel(queue, segment) }}</span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-medium text-zinc-100">
Shot {{ segment.index + 1 }}
<span class="font-normal text-zinc-500">{{ segmentCaption(queue, segment) }}</span>
</p> </p>
<span class="text-xs text-zinc-500">{{ segment.duration }}s</span> <span class="text-xs text-zinc-500">{{ segment.duration }}s</span>
</div> </div>
<p
v-if="segment.status === 'complete'"
class="mt-1 truncate text-xs text-zinc-400"
>{{ segment.prompt }}</p>
<textarea <textarea
v-else
v-model="segment.prompt" v-model="segment.prompt"
rows="3" rows="3"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2 disabled:opacity-70" class="mt-2 w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2 disabled:opacity-70"
:disabled="segment.status === 'complete' || segment.status === 'running' || queue.status === 'running'" :disabled="segment.status === 'running' || queue.status === 'running'"
@focus="markDirty(queue.id, segment.index)"
@change="saveSegment(queue, segment)" @change="saveSegment(queue, segment)"
/> />
<DurationField <DurationField
@@ -124,9 +132,19 @@
label="Duration" label="Duration"
@update:modelValue="saveSegment(queue, segment)" @update:modelValue="saveSegment(queue, segment)"
/> />
<p v-if="segment.error" class="mt-2 text-xs text-red-300">{{ segment.error }}</p> <div v-if="segment.clipId" class="mt-2">
</article> <a
class="text-xs text-amber-200 hover:text-amber-100"
:href="`/api/library/clips/${segment.clipId}/video`"
target="_blank"
rel="noreferrer"
>Open saved clip</a>
</div> </div>
<p v-if="segment.error" class="mt-2 text-xs text-red-300">{{ segment.error }}</p>
</div>
</div>
</li>
</ol>
</section> </section>
</main> </main>
</div> </div>
@@ -161,19 +179,95 @@ const pendingCount = ref(0)
const error = ref('') const error = ref('')
const busyId = ref('') const busyId = ref('')
const liveMessage = ref<Record<string, string>>({}) const liveMessage = ref<Record<string, string>>({})
const dirty = new Set<string>()
const sources = new Map<string, EventSource>() const sources = new Map<string, EventSource>()
let pollTimer: ReturnType<typeof setInterval> | null = null let pollTimer: ReturnType<typeof setInterval> | null = null
function segmentClass(status: Segment['status']) { function dirtyKey(queueId: string, index: number) {
if (status === 'complete') return 'border-emerald-400/20 bg-emerald-400/5' return `${queueId}:${index}`
if (status === 'running') return 'border-amber-300/40 bg-amber-400/5' }
if (status === 'error') return 'border-red-400/30 bg-red-400/5'
function markDirty(queueId: string, index: number) {
dirty.add(dirtyKey(queueId, index))
}
function nextSegment(queue: Queue) {
return queue.segments.find(segment => segment.status === 'pending' || segment.status === 'error')
|| queue.segments.find(segment => segment.status === 'running')
}
function progressLine(queue: Queue) {
const next = nextSegment(queue)
if (queue.status === 'complete') return `All ${queue.totalCount} shots saved.`
if (queue.status === 'running' && next) {
return `Generating shot ${next.index + 1} of ${queue.totalCount}. ${queue.completedCount} saved.`
}
if (queue.completedCount === 0) return `Not started · ${queue.totalCount} shots`
if (next) return `Left off after shot ${queue.completedCount} of ${queue.totalCount}. Next is shot ${next.index + 1}.`
return `Shot ${queue.completedCount} of ${queue.totalCount}`
}
function continueLabel(queue: Queue) {
const next = nextSegment(queue)
if (!next) return 'Process next'
return `Continue shot ${next.index + 1}`
}
function segmentCaption(queue: Queue, segment: Segment) {
if (segment.status === 'complete') return '· saved'
if (segment.status === 'running') return '· generating now'
if (segment.status === 'error') return '· failed · retry from here'
if (nextSegment(queue)?.index === segment.index) return '· next up'
return '· waiting'
}
function segmentClass(queue: Queue, segment: Segment) {
if (segment.status === 'complete') return 'border-emerald-400/25 bg-emerald-400/5'
if (segment.status === 'running') return 'border-amber-300/50 bg-amber-400/10'
if (segment.status === 'error') return 'border-red-400/30 bg-red-400/5'
if (nextSegment(queue)?.index === segment.index) return 'border-amber-300/30 bg-zinc-950/70'
return 'border-white/10 bg-zinc-950/50' return 'border-white/10 bg-zinc-950/50'
} }
function markClass(queue: Queue, segment: Segment) {
if (segment.status === 'complete') return 'bg-emerald-400 text-zinc-950'
if (segment.status === 'running') return 'bg-amber-400 text-zinc-950'
if (segment.status === 'error') return 'bg-red-400 text-zinc-950'
if (nextSegment(queue)?.index === segment.index) return 'border border-amber-300/70 text-amber-100'
return 'border border-white/15 text-zinc-500'
}
function markLabel(queue: Queue, segment: Segment) {
if (segment.status === 'complete') return '✓'
if (segment.status === 'running') return '▶'
if (segment.status === 'error') return '!'
if (nextSegment(queue)?.index === segment.index) return String(segment.index + 1)
return String(segment.index + 1)
}
function mergeQueues(incoming: Queue[]) {
const previous = new Map(queues.value.map(queue => [queue.id, queue]))
return incoming.map((next) => {
const old = previous.get(next.id)
if (!old) return next
return {
...next,
segments: next.segments.map((segment) => {
const prior = old.segments.find(item => item.index === segment.index)
if (!prior || !dirty.has(dirtyKey(next.id, segment.index))) return segment
return {
...segment,
prompt: prior.prompt,
duration: prior.duration
}
})
}
})
}
async function loadQueues() { async function loadQueues() {
const data = await $fetch<{ queues?: Queue[]; pendingCount?: number }>('/api/queues') const data = await $fetch<{ queues?: Queue[]; pendingCount?: number }>('/api/queues')
queues.value = data.queues || [] queues.value = mergeQueues(data.queues || [])
pendingCount.value = data.pendingCount || 0 pendingCount.value = data.pendingCount || 0
for (const queue of queues.value) { for (const queue of queues.value) {
if (queue.status === 'running' && queue.currentJobId) listen(queue) if (queue.status === 'running' && queue.currentJobId) listen(queue)
@@ -188,10 +282,12 @@ function listen(queue: Queue) {
try { try {
const payload = JSON.parse(event.data) as { message?: string; type?: string } const payload = JSON.parse(event.data) as { message?: string; type?: string }
if (payload.message) liveMessage.value = { ...liveMessage.value, [queue.id]: payload.message } if (payload.message) liveMessage.value = { ...liveMessage.value, [queue.id]: payload.message }
if (payload.type === 'checkpoint' || payload.type === 'complete' || payload.type === 'error') {
void loadQueues()
}
if (payload.type === 'complete' || payload.type === 'error') { if (payload.type === 'complete' || payload.type === 'error') {
source.close() source.close()
sources.delete(queue.id) sources.delete(queue.id)
void loadQueues()
} }
} catch { /* ignore */ } } catch { /* ignore */ }
} }
@@ -209,6 +305,7 @@ async function process(queue: Queue, count: number | 'all') {
.map(segment => ({ index: segment.index, prompt: segment.prompt, duration: segment.duration })) .map(segment => ({ index: segment.index, prompt: segment.prompt, duration: segment.duration }))
} }
}).catch(() => null) }).catch(() => null)
for (const segment of queue.segments) dirty.delete(dirtyKey(queue.id, segment.index))
const started = await $fetch<{ jobId: string }>(`/api/queues/${queue.id}/process`, { const started = await $fetch<{ jobId: string }>(`/api/queues/${queue.id}/process`, {
method: 'POST', method: 'POST',
body: { count } body: { count }
@@ -238,11 +335,14 @@ async function remove(queue: Queue) {
let saveTimer: ReturnType<typeof setTimeout> | null = null let saveTimer: ReturnType<typeof setTimeout> | null = null
function saveSegment(queue: Queue, segment: Segment) { function saveSegment(queue: Queue, segment: Segment) {
markDirty(queue.id, segment.index)
if (saveTimer) clearTimeout(saveTimer) if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => { saveTimer = setTimeout(() => {
void $fetch(`/api/queues/${queue.id}`, { void $fetch(`/api/queues/${queue.id}`, {
method: 'PATCH', method: 'PATCH',
body: { segments: [{ index: segment.index, prompt: segment.prompt, duration: segment.duration }] } body: { segments: [{ index: segment.index, prompt: segment.prompt, duration: segment.duration }] }
}).then(() => {
dirty.delete(dirtyKey(queue.id, segment.index))
}).catch(() => null) }).catch(() => null)
}, 400) }, 400)
} }