Keep Output on the generating clip when another job is queued.

Queued generate/extend no longer hijacks the live progress bar, and Generate Extension stays clickable so another extension can wait in the job queue.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-02 08:59:04 -05:00
co-authored by Cursor
parent 1314e8e89f
commit 1d29296a6f
+79 -31
View File
@@ -1290,6 +1290,9 @@
<div class="h-2 overflow-hidden rounded-full bg-zinc-800"> <div class="h-2 overflow-hidden rounded-full bg-zinc-800">
<div class="h-full rounded-full bg-amber-400 progress-glow transition-all duration-300" :style="{ width: liveProgressPct + '%' }" /> <div class="h-full rounded-full bg-amber-400 progress-glow transition-all duration-300" :style="{ width: liveProgressPct + '%' }" />
</div> </div>
<p v-if="studioWaitingCount" class="mt-2 text-xs text-zinc-500">
{{ studioWaitingCount }} waiting in the job queue — Output stays on the clip that is generating.
</p>
</div> </div>
<div v-show="outputPreviewKind === 'video'"> <div v-show="outputPreviewKind === 'video'">
@@ -1448,9 +1451,9 @@
:disabled="!extendPrompt.trim()" :disabled="!extendPrompt.trim()"
@click="generateExtension" @click="generateExtension"
> >
{{ videoBusy ? 'Queue extension' : 'Generate Extension' }} {{ watchingLiveVideo() || videoBusy ? 'Queue extension' : 'Generate Extension' }}
</button> </button>
<p v-if="videoBusy" class="text-xs text-zinc-500">The current job keeps running in Output. This extension waits in the job queue.</p> <p v-if="watchingLiveVideo() || videoBusy" class="text-xs text-zinc-500">The current job keeps running in Output. This extension waits in the job queue.</p>
</div> </div>
</div> </div>
@@ -2597,6 +2600,7 @@ const videoJobId = ref('')
const editJobId = ref('') const editJobId = ref('')
const currentClipId = ref('') const currentClipId = ref('')
const extendOpen = ref(false) const extendOpen = ref(false)
const extendClipId = ref('')
const extendPrompt = ref('') const extendPrompt = ref('')
const extendDuration = ref(5) const extendDuration = ref(5)
const lastFrameUrl = ref('') const lastFrameUrl = ref('')
@@ -2818,6 +2822,25 @@ let videoListenGen = 0
let editListenGen = 0 let editListenGen = 0
let activeDraftId = '' let activeDraftId = ''
function watchingLiveVideo() {
return Boolean(videoJobId.value) && !videoSettledUi && (videoBusy.value || Boolean(videoSource))
}
function watchingLiveEdit() {
return Boolean(editJobId.value) && !editSettledUi && (editBusy.value || Boolean(editSource))
}
function queuedOffLiveOutput(
started: { queued?: boolean; jobId?: string },
kind: 'video' | 'edit'
) {
if (started.queued) return true
if (kind === 'edit') {
return watchingLiveEdit() && Boolean(started.jobId) && started.jobId !== editJobId.value
}
return watchingLiveVideo() && Boolean(started.jobId) && started.jobId !== videoJobId.value
}
function formatElapsed(ms: number) { function formatElapsed(ms: number) {
const seconds = Math.floor(ms / 1000) const seconds = Math.floor(ms / 1000)
const mm = String(Math.floor(seconds / 60)).padStart(2, '0') const mm = String(Math.floor(seconds / 60)).padStart(2, '0')
@@ -2875,7 +2898,7 @@ const liveStatusMessage = computed(() => {
return idleInputStatus('edit') return idleInputStatus('edit')
} }
if (videoBusy.value || videoUrl.value || lockedSave.value) return statusMessage.value if (videoBusy.value || videoUrl.value || lockedSave.value) return statusMessage.value
if (outputQueuedJob.value || videoAwaitingBeast.value) { if (videoAwaitingBeast.value || outputQueuedJob.value) {
return statusMessage.value || 'Queued. Waiting for Beast to be free.' return statusMessage.value || 'Queued. Waiting for Beast to be free.'
} }
return idleInputStatus('video') return idleInputStatus('video')
@@ -5046,16 +5069,15 @@ async function refreshStudioQueue() {
}>('/api/studio-queue').catch(() => ({ jobs: [] })) }>('/api/studio-queue').catch(() => ({ jobs: [] }))
studioJobs.value = data.jobs || [] studioJobs.value = data.jobs || []
const running = studioJobs.value.find(job => job.status === 'running' && job.liveJobId) const running = studioJobs.value.find(job => job.status === 'running' && job.liveJobId)
const queuedMine = Boolean( if (running?.liveJobId) videoAwaitingBeast.value = false
outputStudioJobId.value if (watchingLiveVideo() && running?.liveJobId && running.liveJobId !== videoJobId.value) {
&& studioJobs.value.some(job => job.id === outputStudioJobId.value && (job.status === 'waiting' || job.status === 'held')) return
) }
if (queuedMine && running && running.id !== outputStudioJobId.value) { if (watchingLiveEdit() && running?.liveJobId && running.liveJobId !== editJobId.value) {
return return
} }
if (running?.liveJobId) videoAwaitingBeast.value = false
if (running?.kind === 'edit') { if (running?.kind === 'edit') {
if (running.liveJobId && running.liveJobId !== editJobId.value && !editBusy.value) { if (running.liveJobId && running.liveJobId !== editJobId.value && !editBusy.value && !watchingLiveVideo()) {
outputStudioJobId.value = running.id outputStudioJobId.value = running.id
editJobId.value = running.liveJobId editJobId.value = running.liveJobId
persistActiveJob('edit', running.liveJobId, running.hideThumbnail === true, running.folderLocked === true) persistActiveJob('edit', running.liveJobId, running.hideThumbnail === true, running.folderLocked === true)
@@ -5066,7 +5088,7 @@ async function refreshStudioQueue() {
} else if (running.id && running.liveJobId === editJobId.value) { } else if (running.id && running.liveJobId === editJobId.value) {
outputStudioJobId.value = running.id outputStudioJobId.value = running.id
} }
} else if (running?.liveJobId && running.liveJobId !== videoJobId.value && !videoBusy.value) { } else if (running?.liveJobId && running.liveJobId !== videoJobId.value && !videoBusy.value && !watchingLiveVideo()) {
outputStudioJobId.value = running.id outputStudioJobId.value = running.id
videoJobId.value = running.liveJobId videoJobId.value = running.liveJobId
persistActiveJob('video', running.liveJobId, running.hideThumbnail === true, running.folderLocked === true) persistActiveJob('video', running.liveJobId, running.hideThumbnail === true, running.folderLocked === true)
@@ -5696,6 +5718,7 @@ function clipVideoUrl(clipId: string) {
function closeExtend() { function closeExtend() {
lastFrameGen += 1 lastFrameGen += 1
extendOpen.value = false extendOpen.value = false
extendClipId.value = ''
lastFrameLoading.value = false lastFrameLoading.value = false
lastFrameRevealed.value = false lastFrameRevealed.value = false
if (lastFrameObjectUrl) { if (lastFrameObjectUrl) {
@@ -5739,6 +5762,7 @@ async function openExtend(item?: LibraryClip) {
return return
} }
currentClipId.value = id currentClipId.value = id
extendClipId.value = id
const clip = clips.value.find(entry => entry.id === id) const clip = clips.value.find(entry => entry.id === id)
if (clip) applyPromptRestore(clip.prompt, clip) if (clip) applyPromptRestore(clip.prompt, clip)
extendPrompt.value = clip?.prompt || prompt.value extendPrompt.value = clip?.prompt || prompt.value
@@ -5755,9 +5779,14 @@ async function openExtend(item?: LibraryClip) {
} }
async function generateExtension() { async function generateExtension() {
const id = clipIdFromPlayer() const id = extendClipId.value || clipIdFromPlayer()
if (!id || !extendPrompt.value.trim()) return if (!extendPrompt.value.trim()) return
if (!id) {
toast('Load a generated clip before extending it.')
return
}
const hideOut = hideThumbnail.value const hideOut = hideThumbnail.value
const keepLiveOutput = watchingLiveVideo()
try { try {
await ensureGenerationNotifyPermission() await ensureGenerationNotifyPermission()
const started = await $fetch<{ const started = await $fetch<{
@@ -5780,16 +5809,22 @@ async function generateExtension() {
loraStack: extendLoraStack.value loraStack: extendLoraStack.value
} }
}) })
closeExtend() if (queuedOffLiveOutput(started, 'video')) {
if (started.queued) { if (watchingLiveVideo()) {
toast('Queued behind the current job. Output stays on the clip that is generating.')
await refreshStudioQueue()
return
}
toast('Queued behind the current job.')
outputStudioJobId.value = started.studioJobId || '' outputStudioJobId.value = started.studioJobId || ''
outputFocus.value = 'video' outputFocus.value = 'video'
videoAwaitingBeast.value = true videoAwaitingBeast.value = true
statusMessage.value = 'Queued. Waiting for Beast to be free.' statusMessage.value = 'Queued. Waiting for Beast to be free.'
toast('Queued behind the current job.') closeExtend()
await refreshStudioQueue() await refreshStudioQueue()
return return
} }
closeExtend()
statusBusy.value = false statusBusy.value = false
videoBusy.value = true videoBusy.value = true
watchingLibrary.value = false watchingLibrary.value = false
@@ -5816,12 +5851,14 @@ async function generateExtension() {
void pollComfyHealth() void pollComfyHealth()
void refreshStudioQueue() void refreshStudioQueue()
} catch (error: any) { } catch (error: any) {
videoBusy.value = false
statusBusy.value = false
stopTimer('video')
clearActiveJob('video')
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Failed to start extension' const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Failed to start extension'
statusMessage.value = message if (!keepLiveOutput) {
videoBusy.value = false
statusBusy.value = false
stopTimer('video')
clearActiveJob('video')
statusMessage.value = message
}
toast(message) toast(message)
} }
} }
@@ -6982,6 +7019,7 @@ async function generate() {
: extensionQueue.value.map(item => ({ prompt: item.prompt.trim(), duration: item.duration, ...persistLoraFields(item.loraStack || []) })) : extensionQueue.value.map(item => ({ prompt: item.prompt.trim(), duration: item.duration, ...persistLoraFields(item.loraStack || []) }))
const hideOut = hideThumbnail.value const hideOut = hideThumbnail.value
const folderLockedPref = false const folderLockedPref = false
const keepLiveOutput = watchingLiveVideo()
try { try {
await ensureGenerationNotifyPermission() await ensureGenerationNotifyPermission()
const body = new FormData() const body = new FormData()
@@ -7059,15 +7097,23 @@ async function generate() {
method: 'POST', method: 'POST',
body body
}) })
if (started.queued) { const queuedShotCount = 1 + queued.length
const queuedBehindToast = queuedShotCount > 1
? `Queued ${queuedShotCount} shots behind the current job.`
: 'Queued behind the current job.'
if (queuedOffLiveOutput(started, 'video')) {
if (watchingLiveVideo()) {
toast(queuedShotCount > 1
? `Queued ${queuedShotCount} shots. Output stays on the clip that is generating.`
: 'Queued behind the current job. Output stays on the clip that is generating.')
await refreshStudioQueue()
return
}
outputStudioJobId.value = started.studioJobId || '' outputStudioJobId.value = started.studioJobId || ''
outputFocus.value = 'video' outputFocus.value = 'video'
videoAwaitingBeast.value = true videoAwaitingBeast.value = true
statusMessage.value = 'Queued. Waiting for Beast to be free.' statusMessage.value = 'Queued. Waiting for Beast to be free.'
const n = 1 + queued.length toast(queuedBehindToast)
toast(n > 1
? `Queued ${n} shots behind the current job.`
: 'Queued behind the current job.')
await refreshStudioQueue() await refreshStudioQueue()
return return
} }
@@ -7107,12 +7153,14 @@ async function generate() {
void pollComfyHealth() void pollComfyHealth()
void refreshStudioQueue() void refreshStudioQueue()
} catch (error: any) { } catch (error: any) {
videoBusy.value = false
statusBusy.value = false
stopTimer('video')
clearActiveJob('video')
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Failed to start generation' const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Failed to start generation'
statusMessage.value = message if (!keepLiveOutput) {
videoBusy.value = false
statusBusy.value = false
stopTimer('video')
clearActiveJob('video')
statusMessage.value = message
}
toast(message) toast(message)
} }
} }