Queue more video jobs while a long shot list is generating, and restore that job from Output.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-27 22:02:41 -05:00
co-authored by Cursor
parent e3cf2f4b53
commit dec1a49bef
9 changed files with 991 additions and 186 deletions
+22 -138
View File
@@ -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
}
})
+12
View File
@@ -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
}
})
+8
View File
@@ -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 }
})
+11
View File
@@ -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
})
@@ -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
})