diff --git a/pages/index.vue b/pages/index.vue
index 2a8ba1b..b079530 100644
--- a/pages/index.vue
+++ b/pages/index.vue
@@ -25,9 +25,9 @@
- Queue{{ queuePendingCount }}
+ Queue{{ queueBadgeCount }}
Sign out
@@ -58,7 +58,7 @@
Input
{{ studioMode === 'edit'
? 'Drop the still to edit. An optional second still applies image 2 onto image 1. Files leave the desktop after save.'
- : 'Drop a still, describe the motion, then send the job to the desktop GPU.' }}
+ : 'Drop a still, describe the motion, then send the job. Generate stays available — a long shot list takes one queue slot, and you can line up more while it runs.' }}
+ The current job keeps running in Output. This submit waits in the job queue.
{{ editBlockReason }}
+
+
Job queue
+
A long shot script is one slot. Run next inserts after the current shot as its own clip, then the original sequence continues.
+
+
+
+
{{ job.name }}
+
+ {{ studioJobStatusLabel(job) }}
+ · {{ job.shotCount }} shot{{ job.shotCount === 1 ? '' : 's' }}
+
+
+
+ {{ job.cutIn ? 'Next' : 'Run next' }}
+
+
+ Remove
+
+
+
+
+
Held jobs
Comfy was still busy after 3 minutes. Click one to restore the still, prompt, and settings.
@@ -686,6 +728,17 @@
Output
+
+
+
+
+
} | null>(null)
+const studioJobs = ref>([])
+const outputStudioJobId = ref('')
const pickerOpen = ref(false)
const pickerSlot = ref<'main' | 'editRef' | number>('main')
const libraryFilter = ref<'all' | 'images' | 'videos'>('all')
@@ -1758,7 +1827,7 @@ const queueReady = computed(() => {
return extensionQueue.value.every(item => item.prompt.trim())
})
const videoGenerateDisabled = computed(() => {
- return videoBusy.value || (!textToVideo.value && !file.value) || !prompt.value.trim() || !folderId.value || !queueReady.value
+ return (!textToVideo.value && !file.value) || !prompt.value.trim() || !folderId.value || !queueReady.value
})
const recommendDisabled = computed(() => {
if (recommendBusy.value) return true
@@ -1789,10 +1858,19 @@ const editBlockReason = computed(() => {
})
const generateLabel = computed(() => {
const n = queuedExtensionCount.value
- if (videoBusy.value) return 'Generating…'
+ if (videoBusy.value) return n ? `Queue ${n + 1} shots` : 'Queue job'
if (!n) return 'Generate Video'
return n === 1 ? 'Generate Initial + 1 Extension' : `Generate Initial + ${n} Extensions`
})
+const studioWaitingCount = computed(() => studioJobs.value.filter(job => job.status === 'waiting').length)
+const queueBadgeCount = computed(() => studioWaitingCount.value + queuePendingCount.value)
+const canCutInStudioJob = computed(() => studioJobs.value.some(job => job.status === 'running' || job.status === 'held') || videoBusy.value)
+const outputRestoreId = computed(() => (
+ outputStudioJobId.value
+ || studioJobs.value.find(job => job.liveJobId && job.liveJobId === videoJobId.value)?.id
+ || ''
+))
+const canRestoreOutput = computed(() => Boolean(outputRestoreId.value && outputProgressKind.value === 'video'))
const queuedExtensionCount = computed(() => (
shotScriptMode.value ? Math.max(0, parsedShots.value.length - 1) : extensionQueue.value.length
))
@@ -2153,6 +2231,7 @@ async function loadLibrary() {
])
applyLibrary(data)
queuePendingCount.value = queued.pendingCount || 0
+ await refreshStudioQueue()
await refreshLiveQueue()
}
@@ -2226,9 +2305,11 @@ onMounted(async () => {
applyInstancePrivacy()
healthTimer = setInterval(() => {
void pollComfyHealth()
+ void refreshStudioQueue()
if (activeQueueId.value) void refreshLiveQueue()
}, 5000)
await resumeActiveJob()
+ await refreshStudioQueue()
await refreshLiveQueue()
await loadPromptPresets()
})
@@ -2502,6 +2583,19 @@ function resetImage() {
imageHeight.value = 0
}
+function clearVideoForm() {
+ resetImage()
+ prompt.value = ''
+ clipName.value = ''
+ extensionQueue.value = []
+ shotScriptMode.value = false
+ identityRefs.value.forEach((_, index) => clearIdentityRef(index))
+ recommendText.value = ''
+ recommendError.value = ''
+ recommendApplied.value = false
+ activeDraftId = ''
+}
+
function readFile(next: File, opts?: { keepThumbnailPref?: boolean }) {
file.value = next
preview.value = URL.createObjectURL(next)
@@ -2635,6 +2729,166 @@ async function refreshLiveQueue() {
}
}
+function studioJobStatusLabel(job: { status: string; cutIn?: boolean; holdForCutIn?: boolean; lastError?: string }) {
+ if (job.status === 'running') return 'Generating'
+ if (job.status === 'held') return job.holdForCutIn ? 'Paused for cut-in' : 'Paused'
+ if (job.status === 'waiting') return job.cutIn ? 'Next after current shot' : 'Waiting'
+ if (job.status === 'error') return job.lastError || 'Error'
+ return job.status
+}
+
+async function refreshStudioQueue() {
+ const data = await $fetch<{
+ jobs?: Array<{
+ id: string
+ status: string
+ name: string
+ prompt: string
+ shotCount: number
+ liveJobId?: string
+ shotQueueId?: string
+ stillId?: string
+ hideThumbnail?: boolean
+ folderLocked?: boolean
+ cutIn?: boolean
+ holdForCutIn?: boolean
+ lastError?: string
+ }>
+ }>('/api/studio-queue').catch(() => ({ jobs: [] }))
+ studioJobs.value = data.jobs || []
+ const running = studioJobs.value.find(job => job.status === 'running' && job.liveJobId)
+ if (running?.liveJobId && running.liveJobId !== videoJobId.value && !videoBusy.value) {
+ outputStudioJobId.value = running.id
+ videoJobId.value = running.liveJobId
+ persistActiveJob('video', running.liveJobId, running.hideThumbnail === true, running.folderLocked === true)
+ listen('video', running.liveJobId, running.hideThumbnail === true, running.folderLocked === true)
+ if (running.shotQueueId) {
+ persistActiveQueue(running.shotQueueId)
+ void refreshLiveQueue()
+ }
+ videoBusy.value = true
+ statusMessage.value = 'Starting queued job…'
+ startTimer('video')
+ } else if (running?.id && running.liveJobId === videoJobId.value) {
+ outputStudioJobId.value = running.id
+ }
+}
+
+async function cutInStudioJob(id: string) {
+ try {
+ await $fetch(`/api/studio-queue/${id}/cut-in`, { method: 'POST' })
+ toast('This job will run after the current shot, as its own clip.')
+ await refreshStudioQueue()
+ } catch (error: any) {
+ toast(error?.data?.statusMessage || error?.statusMessage || 'Could not move that job next')
+ }
+}
+
+async function dismissStudioJob(id: string) {
+ try {
+ await $fetch(`/api/studio-queue/${id}`, { method: 'DELETE' })
+ if (outputStudioJobId.value === id) outputStudioJobId.value = ''
+ await refreshStudioQueue()
+ } catch (error: any) {
+ toast(error?.data?.statusMessage || error?.statusMessage || 'Could not remove that job')
+ }
+}
+
+async function restoreStudioJob(id: string) {
+ const job = await $fetch<{
+ payload?: {
+ prompt?: string
+ name?: string
+ folderId?: string
+ aspect?: string
+ width?: number
+ height?: number
+ turbo?: boolean
+ seed?: number
+ cfg?: number
+ fps?: number
+ samplerName?: string
+ scheduler?: string
+ duration?: number
+ sound?: boolean
+ workflow?: string
+ useIdentityRefs?: boolean
+ stillId?: string
+ stillFilename?: string
+ hideThumbnail?: boolean
+ hideInput?: boolean
+ referenceStillIds?: Array
+ extensions?: { prompt: string; duration: number }[]
+ queueAutoRun?: boolean
+ }
+ }>(`/api/studio-queue/${id}`)
+ const payload = job.payload
+ if (!payload) throw new Error('That job has no saved inputs')
+ studioMode.value = 'video'
+ outputFocus.value = 'video'
+ closeExtend()
+ const extensions = payload.extensions || []
+ applyVideoWorkflow(payload.workflow)
+ prompt.value = extensions.length
+ ? [payload.prompt.trim(), ...extensions.map((item, index) => `shot ${index + 2}\n${item.prompt.trim()}`)].join('\n\n')
+ : (payload.prompt || '')
+ clipName.value = payload.name || ''
+ seedInput.value = String(payload.seed || '')
+ restoreAdvanced({
+ turbo: payload.turbo === true,
+ aspect: payload.aspect,
+ width: payload.width,
+ height: payload.height,
+ cfg: payload.cfg,
+ fps: payload.fps,
+ samplerName: payload.samplerName,
+ scheduler: payload.scheduler
+ })
+ duration.value = clampDuration(Number(payload.duration))
+ hideThumbnail.value = showPrivacyToggles.value && payload.hideThumbnail === true
+ if (showPrivacyToggles.value) hideInputPreview.value = payload.hideInput === true
+ if (payload.folderId) {
+ folderId.value = payload.folderId
+ browseFolderId.value = payload.folderId
+ }
+ if (typeof payload.sound === 'boolean') withSound.value = payload.sound
+ shotScriptMode.value = extensions.length > 0
+ useIdentityRefs.value = payload.useIdentityRefs === true
+ identityRefs.value.forEach((_, index) => clearIdentityRef(index))
+ extensionQueue.value = extensions.map(item => ({
+ id: crypto.randomUUID(),
+ prompt: item.prompt,
+ duration: clampDuration(Number(item.duration))
+ }))
+ queueAutoRun.value = payload.queueAutoRun === true
+ const stillId = payload.stillId
+ if (stillId) {
+ const blob = await $fetch(`/api/library/stills/${stillId}`, { responseType: 'blob' })
+ readFile(new File([blob], payload.stillFilename || 'still.png', { type: blob.type || 'image/png' }), { keepThumbnailPref: true })
+ } else {
+ resetImage()
+ }
+ for (const [index, refId] of (payload.referenceStillIds || []).entries()) {
+ if (!refId || index >= 4) continue
+ try {
+ const blob = await $fetch(`/api/library/stills/${refId}`, { responseType: 'blob' })
+ setIdentityRef(index, new File([blob], `identity-ref-${index + 2}.png`, { type: blob.type || 'image/png' }))
+ } catch { /* skip missing identity stills */ }
+ }
+}
+
+async function restoreOutputJob() {
+ const id = outputRestoreId.value
+ if (!id) return
+ try {
+ await restoreStudioJob(id)
+ statusMessage.value = 'Job loaded into Input. Generate when you are ready.'
+ toast('Inputs restored. Make any changes, then generate.')
+ } catch (error: any) {
+ toast(error?.data?.statusMessage || error?.message || 'Could not restore that job')
+ }
+}
+
function clearActiveJob(kind?: 'video' | 'edit') {
try {
if (!kind || kind === 'video') localStorage.removeItem(JOB_STORE)
@@ -3528,27 +3782,8 @@ async function generate() {
const queued = shots
? shots.slice(1).map(shot => ({ prompt: shot.prompt.trim(), duration: duration.value }))
: extensionQueue.value.map(item => ({ prompt: item.prompt.trim(), duration: item.duration }))
- statusBusy.value = false
- videoBusy.value = true
- stopListen('video')
- videoSettledUi = false
- progress.value = 1
- videoUrl.value = ''
- currentClipId.value = ''
- closeExtend()
- awaitingReveal.value = false
- concealOutput.value = hideThumbnail.value
- lockedSave.value = false
- watchingLibrary.value = false
- playerSize.value = outputSize.value
- statusMessage.value = 'Checking ComfyUI...'
- chainStep.value = 1
- chainTotal.value = 1 + queued.length
- chainLabel.value = queued.length ? 'Initial' : ''
- overallProgress.value = 0
- completedChainStep.value = 0
- activeChainPlan.value = plannedChain.value.map(part => ({ ...part }))
- startTimer('video')
+ const hideOut = hideThumbnail.value
+ const folderLockedPref = false
try {
const body = new FormData()
if (file.value) body.append('image', file.value)
@@ -3581,22 +3816,68 @@ async function generate() {
body.append('extensions', JSON.stringify(queued))
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; queueId?: string }>('/api/generate', {
+ const downloadName = clipFileName(clipName.value.trim() || initialPrompt.split(/[.!?\n]/)[0] || 'aigen')
+ const chainPlan = plannedChain.value.map(part => ({ ...part }))
+ const started = await $fetch<{
+ jobId: string
+ studioJobId?: string
+ queued?: boolean
+ seed: number
+ width: number
+ height: number
+ steps: number
+ turbo: boolean
+ hideThumbnail?: boolean
+ folderLocked?: boolean
+ queueId?: string
+ chainTotal?: number
+ }>('/api/generate', {
method: 'POST',
body
})
- videoJobId.value = started.jobId
- maxStep.value = started.steps
- playerSize.value = { width: started.width, height: started.height }
+ if (started.queued) {
+ clearVideoForm()
+ const n = 1 + queued.length
+ toast(n > 1
+ ? `Queued ${n} shots behind the current job.`
+ : 'Queued behind the current job.')
+ await refreshStudioQueue()
+ return
+ }
+ clearVideoForm()
+ statusBusy.value = false
+ videoBusy.value = true
+ stopListen('video')
videoSettledUi = false
- persistActiveJob('video', started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true)
+ progress.value = 1
+ videoUrl.value = ''
+ currentClipId.value = ''
+ closeExtend()
+ awaitingReveal.value = false
+ concealOutput.value = started.hideThumbnail === true || hideOut
+ lockedSave.value = false
+ watchingLibrary.value = false
+ playerSize.value = { width: started.width, height: started.height }
+ statusMessage.value = 'Checking ComfyUI...'
+ chainStep.value = 1
+ chainTotal.value = started.chainTotal || (1 + queued.length)
+ chainLabel.value = queued.length ? 'Initial' : ''
+ overallProgress.value = 0
+ completedChainStep.value = 0
+ activeChainPlan.value = chainPlan
+ startTimer('video')
+ videoDownloadName.value = downloadName
+ videoJobId.value = started.jobId
+ outputStudioJobId.value = started.studioJobId || ''
+ maxStep.value = started.steps
+ persistActiveJob('video', started.jobId, started.hideThumbnail === true || hideOut, started.folderLocked === true || folderLockedPref)
if (started.queueId) {
persistActiveQueue(started.queueId)
void refreshLiveQueue()
}
- listen('video', started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true)
+ listen('video', started.jobId, started.hideThumbnail === true || hideOut, started.folderLocked === true)
void pollComfyHealth()
+ void refreshStudioQueue()
} catch (error: any) {
videoBusy.value = false
statusBusy.value = false
@@ -3761,6 +4042,7 @@ function applyVideoEvent(payload: Record, hidden: boolean, folderLo
clearActiveJob('video')
loadLibrary().catch(() => null)
void pollComfyHealth()
+ void refreshStudioQueue()
toast(payload.message || 'Comfy was still busy. This job was saved under Held jobs.')
return
}
@@ -3794,6 +4076,7 @@ function applyVideoEvent(payload: Record, hidden: boolean, folderLo
clearActiveJob('video')
activeDraftId = ''
void pollComfyHealth()
+ void refreshStudioQueue()
videoSettledUi = true
return
}
@@ -3812,6 +4095,7 @@ function applyVideoEvent(payload: Record, hidden: boolean, folderLo
activeDraftId = ''
loadLibrary().catch(() => null)
void pollComfyHealth()
+ void refreshStudioQueue()
statusMessage.value = currentClip.value?.hideThumbnail ? 'Hidden clip ready' : 'Loaded from library'
toast(locked
? 'Saved to the locked folder. Unlock it to view.'
@@ -3846,6 +4130,7 @@ function applyVideoEvent(payload: Record, hidden: boolean, folderLo
activeDraftId = ''
loadLibrary().catch(() => null)
void pollComfyHealth()
+ void refreshStudioQueue()
return
}
@@ -3858,6 +4143,7 @@ function applyVideoEvent(payload: Record, hidden: boolean, folderLo
clearActiveJob('video')
loadLibrary().catch(() => null)
void pollComfyHealth()
+ void refreshStudioQueue()
toast(payload.error || payload.message || 'Generation failed')
}
}
diff --git a/server/api/generate.post.ts b/server/api/generate.post.ts
index b4013a6..99fcb9d 100644
--- a/server/api/generate.post.ts
+++ b/server/api/generate.post.ts
@@ -1,14 +1,6 @@
-import { frameLength, runGeneration } from '~/server/utils/videoChain'
-import { createShotQueue, finishQueueBurst, setQueueJob } from '~/server/utils/shotQueue'
+import { addStudioJob, kickStudioQueue, listStudioJobs } from '~/server/utils/studioQueue'
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, parseVideoWorkflow } from '~/utils/videoModels'
-function isComfyBusyTimeout(error: unknown) {
- const err = error as { statusCode?: number; status?: number; data?: { code?: string }; message?: string; statusMessage?: string }
- if (err?.statusCode === 409 || err?.status === 409 || err?.data?.code === 'COMFY_BUSY') return true
- const text = `${err?.statusMessage || ''} ${err?.message || ''}`
- return text.includes('still busy after 3 minutes')
-}
-
function parseDuration(raw: unknown) {
const seconds = Number(raw)
if (!Number.isFinite(seconds)) return 5
@@ -114,7 +106,6 @@ export default defineEventHandler(async (event) => {
const cfg = parseCfg(fields.cfg, turbo, isLtxWorkflow(workflow))
const samplerName = parseSampler(fields.sampler_name, isLtxWorkflow(workflow))
const scheduler = parseScheduler(fields.scheduler)
- const length = frameLength(durationSeconds, fps)
const hideThumbnail = fields.hideThumbnail === 'true'
const hideInput = fields.hideInput === 'true'
const sound = !isLtxWorkflow(workflow) && fields.sound !== 'false'
@@ -159,156 +150,49 @@ export default defineEventHandler(async (event) => {
}
const chainTotal = 1 + extensions.length
- const job = createJob()
- job.kind = 'video'
- job.maxStep = steps
- job.hideThumbnail = hideThumbnail
const familyId = crypto.randomUUID()
const queueAutoRun = fields.queueAutoRun === 'true'
- job.library = {
+ const studio = await addStudioJob({
ownerKey,
- folderId,
- hideThumbnail,
- hideInput,
- folderLocked,
- name: clipName,
- prompt,
- aspect: fields.aspect || 'auto',
- width,
- height,
- steps,
- turbo,
- seed,
- cfg,
- fps,
- samplerName,
- scheduler,
- thumb: image && !isPipelineFrameFilename(image.filename) ? image.data : undefined,
- stillId: still?.id,
- stillFilename: still?.filename,
- referenceStillIds,
- duration: durationSeconds,
- sound,
- draftId: (fields.draftId || '').trim() || undefined,
- extensions,
- chainIndex: 0,
- chainStep: 1,
- chainTotal,
- chainLabel: extensions.length ? 'Initial' : undefined,
familyId,
- workflow,
- useIdentityRefs,
- queueAutoRun: extensions.length ? queueAutoRun : false,
- queueBudget: extensions.length && queueAutoRun ? extensions.length : 0
- }
- if (extensions.length) {
- const queue = await createShotQueue({
- ownerKey,
+ payload: {
+ prompt,
name: clipName,
- familyId,
folderId,
- autoRun: queueAutoRun,
- stillId: still?.id,
- stillFilename: still?.filename,
- hideThumbnail,
- hideInput,
aspect: fields.aspect || 'auto',
width,
height,
steps,
turbo,
+ seed,
cfg,
fps,
samplerName,
scheduler,
- workflow,
+ duration: durationSeconds,
sound,
+ workflow,
useIdentityRefs,
+ stillId: still?.id,
+ stillFilename: still?.filename,
+ hideThumbnail,
+ hideInput,
+ folderLocked,
referenceStillIds,
- initial: { prompt, duration: durationSeconds },
extensions,
- jobId: job.id
- })
- job.library.queueId = queue.id
- setQueueJob(queue.id, job.id)
- }
- emitChainJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
-
- void runGeneration(job, {
- prompt,
- image,
- width,
- height,
- steps,
- seed,
- turbo,
- length,
- sound,
- cfg,
- fps,
- samplerName,
- scheduler,
- extensions,
- workflow,
- duration: durationSeconds,
- useIdentityRefs,
- referenceImages
- }).catch(async (error) => {
- removeExtendTemp(job.library?.extendTmpDir)
- if (isComfyBusyTimeout(error) && job.library) {
- try {
- const draft = await saveRetryDraft({
- ownerKey: job.library.ownerKey,
- id: job.library.draftId,
- folderId: job.library.folderId,
- stillId: job.library.stillId,
- stillFilename: job.library.stillFilename,
- name: job.library.name,
- prompt: job.library.prompt,
- aspect: job.library.aspect,
- width: job.library.width,
- height: job.library.height,
- steps: job.library.steps,
- turbo: job.library.turbo,
- seed: job.library.seed,
- duration: job.library.duration || durationSeconds,
- hideThumbnail: job.library.hideThumbnail,
- hideInput: job.library.hideInput === true,
- sound: job.library.sound !== false,
- cfg: job.library.cfg,
- fps: job.library.fps,
- samplerName: job.library.samplerName,
- scheduler: job.library.scheduler,
- extensions: job.library.extensions,
- workflow: job.library.workflow
- })
- job.library.draftId = draft.id
- job.status = 'deferred'
- const message = 'ComfyUI was still busy after 3 minutes. Saved this job — click it under Held jobs to restore the form.'
- emitJob(job, { type: 'deferred', draftId: draft.id, message, progress: job.progress })
- if (job.library.queueId) {
- await finishQueueBurst(job.library.ownerKey, job.library.queueId, true, 'Held because ComfyUI was busy').catch(() => null)
- setQueueJob(job.library.queueId, null)
- }
- return
- } catch {
- // fall through to a normal error if the held job could not be saved
- }
- }
- if (job.status === 'error' || job.status === 'cancelled' || job.status === 'deferred') return
- const message = error instanceof Error ? error.message : String(error)
- job.status = 'error'
- job.error = message
- emitJob(job, { type: 'error', error: message, message })
- if (job.library?.queueId) {
- await finishQueueBurst(job.library.ownerKey, job.library.queueId, false, message).catch(() => null)
- setQueueJob(job.library.queueId, null)
+ queueAutoRun
}
})
+ await kickStudioQueue()
+ const latest = listStudioJobs(ownerKey).find(item => item.id === studio.id)
+ const liveJobId = latest?.liveJobId || ''
+ const queueId = latest?.shotQueueId
return {
- jobId: job.id,
- clientId: job.clientId,
+ jobId: liveJobId || studio.id,
+ studioJobId: studio.id,
+ queued: !liveJobId,
+ clientId: '',
seed,
width,
height,
@@ -323,7 +207,7 @@ export default defineEventHandler(async (event) => {
hideThumbnail,
folderLocked,
chainTotal,
- queueId: job.library?.queueId,
+ queueId,
queuedRemaining: extensions.length
}
})
diff --git a/server/api/studio-queue.get.ts b/server/api/studio-queue.get.ts
new file mode 100644
index 0000000..3cea93b
--- /dev/null
+++ b/server/api/studio-queue.get.ts
@@ -0,0 +1,12 @@
+import { listStudioJobs, summarizeStudioJob } from '~/server/utils/studioQueue'
+
+export default defineEventHandler((event) => {
+ const { owner } = assertLibraryOwner(event)
+ const jobs = listStudioJobs(owner)
+ .filter(job => job.status === 'waiting' || job.status === 'running' || job.status === 'held')
+ .map(summarizeStudioJob)
+ return {
+ jobs,
+ waitingCount: jobs.filter(job => job.status === 'waiting').length
+ }
+})
diff --git a/server/api/studio-queue/[id].delete.ts b/server/api/studio-queue/[id].delete.ts
new file mode 100644
index 0000000..bff40bf
--- /dev/null
+++ b/server/api/studio-queue/[id].delete.ts
@@ -0,0 +1,8 @@
+import { cancelStudioJob } from '~/server/utils/studioQueue'
+
+export default defineEventHandler(async (event) => {
+ const { owner } = assertLibraryOwner(event)
+ const id = String(getRouterParam(event, 'id') || '')
+ await cancelStudioJob(owner, id)
+ return { ok: true }
+})
diff --git a/server/api/studio-queue/[id].get.ts b/server/api/studio-queue/[id].get.ts
new file mode 100644
index 0000000..34c874a
--- /dev/null
+++ b/server/api/studio-queue/[id].get.ts
@@ -0,0 +1,11 @@
+import { listStudioJobs } from '~/server/utils/studioQueue'
+
+export default defineEventHandler((event) => {
+ const { owner } = assertLibraryOwner(event)
+ const id = String(getRouterParam(event, 'id') || '')
+ const job = listStudioJobs(owner).find(item => item.id === id)
+ if (!job) {
+ throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
+ }
+ return job
+})
diff --git a/server/api/studio-queue/[id]/cut-in.post.ts b/server/api/studio-queue/[id]/cut-in.post.ts
new file mode 100644
index 0000000..a433ab1
--- /dev/null
+++ b/server/api/studio-queue/[id]/cut-in.post.ts
@@ -0,0 +1,8 @@
+import { requestCutIn } from '~/server/utils/studioQueue'
+
+export default defineEventHandler(async (event) => {
+ const { owner } = assertLibraryOwner(event)
+ const id = String(getRouterParam(event, 'id') || '')
+ const job = await requestCutIn(owner, id)
+ return job
+})
diff --git a/server/plugins/resume-queues.ts b/server/plugins/resume-queues.ts
index a83d72e..4060c51 100644
--- a/server/plugins/resume-queues.ts
+++ b/server/plugins/resume-queues.ts
@@ -1,11 +1,22 @@
import { listPendingJobs } from '~/server/utils/pending'
import { listOwnersWithQueues, listShotQueues, pendingSegmentCount } from '~/server/utils/shotQueue'
+import { kickStudioQueue, listOwnersWithStudioQueues, listStudioJobs } from '~/server/utils/studioQueue'
import { startQueueBurst } from '~/server/utils/videoChain'
export default defineNitroPlugin(() => {
const pendingQueueIds = new Set(listPendingJobs().map(job => job.queueId).filter(Boolean))
+ const claimedQueueIds = new Set()
+ for (const owner of listOwnersWithStudioQueues()) {
+ for (const job of listStudioJobs(owner)) {
+ if (!job.shotQueueId) continue
+ if (job.status === 'waiting' || job.status === 'running' || job.status === 'held') {
+ claimedQueueIds.add(job.shotQueueId)
+ }
+ }
+ }
for (const owner of listOwnersWithQueues()) {
for (const queue of listShotQueues(owner)) {
+ if (claimedQueueIds.has(queue.id)) continue
if (!queue.autoRun) continue
if (queue.status === 'complete') continue
if (pendingQueueIds.has(queue.id)) continue
@@ -13,4 +24,5 @@ export default defineNitroPlugin(() => {
void startQueueBurst(owner, queue.id, 'all').catch(() => null)
}
}
+ void kickStudioQueue()
})
diff --git a/server/utils/studioQueue.ts b/server/utils/studioQueue.ts
new file mode 100644
index 0000000..813e3f7
--- /dev/null
+++ b/server/utils/studioQueue.ts
@@ -0,0 +1,575 @@
+import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { getJob, listJobs, type Job } from '~/server/utils/jobs'
+import { parseVideoWorkflow, type VideoWorkflowId } from '~/utils/videoModels'
+
+export type StudioJobStatus = 'waiting' | 'running' | 'held' | 'complete' | 'error' | 'cancelled'
+
+export interface StudioJobPayload {
+ prompt: string
+ name: string
+ folderId: string
+ aspect: string
+ width: number
+ height: number
+ steps: number
+ turbo: boolean
+ seed: number
+ cfg: number
+ fps: number
+ samplerName: string
+ scheduler: string
+ duration: number
+ sound: boolean
+ workflow: VideoWorkflowId
+ useIdentityRefs: boolean
+ stillId?: string
+ stillFilename?: string
+ hideThumbnail: boolean
+ hideInput?: boolean
+ folderLocked?: boolean
+ referenceStillIds: Array
+ extensions: { prompt: string; duration: number }[]
+ queueAutoRun: boolean
+}
+
+export interface StudioJob {
+ id: string
+ ownerKey: string
+ createdAt: number
+ updatedAt: number
+ status: StudioJobStatus
+ name: string
+ prompt: string
+ shotCount: number
+ familyId: string
+ shotQueueId?: string
+ liveJobId?: string
+ payload: StudioJobPayload
+ cutIn?: boolean
+ holdForCutIn?: boolean
+ resumeAutoRun?: boolean
+ lastError?: string
+}
+
+const writeChains = new Map>()
+let dispatchChain: Promise = Promise.resolve()
+
+function libraryRoot() {
+ const config = useRuntimeConfig()
+ return (config.libraryDir || process.env.LIBRARY_DIR || '/data/library').replace(/\/$/, '')
+}
+
+function queuePath(owner: string) {
+ return join(libraryRoot(), 'users', owner, 'studio-queue.json')
+}
+
+function ensureOwner(owner: string) {
+ mkdirSync(join(libraryRoot(), 'users', owner), { recursive: true })
+}
+
+function readJobs(owner: string): StudioJob[] {
+ ensureOwner(owner)
+ const path = queuePath(owner)
+ if (!existsSync(path)) return []
+ try {
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
+ return Array.isArray(parsed) ? parsed : []
+ } catch {
+ return []
+ }
+}
+
+function writeJobs(owner: string, jobs: StudioJob[]) {
+ ensureOwner(owner)
+ const path = queuePath(owner)
+ const tmp = `${path}.tmp`
+ writeFileSync(tmp, JSON.stringify(jobs, null, 2))
+ renameSync(tmp, path)
+}
+
+function mutate(owner: string, fn: (jobs: StudioJob[]) => T): Promise {
+ const prev = writeChains.get(owner) || Promise.resolve()
+ const run = prev.then(() => {
+ const jobs = readJobs(owner)
+ const result = fn(jobs)
+ writeJobs(owner, jobs)
+ return result
+ })
+ writeChains.set(owner, run.then(() => undefined, () => undefined))
+ return run
+}
+
+export function listOwnersWithStudioQueues() {
+ const root = join(libraryRoot(), 'users')
+ if (!existsSync(root)) return [] as string[]
+ return readdirSync(root, { withFileTypes: true })
+ .filter(entry => entry.isDirectory())
+ .map(entry => entry.name)
+ .filter(owner => existsSync(queuePath(owner)))
+}
+
+export function listStudioJobs(owner: string) {
+ return readJobs(owner)
+}
+
+export function summarizeStudioJob(job: StudioJob) {
+ return {
+ id: job.id,
+ createdAt: job.createdAt,
+ updatedAt: job.updatedAt,
+ status: job.status,
+ name: job.name,
+ prompt: job.prompt,
+ shotCount: job.shotCount,
+ familyId: job.familyId,
+ shotQueueId: job.shotQueueId,
+ liveJobId: job.liveJobId,
+ stillId: job.payload.stillId,
+ workflow: job.payload.workflow,
+ duration: job.payload.duration,
+ hideThumbnail: job.payload.hideThumbnail,
+ folderLocked: job.payload.folderLocked === true,
+ queueAutoRun: job.payload.queueAutoRun === true,
+ cutIn: job.cutIn === true,
+ holdForCutIn: job.holdForCutIn === true,
+ lastError: job.lastError
+ }
+}
+
+export function videoJobsBusy() {
+ return listJobs().some(job => job.kind !== 'edit' && (job.status === 'queued' || job.status === 'uploading' || job.status === 'running'))
+}
+
+export async function addStudioJob(params: {
+ ownerKey: string
+ payload: StudioJobPayload
+ familyId: string
+}) {
+ const now = Date.now()
+ const shotCount = 1 + (params.payload.extensions?.length || 0)
+ const job: StudioJob = {
+ id: crypto.randomUUID(),
+ ownerKey: params.ownerKey,
+ createdAt: now,
+ updatedAt: now,
+ status: 'waiting',
+ name: (params.payload.name || '').trim() || params.payload.prompt.slice(0, 80),
+ prompt: params.payload.prompt,
+ shotCount,
+ familyId: params.familyId,
+ payload: params.payload,
+ cutIn: false
+ }
+ await mutate(params.ownerKey, (jobs) => {
+ jobs.push(job)
+ return job
+ })
+ return job
+}
+
+export async function patchStudioJob(owner: string, id: string, patch: (job: StudioJob) => void) {
+ return mutate(owner, (jobs) => {
+ const job = jobs.find(item => item.id === id)
+ if (!job) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
+ patch(job)
+ job.updatedAt = Date.now()
+ return structuredClone(job)
+ })
+}
+
+export async function cancelStudioJob(owner: string, id: string) {
+ const result = await mutate(owner, (jobs) => {
+ const job = jobs.find(item => item.id === id)
+ if (!job) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
+ if (job.status === 'running') {
+ throw createError({ statusCode: 409, statusMessage: 'Stop the live job from Output if you want to cancel the one that is generating' })
+ }
+ if (job.status !== 'waiting' && job.status !== 'held') {
+ throw createError({ statusCode: 409, statusMessage: 'Only a waiting or paused job can be removed from the queue' })
+ }
+ job.status = 'cancelled'
+ job.cutIn = false
+ job.holdForCutIn = false
+ job.updatedAt = Date.now()
+ const stillCutIn = jobs.some(item => item.status === 'waiting' && item.cutIn)
+ if (!stillCutIn) {
+ for (const item of jobs) {
+ if (item.status === 'running' || item.status === 'held') {
+ item.holdForCutIn = false
+ }
+ }
+ }
+ return structuredClone(job)
+ })
+ const jobs = readJobs(owner)
+ if (!jobs.some(item => item.status === 'waiting' && item.cutIn)) {
+ const running = jobs.find(item => item.status === 'running')
+ if (running?.liveJobId) {
+ const live = getJob(running.liveJobId)
+ if (live?.library) live.library.stopAfterCurrent = false
+ }
+ }
+ kickStudioQueue()
+ return result
+}
+
+export async function requestCutIn(owner: string, id: string) {
+ const jobsNow = readJobs(owner)
+ const running = jobsNow.find(job => job.status === 'running')
+ || jobsNow.find(job => job.status === 'held')
+ if (!running) {
+ throw createError({ statusCode: 409, statusMessage: 'Nothing is generating, so this job can just wait its turn' })
+ }
+ if (running.id === id) {
+ throw createError({ statusCode: 409, statusMessage: 'That job is already running' })
+ }
+ const updated = await mutate(owner, (jobs) => {
+ const job = jobs.find(item => item.id === id)
+ if (!job) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
+ if (job.status !== 'waiting') {
+ throw createError({ statusCode: 409, statusMessage: 'Only a waiting job can cut in' })
+ }
+ job.cutIn = true
+ job.updatedAt = Date.now()
+ for (const item of jobs) {
+ if (item.id !== id && item.status === 'waiting') item.cutIn = false
+ }
+ const active = jobs.find(item => item.id === running.id)
+ if (active && active.status === 'running') {
+ active.holdForCutIn = true
+ active.resumeAutoRun = active.payload.queueAutoRun === true
+ active.updatedAt = Date.now()
+ }
+ return structuredClone(job)
+ })
+ const liveId = running.liveJobId
+ if (liveId) {
+ const live = getJob(liveId)
+ if (live?.library) {
+ live.library.stopAfterCurrent = true
+ }
+ }
+ kickStudioQueue()
+ return updated
+}
+
+function pruneDone(jobs: StudioJob[]) {
+ const cutoff = Date.now() - 1000 * 60 * 60 * 24
+ return jobs.filter(job => {
+ if (job.status === 'waiting' || job.status === 'running' || job.status === 'held') return true
+ return job.updatedAt > cutoff
+ }).slice(-40)
+}
+
+export async function markStudioLive(owner: string, id: string, liveJobId: string, shotQueueId?: string) {
+ return patchStudioJob(owner, id, (job) => {
+ job.status = 'running'
+ job.liveJobId = liveJobId
+ if (shotQueueId) job.shotQueueId = shotQueueId
+ job.lastError = undefined
+ })
+}
+
+export async function markStudioHeld(owner: string, id: string) {
+ return patchStudioJob(owner, id, (job) => {
+ job.status = 'held'
+ job.liveJobId = undefined
+ })
+}
+
+export async function markStudioSettled(owner: string, liveJobId: string, status: 'complete' | 'error' | 'cancelled', error?: string) {
+ return mutate(owner, (jobs) => {
+ const job = jobs.find(item => item.liveJobId === liveJobId) || jobs.find(item => item.status === 'running')
+ if (!job) return null
+ if (job.holdForCutIn && status === 'complete') {
+ job.status = 'held'
+ job.liveJobId = undefined
+ job.updatedAt = Date.now()
+ return structuredClone(job)
+ }
+ job.status = status
+ job.liveJobId = undefined
+ job.cutIn = false
+ job.holdForCutIn = false
+ job.lastError = error
+ job.updatedAt = Date.now()
+ return structuredClone(job)
+ })
+}
+
+function pickCutIn(jobs: StudioJob[]) {
+ return jobs.find(job => job.status === 'waiting' && job.cutIn) || null
+}
+
+function pickHeld(jobs: StudioJob[]) {
+ const held = jobs
+ .filter(job => job.status === 'held' && job.shotQueueId)
+ .sort((a, b) => b.updatedAt - a.updatedAt)
+ const cutInHold = held.find(job => job.holdForCutIn)
+ return cutInHold || held[0] || null
+}
+
+function pickWaiting(jobs: StudioJob[]) {
+ return jobs.find(job => job.status === 'waiting') || null
+}
+
+export function kickStudioQueue() {
+ const run = dispatchChain.then(() => dispatchStudioQueue()).catch(() => undefined)
+ dispatchChain = run
+ return run
+}
+
+function repairStaleJobs(jobs: StudioJob[]) {
+ for (const job of jobs) {
+ if (job.status !== 'running') continue
+ const live = job.liveJobId ? getJob(job.liveJobId) : undefined
+ const liveBusy = live && (live.status === 'queued' || live.status === 'uploading' || live.status === 'running')
+ if (liveBusy) continue
+ if (job.shotQueueId) {
+ job.status = 'held'
+ job.liveJobId = undefined
+ job.holdForCutIn = true
+ job.resumeAutoRun = job.payload.queueAutoRun === true || job.resumeAutoRun === true
+ job.updatedAt = Date.now()
+ } else {
+ job.status = 'complete'
+ job.liveJobId = undefined
+ job.updatedAt = Date.now()
+ }
+ }
+}
+
+async function dispatchStudioQueue() {
+ if (videoJobsBusy()) return
+ for (const owner of listOwnersWithStudioQueues()) {
+ const jobs = await mutate(owner, (list) => {
+ repairStaleJobs(list)
+ const next = pruneDone(list)
+ list.splice(0, list.length, ...next)
+ return list.map(item => structuredClone(item))
+ })
+ const cutIn = pickCutIn(jobs)
+ if (cutIn) {
+ await startStudioJob(cutIn)
+ return
+ }
+ const held = pickHeld(jobs)
+ if (held?.shotQueueId) {
+ await resumeHeldStudioJob(held)
+ return
+ }
+ const waiting = pickWaiting(jobs)
+ if (waiting) {
+ await startStudioJob(waiting)
+ return
+ }
+ }
+}
+
+async function resumeHeldStudioJob(item: StudioJob) {
+ if (!item.shotQueueId) {
+ await patchStudioJob(item.ownerKey, item.id, (job) => {
+ job.status = 'complete'
+ job.holdForCutIn = false
+ })
+ return
+ }
+ const { startQueueBurst } = await import('~/server/utils/videoChain')
+ const count = item.resumeAutoRun ? 'all' as const : 1
+ try {
+ const started = await startQueueBurst(item.ownerKey, item.shotQueueId, count)
+ await patchStudioJob(item.ownerKey, item.id, (job) => {
+ job.status = 'running'
+ job.liveJobId = started.jobId
+ job.holdForCutIn = false
+ job.cutIn = false
+ })
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ await patchStudioJob(item.ownerKey, item.id, (job) => {
+ job.status = 'error'
+ job.lastError = message
+ job.holdForCutIn = false
+ })
+ }
+}
+
+export async function startStudioJob(item: StudioJob) {
+ try {
+ const { createJob } = await import('~/server/utils/jobs')
+ const { runGeneration, frameLength } = await import('~/server/utils/videoChain')
+ const { createShotQueue, setQueueJob } = await import('~/server/utils/shotQueue')
+ const { stillPath } = await import('~/server/utils/library')
+ const { existsSync, readFileSync } = await import('node:fs')
+
+ const payload = item.payload
+ const job = createJob()
+ job.kind = 'video'
+ job.maxStep = payload.steps
+ job.hideThumbnail = payload.hideThumbnail
+ const extensions = payload.extensions || []
+ job.library = {
+ ownerKey: item.ownerKey,
+ folderId: payload.folderId,
+ hideThumbnail: payload.hideThumbnail,
+ hideInput: payload.hideInput,
+ folderLocked: payload.folderLocked,
+ name: payload.name,
+ prompt: payload.prompt,
+ aspect: payload.aspect,
+ width: payload.width,
+ height: payload.height,
+ steps: payload.steps,
+ turbo: payload.turbo,
+ seed: payload.seed || Math.floor(Math.random() * 2_147_483_647),
+ cfg: payload.cfg,
+ fps: payload.fps,
+ samplerName: payload.samplerName,
+ scheduler: payload.scheduler,
+ stillId: payload.stillId,
+ stillFilename: payload.stillFilename,
+ referenceStillIds: payload.referenceStillIds,
+ duration: payload.duration,
+ sound: payload.sound,
+ extensions,
+ chainIndex: 0,
+ chainStep: 1,
+ chainTotal: 1 + extensions.length,
+ chainLabel: extensions.length ? 'Initial' : undefined,
+ familyId: item.familyId,
+ workflow: parseVideoWorkflow(payload.workflow),
+ useIdentityRefs: payload.useIdentityRefs,
+ queueAutoRun: extensions.length ? payload.queueAutoRun : false,
+ queueBudget: extensions.length && payload.queueAutoRun ? extensions.length : 0
+ }
+
+ if (extensions.length) {
+ const queue = await createShotQueue({
+ ownerKey: item.ownerKey,
+ name: payload.name,
+ familyId: item.familyId,
+ folderId: payload.folderId,
+ autoRun: payload.queueAutoRun,
+ stillId: payload.stillId,
+ stillFilename: payload.stillFilename,
+ hideThumbnail: payload.hideThumbnail,
+ hideInput: payload.hideInput,
+ aspect: payload.aspect,
+ width: payload.width,
+ height: payload.height,
+ steps: payload.steps,
+ turbo: payload.turbo,
+ cfg: payload.cfg,
+ fps: payload.fps,
+ samplerName: payload.samplerName,
+ scheduler: payload.scheduler,
+ workflow: parseVideoWorkflow(payload.workflow),
+ sound: payload.sound,
+ useIdentityRefs: payload.useIdentityRefs,
+ referenceStillIds: payload.referenceStillIds,
+ initial: { prompt: payload.prompt, duration: payload.duration },
+ extensions,
+ jobId: job.id
+ })
+ job.library.queueId = queue.id
+ setQueueJob(queue.id, job.id)
+ await patchStudioJob(item.ownerKey, item.id, (row) => {
+ row.shotQueueId = queue.id
+ })
+ }
+
+ await markStudioLive(item.ownerKey, item.id, job.id, job.library.queueId)
+
+ const stillFile = payload.stillId && existsSync(stillPath(item.ownerKey, payload.stillId))
+ ? {
+ filename: payload.stillFilename || 'still.png',
+ data: readFileSync(stillPath(item.ownerKey, payload.stillId)),
+ type: 'image/png'
+ }
+ : null
+ const referenceImages: Array<{ filename: string; data: Buffer; type?: string } | null> = [null, null, null, null]
+ for (const [index, stillId] of (payload.referenceStillIds || []).entries()) {
+ if (!stillId || index >= 4) continue
+ const path = stillPath(item.ownerKey, stillId)
+ if (!existsSync(path)) continue
+ referenceImages[index] = {
+ filename: `identity-ref-${index + 2}.png`,
+ data: readFileSync(path),
+ type: 'image/png'
+ }
+ }
+
+ void runGeneration(job, {
+ prompt: payload.prompt,
+ image: stillFile,
+ width: payload.width,
+ height: payload.height,
+ steps: payload.steps,
+ seed: job.library.seed,
+ turbo: payload.turbo,
+ length: frameLength(payload.duration, payload.fps),
+ sound: payload.sound,
+ cfg: payload.cfg,
+ fps: payload.fps,
+ samplerName: payload.samplerName,
+ scheduler: payload.scheduler,
+ extensions,
+ workflow: parseVideoWorkflow(payload.workflow),
+ duration: payload.duration,
+ useIdentityRefs: payload.useIdentityRefs,
+ referenceImages
+ }).catch((error) => {
+ const message = error instanceof Error ? error.message : String(error)
+ if (job.status !== 'error' && job.status !== 'cancelled' && job.status !== 'deferred') {
+ job.status = 'error'
+ job.error = message
+ }
+ })
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ await patchStudioJob(item.ownerKey, item.id, (job) => {
+ job.status = 'error'
+ job.lastError = message
+ }).catch(() => null)
+ kickStudioQueue()
+ }
+}
+
+export async function onLiveVideoSettled(job: Job) {
+ const owner = job.library?.ownerKey
+ if (!owner) {
+ kickStudioQueue()
+ return
+ }
+ const remaining = job.library
+ ? Math.max(0, (job.library.chainTotal || 1) - (job.library.chainStep || 1))
+ : 0
+ const interrupted = job.library?.stopAfterCurrent === true
+ && remaining > 0
+ && job.status !== 'error'
+ && job.status !== 'cancelled'
+ if (interrupted && job.status === 'running') {
+ job.status = 'complete'
+ }
+ const hold = interrupted
+ if (hold) {
+ await mutate(owner, (jobs) => {
+ const row = jobs.find(item => item.liveJobId === job.id) || jobs.find(item => item.status === 'running')
+ if (!row) return
+ row.status = 'held'
+ row.liveJobId = undefined
+ row.holdForCutIn = true
+ row.resumeAutoRun = row.payload.queueAutoRun === true || job.library?.queueAutoRun === true
+ row.updatedAt = Date.now()
+ })
+ } else {
+ const status = job.status === 'cancelled'
+ ? 'cancelled'
+ : job.status === 'complete'
+ ? 'complete'
+ : 'error'
+ await markStudioSettled(owner, job.id, status, job.error)
+ }
+ kickStudioQueue()
+}
diff --git a/server/utils/videoChain.ts b/server/utils/videoChain.ts
index 6fae19e..3e81b31 100644
--- a/server/utils/videoChain.ts
+++ b/server/utils/videoChain.ts
@@ -9,6 +9,7 @@ import { isLtxWorkflow, isTextToVideo, parseVideoWorkflow, workflowForExtension,
import { clipVideoPath, clipTitle, deleteRetryDraft, extendTempDir, getClip, nextClipPartName, removeExtendTemp, stillPath } from '~/server/utils/library'
import { extractLastFrame, probeHasAudio } from '~/server/utils/ffmpeg'
import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
+import { onLiveVideoSettled } from '~/server/utils/studioQueue'
import {
finishQueueBurst,
getShotQueue,
@@ -252,7 +253,7 @@ export async function continueQueuedExtensions(
for (let i = startFrom; i < extensions.length; i++) {
if (shotsLeft <= 0) break
assertJobActive(job)
- if (job.library.stopAfterCurrent && i > startFrom) break
+ if (job.library.stopAfterCurrent) break
const liveQueue = job.library.queueId
? getShotQueue(job.library.ownerKey, job.library.queueId)
@@ -350,10 +351,12 @@ export async function continueQueuedExtensions(
if (job.library) job.library.chainContinuing = false
if (job.library?.queueId) {
const paused = job.status !== 'error' && job.status !== 'cancelled'
+ const hold = job.library.stopAfterCurrent === true && paused
+ if (hold && job.status === 'running') job.status = 'complete'
await finishQueueBurst(
job.library.ownerKey,
job.library.queueId,
- paused && !job.library.queueAutoRun,
+ hold || (paused && !job.library.queueAutoRun),
job.status === 'error' || job.status === 'cancelled' ? (job.error || 'Stopped') : undefined
).catch(() => null)
setQueueJob(job.library.queueId, null)
@@ -394,22 +397,26 @@ export async function runGeneration(job: Job, params: VideoChainParams) {
})
}
- await ensureComfyReady(ready)
+ try {
+ await ensureComfyReady(ready)
- const { extensions, ...base } = params
- const autoRun = job.library?.queueAutoRun === true
- await queueMiniMax(job, {
- ...base,
- persist: extensions.length === 0 || !autoRun
- })
+ const { extensions, ...base } = params
+ const autoRun = job.library?.queueAutoRun === true
+ await queueMiniMax(job, {
+ ...base,
+ persist: extensions.length === 0 || !autoRun
+ })
- if (job.library?.queueId && !autoRun) {
- await finishQueueBurst(job.library.ownerKey, job.library.queueId, true).catch(() => null)
- setQueueJob(job.library.queueId, null)
+ if (job.library?.queueId && !autoRun) {
+ await finishQueueBurst(job.library.ownerKey, job.library.queueId, true).catch(() => null)
+ setQueueJob(job.library.queueId, null)
+ }
+
+ if (!extensions.length || !job.library || !autoRun) return
+ await continueQueuedExtensions(job, params, { maxShots: extensions.length, autoRun: true })
+ } finally {
+ await onLiveVideoSettled(job)
}
-
- if (!extensions.length || !job.library || !autoRun) return
- await continueQueuedExtensions(job, params, { maxShots: extensions.length, autoRun: true })
}
export async function startQueueBurst(owner: string, queueId: string, count: number | 'all') {
@@ -485,6 +492,8 @@ export async function startQueueBurst(owner: string, queueId: string, count: num
emitJob(job, { type: 'error', error: message, message })
await finishQueueBurst(owner, queue.id, false, message).catch(() => null)
setQueueJob(queue.id, null)
+ }).finally(() => {
+ void onLiveVideoSettled(job)
})
return { jobId: job.id, queueId: queue.id, count: n, chainTotal: queue.segments.length, chainStep: lastIndex + 1 }