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:
+68
-13
@@ -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.`
|
||||
|
||||
@@ -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<string | null> = [null, null, null, null]
|
||||
if (useIdentityRefs) {
|
||||
for (const [index, ref] of referenceImages.entries()) {
|
||||
|
||||
+58
-16
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user