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:
+319
-33
@@ -25,9 +25,9 @@
|
|||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/queue"
|
to="/queue"
|
||||||
class="rounded-full border px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white"
|
class="rounded-full border px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white"
|
||||||
:class="queuePendingCount ? 'border-amber-300/40 text-amber-100' : 'border-white/10'"
|
:class="queueBadgeCount ? 'border-amber-300/40 text-amber-100' : 'border-white/10'"
|
||||||
>
|
>
|
||||||
Queue<span v-if="queuePendingCount" class="ml-1.5 text-amber-200">{{ queuePendingCount }}</span>
|
Queue<span v-if="queueBadgeCount" class="ml-1.5 text-amber-200">{{ queueBadgeCount }}</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<a v-if="authEnabled" href="/api/auth/logout" class="text-zinc-400 hover:text-white">Sign out</a>
|
<a v-if="authEnabled" href="/api/auth/logout" class="text-zinc-400 hover:text-white">Sign out</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<h2 class="font-display text-2xl font-bold">Input</h2>
|
<h2 class="font-display text-2xl font-bold">Input</h2>
|
||||||
<p class="text-sm text-zinc-400">{{ studioMode === 'edit'
|
<p class="text-sm text-zinc-400">{{ studioMode === 'edit'
|
||||||
? 'Drop the still to edit. An optional second still applies image 2 onto image 1. Files leave the desktop after save.'
|
? '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.' }}</p>
|
: '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.' }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -660,8 +660,50 @@
|
|||||||
Cancel / Interrupt
|
Cancel / Interrupt
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="studioMode === 'video' && videoBusy" class="text-xs text-zinc-400">The current job keeps running in Output. This submit waits in the job queue.</p>
|
||||||
<p v-if="studioMode === 'edit' && editBlockReason" class="text-xs text-zinc-400">{{ editBlockReason }}</p>
|
<p v-if="studioMode === 'edit' && editBlockReason" class="text-xs text-zinc-400">{{ editBlockReason }}</p>
|
||||||
|
|
||||||
|
<div v-if="studioMode === 'video' && studioJobs.length" class="rounded-2xl border border-white/10 bg-zinc-950/40 p-4">
|
||||||
|
<h3 class="text-sm font-semibold text-zinc-100">Job queue</h3>
|
||||||
|
<p class="mt-1 text-xs text-zinc-500">A long shot script is one slot. Run next inserts after the current shot as its own clip, then the original sequence continues.</p>
|
||||||
|
<ul class="mt-3 space-y-2">
|
||||||
|
<li
|
||||||
|
v-for="job in studioJobs"
|
||||||
|
:key="job.id"
|
||||||
|
class="flex items-start gap-3 rounded-xl border px-3 py-2"
|
||||||
|
:class="job.status === 'running'
|
||||||
|
? 'border-amber-300/40 bg-amber-400/5'
|
||||||
|
: job.cutIn
|
||||||
|
? 'border-sky-300/30 bg-sky-400/5'
|
||||||
|
: 'border-white/10 bg-zinc-950/50'"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="truncate text-sm font-medium text-zinc-100">{{ job.name }}</p>
|
||||||
|
<p class="mt-0.5 text-xs text-zinc-500">
|
||||||
|
{{ studioJobStatusLabel(job) }}
|
||||||
|
· {{ job.shotCount }} shot{{ job.shotCount === 1 ? '' : 's' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="job.status === 'waiting' && canCutInStudioJob"
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded-full border border-sky-300/40 px-2.5 py-1 text-xs text-sky-100 hover:border-sky-200 hover:text-white"
|
||||||
|
@click="cutInStudioJob(job.id)"
|
||||||
|
>
|
||||||
|
{{ job.cutIn ? 'Next' : 'Run next' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="job.status === 'waiting' || job.status === 'held'"
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 text-xs text-zinc-400 hover:text-red-200"
|
||||||
|
@click="dismissStudioJob(job.id)"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="drafts.length" class="rounded-2xl border border-amber-300/20 bg-amber-400/5 p-4">
|
<div v-if="drafts.length" class="rounded-2xl border border-amber-300/20 bg-amber-400/5 p-4">
|
||||||
<h3 class="text-sm font-semibold text-amber-100">Held jobs</h3>
|
<h3 class="text-sm font-semibold text-amber-100">Held jobs</h3>
|
||||||
<p class="mt-1 text-xs text-zinc-400">Comfy was still busy after 3 minutes. Click one to restore the still, prompt, and settings.</p>
|
<p class="mt-1 text-xs text-zinc-400">Comfy was still busy after 3 minutes. Click one to restore the still, prompt, and settings.</p>
|
||||||
@@ -686,6 +728,17 @@
|
|||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<h2 class="font-display text-2xl font-bold">Output</h2>
|
<h2 class="font-display text-2xl font-bold">Output</h2>
|
||||||
|
<button
|
||||||
|
v-if="canRestoreOutput"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/15 text-zinc-300 hover:border-amber-300/50 hover:text-white"
|
||||||
|
title="Load this job back into Input"
|
||||||
|
@click="restoreOutputJob"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
|
||||||
|
<path d="M13.586 3.586a2 2 0 1 1 2.828 2.828l-.793.793-2.828-2.828.793-.793ZM11.379 5.793 3 14.172V17h2.828l8.38-8.379-2.83-2.828Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="canClearPreview"
|
v-if="canClearPreview"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1582,6 +1635,22 @@ const liveQueue = ref<{
|
|||||||
totalCount: number
|
totalCount: number
|
||||||
segments: Array<{ index: number; prompt: string; duration: number; status: string; clipId?: string }>
|
segments: Array<{ index: number; prompt: string; duration: number; status: string; clipId?: string }>
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
const studioJobs = ref<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
|
||||||
|
}>>([])
|
||||||
|
const outputStudioJobId = ref('')
|
||||||
const pickerOpen = ref(false)
|
const pickerOpen = ref(false)
|
||||||
const pickerSlot = ref<'main' | 'editRef' | number>('main')
|
const pickerSlot = ref<'main' | 'editRef' | number>('main')
|
||||||
const libraryFilter = ref<'all' | 'images' | 'videos'>('all')
|
const libraryFilter = ref<'all' | 'images' | 'videos'>('all')
|
||||||
@@ -1758,7 +1827,7 @@ const queueReady = computed(() => {
|
|||||||
return extensionQueue.value.every(item => item.prompt.trim())
|
return extensionQueue.value.every(item => item.prompt.trim())
|
||||||
})
|
})
|
||||||
const videoGenerateDisabled = computed(() => {
|
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(() => {
|
const recommendDisabled = computed(() => {
|
||||||
if (recommendBusy.value) return true
|
if (recommendBusy.value) return true
|
||||||
@@ -1789,10 +1858,19 @@ const editBlockReason = computed(() => {
|
|||||||
})
|
})
|
||||||
const generateLabel = computed(() => {
|
const generateLabel = computed(() => {
|
||||||
const n = queuedExtensionCount.value
|
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'
|
if (!n) return 'Generate Video'
|
||||||
return n === 1 ? 'Generate Initial + 1 Extension' : `Generate Initial + ${n} Extensions`
|
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(() => (
|
const queuedExtensionCount = computed(() => (
|
||||||
shotScriptMode.value ? Math.max(0, parsedShots.value.length - 1) : extensionQueue.value.length
|
shotScriptMode.value ? Math.max(0, parsedShots.value.length - 1) : extensionQueue.value.length
|
||||||
))
|
))
|
||||||
@@ -2153,6 +2231,7 @@ async function loadLibrary() {
|
|||||||
])
|
])
|
||||||
applyLibrary(data)
|
applyLibrary(data)
|
||||||
queuePendingCount.value = queued.pendingCount || 0
|
queuePendingCount.value = queued.pendingCount || 0
|
||||||
|
await refreshStudioQueue()
|
||||||
await refreshLiveQueue()
|
await refreshLiveQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2226,9 +2305,11 @@ onMounted(async () => {
|
|||||||
applyInstancePrivacy()
|
applyInstancePrivacy()
|
||||||
healthTimer = setInterval(() => {
|
healthTimer = setInterval(() => {
|
||||||
void pollComfyHealth()
|
void pollComfyHealth()
|
||||||
|
void refreshStudioQueue()
|
||||||
if (activeQueueId.value) void refreshLiveQueue()
|
if (activeQueueId.value) void refreshLiveQueue()
|
||||||
}, 5000)
|
}, 5000)
|
||||||
await resumeActiveJob()
|
await resumeActiveJob()
|
||||||
|
await refreshStudioQueue()
|
||||||
await refreshLiveQueue()
|
await refreshLiveQueue()
|
||||||
await loadPromptPresets()
|
await loadPromptPresets()
|
||||||
})
|
})
|
||||||
@@ -2502,6 +2583,19 @@ function resetImage() {
|
|||||||
imageHeight.value = 0
|
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 }) {
|
function readFile(next: File, opts?: { keepThumbnailPref?: boolean }) {
|
||||||
file.value = next
|
file.value = next
|
||||||
preview.value = URL.createObjectURL(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<string | null>
|
||||||
|
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<Blob>(`/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<Blob>(`/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') {
|
function clearActiveJob(kind?: 'video' | 'edit') {
|
||||||
try {
|
try {
|
||||||
if (!kind || kind === 'video') localStorage.removeItem(JOB_STORE)
|
if (!kind || kind === 'video') localStorage.removeItem(JOB_STORE)
|
||||||
@@ -3528,27 +3782,8 @@ async function generate() {
|
|||||||
const queued = shots
|
const queued = shots
|
||||||
? shots.slice(1).map(shot => ({ prompt: shot.prompt.trim(), duration: duration.value }))
|
? shots.slice(1).map(shot => ({ prompt: shot.prompt.trim(), duration: duration.value }))
|
||||||
: extensionQueue.value.map(item => ({ prompt: item.prompt.trim(), duration: item.duration }))
|
: extensionQueue.value.map(item => ({ prompt: item.prompt.trim(), duration: item.duration }))
|
||||||
statusBusy.value = false
|
const hideOut = hideThumbnail.value
|
||||||
videoBusy.value = true
|
const folderLockedPref = false
|
||||||
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')
|
|
||||||
try {
|
try {
|
||||||
const body = new FormData()
|
const body = new FormData()
|
||||||
if (file.value) body.append('image', file.value)
|
if (file.value) body.append('image', file.value)
|
||||||
@@ -3581,22 +3816,68 @@ async function generate() {
|
|||||||
body.append('extensions', JSON.stringify(queued))
|
body.append('extensions', JSON.stringify(queued))
|
||||||
body.append('queueAutoRun', String(queueAutoRun.value))
|
body.append('queueAutoRun', String(queueAutoRun.value))
|
||||||
}
|
}
|
||||||
videoDownloadName.value = clipFileName(clipName.value.trim() || initialPrompt.split(/[.!?\n]/)[0] || 'aigen')
|
const downloadName = 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 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',
|
method: 'POST',
|
||||||
body
|
body
|
||||||
})
|
})
|
||||||
videoJobId.value = started.jobId
|
if (started.queued) {
|
||||||
maxStep.value = started.steps
|
clearVideoForm()
|
||||||
playerSize.value = { width: started.width, height: started.height }
|
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
|
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) {
|
if (started.queueId) {
|
||||||
persistActiveQueue(started.queueId)
|
persistActiveQueue(started.queueId)
|
||||||
void refreshLiveQueue()
|
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 pollComfyHealth()
|
||||||
|
void refreshStudioQueue()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
videoBusy.value = false
|
videoBusy.value = false
|
||||||
statusBusy.value = false
|
statusBusy.value = false
|
||||||
@@ -3761,6 +4042,7 @@ function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLo
|
|||||||
clearActiveJob('video')
|
clearActiveJob('video')
|
||||||
loadLibrary().catch(() => null)
|
loadLibrary().catch(() => null)
|
||||||
void pollComfyHealth()
|
void pollComfyHealth()
|
||||||
|
void refreshStudioQueue()
|
||||||
toast(payload.message || 'Comfy was still busy. This job was saved under Held jobs.')
|
toast(payload.message || 'Comfy was still busy. This job was saved under Held jobs.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -3794,6 +4076,7 @@ function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLo
|
|||||||
clearActiveJob('video')
|
clearActiveJob('video')
|
||||||
activeDraftId = ''
|
activeDraftId = ''
|
||||||
void pollComfyHealth()
|
void pollComfyHealth()
|
||||||
|
void refreshStudioQueue()
|
||||||
videoSettledUi = true
|
videoSettledUi = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -3812,6 +4095,7 @@ function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLo
|
|||||||
activeDraftId = ''
|
activeDraftId = ''
|
||||||
loadLibrary().catch(() => null)
|
loadLibrary().catch(() => null)
|
||||||
void pollComfyHealth()
|
void pollComfyHealth()
|
||||||
|
void refreshStudioQueue()
|
||||||
statusMessage.value = currentClip.value?.hideThumbnail ? 'Hidden clip ready' : 'Loaded from library'
|
statusMessage.value = currentClip.value?.hideThumbnail ? 'Hidden clip ready' : 'Loaded from library'
|
||||||
toast(locked
|
toast(locked
|
||||||
? 'Saved to the locked folder. Unlock it to view.'
|
? 'Saved to the locked folder. Unlock it to view.'
|
||||||
@@ -3846,6 +4130,7 @@ function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLo
|
|||||||
activeDraftId = ''
|
activeDraftId = ''
|
||||||
loadLibrary().catch(() => null)
|
loadLibrary().catch(() => null)
|
||||||
void pollComfyHealth()
|
void pollComfyHealth()
|
||||||
|
void refreshStudioQueue()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3858,6 +4143,7 @@ function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLo
|
|||||||
clearActiveJob('video')
|
clearActiveJob('video')
|
||||||
loadLibrary().catch(() => null)
|
loadLibrary().catch(() => null)
|
||||||
void pollComfyHealth()
|
void pollComfyHealth()
|
||||||
|
void refreshStudioQueue()
|
||||||
toast(payload.error || payload.message || 'Generation failed')
|
toast(payload.error || payload.message || 'Generation failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-138
@@ -1,14 +1,6 @@
|
|||||||
import { frameLength, runGeneration } from '~/server/utils/videoChain'
|
import { addStudioJob, kickStudioQueue, listStudioJobs } from '~/server/utils/studioQueue'
|
||||||
import { createShotQueue, finishQueueBurst, setQueueJob } from '~/server/utils/shotQueue'
|
|
||||||
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, parseVideoWorkflow } from '~/utils/videoModels'
|
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) {
|
function parseDuration(raw: unknown) {
|
||||||
const seconds = Number(raw)
|
const seconds = Number(raw)
|
||||||
if (!Number.isFinite(seconds)) return 5
|
if (!Number.isFinite(seconds)) return 5
|
||||||
@@ -114,7 +106,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
const cfg = parseCfg(fields.cfg, turbo, isLtxWorkflow(workflow))
|
const cfg = parseCfg(fields.cfg, turbo, isLtxWorkflow(workflow))
|
||||||
const samplerName = parseSampler(fields.sampler_name, isLtxWorkflow(workflow))
|
const samplerName = parseSampler(fields.sampler_name, isLtxWorkflow(workflow))
|
||||||
const scheduler = parseScheduler(fields.scheduler)
|
const scheduler = parseScheduler(fields.scheduler)
|
||||||
const length = frameLength(durationSeconds, fps)
|
|
||||||
const hideThumbnail = fields.hideThumbnail === 'true'
|
const hideThumbnail = fields.hideThumbnail === 'true'
|
||||||
const hideInput = fields.hideInput === 'true'
|
const hideInput = fields.hideInput === 'true'
|
||||||
const sound = !isLtxWorkflow(workflow) && fields.sound !== 'false'
|
const sound = !isLtxWorkflow(workflow) && fields.sound !== 'false'
|
||||||
@@ -159,156 +150,49 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chainTotal = 1 + extensions.length
|
const chainTotal = 1 + extensions.length
|
||||||
const job = createJob()
|
|
||||||
job.kind = 'video'
|
|
||||||
job.maxStep = steps
|
|
||||||
job.hideThumbnail = hideThumbnail
|
|
||||||
const familyId = crypto.randomUUID()
|
const familyId = crypto.randomUUID()
|
||||||
const queueAutoRun = fields.queueAutoRun === 'true'
|
const queueAutoRun = fields.queueAutoRun === 'true'
|
||||||
job.library = {
|
const studio = await addStudioJob({
|
||||||
ownerKey,
|
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,
|
familyId,
|
||||||
workflow,
|
payload: {
|
||||||
useIdentityRefs,
|
prompt,
|
||||||
queueAutoRun: extensions.length ? queueAutoRun : false,
|
|
||||||
queueBudget: extensions.length && queueAutoRun ? extensions.length : 0
|
|
||||||
}
|
|
||||||
if (extensions.length) {
|
|
||||||
const queue = await createShotQueue({
|
|
||||||
ownerKey,
|
|
||||||
name: clipName,
|
name: clipName,
|
||||||
familyId,
|
|
||||||
folderId,
|
folderId,
|
||||||
autoRun: queueAutoRun,
|
|
||||||
stillId: still?.id,
|
|
||||||
stillFilename: still?.filename,
|
|
||||||
hideThumbnail,
|
|
||||||
hideInput,
|
|
||||||
aspect: fields.aspect || 'auto',
|
aspect: fields.aspect || 'auto',
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
steps,
|
steps,
|
||||||
turbo,
|
turbo,
|
||||||
|
seed,
|
||||||
cfg,
|
cfg,
|
||||||
fps,
|
fps,
|
||||||
samplerName,
|
samplerName,
|
||||||
scheduler,
|
scheduler,
|
||||||
workflow,
|
duration: durationSeconds,
|
||||||
sound,
|
sound,
|
||||||
|
workflow,
|
||||||
useIdentityRefs,
|
useIdentityRefs,
|
||||||
|
stillId: still?.id,
|
||||||
|
stillFilename: still?.filename,
|
||||||
|
hideThumbnail,
|
||||||
|
hideInput,
|
||||||
|
folderLocked,
|
||||||
referenceStillIds,
|
referenceStillIds,
|
||||||
initial: { prompt, duration: durationSeconds },
|
|
||||||
extensions,
|
extensions,
|
||||||
jobId: job.id
|
queueAutoRun
|
||||||
})
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
await kickStudioQueue()
|
||||||
|
const latest = listStudioJobs(ownerKey).find(item => item.id === studio.id)
|
||||||
|
const liveJobId = latest?.liveJobId || ''
|
||||||
|
const queueId = latest?.shotQueueId
|
||||||
|
|
||||||
return {
|
return {
|
||||||
jobId: job.id,
|
jobId: liveJobId || studio.id,
|
||||||
clientId: job.clientId,
|
studioJobId: studio.id,
|
||||||
|
queued: !liveJobId,
|
||||||
|
clientId: '',
|
||||||
seed,
|
seed,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
@@ -323,7 +207,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
hideThumbnail,
|
hideThumbnail,
|
||||||
folderLocked,
|
folderLocked,
|
||||||
chainTotal,
|
chainTotal,
|
||||||
queueId: job.library?.queueId,
|
queueId,
|
||||||
queuedRemaining: extensions.length
|
queuedRemaining: extensions.length
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
})
|
||||||
@@ -1,11 +1,22 @@
|
|||||||
import { listPendingJobs } from '~/server/utils/pending'
|
import { listPendingJobs } from '~/server/utils/pending'
|
||||||
import { listOwnersWithQueues, listShotQueues, pendingSegmentCount } from '~/server/utils/shotQueue'
|
import { listOwnersWithQueues, listShotQueues, pendingSegmentCount } from '~/server/utils/shotQueue'
|
||||||
|
import { kickStudioQueue, listOwnersWithStudioQueues, listStudioJobs } from '~/server/utils/studioQueue'
|
||||||
import { startQueueBurst } from '~/server/utils/videoChain'
|
import { startQueueBurst } from '~/server/utils/videoChain'
|
||||||
|
|
||||||
export default defineNitroPlugin(() => {
|
export default defineNitroPlugin(() => {
|
||||||
const pendingQueueIds = new Set(listPendingJobs().map(job => job.queueId).filter(Boolean))
|
const pendingQueueIds = new Set(listPendingJobs().map(job => job.queueId).filter(Boolean))
|
||||||
|
const claimedQueueIds = new Set<string>()
|
||||||
|
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 owner of listOwnersWithQueues()) {
|
||||||
for (const queue of listShotQueues(owner)) {
|
for (const queue of listShotQueues(owner)) {
|
||||||
|
if (claimedQueueIds.has(queue.id)) continue
|
||||||
if (!queue.autoRun) continue
|
if (!queue.autoRun) continue
|
||||||
if (queue.status === 'complete') continue
|
if (queue.status === 'complete') continue
|
||||||
if (pendingQueueIds.has(queue.id)) continue
|
if (pendingQueueIds.has(queue.id)) continue
|
||||||
@@ -13,4 +24,5 @@ export default defineNitroPlugin(() => {
|
|||||||
void startQueueBurst(owner, queue.id, 'all').catch(() => null)
|
void startQueueBurst(owner, queue.id, 'all').catch(() => null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
void kickStudioQueue()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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<string | null>
|
||||||
|
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<string, Promise<unknown>>()
|
||||||
|
let dispatchChain: Promise<unknown> = 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<T>(owner: string, fn: (jobs: StudioJob[]) => T): Promise<T> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
+24
-15
@@ -9,6 +9,7 @@ import { isLtxWorkflow, isTextToVideo, parseVideoWorkflow, workflowForExtension,
|
|||||||
import { clipVideoPath, clipTitle, deleteRetryDraft, extendTempDir, getClip, nextClipPartName, removeExtendTemp, stillPath } from '~/server/utils/library'
|
import { clipVideoPath, clipTitle, deleteRetryDraft, extendTempDir, getClip, nextClipPartName, removeExtendTemp, stillPath } from '~/server/utils/library'
|
||||||
import { extractLastFrame, probeHasAudio } from '~/server/utils/ffmpeg'
|
import { extractLastFrame, probeHasAudio } from '~/server/utils/ffmpeg'
|
||||||
import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
|
import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
|
||||||
|
import { onLiveVideoSettled } from '~/server/utils/studioQueue'
|
||||||
import {
|
import {
|
||||||
finishQueueBurst,
|
finishQueueBurst,
|
||||||
getShotQueue,
|
getShotQueue,
|
||||||
@@ -252,7 +253,7 @@ export async function continueQueuedExtensions(
|
|||||||
for (let i = startFrom; i < extensions.length; i++) {
|
for (let i = startFrom; i < extensions.length; i++) {
|
||||||
if (shotsLeft <= 0) break
|
if (shotsLeft <= 0) break
|
||||||
assertJobActive(job)
|
assertJobActive(job)
|
||||||
if (job.library.stopAfterCurrent && i > startFrom) break
|
if (job.library.stopAfterCurrent) break
|
||||||
|
|
||||||
const liveQueue = job.library.queueId
|
const liveQueue = job.library.queueId
|
||||||
? getShotQueue(job.library.ownerKey, 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) job.library.chainContinuing = false
|
||||||
if (job.library?.queueId) {
|
if (job.library?.queueId) {
|
||||||
const paused = job.status !== 'error' && job.status !== 'cancelled'
|
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(
|
await finishQueueBurst(
|
||||||
job.library.ownerKey,
|
job.library.ownerKey,
|
||||||
job.library.queueId,
|
job.library.queueId,
|
||||||
paused && !job.library.queueAutoRun,
|
hold || (paused && !job.library.queueAutoRun),
|
||||||
job.status === 'error' || job.status === 'cancelled' ? (job.error || 'Stopped') : undefined
|
job.status === 'error' || job.status === 'cancelled' ? (job.error || 'Stopped') : undefined
|
||||||
).catch(() => null)
|
).catch(() => null)
|
||||||
setQueueJob(job.library.queueId, 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 { extensions, ...base } = params
|
||||||
const autoRun = job.library?.queueAutoRun === true
|
const autoRun = job.library?.queueAutoRun === true
|
||||||
await queueMiniMax(job, {
|
await queueMiniMax(job, {
|
||||||
...base,
|
...base,
|
||||||
persist: extensions.length === 0 || !autoRun
|
persist: extensions.length === 0 || !autoRun
|
||||||
})
|
})
|
||||||
|
|
||||||
if (job.library?.queueId && !autoRun) {
|
if (job.library?.queueId && !autoRun) {
|
||||||
await finishQueueBurst(job.library.ownerKey, job.library.queueId, true).catch(() => null)
|
await finishQueueBurst(job.library.ownerKey, job.library.queueId, true).catch(() => null)
|
||||||
setQueueJob(job.library.queueId, 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') {
|
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 })
|
emitJob(job, { type: 'error', error: message, message })
|
||||||
await finishQueueBurst(owner, queue.id, false, message).catch(() => null)
|
await finishQueueBurst(owner, queue.id, false, message).catch(() => null)
|
||||||
setQueueJob(queue.id, 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 }
|
return { jobId: job.id, queueId: queue.id, count: n, chainTotal: queue.segments.length, chainStep: lastIndex + 1 }
|
||||||
|
|||||||
Reference in New Issue
Block a user