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:
+191
-10
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user