Post stillId with video generate and do not queue I2V behind a phantom busy job.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-28 17:37:03 -05:00
co-authored by Cursor
parent b48531d3c2
commit 2171ad3b44
3 changed files with 150 additions and 32 deletions
+68 -13
View File
@@ -1825,6 +1825,7 @@ const outputPanel = ref<HTMLElement | null>(null)
const editLightbox = ref<HTMLElement | null>(null)
const editLightboxUrl = ref('')
const file = ref<File | null>(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<Blob>(`/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<Blob>(`/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<Blob>(`/api/library/clips/${item.id}/thumb`, { responseType: 'blob' })
@@ -4070,7 +4091,7 @@ async function loadDraft(draft: RetryDraft) {
try {
const blob = await $fetch<Blob>(`/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<Blob>(`/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<Blob>(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<Blob>(`/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.`