Keep image jobs on the studio queue and confirm each job or library delete. Label stitch-frame slots when thumbs are hidden, and open output stills in a lightbox.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-28 09:04:08 -05:00
co-authored by Cursor
parent 8598dab516
commit fb748e9b6e
10 changed files with 996 additions and 369 deletions
+191 -10
View File
@@ -69,11 +69,22 @@
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-[11px] uppercase tracking-[0.18em] text-zinc-500">Job</p>
<div class="flex items-start gap-3">
<img
v-if="jobThumb(job)"
:src="jobThumb(job)"
alt=""
class="h-12 w-12 shrink-0 rounded-lg bg-black/40 object-cover"
>
<div class="min-w-0">
<p class="text-[11px] uppercase tracking-[0.18em] text-zinc-500">{{ jobKindLabel(job) }}</p>
<h3 class="truncate font-display text-lg font-semibold text-zinc-100">{{ job.name }}</h3>
<p class="mt-1 text-sm text-zinc-300">{{ jobLine(job) }}</p>
<p v-if="job.shots && liveMessage[job.shots.id]" class="mt-1 text-xs text-amber-100">{{ liveMessage[job.shots.id] }}</p>
<p v-else-if="liveMessage[job.id]" class="mt-1 text-xs text-amber-100">{{ liveMessage[job.id] }}</p>
<p v-if="job.lastError" class="mt-1 text-xs text-red-300">{{ job.lastError }}</p>
</div>
</div>
</div>
<div class="flex flex-wrap gap-2">
<button
@@ -138,12 +149,13 @@
{{ jobPauseLabel(job) }}
</button>
<button
v-if="job.status === 'waiting' || job.status === 'held'"
v-if="canRemoveJob(job)"
type="button"
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-400 hover:text-red-200"
@click="removeJob(job)"
:disabled="Boolean(busyId)"
@click="askRemoveJob(job)"
>
Remove job
{{ job.status === 'running' ? 'Cancel job' : 'Remove job' }}
</button>
</div>
</div>
@@ -207,7 +219,7 @@
:key="index"
class="rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3"
>
<p class="text-sm font-medium text-zinc-100">Shot {{ index + 1 }} <span class="font-normal text-zinc-500">· queued with this job</span></p>
<p class="text-sm font-medium text-zinc-100">{{ job.kind === 'edit' ? `Pass ${index + 1}` : `Shot ${index + 1}` }} <span class="font-normal text-zinc-500">· queued with this job</span></p>
<p class="mt-1 max-h-24 overflow-y-auto whitespace-pre-wrap text-xs text-zinc-400 [overflow-wrap:anywhere]">{{ shot.prompt }}</p>
</li>
</ol>
@@ -266,12 +278,12 @@
{{ queuePauseLabel(queue) }}
</button>
<button
v-if="queue.status !== 'running'"
type="button"
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-400 hover:text-red-200"
@click="remove(queue)"
:disabled="Boolean(busyId)"
@click="askRemoveQueue(queue)"
>
Dismiss
{{ queue.status === 'running' ? 'Cancel job' : 'Remove job' }}
</button>
</div>
</div>
@@ -331,6 +343,41 @@
</ol>
</section>
</main>
<Teleport to="body">
<div
v-if="pendingRemove"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="remove-job-title"
@click.self="pendingRemove = null"
>
<div class="glass w-full max-w-md rounded-3xl p-5 sm:p-6">
<h2 id="remove-job-title" class="font-display text-xl font-bold">{{ pendingRemove.title }}</h2>
<p class="mt-2 text-sm font-medium text-zinc-100">{{ pendingRemove.name }}</p>
<p v-if="pendingRemove.detail" class="mt-1 text-xs text-zinc-500">{{ pendingRemove.detail }}</p>
<p class="mt-3 text-sm text-zinc-400">{{ pendingRemove.consequence }}</p>
<div class="mt-5 flex flex-wrap justify-end gap-2">
<button
type="button"
class="rounded-full border border-white/15 px-4 py-2 text-sm text-zinc-300 hover:text-white"
@click="pendingRemove = null"
>
Keep it
</button>
<button
type="button"
class="rounded-full border border-red-400/40 bg-red-400/10 px-4 py-2 text-sm font-semibold text-red-200 hover:border-red-300 hover:text-red-100 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="Boolean(busyId)"
@click="confirmRemove"
>
Remove this job
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
@@ -360,10 +407,15 @@ type Queue = {
type StudioJobRow = {
id: string
status: string
kind?: string
name: string
prompt?: string
shotCount: number
liveJobId?: string
shotQueueId?: string
stillId?: string
hideThumbnail?: boolean
folderLocked?: boolean
cutIn?: boolean
holdForCutIn?: boolean
pauseAfterCurrent?: boolean
@@ -374,6 +426,15 @@ type StudioJobRow = {
completedCount?: number
}
type PendingRemove = {
title: string
name: string
detail: string
consequence: string
job?: StudioJobRow
queue?: Queue
}
const instanceName = ref(String(useRuntimeConfig().public.instanceName || 'AIGen'))
const authEnabled = ref(false)
const jobs = ref<StudioJobRow[]>([])
@@ -386,6 +447,7 @@ const busyId = ref('')
const liveMessage = ref<Record<string, string>>({})
const dirty = new Set<string>()
const sources = new Map<string, EventSource>()
const pendingRemove = ref<PendingRemove | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
const canCutIn = computed(() => jobs.value.some(job => job.status === 'running' || job.status === 'held'))
@@ -452,6 +514,24 @@ function progressLine(queue: Queue) {
}
function jobLine(job: StudioJobRow) {
if (job.kind === 'edit') {
const passes = job.shotCount || 1
const passLine = passes > 1 ? `${passes} passes` : 'image edit'
if (job.status === 'waiting') {
return job.cutIn
? `Next after the current shot · ${passLine}`
: `Waiting · ${passLine}`
}
if (job.status === 'held') {
if (jobIsHeldPaused(job)) return 'Paused. This edit will not continue until you resume.'
return job.holdForCutIn ? 'Paused so another job can cut in' : 'Paused'
}
if (job.status === 'running') {
if (jobIsLatched(job)) return `Editing · will pause after this · ${passLine}`
return `Editing · ${passLine}`
}
return job.status
}
if (job.status === 'waiting') {
return job.cutIn
? `Next after the current shot · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'} · own clip, not part of the current sequence`
@@ -559,6 +639,9 @@ async function loadQueues() {
for (const queue of [...nextJobs.map(job => job.shots).filter(Boolean), ...orphans.value] as Queue[]) {
if (queue.status === 'running' && queue.currentJobId) listen(queue)
}
for (const job of nextJobs) {
if (job.status === 'running' && job.liveJobId && !job.shots) listenJob(job)
}
}
function listen(queue: Queue) {
@@ -593,8 +676,101 @@ async function cutIn(job: StudioJobRow) {
}
}
function listenJob(job: StudioJobRow) {
if (!job.liveJobId || sources.has(job.id)) return
const source = new EventSource(`/api/generate/${job.liveJobId}/stream`)
sources.set(job.id, source)
source.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as { message?: string; type?: string }
if (payload.message) liveMessage.value = { ...liveMessage.value, [job.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(job.id)
}
} catch { /* ignore */ }
}
}
function jobKindLabel(job: StudioJobRow) {
if (job.kind === 'edit') return (job.shotCount || 1) > 1 ? 'EDIT' : 'IMAGE'
return 'Job'
}
function jobThumb(job: StudioJobRow) {
if (!job.stillId || job.hideThumbnail || job.folderLocked) return ''
return `/api/library/stills/${job.stillId}`
}
function canRemoveJob(job: StudioJobRow) {
return job.status === 'waiting' || job.status === 'held' || job.status === 'running'
}
function promptSnippet(text?: string) {
const value = String(text || '').replace(/\s+/g, ' ').trim()
if (!value) return ''
return value.length > 120 ? `${value.slice(0, 117)}…` : value
}
function shotProgressDetail(job: StudioJobRow) {
const queue = job.shots
if (!queue) return job.kind === 'edit' ? promptSnippet(job.prompt) : ''
const total = queue.totalCount || job.shotCount || 1
const next = nextSegment(queue)
const shot = next ? next.index + 1 : Math.min(total, (queue.completedCount || 0) + 1)
const snippet = promptSnippet(next?.prompt || job.prompt)
return `Shot ${shot} of ${total}${snippet ? ` · ${snippet}` : ''}`
}
function askRemoveJob(job: StudioJobRow) {
const generating = jobIsGenerating(job)
const pausedBatch = jobIsHeldPaused(job) || (job.status === 'held' && Boolean(job.shotQueueId))
pendingRemove.value = {
title: generating ? 'Cancel this job?' : 'Remove this job?',
name: job.name || 'Untitled job',
detail: shotProgressDetail(job),
consequence: generating
? 'This stops the job that’s generating on Beast and removes only this row. Other queue items stay. Saved clips stay in the library.'
: pausedBatch
? 'This drops the rest of this shot list only. It will not resume, and it will not stop a different job that’s generating. Saved clips stay in the library.'
: 'This removes only this job from the queue. Other items stay. Saved clips stay in the library.',
job
}
}
function askRemoveQueue(queue: Queue) {
const generating = queue.status === 'running'
const next = nextSegment(queue)
const shot = next ? next.index + 1 : Math.min(queue.totalCount, (queue.completedCount || 0) + 1)
pendingRemove.value = {
title: generating ? 'Cancel this job?' : 'Remove this job?',
name: queue.name || 'Shot batch',
detail: `Shot ${shot} of ${queue.totalCount}${next?.prompt ? ` · ${promptSnippet(next.prompt)}` : ''}`,
consequence: generating
? 'This stops the job that’s generating on Beast and removes only this batch. Other queue items stay. Saved clips stay in the library.'
: 'This drops the rest of this shot list only. It will not resume, and it will not stop a different job that’s generating. Saved clips stay in the library.',
queue
}
}
async function confirmRemove() {
const pending = pendingRemove.value
if (!pending) return
pendingRemove.value = null
busyId.value = pending.job?.id || pending.queue?.id || 'remove'
error.value = ''
try {
if (pending.job) await removeJob(pending.job)
else if (pending.queue) await remove(pending.queue)
} finally {
busyId.value = ''
}
}
async function removeJob(job: StudioJobRow) {
if (!confirm('Remove this job from the queue? Saved clips stay in the library.')) return
await $fetch(`/api/studio-queue/${job.id}`, { method: 'DELETE' }).catch((err: any) => {
error.value = err?.data?.statusMessage || err?.message || 'Could not remove that job'
})
@@ -739,7 +915,6 @@ async function togglePause(target?: { job?: StudioJobRow; queue?: Queue }) {
}
async function remove(queue: Queue) {
if (!confirm('Remove this batch from the queue? Saved clips stay in the library.')) return
await $fetch(`/api/queues/${queue.id}`, { method: 'DELETE' }).catch((err: any) => {
error.value = err?.data?.statusMessage || err?.message || 'Could not remove that batch'
})
@@ -785,6 +960,7 @@ onMounted(async () => {
const me = await $fetch<{ user?: { name?: string }; authEnabled?: boolean; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null }))
authEnabled.value = Boolean(me.authEnabled)
instanceName.value = me.instanceName || instanceName.value
window.addEventListener('keydown', onRemoveKey)
await loadQueues().catch((err: any) => {
error.value = err?.data?.statusMessage || err?.message || 'Could not load the queue'
})
@@ -794,7 +970,12 @@ onMounted(async () => {
}, 4000)
})
function onRemoveKey(event: KeyboardEvent) {
if (event.key === 'Escape' && pendingRemove.value) pendingRemove.value = null
}
onBeforeUnmount(() => {
window.removeEventListener('keydown', onRemoveKey)
if (pollTimer) clearInterval(pollTimer)
if (saveTimer) clearTimeout(saveTimer)
for (const source of sources.values()) source.close()