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
+140 -40
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,37 +92,59 @@
</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>
</p>
<span class="text-xs text-zinc-500">{{ segment.duration }}s</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="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
v-if="segment.status !== 'complete' && segment.status !== 'running'"
v-model="segment.duration"
class="mt-3"
label="Duration"
@update:modelValue="saveSegment(queue, segment)"
/>
<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>
<textarea
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'"
@change="saveSegment(queue, segment)"
/>
<DurationField
v-if="segment.status !== 'complete' && segment.status !== 'running'"
v-model="segment.duration"
class="mt-3"
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>
</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)
}