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}` : '' }}
<template v-if="editOverallProgress"> · {{ editOverallProgress }}% overall</template>
</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
v-for="(part, index) in activeChainPlan"
v-for="(part, index) in shotChecklist"
:key="index"
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="min-w-0 truncate text-zinc-400">{{ part.prompt }}</span>
<span class="shrink-0 text-zinc-500">{{ formatClipDuration(part.duration) }}</span>
</li>
</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">
<li
v-for="(part, index) in editActiveChainPlan"
@@ -1346,6 +1350,7 @@ const hideInputPreview = ref(false)
const autoplayEnabled = ref(false)
const JOB_STORE = 'aigen-active-job'
const EDIT_JOB_STORE = 'aigen-active-edit-job'
const QUEUE_STORE = 'aigen-active-queue'
const videoBusy = ref(false)
const editBusy = ref(false)
const studioMode = ref('video')
@@ -1433,6 +1438,15 @@ const unlockPassword = ref('')
const settingsOpen = ref(false)
const queueAutoRun = ref(false)
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 pickerSlot = ref<'main' | 'editRef' | number>('main')
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 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'
}
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) {
return index < editCompletedChainStep.value
}
@@ -1917,6 +1982,7 @@ async function loadLibrary() {
])
applyLibrary(data)
queuePendingCount.value = queued.pendingCount || 0
await refreshLiveQueue()
}
function applyComfyHealth(health: {
@@ -1957,6 +2023,7 @@ onMounted(async () => {
autoplayEnabled.value = localStorage.getItem('aigen-autoplay') === 'true'
videoWorkflow.value = localStorage.getItem('aigen-video-workflow') === 'v2' ? 'v2' : 'v1'
useIdentityRefs.value = localStorage.getItem('aigen-use-identity-refs') === 'true'
activeQueueId.value = localStorage.getItem(QUEUE_STORE) || ''
} catch { /* ignore */ }
applyInstancePrivacy()
window.addEventListener('pointerdown', onSitePointerDown, true)
@@ -1973,8 +2040,10 @@ onMounted(async () => {
applyInstancePrivacy()
healthTimer = setInterval(() => {
void pollComfyHealth()
if (activeQueueId.value) void refreshLiveQueue()
}, 5000)
await resumeActiveJob()
await refreshLiveQueue()
})
onBeforeUnmount(() => {
@@ -2228,6 +2297,33 @@ function persistActiveJob(kind: 'video' | 'edit', id: string, hidden: boolean, f
} 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') {
try {
if (!kind || kind === 'video') localStorage.removeItem(JOB_STORE)
@@ -3142,7 +3238,7 @@ async function generate() {
body.append('queueAutoRun', String(queueAutoRun.value))
}
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',
body
})
@@ -3151,6 +3247,10 @@ async function generate() {
playerSize.value = { width: started.width, height: started.height }
videoSettledUi = false
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)
void pollComfyHealth()
} 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) {
if (payload.queueId) persistActiveQueue(String(payload.queueId))
if (payload.message) statusMessage.value = payload.message
statusBusy.value = payload.type === 'busy' || payload.busy === true
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) {
completedChainStep.value = Math.max(completedChainStep.value, Number(payload.chainStep || chainStep.value) || 0)
loadLibrary().catch(() => null)
void refreshLiveQueue()
const locked = folderLocked || payload.folderLocked === true
if (!watchingLibrary.value && !locked) {
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)
toast(`Shot ${step} of ${total} saved. ${left} shot${left === 1 ? '' : 's'} waiting on Queue — edit prompts or process next.`)
queuePendingCount.value = left
void refreshLiveQueue()
videoBusy.value = false
statusBusy.value = false
stopTimer('video')
+125 -25
View File
@@ -23,7 +23,7 @@
<div>
<h2 class="font-display text-2xl font-bold">Queue</h2>
<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>
</div>
<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="min-w-0">
<h3 class="truncate font-display text-lg font-semibold text-zinc-100">{{ queue.name }}</h3>
<p class="mt-1 text-xs text-zinc-500">
Shot {{ queue.completedCount }} of {{ queue.totalCount }}
<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 class="mt-1 text-sm text-zinc-300">
{{ progressLine(queue) }}
</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>
@@ -59,7 +55,7 @@
:disabled="busyId === queue.id || queue.status === 'running' || queue.status === 'complete' || queue.pendingCount === 0"
@click="process(queue, 1)"
>
Process next
{{ continueLabel(queue) }}
</button>
<button
type="button"
@@ -96,25 +92,37 @@
</div>
</div>
<div class="space-y-3">
<article
<ol class="space-y-2">
<li
v-for="segment in queue.segments"
:key="segment.index"
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">
<p class="text-sm font-medium text-zinc-200">
{{ segment.index === 0 ? 'Shot 1 · initial' : `Shot ${segment.index + 1}` }}
<span class="font-normal text-zinc-500"> · {{ segment.status }}</span>
<div class="flex items-start gap-3">
<span
class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold"
: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>
<span class="text-xs text-zinc-500">{{ segment.duration }}s</span>
</div>
<p
v-if="segment.status === 'complete'"
class="mt-1 truncate text-xs text-zinc-400"
>{{ segment.prompt }}</p>
<textarea
v-else
v-model="segment.prompt"
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"
:disabled="segment.status === 'complete' || segment.status === 'running' || queue.status === 'running'"
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 === 'running' || queue.status === 'running'"
@focus="markDirty(queue.id, segment.index)"
@change="saveSegment(queue, segment)"
/>
<DurationField
@@ -124,9 +132,19 @@
label="Duration"
@update:modelValue="saveSegment(queue, segment)"
/>
<p v-if="segment.error" class="mt-2 text-xs text-red-300">{{ segment.error }}</p>
</article>
<div v-if="segment.clipId" class="mt-2">
<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>
<p v-if="segment.error" class="mt-2 text-xs text-red-300">{{ segment.error }}</p>
</div>
</div>
</li>
</ol>
</section>
</main>
</div>
@@ -161,19 +179,95 @@ const pendingCount = ref(0)
const error = ref('')
const busyId = ref('')
const liveMessage = ref<Record<string, string>>({})
const dirty = new Set<string>()
const sources = new Map<string, EventSource>()
let pollTimer: ReturnType<typeof setInterval> | null = null
function segmentClass(status: Segment['status']) {
if (status === 'complete') return 'border-emerald-400/20 bg-emerald-400/5'
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 dirtyKey(queueId: string, index: number) {
return `${queueId}:${index}`
}
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'
}
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() {
const data = await $fetch<{ queues?: Queue[]; pendingCount?: number }>('/api/queues')
queues.value = data.queues || []
queues.value = mergeQueues(data.queues || [])
pendingCount.value = data.pendingCount || 0
for (const queue of queues.value) {
if (queue.status === 'running' && queue.currentJobId) listen(queue)
@@ -188,10 +282,12 @@ function listen(queue: Queue) {
try {
const payload = JSON.parse(event.data) as { message?: string; type?: string }
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') {
source.close()
sources.delete(queue.id)
void loadQueues()
}
} 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 }))
}
}).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`, {
method: 'POST',
body: { count }
@@ -238,11 +335,14 @@ async function remove(queue: Queue) {
let saveTimer: ReturnType<typeof setTimeout> | null = null
function saveSegment(queue: Queue, segment: Segment) {
markDirty(queue.id, segment.index)
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
void $fetch(`/api/queues/${queue.id}`, {
method: 'PATCH',
body: { segments: [{ index: segment.index, prompt: segment.prompt, duration: segment.duration }] }
}).then(() => {
dirty.delete(dirtyKey(queue.id, segment.index))
}).catch(() => null)
}, 400)
}