From 2171ad3b44258616e3b5fad447e5aa433159ca1f Mon Sep 17 00:00:00 2001 From: Towsty Date: Fri, 28 Aug 2026 17:37:03 -0500 Subject: [PATCH] Post stillId with video generate and do not queue I2V behind a phantom busy job. Co-authored-by: Cursor --- pages/index.vue | 81 +++++++++++++++++++++++++++++++------ server/api/generate.post.ts | 27 +++++++++++-- server/utils/studioQueue.ts | 74 +++++++++++++++++++++++++-------- 3 files changed, 150 insertions(+), 32 deletions(-) diff --git a/pages/index.vue b/pages/index.vue index 57cdb93..34afb31 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -1825,6 +1825,7 @@ const outputPanel = ref(null) const editLightbox = ref(null) const editLightboxUrl = ref('') const file = ref(null) +const inputStillId = ref('') const preview = ref('') const dragging = ref(false) const prompt = ref('') @@ -2106,14 +2107,24 @@ const liveElapsedLabel = computed(() => ( const playingLastSavedWhileBusy = computed(() => ( videoBusy.value && Boolean(videoUrl.value) && !watchingLibrary.value && !lockedSave.value )) +const hasInputStill = computed(() => { + if (file.value || inputStillId.value) return true + const selected = selectedLibraryEntries().filter(entry => entry.kind === 'still') + return selected.length === 1 && Boolean(selected[0].stillGroup?.head.id) +}) +const outputQueuedJob = computed(() => { + const id = outputStudioJobId.value + if (!id) return null + return studioJobs.value.find(job => job.id === id && (job.status === 'waiting' || job.status === 'held')) || null +}) function idleInputStatus(kind: 'video' | 'edit') { - if (kind === 'edit') return file.value ? 'Still ready. Edit when you are ready.' : 'Waiting for a still.' + if (kind === 'edit') return hasInputStill.value ? 'Still ready. Edit when you are ready.' : 'Waiting for a still.' if (textToVideo.value) { - return file.value + return hasInputStill.value ? 'Still loaded (optional). Generate when you are ready.' : 'Ready for text-to-video.' } - return file.value ? 'Still ready. Generate when you are ready.' : 'Waiting for a still.' + return hasInputStill.value ? 'Still ready. Generate when you are ready.' : 'Waiting for a still.' } const liveStatusMessage = computed(() => { @@ -2122,6 +2133,7 @@ const liveStatusMessage = computed(() => { return idleInputStatus('edit') } if (videoBusy.value || videoUrl.value || lockedSave.value) return statusMessage.value + if (outputQueuedJob.value) return statusMessage.value || 'Queued. Waiting for Beast to be free.' return idleInputStatus('video') }) const liveStatusBusy = computed(() => ( @@ -2240,7 +2252,7 @@ const canUseGeneratedStill = computed(() => Boolean( editResultUrl.value && !editLockedSave.value && !editAwaitingReveal.value )) const videoBlockReason = computed(() => { - if (!textToVideo.value && !file.value) return 'Load a still first. Image-to-video needs an input still.' + if (!textToVideo.value && !hasInputStill.value) return 'Load a still first. Image-to-video needs an input still.' if (!prompt.value.trim()) return 'Write a motion prompt first.' if (!folderId.value) return 'Choose a library folder before generating.' if (!queueReady.value) { @@ -3327,6 +3339,7 @@ async function copyPrompt(text: string) { function resetImage() { if (preview.value) URL.revokeObjectURL(preview.value) file.value = null + inputStillId.value = '' preview.value = '' imageWidth.value = 0 imageHeight.value = 0 @@ -3349,9 +3362,15 @@ function clearVideoForm() { activeDraftId = '' } -function readFile(next: File, opts?: { keepThumbnailPref?: boolean }) { +function stillIdFromLibraryUrl(url: string) { + const match = String(url || '').match(/\/api\/library\/stills\/([^/?#]+)/) + return match ? decodeURIComponent(match[1]) : '' +} + +function readFile(next: File, opts?: { keepThumbnailPref?: boolean; stillId?: string }) { if (preview.value) URL.revokeObjectURL(preview.value) file.value = next + inputStillId.value = opts?.stillId || '' preview.value = URL.createObjectURL(next) if (!opts?.keepThumbnailPref) applyThumbnailDefault() const img = new Image() @@ -3514,7 +3533,7 @@ function onPickedImage(next: File, hidden = false, stillId?: string) { return } if (hidden && showPrivacyToggles.value) hideInputPreview.value = true - readFile(next) + readFile(next, stillId ? { stillId } : undefined) } async function persistDroppedStill(next: File) { @@ -3524,7 +3543,9 @@ async function persistDroppedStill(next: File) { body.append('folderId', folderId.value) body.append('hideInput', String(hideInputPreview.value)) try { - applyLibrary(await $fetch('/api/library/stills', { method: 'POST', body })) + const result = await $fetch<{ still?: { id: string } }>('/api/library/stills', { method: 'POST', body }) + applyLibrary(result) + if (result.still?.id) inputStillId.value = result.still.id } catch (error: any) { toast(error?.data?.statusMessage || 'Could not save still to the library') } @@ -3765,7 +3786,7 @@ async function restoreStudioJob(id: string) { 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 }) + readFile(new File([blob], payload.stillFilename || 'still.png', { type: blob.type || 'image/png' }), { keepThumbnailPref: true, stillId }) } else { resetImage() } @@ -3969,7 +3990,7 @@ async function loadRerunImage(item: LibraryClip, parts: LibraryClip[]) { if (stillId) { const blob = await $fetch(`/api/library/stills/${stillId}`, { responseType: 'blob' }) if (still?.hideInput && showPrivacyToggles.value) hideInputPreview.value = true - readFile(new File([blob], still?.filename || 'still.png', { type: blob.type || 'image/png' }), { keepThumbnailPref: true }) + readFile(new File([blob], still?.filename || 'still.png', { type: blob.type || 'image/png' }), { keepThumbnailPref: true, stillId }) return true } const blob = await $fetch(`/api/library/clips/${item.id}/thumb`, { responseType: 'blob' }) @@ -4070,7 +4091,7 @@ async function loadDraft(draft: RetryDraft) { try { const blob = await $fetch(`/api/library/drafts/${draft.id}/still`, { responseType: 'blob' }) const filename = draft.stillFilename || 'held-still.png' - readFile(new File([blob], filename, { type: blob.type || 'image/png' }), { keepThumbnailPref: true }) + readFile(new File([blob], filename, { type: blob.type || 'image/png' }), { keepThumbnailPref: true, stillId: draft.stillId }) statusMessage.value = 'Held job restored. Generate when ComfyUI is free.' } catch { if (textToVideo.value) { @@ -4266,7 +4287,7 @@ async function useStillAsInput(still: LibraryStill) { try { const blob = await $fetch(`/api/library/stills/${still.id}`, { responseType: 'blob' }) if (still.hideInput && showPrivacyToggles.value) hideInputPreview.value = true - readFile(new File([blob], still.filename, { type: blob.type || 'image/png' })) + readFile(new File([blob], still.filename, { type: blob.type || 'image/png' }), { stillId: still.id }) toast('Loaded as input. It stays in Output, not the upload library.') } catch (error: any) { toast(error?.data?.statusMessage || 'Could not load that still') @@ -4801,7 +4822,9 @@ async function useEditAsInput() { try { const blob = await $fetch(editResultUrl.value, { responseType: 'blob' }) if (!blob || !blob.size) throw new Error('empty still') - readFile(new File([blob], `${editDownloadName.value || 'edited'}.png`, { type: blob.type || 'image/png' })) + readFile(new File([blob], `${editDownloadName.value || 'edited'}.png`, { type: blob.type || 'image/png' }), { + stillId: stillIdFromLibraryUrl(editResultUrl.value) + }) toast('Edited still is now the input') } catch { toast('Could not load that still as input') @@ -4833,6 +4856,25 @@ function applyVideoWorkflow(raw?: string) { minimaxGraph.value = minimaxGraphOf(parsed) } +async function ensureVideoInputStill() { + if (inputStillId.value && file.value) return + let stillId = inputStillId.value + if (!stillId) { + const selected = selectedLibraryEntries().filter(entry => entry.kind === 'still') + if (selected.length === 1) stillId = selected[0].stillGroup?.head.id || '' + } + if (!stillId) return + inputStillId.value = stillId + if (file.value) return + const blob = await $fetch(`/api/library/stills/${stillId}`, { responseType: 'blob' }) + if (!blob?.size) throw new Error('Could not load that still') + const still = stills.value.find(item => item.id === stillId) + readFile(new File([blob], still?.filename || 'still.png', { type: blob.type || 'image/png' }), { + keepThumbnailPref: true, + stillId + }) +} + async function generate() { if (!ltxEnabled.value && isLtxWorkflow(videoWorkflow.value)) { videoEngine.value = 'minimax' @@ -4848,6 +4890,16 @@ async function generate() { toast('Choose a library folder before generating.') return } + try { + await ensureVideoInputStill() + } catch { + toast('Could not load the selected still for image-to-video.') + return + } + if (!textToVideo.value && !file.value && !inputStillId.value) { + toast('Load a still first. Image-to-video needs an input still.') + return + } const shots = shotScriptMode.value ? parsedShots.value : null const initialPrompt = (shots?.[0]?.prompt || prompt.value).trim() const queued = shots @@ -4858,6 +4910,7 @@ async function generate() { try { const body = new FormData() if (file.value) body.append('image', file.value) + if (inputStillId.value) body.append('stillId', inputStillId.value) body.append('prompt', initialPrompt) body.append('aspect', aspect.value) body.append('width', String(outputSize.value.width)) @@ -4922,7 +4975,9 @@ async function generate() { body }) if (started.queued) { - clearVideoForm() + outputStudioJobId.value = started.studioJobId || '' + outputFocus.value = 'video' + statusMessage.value = 'Queued. Waiting for Beast to be free.' const n = 1 + queued.length toast(n > 1 ? `Queued ${n} shots behind the current job.` diff --git a/server/api/generate.post.ts b/server/api/generate.post.ts index a43f8cd..8791570 100644 --- a/server/api/generate.post.ts +++ b/server/api/generate.post.ts @@ -1,3 +1,4 @@ +import { existsSync, readFileSync } from 'node:fs' import { addStudioJob, kickStudioQueue, listStudioJobs } from '~/server/utils/studioQueue' import { listStudioLoras, parsePostedLoraStack, parseShotLoraStacks, persistLoraFields } from '~/server/utils/loras' import type { LoraStackItem } from '~/utils/loras' @@ -112,6 +113,27 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, statusMessage: LTX_DISABLED_MESSAGE }) } const textToVideo = isTextToVideo(workflow) + const ownerKey = libraryOwnerKey(event) + const postedStillId = (fields.stillId || '').trim() + let libraryStill: { id: string; filename: string } | null = null + if (postedStillId) { + const path = stillPath(ownerKey, postedStillId) + if (existsSync(path)) { + try { + const existing = getStill(ownerKey, postedStillId) + libraryStill = { id: existing.id, filename: existing.filename } + if (!image) { + image = { + filename: existing.filename || 'still.png', + data: readFileSync(path), + type: 'image/png' + } + } + } catch { + libraryStill = null + } + } + } if (!image && !textToVideo) { throw createError({ statusCode: 400, statusMessage: 'An initial image is required' }) } @@ -158,7 +180,6 @@ export default defineEventHandler(async (event) => { const hideInput = fields.hideInput === 'true' const sound = !isLtxWorkflow(workflow) && fields.sound !== 'false' const clipName = (fields.name || '').trim().slice(0, 80) - const ownerKey = libraryOwnerKey(event) const library = publicLibrary(event) const folderId = library.folders.some(folder => folder.id === fields.folderId) ? fields.folderId @@ -169,7 +190,7 @@ export default defineEventHandler(async (event) => { const destFolder = library.folders.find(folder => folder.id === folderId) const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked) assertFolderExists(event, folderId) - const still = image + const still = libraryStill || (image ? await rememberInputStill({ ownerKey, folderId, @@ -179,7 +200,7 @@ export default defineEventHandler(async (event) => { height, hideInput }) - : null + : null) const referenceStillIds: Array = [null, null, null, null] if (useIdentityRefs) { for (const [index, ref] of referenceImages.entries()) { diff --git a/server/utils/studioQueue.ts b/server/utils/studioQueue.ts index a366b37..e230bac 100644 --- a/server/utils/studioQueue.ts +++ b/server/utils/studioQueue.ts @@ -220,11 +220,35 @@ export function summarizeStudioJob(job: StudioJob) { } } -export function videoJobsBusy() { - if (listJobs().some(job => job.status === 'queued' || job.status === 'uploading' || job.status === 'running')) { - return true +const QUEUED_GRACE_MS = 4 * 60 * 1000 +const PENDING_ORPHAN_MS = 2 * 60 * 1000 + +function liveJobIsActive(job: Job) { + if (job.status === 'uploading' || job.status === 'running') return true + if (job.status === 'queued') return Date.now() - job.startedAt < QUEUED_GRACE_MS + return false +} + +function sweepStaleLiveJobs() { + const now = Date.now() + for (const job of listJobs()) { + if (job.status !== 'queued') continue + if (job.promptId) continue + if (now - job.startedAt < QUEUED_GRACE_MS) continue + job.status = 'error' + job.error = job.error || 'Job never started' } - return listPendingJobs().some(job => Boolean(job.promptId)) +} + +export function videoJobsBusy() { + sweepStaleLiveJobs() + if (listJobs().some(liveJobIsActive)) return true + return listPendingJobs().some((pending) => { + if (!pending.promptId) return false + const live = getJob(pending.jobId) + if (live) return liveJobIsActive(live) + return Date.now() - pending.startedAt < PENDING_ORPHAN_MS + }) } export function applyPersistedPauseToJob(job: Job) { @@ -534,7 +558,7 @@ function repairStaleJobs(jobs: StudioJob[]) { } async function dispatchStudioQueue() { - if (videoJobsBusy()) return + sweepStaleLiveJobs() for (const owner of listOwnersWithStudioQueues()) { const store = await mutateStore(owner, (current) => { migrateGlobalPause(current) @@ -547,17 +571,22 @@ async function dispatchStudioQueue() { jobs: current.jobs.map(item => structuredClone(item)) } }) + if (videoJobsBusy()) return const cutIn = pickCutIn(store.jobs) if (cutIn) { await startStudioJob(cutIn) return } + const waiting = pickWaiting(store.jobs) const held = pickHeld(store.jobs) - if (held?.shotQueueId) { - await resumeHeldStudioJob(held) + if (waiting && (!held || waiting.createdAt >= held.updatedAt)) { + await startStudioJob(waiting) return } - const waiting = pickWaiting(store.jobs) + if (held?.shotQueueId) { + const resumed = await resumeHeldStudioJob(held) + if (resumed) return + } if (waiting) { await startStudioJob(waiting) return @@ -571,7 +600,7 @@ async function resumeHeldStudioJob(item: StudioJob) { job.status = 'complete' job.holdForCutIn = false }) - return + return false } const { startQueueBurst } = await import('~/server/utils/videoChain') const count = item.resumeAutoRun ? 'all' as const : 1 @@ -583,6 +612,7 @@ async function resumeHeldStudioJob(item: StudioJob) { job.holdForCutIn = false job.cutIn = false }) + return true } catch (error) { const message = error instanceof Error ? error.message : String(error) await patchStudioJob(item.ownerKey, item.id, (job) => { @@ -590,10 +620,12 @@ async function resumeHeldStudioJob(item: StudioJob) { job.lastError = message job.holdForCutIn = false }) + return false } } async function startStudioEditJob(item: StudioJob) { + let live: Job | undefined try { const { stillPath } = await import('~/server/utils/library') const { existsSync, readFileSync } = await import('node:fs') @@ -618,7 +650,7 @@ async function startStudioEditJob(item: StudioJob) { : null const passes = payload.passes || [] - const job = createEditLiveJob({ + live = createEditLiveJob({ steps: payload.steps, hideThumbnail: payload.hideThumbnail, library: { @@ -648,28 +680,32 @@ async function startStudioEditJob(item: StudioJob) { } }) - await markStudioLive(item.ownerKey, item.id, job.id) - void runEdit(job, { + await markStudioLive(item.ownerKey, item.id, live.id) + void runEdit(live, { image, reference, prompt: payload.prompt, passes, negative: payload.negative || '', steps: payload.steps, - seed: job.library?.seed || payload.seed, + seed: live.library?.seed || payload.seed, cfg: payload.cfg, scaleToTotalPixels: payload.scaleToTotalPixels === true, scaleMegapixels: payload.scaleMegapixels, ...persistLoraFields(payload.loraStack || payload.loraName) }).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 + if (live && live.status !== 'error' && live.status !== 'cancelled' && live.status !== 'deferred') { + live.status = 'error' + live.error = message } }) } catch (error) { const message = error instanceof Error ? error.message : String(error) + if (live && (live.status === 'queued' || live.status === 'uploading' || live.status === 'running')) { + live.status = 'error' + live.error = message + } await patchStudioJob(item.ownerKey, item.id, (job) => { job.status = 'error' job.lastError = message @@ -683,6 +719,7 @@ export async function startStudioJob(item: StudioJob) { await startStudioEditJob(item) return } + let live: Job | undefined try { const { createJob } = await import('~/server/utils/jobs') const { runGeneration, frameLength } = await import('~/server/utils/videoChain') @@ -702,6 +739,7 @@ export async function startStudioJob(item: StudioJob) { payload.shotPermanenceRefs ) const job = createJob() + live = job job.kind = 'video' job.maxStep = payload.steps job.hideThumbnail = payload.hideThumbnail @@ -840,6 +878,10 @@ export async function startStudioJob(item: StudioJob) { }) } catch (error) { const message = error instanceof Error ? error.message : String(error) + if (live && (live.status === 'queued' || live.status === 'uploading' || live.status === 'running')) { + live.status = 'error' + live.error = message + } await patchStudioJob(item.ownerKey, item.id, (job) => { job.status = 'error' job.lastError = message