Keep remaining multi-shot clips on a Queue page so prompts can be edited between shots, with process-next or overnight run-all.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+36
-6
@@ -22,6 +22,13 @@
|
|||||||
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white" @click="settingsOpen = true">
|
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white" @click="settingsOpen = true">
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
|
<NuxtLink
|
||||||
|
to="/queue"
|
||||||
|
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'"
|
||||||
|
>
|
||||||
|
Queue<span v-if="queuePendingCount" class="ml-1.5 text-amber-200">{{ queuePendingCount }}</span>
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
@@ -519,6 +526,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<label
|
||||||
|
v-if="studioMode === 'video' && queuedExtensionCount > 0"
|
||||||
|
class="flex cursor-pointer items-start gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3"
|
||||||
|
>
|
||||||
|
<input v-model="queueAutoRun" type="checkbox" class="mt-1">
|
||||||
|
<span>
|
||||||
|
<span class="block text-sm font-medium text-zinc-200">Run remaining shots automatically</span>
|
||||||
|
<span class="mt-0.5 block text-xs text-zinc-500">Overnight set-and-forget. Leave this off to edit prompts on the Queue page and process the next shot, or the next few, when you are ready.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1414,6 +1431,8 @@ const renameDraft = ref('')
|
|||||||
const renameInput = ref<HTMLInputElement | null>(null)
|
const renameInput = ref<HTMLInputElement | null>(null)
|
||||||
const unlockPassword = ref('')
|
const unlockPassword = ref('')
|
||||||
const settingsOpen = ref(false)
|
const settingsOpen = ref(false)
|
||||||
|
const queueAutoRun = ref(false)
|
||||||
|
const queuePendingCount = ref(0)
|
||||||
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')
|
||||||
@@ -1588,11 +1607,14 @@ const editBlockReason = computed(() => {
|
|||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
const generateLabel = computed(() => {
|
const generateLabel = computed(() => {
|
||||||
const n = shotScriptMode.value ? Math.max(0, parsedShots.value.length - 1) : extensionQueue.value.length
|
const n = queuedExtensionCount.value
|
||||||
if (videoBusy.value) return 'Generating…'
|
if (videoBusy.value) return 'Generating…'
|
||||||
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 queuedExtensionCount = computed(() => (
|
||||||
|
shotScriptMode.value ? Math.max(0, parsedShots.value.length - 1) : extensionQueue.value.length
|
||||||
|
))
|
||||||
const editLabel = computed(() => {
|
const editLabel = computed(() => {
|
||||||
const n = editPassQueue.value.length
|
const n = editPassQueue.value.length
|
||||||
if (!n) return 'Edit image'
|
if (!n) return 'Edit image'
|
||||||
@@ -1889,8 +1911,12 @@ function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[];
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadLibrary() {
|
async function loadLibrary() {
|
||||||
const data = await $fetch<{ folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[] }>('/api/library')
|
const [data, queued] = await Promise.all([
|
||||||
|
$fetch<{ folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[] }>('/api/library'),
|
||||||
|
$fetch<{ pendingCount?: number }>('/api/queues').catch(() => ({ pendingCount: 0 }))
|
||||||
|
])
|
||||||
applyLibrary(data)
|
applyLibrary(data)
|
||||||
|
queuePendingCount.value = queued.pendingCount || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyComfyHealth(health: {
|
function applyComfyHealth(health: {
|
||||||
@@ -3113,6 +3139,7 @@ async function generate() {
|
|||||||
if (activeDraftId) body.append('draftId', activeDraftId)
|
if (activeDraftId) body.append('draftId', activeDraftId)
|
||||||
if (queued.length) {
|
if (queued.length) {
|
||||||
body.append('extensions', JSON.stringify(queued))
|
body.append('extensions', JSON.stringify(queued))
|
||||||
|
body.append('queueAutoRun', String(queueAutoRun.value))
|
||||||
}
|
}
|
||||||
videoDownloadName.value = clipFileName(clipName.value.trim() || initialPrompt.split(/[.!?\n]/)[0] || 'aigen')
|
videoDownloadName.value = 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 }>('/api/generate', {
|
const started = await $fetch<{ jobId: string; seed: number; width: number; height: number; steps: number; turbo: boolean; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/generate', {
|
||||||
@@ -3295,7 +3322,8 @@ function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLo
|
|||||||
if (complete && (payload.clipId || payload.filename)) {
|
if (complete && (payload.clipId || payload.filename)) {
|
||||||
const total = Math.max(1, Number(payload.chainTotal || 0) || 0, Number(chainTotal.value || 0) || 0)
|
const total = Math.max(1, Number(payload.chainTotal || 0) || 0, Number(chainTotal.value || 0) || 0)
|
||||||
const step = Math.max(1, Number(payload.chainStep || 0) || 0, Number(chainStep.value || 0) || 0)
|
const step = Math.max(1, Number(payload.chainStep || 0) || 0, Number(chainStep.value || 0) || 0)
|
||||||
if (total > 1 && step < total) {
|
const remaining = Number(payload.queuedRemaining || 0)
|
||||||
|
if (remaining > 0 || (total > 1 && step < total)) {
|
||||||
completedChainStep.value = Math.max(completedChainStep.value, step)
|
completedChainStep.value = Math.max(completedChainStep.value, step)
|
||||||
loadLibrary().catch(() => null)
|
loadLibrary().catch(() => null)
|
||||||
const dest = folders.value.find(folder => folder.id === folderId.value)
|
const dest = folders.value.find(folder => folder.id === folderId.value)
|
||||||
@@ -3307,9 +3335,11 @@ function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLo
|
|||||||
videoUrl.value = clipVideoUrl(payload.clipId)
|
videoUrl.value = clipVideoUrl(payload.clipId)
|
||||||
concealOutput.value = hidden || payload.hideThumbnail === true
|
concealOutput.value = hidden || payload.hideThumbnail === true
|
||||||
}
|
}
|
||||||
statusMessage.value = `Shot ${step} of ${total} saved, but the remaining shots did not start.`
|
const left = remaining || Math.max(0, total - step)
|
||||||
progress.value = Math.min(Number(payload.progress || progress.value || 99), 99)
|
statusMessage.value = `Shot ${step} of ${total} saved. ${left} waiting on Queue.`
|
||||||
toast(`Shot ${step} of ${total} saved. Remaining shots did not run — extend this clip from the library.`)
|
progress.value = Number(payload.progress || progress.value || 100)
|
||||||
|
toast(`Shot ${step} of ${total} saved. ${left} shot${left === 1 ? '' : 's'} waiting on Queue — edit prompts or process next.`)
|
||||||
|
queuePendingCount.value = left
|
||||||
videoBusy.value = false
|
videoBusy.value = false
|
||||||
statusBusy.value = false
|
statusBusy.value = false
|
||||||
stopTimer('video')
|
stopTimer('video')
|
||||||
|
|||||||
+266
@@ -0,0 +1,266 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen">
|
||||||
|
<header class="sticky top-0 z-20 border-b border-white/5 bg-zinc-950/70 backdrop-blur-xl">
|
||||||
|
<div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<NuxtLink to="/" class="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-400 text-zinc-950 font-display font-extrabold">A</NuxtLink>
|
||||||
|
<div>
|
||||||
|
<h1 class="font-display text-xl font-bold leading-none">{{ instanceName }}</h1>
|
||||||
|
<p class="text-[11px] uppercase tracking-[0.22em] text-zinc-500">Shot queue</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
<NuxtLink to="/" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white">
|
||||||
|
Studio
|
||||||
|
</NuxtLink>
|
||||||
|
<a v-if="authEnabled" href="/api/auth/logout" class="text-zinc-400 hover:text-white">Sign out</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="mx-auto max-w-7xl space-y-6 px-4 py-6 sm:px-6">
|
||||||
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-display text-2xl font-bold">Queue</h2>
|
||||||
|
<p class="mt-1 max-w-2xl text-sm text-zinc-400">
|
||||||
|
Multi-shot batches wait here after the first clip. Edit remaining prompts, process the next shot, the next few, or run the rest overnight.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="pendingCount" class="text-sm text-amber-100">{{ pendingCount }} shot{{ pendingCount === 1 ? '' : 's' }} waiting</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm text-red-200">{{ error }}</p>
|
||||||
|
<p v-else-if="!queues.length" class="glass rounded-3xl px-5 py-10 text-center text-sm text-zinc-400">
|
||||||
|
No batches yet. Queue extensions or paste a shot script on the studio page, generate the first shot, then come back here for the rest.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-for="queue in queues"
|
||||||
|
:key="queue.id"
|
||||||
|
class="glass rounded-3xl p-5 sm:p-6 space-y-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h3 class="truncate font-display text-lg font-semibold text-zinc-100">{{ queue.name }}</h3>
|
||||||
|
<p class="mt-1 text-xs text-zinc-500">
|
||||||
|
Shot {{ queue.completedCount }} of {{ queue.totalCount }}
|
||||||
|
<span v-if="queue.status === 'running'"> · processing</span>
|
||||||
|
<span v-else-if="queue.status === 'paused'"> · paused</span>
|
||||||
|
<span v-else-if="queue.status === 'complete'"> · finished</span>
|
||||||
|
<span v-else-if="queue.status === 'error'"> · needs attention</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="liveMessage[queue.id]" class="mt-1 text-xs text-amber-100">{{ liveMessage[queue.id] }}</p>
|
||||||
|
<p v-if="queue.lastError" class="mt-1 text-xs text-red-300">{{ queue.lastError }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full bg-amber-400 px-3 py-1.5 text-xs font-semibold text-zinc-950 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="busyId === queue.id || queue.status === 'running' || queue.status === 'complete' || queue.pendingCount === 0"
|
||||||
|
@click="process(queue, 1)"
|
||||||
|
>
|
||||||
|
Process next
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-200 hover:border-amber-300/40 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="busyId === queue.id || queue.status === 'running' || queue.status === 'complete' || queue.pendingCount < 2"
|
||||||
|
@click="process(queue, Math.min(3, queue.pendingCount))"
|
||||||
|
>
|
||||||
|
Next {{ Math.min(3, queue.pendingCount) || 3 }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-200 hover:border-amber-300/40 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="busyId === queue.id || queue.status === 'running' || queue.status === 'complete' || queue.pendingCount === 0"
|
||||||
|
@click="process(queue, 'all')"
|
||||||
|
>
|
||||||
|
Run remaining
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="queue.status === 'running'"
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-red-400/40 px-3 py-1.5 text-xs text-red-200"
|
||||||
|
@click="pause(queue)"
|
||||||
|
>
|
||||||
|
Pause after this shot
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="queue.status !== 'running'"
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-400 hover:text-red-200"
|
||||||
|
@click="remove(queue)"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<article
|
||||||
|
v-for="segment in queue.segments"
|
||||||
|
:key="segment.index"
|
||||||
|
class="rounded-2xl border px-4 py-3"
|
||||||
|
:class="segmentClass(segment.status)"
|
||||||
|
>
|
||||||
|
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p class="text-sm font-medium text-zinc-200">
|
||||||
|
{{ segment.index === 0 ? 'Shot 1 · initial' : `Shot ${segment.index + 1}` }}
|
||||||
|
<span class="font-normal text-zinc-500"> · {{ segment.status }}</span>
|
||||||
|
</p>
|
||||||
|
<span class="text-xs text-zinc-500">{{ segment.duration }}s</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="segment.prompt"
|
||||||
|
rows="3"
|
||||||
|
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2 disabled:opacity-70"
|
||||||
|
:disabled="segment.status === 'complete' || segment.status === 'running' || queue.status === 'running'"
|
||||||
|
@change="saveSegment(queue, segment)"
|
||||||
|
/>
|
||||||
|
<DurationField
|
||||||
|
v-if="segment.status !== 'complete' && segment.status !== 'running'"
|
||||||
|
v-model="segment.duration"
|
||||||
|
class="mt-3"
|
||||||
|
label="Duration"
|
||||||
|
@update:modelValue="saveSegment(queue, segment)"
|
||||||
|
/>
|
||||||
|
<p v-if="segment.error" class="mt-2 text-xs text-red-300">{{ segment.error }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
type Segment = {
|
||||||
|
index: number
|
||||||
|
prompt: string
|
||||||
|
duration: number
|
||||||
|
status: 'pending' | 'running' | 'complete' | 'error'
|
||||||
|
clipId?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Queue = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
lastError?: string
|
||||||
|
pendingCount: number
|
||||||
|
completedCount: number
|
||||||
|
totalCount: number
|
||||||
|
currentJobId?: string
|
||||||
|
segments: Segment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const instanceName = ref(String(useRuntimeConfig().public.instanceName || 'AIGen'))
|
||||||
|
const authEnabled = ref(false)
|
||||||
|
const queues = ref<Queue[]>([])
|
||||||
|
const pendingCount = ref(0)
|
||||||
|
const error = ref('')
|
||||||
|
const busyId = ref('')
|
||||||
|
const liveMessage = ref<Record<string, string>>({})
|
||||||
|
const sources = new Map<string, EventSource>()
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function segmentClass(status: Segment['status']) {
|
||||||
|
if (status === 'complete') return 'border-emerald-400/20 bg-emerald-400/5'
|
||||||
|
if (status === 'running') return 'border-amber-300/40 bg-amber-400/5'
|
||||||
|
if (status === 'error') return 'border-red-400/30 bg-red-400/5'
|
||||||
|
return 'border-white/10 bg-zinc-950/50'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadQueues() {
|
||||||
|
const data = await $fetch<{ queues?: Queue[]; pendingCount?: number }>('/api/queues')
|
||||||
|
queues.value = data.queues || []
|
||||||
|
pendingCount.value = data.pendingCount || 0
|
||||||
|
for (const queue of queues.value) {
|
||||||
|
if (queue.status === 'running' && queue.currentJobId) listen(queue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function listen(queue: Queue) {
|
||||||
|
if (!queue.currentJobId || sources.has(queue.id)) return
|
||||||
|
const source = new EventSource(`/api/generate/${queue.currentJobId}/stream`)
|
||||||
|
sources.set(queue.id, source)
|
||||||
|
source.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.data) as { message?: string; type?: string }
|
||||||
|
if (payload.message) liveMessage.value = { ...liveMessage.value, [queue.id]: payload.message }
|
||||||
|
if (payload.type === 'complete' || payload.type === 'error') {
|
||||||
|
source.close()
|
||||||
|
sources.delete(queue.id)
|
||||||
|
void loadQueues()
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function process(queue: Queue, count: number | 'all') {
|
||||||
|
busyId.value = queue.id
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/queues/${queue.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: {
|
||||||
|
segments: queue.segments
|
||||||
|
.filter(segment => segment.status !== 'complete')
|
||||||
|
.map(segment => ({ index: segment.index, prompt: segment.prompt, duration: segment.duration }))
|
||||||
|
}
|
||||||
|
}).catch(() => null)
|
||||||
|
const started = await $fetch<{ jobId: string }>(`/api/queues/${queue.id}/process`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { count }
|
||||||
|
})
|
||||||
|
queue.currentJobId = started.jobId
|
||||||
|
listen({ ...queue, currentJobId: started.jobId })
|
||||||
|
await loadQueues()
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not start the next shot'
|
||||||
|
} finally {
|
||||||
|
busyId.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pause(queue: Queue) {
|
||||||
|
await $fetch(`/api/queues/${queue.id}/pause`, { method: 'POST' }).catch(() => null)
|
||||||
|
await loadQueues()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(queue: Queue) {
|
||||||
|
if (!confirm('Remove this batch from the queue? Saved clips stay in the library.')) return
|
||||||
|
await $fetch(`/api/queues/${queue.id}`, { method: 'DELETE' }).catch((err: any) => {
|
||||||
|
error.value = err?.data?.statusMessage || err?.message || 'Could not remove that batch'
|
||||||
|
})
|
||||||
|
await loadQueues()
|
||||||
|
}
|
||||||
|
|
||||||
|
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
function saveSegment(queue: Queue, segment: Segment) {
|
||||||
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
void $fetch(`/api/queues/${queue.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { segments: [{ index: segment.index, prompt: segment.prompt, duration: segment.duration }] }
|
||||||
|
}).catch(() => null)
|
||||||
|
}, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const me = await $fetch<{ user?: { name?: string }; authEnabled?: boolean; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null }))
|
||||||
|
authEnabled.value = Boolean(me.authEnabled)
|
||||||
|
instanceName.value = me.instanceName || instanceName.value
|
||||||
|
await loadQueues().catch((err: any) => {
|
||||||
|
error.value = err?.data?.statusMessage || err?.message || 'Could not load the queue'
|
||||||
|
})
|
||||||
|
pollTimer = setInterval(() => { void loadQueues().catch(() => null) }, 4000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
|
for (const source of sources.values()) source.close()
|
||||||
|
sources.clear()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { frameLength, runGeneration } from '~/server/utils/videoChain'
|
import { frameLength, runGeneration } from '~/server/utils/videoChain'
|
||||||
|
import { createShotQueue, finishQueueBurst, setQueueJob } from '~/server/utils/shotQueue'
|
||||||
|
|
||||||
function isComfyBusyTimeout(error: unknown) {
|
function isComfyBusyTimeout(error: unknown) {
|
||||||
const err = error as { statusCode?: number; status?: number; data?: { code?: string }; message?: string; statusMessage?: string }
|
const err = error as { statusCode?: number; status?: number; data?: { code?: string }; message?: string; statusMessage?: string }
|
||||||
@@ -154,6 +155,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
job.kind = 'video'
|
job.kind = 'video'
|
||||||
job.maxStep = steps
|
job.maxStep = steps
|
||||||
job.hideThumbnail = hideThumbnail
|
job.hideThumbnail = hideThumbnail
|
||||||
|
const familyId = crypto.randomUUID()
|
||||||
|
const queueAutoRun = fields.queueAutoRun === 'true'
|
||||||
job.library = {
|
job.library = {
|
||||||
ownerKey,
|
ownerKey,
|
||||||
folderId,
|
folderId,
|
||||||
@@ -184,9 +187,42 @@ export default defineEventHandler(async (event) => {
|
|||||||
chainStep: 1,
|
chainStep: 1,
|
||||||
chainTotal,
|
chainTotal,
|
||||||
chainLabel: extensions.length ? 'Initial' : undefined,
|
chainLabel: extensions.length ? 'Initial' : undefined,
|
||||||
familyId: crypto.randomUUID(),
|
familyId,
|
||||||
workflow,
|
workflow,
|
||||||
useIdentityRefs
|
useIdentityRefs,
|
||||||
|
queueAutoRun: extensions.length ? queueAutoRun : false,
|
||||||
|
queueBudget: extensions.length && queueAutoRun ? extensions.length : 0
|
||||||
|
}
|
||||||
|
if (extensions.length) {
|
||||||
|
const queue = await createShotQueue({
|
||||||
|
ownerKey,
|
||||||
|
name: clipName,
|
||||||
|
familyId,
|
||||||
|
folderId,
|
||||||
|
autoRun: queueAutoRun,
|
||||||
|
stillId: still?.id,
|
||||||
|
stillFilename: still?.filename,
|
||||||
|
hideThumbnail,
|
||||||
|
hideInput,
|
||||||
|
aspect: fields.aspect || 'auto',
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
steps,
|
||||||
|
turbo,
|
||||||
|
cfg,
|
||||||
|
fps,
|
||||||
|
samplerName,
|
||||||
|
scheduler,
|
||||||
|
workflow,
|
||||||
|
sound,
|
||||||
|
useIdentityRefs,
|
||||||
|
referenceStillIds,
|
||||||
|
initial: { prompt, duration: durationSeconds },
|
||||||
|
extensions,
|
||||||
|
jobId: job.id
|
||||||
|
})
|
||||||
|
job.library.queueId = queue.id
|
||||||
|
setQueueJob(queue.id, job.id)
|
||||||
}
|
}
|
||||||
emitChainJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
|
emitChainJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
|
||||||
|
|
||||||
@@ -242,6 +278,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
job.status = 'deferred'
|
job.status = 'deferred'
|
||||||
const message = 'ComfyUI was still busy after 3 minutes. Saved this job — click it under Held jobs to restore the form.'
|
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 })
|
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
|
return
|
||||||
} catch {
|
} catch {
|
||||||
// fall through to a normal error if the held job could not be saved
|
// fall through to a normal error if the held job could not be saved
|
||||||
@@ -252,6 +292,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
job.status = 'error'
|
job.status = 'error'
|
||||||
job.error = message
|
job.error = message
|
||||||
emitJob(job, { type: 'error', error: message, 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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -270,6 +314,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
folderId,
|
folderId,
|
||||||
hideThumbnail,
|
hideThumbnail,
|
||||||
folderLocked,
|
folderLocked,
|
||||||
chainTotal
|
chainTotal,
|
||||||
|
queueId: job.library?.queueId,
|
||||||
|
queuedRemaining: extensions.length
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { pauseShotQueue, setQueueJob } from '~/server/utils/shotQueue'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const body = await readBody<{ jobId?: string; kind?: string }>(event).catch(() => ({}))
|
const body = await readBody<{ jobId?: string; kind?: string }>(event).catch(() => ({}))
|
||||||
const jobId = String(body?.jobId || '')
|
const jobId = String(body?.jobId || '')
|
||||||
@@ -6,6 +8,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
job.status = 'cancelled'
|
job.status = 'cancelled'
|
||||||
emitJob(job, { type: 'status', message: 'Cancelling job...' })
|
emitJob(job, { type: 'status', message: 'Cancelling job...' })
|
||||||
removeExtendTemp(job.library?.extendTmpDir)
|
removeExtendTemp(job.library?.extendTmpDir)
|
||||||
|
if (job.library?.queueId) {
|
||||||
|
job.library.stopAfterCurrent = true
|
||||||
|
job.library.queueAutoRun = false
|
||||||
|
await pauseShotQueue(job.library.ownerKey, job.library.queueId).catch(() => null)
|
||||||
|
setQueueJob(job.library.queueId, null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (jobId) deletePendingJob(jobId)
|
if (jobId) deletePendingJob(jobId)
|
||||||
const ok = await interruptComfy()
|
const ok = await interruptComfy()
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { deleteShotQueue } from '~/server/utils/shotQueue'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { owner } = assertLibraryOwner(event)
|
||||||
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
|
await deleteShotQueue(owner, id)
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { getShotQueue, summarizeQueue } from '~/server/utils/shotQueue'
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const { owner } = assertLibraryOwner(event)
|
||||||
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
|
const queue = getShotQueue(owner, id)
|
||||||
|
if (!queue) throw createError({ statusCode: 404, statusMessage: 'Queue not found' })
|
||||||
|
return summarizeQueue(queue)
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { getShotQueue, patchShotQueueSegments, updateShotQueue, summarizeQueue } from '~/server/utils/shotQueue'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { owner } = assertLibraryOwner(event)
|
||||||
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
|
const body = await readBody<{
|
||||||
|
name?: string
|
||||||
|
autoRun?: boolean
|
||||||
|
segments?: Array<{ index: number; prompt?: string; duration?: number }>
|
||||||
|
}>().catch(() => ({}))
|
||||||
|
|
||||||
|
if (Array.isArray(body?.segments) && body.segments.length) {
|
||||||
|
await patchShotQueueSegments(owner, id, body.segments)
|
||||||
|
}
|
||||||
|
if (typeof body?.name === 'string' || typeof body?.autoRun === 'boolean') {
|
||||||
|
await updateShotQueue(owner, id, (queue) => {
|
||||||
|
if (typeof body.name === 'string') queue.name = body.name.trim().slice(0, 80) || queue.name
|
||||||
|
if (typeof body.autoRun === 'boolean' && queue.status !== 'running') queue.autoRun = body.autoRun
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const queue = getShotQueue(owner, id)
|
||||||
|
if (!queue) throw createError({ statusCode: 404, statusMessage: 'Queue not found' })
|
||||||
|
return summarizeQueue(queue)
|
||||||
|
})
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { pauseShotQueue, summarizeQueue, getShotQueue } from '~/server/utils/shotQueue'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { owner } = assertLibraryOwner(event)
|
||||||
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
|
await pauseShotQueue(owner, id)
|
||||||
|
const queue = getShotQueue(owner, id)
|
||||||
|
if (!queue) throw createError({ statusCode: 404, statusMessage: 'Queue not found' })
|
||||||
|
return summarizeQueue(queue)
|
||||||
|
})
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { startQueueBurst } from '~/server/utils/videoChain'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { owner } = assertLibraryOwner(event)
|
||||||
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
|
const body = await readBody<{ count?: number | 'all' }>().catch(() => ({}))
|
||||||
|
const raw = body?.count
|
||||||
|
const count = raw === 'all' || raw === 'remaining' ? 'all' as const : Math.max(1, Math.min(50, Number(raw) || 1))
|
||||||
|
return startQueueBurst(owner, id, count)
|
||||||
|
})
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { listShotQueues, summarizeQueue } from '~/server/utils/shotQueue'
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const { owner } = assertLibraryOwner(event)
|
||||||
|
const queues = listShotQueues(owner).map(summarizeQueue)
|
||||||
|
const pendingCount = queues.reduce((sum, queue) => (
|
||||||
|
queue.status === 'complete' ? sum : sum + queue.pendingCount
|
||||||
|
), 0)
|
||||||
|
return { queues, pendingCount }
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { listPendingJobs } from '~/server/utils/pending'
|
||||||
|
import { listOwnersWithQueues, listShotQueues, pendingSegmentCount } from '~/server/utils/shotQueue'
|
||||||
|
import { startQueueBurst } from '~/server/utils/videoChain'
|
||||||
|
|
||||||
|
export default defineNitroPlugin(() => {
|
||||||
|
const pendingQueueIds = new Set(listPendingJobs().map(job => job.queueId).filter(Boolean))
|
||||||
|
for (const owner of listOwnersWithQueues()) {
|
||||||
|
for (const queue of listShotQueues(owner)) {
|
||||||
|
if (!queue.autoRun) continue
|
||||||
|
if (queue.status === 'complete') continue
|
||||||
|
if (pendingQueueIds.has(queue.id)) continue
|
||||||
|
if (!queue.currentClipId || pendingSegmentCount(queue) === 0) continue
|
||||||
|
void startQueueBurst(owner, queue.id, 'all').catch(() => null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
+11
-1
@@ -25,6 +25,8 @@ export interface JobEvent {
|
|||||||
chainTotal?: number
|
chainTotal?: number
|
||||||
chainLabel?: string
|
chainLabel?: string
|
||||||
overallProgress?: number
|
overallProgress?: number
|
||||||
|
queuedRemaining?: number
|
||||||
|
queueId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Job {
|
export interface Job {
|
||||||
@@ -86,6 +88,10 @@ export interface Job {
|
|||||||
workflow?: 'v1' | 'v2'
|
workflow?: 'v1' | 'v2'
|
||||||
chainContinuing?: boolean
|
chainContinuing?: boolean
|
||||||
passes?: { prompt: string }[]
|
passes?: { prompt: string }[]
|
||||||
|
queueId?: string
|
||||||
|
queueAutoRun?: boolean
|
||||||
|
queueBudget?: number
|
||||||
|
stopAfterCurrent?: boolean
|
||||||
}
|
}
|
||||||
error?: string
|
error?: string
|
||||||
socketReady?: boolean
|
socketReady?: boolean
|
||||||
@@ -181,7 +187,11 @@ export function jobSnapshot(job: Job) {
|
|||||||
draftId: job.library?.draftId,
|
draftId: job.library?.draftId,
|
||||||
chainStep: job.library?.chainStep,
|
chainStep: job.library?.chainStep,
|
||||||
chainTotal: job.library?.chainTotal,
|
chainTotal: job.library?.chainTotal,
|
||||||
chainLabel: job.library?.chainLabel
|
chainLabel: job.library?.chainLabel,
|
||||||
|
queueId: job.library?.queueId,
|
||||||
|
queuedRemaining: job.library?.queueId
|
||||||
|
? Math.max(0, (job.library.chainTotal || 1) - (job.library.chainStep || 1))
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -45,6 +45,9 @@ export interface PendingJob {
|
|||||||
extensions?: { prompt: string; duration: number }[]
|
extensions?: { prompt: string; duration: number }[]
|
||||||
remainingExtensions?: { prompt: string; duration: number }[]
|
remainingExtensions?: { prompt: string; duration: number }[]
|
||||||
currentClipId?: string
|
currentClipId?: string
|
||||||
|
queueId?: string
|
||||||
|
queueAutoRun?: boolean
|
||||||
|
queueBudget?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function pendingRoot() {
|
function pendingRoot() {
|
||||||
@@ -127,6 +130,9 @@ export function pendingFromJob(job: Job, overrides: Partial<PendingJob> = {}): P
|
|||||||
extensions: library.extensions,
|
extensions: library.extensions,
|
||||||
remainingExtensions: remaining,
|
remainingExtensions: remaining,
|
||||||
currentClipId: job.clipId,
|
currentClipId: job.clipId,
|
||||||
|
queueId: library.queueId,
|
||||||
|
queueAutoRun: library.queueAutoRun,
|
||||||
|
queueBudget: library.queueBudget,
|
||||||
...overrides
|
...overrides
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,7 +174,10 @@ export function libraryFromPending(pending: PendingJob): NonNullable<Job['librar
|
|||||||
useIdentityRefs: pending.useIdentityRefs,
|
useIdentityRefs: pending.useIdentityRefs,
|
||||||
workflow: pending.workflow,
|
workflow: pending.workflow,
|
||||||
duration: pending.duration,
|
duration: pending.duration,
|
||||||
extensions: pending.extensions || pending.remainingExtensions
|
extensions: pending.extensions || pending.remainingExtensions,
|
||||||
|
queueId: pending.queueId,
|
||||||
|
queueAutoRun: pending.queueAutoRun,
|
||||||
|
queueBudget: pending.queueBudget
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { getJob, type Job } from '~/server/utils/jobs'
|
||||||
|
|
||||||
|
export type ShotQueueStatus = 'idle' | 'running' | 'paused' | 'complete' | 'error'
|
||||||
|
export type ShotSegmentStatus = 'pending' | 'running' | 'complete' | 'error'
|
||||||
|
|
||||||
|
export interface ShotQueueSegment {
|
||||||
|
index: number
|
||||||
|
prompt: string
|
||||||
|
duration: number
|
||||||
|
status: ShotSegmentStatus
|
||||||
|
clipId?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShotQueue {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
familyId: string
|
||||||
|
folderId: string
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
status: ShotQueueStatus
|
||||||
|
autoRun: boolean
|
||||||
|
currentClipId?: string
|
||||||
|
currentJobId?: string
|
||||||
|
stillId?: string
|
||||||
|
stillFilename?: string
|
||||||
|
hideThumbnail: boolean
|
||||||
|
hideInput?: boolean
|
||||||
|
aspect: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
steps: number
|
||||||
|
turbo: boolean
|
||||||
|
cfg?: number
|
||||||
|
fps?: number
|
||||||
|
samplerName?: string
|
||||||
|
scheduler?: string
|
||||||
|
workflow?: 'v1' | 'v2'
|
||||||
|
sound?: boolean
|
||||||
|
useIdentityRefs?: boolean
|
||||||
|
referenceStillIds?: Array<string | null>
|
||||||
|
segments: ShotQueueSegment[]
|
||||||
|
lastError?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeChains = new Map<string, Promise<unknown>>()
|
||||||
|
const activeJobs = new Map<string, string>()
|
||||||
|
|
||||||
|
function libraryRoot() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
return (config.libraryDir || process.env.LIBRARY_DIR || '/data/library').replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function queuesPath(owner: string) {
|
||||||
|
return join(libraryRoot(), 'users', owner, 'shot-queues.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureOwner(owner: string) {
|
||||||
|
mkdirSync(join(libraryRoot(), 'users', owner), { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function readQueues(owner: string): ShotQueue[] {
|
||||||
|
ensureOwner(owner)
|
||||||
|
const path = queuesPath(owner)
|
||||||
|
if (!existsSync(path)) return []
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
||||||
|
return Array.isArray(parsed) ? parsed : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeQueues(owner: string, queues: ShotQueue[]) {
|
||||||
|
ensureOwner(owner)
|
||||||
|
const path = queuesPath(owner)
|
||||||
|
const tmp = `${path}.tmp`
|
||||||
|
writeFileSync(tmp, JSON.stringify(queues, null, 2))
|
||||||
|
renameSync(tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mutate<T>(owner: string, fn: (queues: ShotQueue[]) => T): Promise<T> {
|
||||||
|
const prev = writeChains.get(owner) || Promise.resolve()
|
||||||
|
const run = prev.then(() => {
|
||||||
|
const queues = readQueues(owner)
|
||||||
|
const result = fn(queues)
|
||||||
|
writeQueues(owner, queues)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
writeChains.set(owner, run.then(() => undefined, () => undefined))
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pendingSegmentCount(queue: ShotQueue) {
|
||||||
|
return queue.segments.filter(segment => segment.status === 'pending' || segment.status === 'error').length
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completedSegmentCount(queue: ShotQueue) {
|
||||||
|
return queue.segments.filter(segment => segment.status === 'complete').length
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeQueue(queue: ShotQueue) {
|
||||||
|
return {
|
||||||
|
...queue,
|
||||||
|
pendingCount: pendingSegmentCount(queue),
|
||||||
|
completedCount: completedSegmentCount(queue),
|
||||||
|
totalCount: queue.segments.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listShotQueues(owner: string) {
|
||||||
|
return readQueues(owner)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShotQueue(owner: string, id: string) {
|
||||||
|
return readQueues(owner).find(queue => queue.id === id) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queueIsProcessing(id: string) {
|
||||||
|
return activeJobs.has(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setQueueJob(id: string, jobId: string | null) {
|
||||||
|
if (jobId) activeJobs.set(id, jobId)
|
||||||
|
else activeJobs.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQueueJobId(id: string) {
|
||||||
|
return activeJobs.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createShotQueue(params: {
|
||||||
|
ownerKey: string
|
||||||
|
name?: string
|
||||||
|
familyId: string
|
||||||
|
folderId: string
|
||||||
|
autoRun: boolean
|
||||||
|
stillId?: string
|
||||||
|
stillFilename?: string
|
||||||
|
hideThumbnail: boolean
|
||||||
|
hideInput?: boolean
|
||||||
|
aspect: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
steps: number
|
||||||
|
turbo: boolean
|
||||||
|
cfg?: number
|
||||||
|
fps?: number
|
||||||
|
samplerName?: string
|
||||||
|
scheduler?: string
|
||||||
|
workflow?: 'v1' | 'v2'
|
||||||
|
sound?: boolean
|
||||||
|
useIdentityRefs?: boolean
|
||||||
|
referenceStillIds?: Array<string | null>
|
||||||
|
initial: { prompt: string; duration: number }
|
||||||
|
extensions: { prompt: string; duration: number }[]
|
||||||
|
jobId?: string
|
||||||
|
}): Promise<ShotQueue> {
|
||||||
|
const now = Date.now()
|
||||||
|
const queue: ShotQueue = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: (params.name || '').trim() || params.initial.prompt.slice(0, 80),
|
||||||
|
familyId: params.familyId,
|
||||||
|
folderId: params.folderId,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
status: 'running',
|
||||||
|
autoRun: params.autoRun,
|
||||||
|
currentJobId: params.jobId,
|
||||||
|
stillId: params.stillId,
|
||||||
|
stillFilename: params.stillFilename,
|
||||||
|
hideThumbnail: params.hideThumbnail,
|
||||||
|
hideInput: params.hideInput,
|
||||||
|
aspect: params.aspect,
|
||||||
|
width: params.width,
|
||||||
|
height: params.height,
|
||||||
|
steps: params.steps,
|
||||||
|
turbo: params.turbo,
|
||||||
|
cfg: params.cfg,
|
||||||
|
fps: params.fps,
|
||||||
|
samplerName: params.samplerName,
|
||||||
|
scheduler: params.scheduler,
|
||||||
|
workflow: params.workflow,
|
||||||
|
sound: params.sound,
|
||||||
|
useIdentityRefs: params.useIdentityRefs,
|
||||||
|
referenceStillIds: params.referenceStillIds,
|
||||||
|
segments: [
|
||||||
|
{ index: 0, prompt: params.initial.prompt, duration: params.initial.duration, status: 'running' },
|
||||||
|
...params.extensions.map((item, index) => ({
|
||||||
|
index: index + 1,
|
||||||
|
prompt: item.prompt,
|
||||||
|
duration: item.duration,
|
||||||
|
status: 'pending' as const
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
}
|
||||||
|
await mutate(params.ownerKey, (queues) => {
|
||||||
|
queues.unshift(queue)
|
||||||
|
return queue
|
||||||
|
})
|
||||||
|
if (params.jobId) setQueueJob(queue.id, params.jobId)
|
||||||
|
return queue
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateShotQueue(owner: string, id: string, patch: (queue: ShotQueue) => void) {
|
||||||
|
return mutate(owner, (queues) => {
|
||||||
|
const queue = queues.find(item => item.id === id)
|
||||||
|
if (!queue) throw createError({ statusCode: 404, statusMessage: 'Queue not found' })
|
||||||
|
patch(queue)
|
||||||
|
queue.updatedAt = Date.now()
|
||||||
|
return structuredClone(queue)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchShotQueueSegments(
|
||||||
|
owner: string,
|
||||||
|
id: string,
|
||||||
|
segments: Array<{ index: number; prompt?: string; duration?: number }>
|
||||||
|
) {
|
||||||
|
return updateShotQueue(owner, id, (queue) => {
|
||||||
|
if (queue.status === 'complete') {
|
||||||
|
throw createError({ statusCode: 409, statusMessage: 'This batch is already finished' })
|
||||||
|
}
|
||||||
|
for (const next of segments) {
|
||||||
|
const segment = queue.segments.find(item => item.index === next.index)
|
||||||
|
if (!segment) continue
|
||||||
|
if (segment.status === 'complete' || segment.status === 'running') continue
|
||||||
|
if (typeof next.prompt === 'string') {
|
||||||
|
const prompt = next.prompt.trim()
|
||||||
|
if (!prompt) throw createError({ statusCode: 400, statusMessage: `Shot ${next.index + 1} needs a prompt` })
|
||||||
|
segment.prompt = prompt
|
||||||
|
}
|
||||||
|
if (typeof next.duration === 'number' && Number.isFinite(next.duration)) {
|
||||||
|
segment.duration = Math.min(120, Math.max(0.5, Math.round(next.duration * 10) / 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markQueueSegment(owner: string, id: string, index: number, status: ShotSegmentStatus, extra: Partial<ShotQueueSegment> = {}) {
|
||||||
|
return updateShotQueue(owner, id, (queue) => {
|
||||||
|
const segment = queue.segments.find(item => item.index === index)
|
||||||
|
if (!segment) return
|
||||||
|
segment.status = status
|
||||||
|
if (extra.clipId) segment.clipId = extra.clipId
|
||||||
|
if (extra.error) segment.error = extra.error
|
||||||
|
else if (status !== 'error') delete segment.error
|
||||||
|
if (extra.clipId) queue.currentClipId = extra.clipId
|
||||||
|
if (status === 'complete' && pendingSegmentCount(queue) === 0) {
|
||||||
|
queue.status = 'complete'
|
||||||
|
queue.autoRun = false
|
||||||
|
queue.currentJobId = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncQueueFromJob(job: Job, clipId?: string) {
|
||||||
|
const library = job.library
|
||||||
|
if (!library?.queueId) return null
|
||||||
|
const index = library.chainIndex || 0
|
||||||
|
const failed = job.status === 'error' || job.status === 'cancelled'
|
||||||
|
if (failed) {
|
||||||
|
return updateShotQueue(library.ownerKey, library.queueId, (queue) => {
|
||||||
|
const segment = queue.segments.find(item => item.index === index)
|
||||||
|
if (segment && segment.status !== 'complete') {
|
||||||
|
segment.status = 'error'
|
||||||
|
segment.error = job.error || 'Generation failed'
|
||||||
|
}
|
||||||
|
queue.status = job.status === 'cancelled' ? 'paused' : 'error'
|
||||||
|
queue.lastError = job.error
|
||||||
|
queue.currentJobId = undefined
|
||||||
|
queue.autoRun = job.status === 'cancelled' ? false : queue.autoRun
|
||||||
|
}).catch(() => null)
|
||||||
|
}
|
||||||
|
if (!clipId) return getShotQueue(library.ownerKey, library.queueId)
|
||||||
|
return markQueueSegment(library.ownerKey, library.queueId, index, 'complete', { clipId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareQueueBurst(owner: string, id: string, count: number | 'all') {
|
||||||
|
return mutate(owner, (queues) => {
|
||||||
|
const queue = queues.find(item => item.id === id)
|
||||||
|
if (!queue) throw createError({ statusCode: 404, statusMessage: 'Queue not found' })
|
||||||
|
if (activeJobs.has(queue.id)) {
|
||||||
|
throw createError({ statusCode: 409, statusMessage: 'This batch is already processing' })
|
||||||
|
}
|
||||||
|
const remaining = queue.segments.filter(segment => segment.status === 'pending' || segment.status === 'error')
|
||||||
|
if (!remaining.length) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'No remaining shots to process' })
|
||||||
|
}
|
||||||
|
const initial = queue.segments[0]
|
||||||
|
if (initial && initial.status !== 'complete') {
|
||||||
|
throw createError({ statusCode: 409, statusMessage: 'The first shot is still running or did not save. Wait for it, or generate again.' })
|
||||||
|
}
|
||||||
|
if (!queue.currentClipId) {
|
||||||
|
throw createError({ statusCode: 409, statusMessage: 'The previous clip is missing, so the next shot cannot start' })
|
||||||
|
}
|
||||||
|
const n = count === 'all' ? remaining.length : Math.min(remaining.length, Math.max(1, Math.floor(count)))
|
||||||
|
remaining.slice(0, n).forEach((segment) => {
|
||||||
|
segment.status = segment.index === remaining[0].index ? 'running' : 'pending'
|
||||||
|
delete segment.error
|
||||||
|
})
|
||||||
|
queue.autoRun = count === 'all'
|
||||||
|
queue.status = 'running'
|
||||||
|
queue.lastError = undefined
|
||||||
|
queue.updatedAt = Date.now()
|
||||||
|
return { queue: structuredClone(queue), count: n }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function finishQueueBurst(owner: string, id: string, paused: boolean, error?: string) {
|
||||||
|
return updateShotQueue(owner, id, (queue) => {
|
||||||
|
queue.currentJobId = undefined
|
||||||
|
for (const segment of queue.segments) {
|
||||||
|
if (segment.status !== 'running') continue
|
||||||
|
if (error) {
|
||||||
|
segment.status = 'error'
|
||||||
|
segment.error = error
|
||||||
|
} else {
|
||||||
|
segment.status = 'pending'
|
||||||
|
delete segment.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
queue.status = 'error'
|
||||||
|
queue.lastError = error
|
||||||
|
queue.autoRun = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pendingSegmentCount(queue) === 0) {
|
||||||
|
queue.status = 'complete'
|
||||||
|
queue.autoRun = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queue.status = paused || !queue.autoRun ? 'paused' : 'idle'
|
||||||
|
if (paused) queue.autoRun = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pauseShotQueue(owner: string, id: string) {
|
||||||
|
const jobId = activeJobs.get(id)
|
||||||
|
if (jobId) {
|
||||||
|
const job = getJob(jobId)
|
||||||
|
if (job?.library) job.library.stopAfterCurrent = true
|
||||||
|
if (job?.library) job.library.queueAutoRun = false
|
||||||
|
}
|
||||||
|
return updateShotQueue(owner, id, (queue) => {
|
||||||
|
queue.autoRun = false
|
||||||
|
if (queue.status === 'running') {
|
||||||
|
queue.lastError = undefined
|
||||||
|
} else if (queue.status !== 'complete') {
|
||||||
|
queue.status = 'paused'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteShotQueue(owner: string, id: string) {
|
||||||
|
if (activeJobs.has(id)) {
|
||||||
|
throw createError({ statusCode: 409, statusMessage: 'Stop this batch before deleting it' })
|
||||||
|
}
|
||||||
|
return mutate(owner, (queues) => {
|
||||||
|
const index = queues.findIndex(item => item.id === id)
|
||||||
|
if (index < 0) throw createError({ statusCode: 404, statusMessage: 'Queue not found' })
|
||||||
|
const [removed] = queues.splice(index, 1)
|
||||||
|
return removed
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listOwnersWithQueues() {
|
||||||
|
const root = join(libraryRoot(), 'users')
|
||||||
|
if (!existsSync(root)) return []
|
||||||
|
return readdirSync(root, { withFileTypes: true })
|
||||||
|
.filter(entry => entry.isDirectory())
|
||||||
|
.map(entry => entry.name)
|
||||||
|
.filter(owner => existsSync(queuesPath(owner)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remainingFromQueue(queue: ShotQueue): { prompt: string; duration: number }[] {
|
||||||
|
return queue.segments
|
||||||
|
.filter(segment => segment.index > 0)
|
||||||
|
.map(segment => ({ prompt: segment.prompt, duration: segment.duration }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lastCompletedIndex(queue: ShotQueue) {
|
||||||
|
const done = queue.segments.filter(segment => segment.status === 'complete')
|
||||||
|
if (!done.length) return -1
|
||||||
|
return Math.max(...done.map(segment => segment.index))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function liveSegmentPrompt(queue: ShotQueue, extensionIndex: number) {
|
||||||
|
const segment = queue.segments.find(item => item.index === extensionIndex + 1)
|
||||||
|
return {
|
||||||
|
prompt: segment?.prompt || '',
|
||||||
|
duration: segment?.duration || 5
|
||||||
|
}
|
||||||
|
}
|
||||||
+171
-11
@@ -1,13 +1,23 @@
|
|||||||
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
|
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import type { Job } from '~/server/utils/jobs'
|
import { createJob, emitJob, type Job } from '~/server/utils/jobs'
|
||||||
import { pendingFromJob, remainingAfterCurrentShot, writePendingJob } from '~/server/utils/pending'
|
import { pendingFromJob, remainingAfterCurrentShot, writePendingJob } from '~/server/utils/pending'
|
||||||
import { emitChainJob, waitForComfySocket, watchComfyJob } from '~/server/utils/watch'
|
import { emitChainJob, waitForComfySocket, watchComfyJob } from '~/server/utils/watch'
|
||||||
import { comfyFilenamePrefix, queuePrompt, uploadImage } from '~/server/utils/comfy'
|
import { comfyFilenamePrefix, queuePrompt, uploadImage } from '~/server/utils/comfy'
|
||||||
import { buildWorkflow } from '~/server/utils/workflow'
|
import { buildWorkflow } from '~/server/utils/workflow'
|
||||||
import { clipVideoPath, deleteRetryDraft, extendTempDir, 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 {
|
||||||
|
finishQueueBurst,
|
||||||
|
getShotQueue,
|
||||||
|
lastCompletedIndex,
|
||||||
|
liveSegmentPrompt,
|
||||||
|
prepareQueueBurst,
|
||||||
|
remainingFromQueue,
|
||||||
|
setQueueJob,
|
||||||
|
updateShotQueue
|
||||||
|
} from '~/server/utils/shotQueue'
|
||||||
|
|
||||||
export type ChainImage = { filename: string; data: Buffer; type?: string }
|
export type ChainImage = { filename: string; data: Buffer; type?: string }
|
||||||
|
|
||||||
@@ -202,11 +212,17 @@ function seedCurrentVideo(job: Job) {
|
|||||||
return { tmpDir, currentPath, part1Path: join(tmpDir, 'part1.mp4'), framePath: join(tmpDir, 'last_frame.png') }
|
return { tmpDir, currentPath, part1Path: join(tmpDir, 'part1.mp4'), framePath: join(tmpDir, 'last_frame.png') }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function continueQueuedExtensions(job: Job, params: VideoChainParams) {
|
export async function continueQueuedExtensions(
|
||||||
|
job: Job,
|
||||||
|
params: VideoChainParams,
|
||||||
|
options: { maxShots?: number; autoRun?: boolean } = {}
|
||||||
|
) {
|
||||||
const extensions = params.extensions || []
|
const extensions = params.extensions || []
|
||||||
if (!extensions.length || !job.library) return
|
if (!extensions.length || !job.library) return
|
||||||
if (job.library.chainContinuing) return
|
if (job.library.chainContinuing) return
|
||||||
job.library.chainContinuing = true
|
job.library.chainContinuing = true
|
||||||
|
const autoRun = options.autoRun === true || job.library.queueAutoRun === true
|
||||||
|
let shotsLeft = options.maxShots ?? (autoRun ? extensions.length : (job.library.queueBudget || 0))
|
||||||
|
|
||||||
const ready = (status: { state: string; message: string; queueRunning?: number; queuePending?: number }) => {
|
const ready = (status: { state: string; message: string; queueRunning?: number; queuePending?: number }) => {
|
||||||
emitChainJob(job, {
|
emitChainJob(job, {
|
||||||
@@ -220,18 +236,49 @@ export async function continueQueuedExtensions(job: Job, params: VideoChainParam
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (shotsLeft <= 0) return
|
||||||
const { currentPath, part1Path, framePath } = seedCurrentVideo(job)
|
const { currentPath, part1Path, framePath } = seedCurrentVideo(job)
|
||||||
const startFrom = job.library.chainIndex || 0
|
const startFrom = job.library.chainIndex || 0
|
||||||
|
|
||||||
for (let i = startFrom; i < extensions.length; i++) {
|
for (let i = startFrom; i < extensions.length; i++) {
|
||||||
|
if (shotsLeft <= 0) break
|
||||||
assertJobActive(job)
|
assertJobActive(job)
|
||||||
const ext = extensions[i]
|
if (job.library.stopAfterCurrent && i > startFrom) break
|
||||||
const isLast = i === extensions.length - 1
|
|
||||||
|
const liveQueue = job.library.queueId
|
||||||
|
? getShotQueue(job.library.ownerKey, job.library.queueId)
|
||||||
|
: null
|
||||||
|
const live = liveQueue ? liveSegmentPrompt(liveQueue, i) : extensions[i]
|
||||||
|
const ext = {
|
||||||
|
prompt: live.prompt || extensions[i].prompt,
|
||||||
|
duration: live.duration || extensions[i].duration
|
||||||
|
}
|
||||||
|
if (!ext.prompt.trim()) {
|
||||||
|
throw new Error(`Shot ${i + 2} needs a prompt`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingAfter = extensions.length - (i + 1)
|
||||||
|
const lastOfChain = remainingAfter === 0
|
||||||
|
const lastOfBurst = !autoRun && shotsLeft === 1
|
||||||
|
const persist = lastOfChain || lastOfBurst || job.library.stopAfterCurrent === true
|
||||||
|
|
||||||
job.library.chainIndex = i + 1
|
job.library.chainIndex = i + 1
|
||||||
job.library.chainStep = i + 2
|
job.library.chainStep = i + 2
|
||||||
job.library.chainLabel = `Extension ${i + 1}`
|
job.library.chainLabel = `Extension ${i + 1}`
|
||||||
job.library.extendPart1Path = undefined
|
job.library.extendPart1Path = undefined
|
||||||
job.library.thumb = undefined
|
job.library.thumb = undefined
|
||||||
|
job.library.duration = ext.duration
|
||||||
|
if (liveQueue) {
|
||||||
|
await updateShotQueue(job.library.ownerKey, liveQueue.id, (queue) => {
|
||||||
|
const segment = queue.segments.find(item => item.index === i + 1)
|
||||||
|
if (segment) {
|
||||||
|
segment.status = 'running'
|
||||||
|
delete segment.error
|
||||||
|
}
|
||||||
|
queue.status = 'running'
|
||||||
|
queue.currentJobId = job.id
|
||||||
|
}).catch(() => null)
|
||||||
|
}
|
||||||
|
|
||||||
emitChainJob(job, { type: 'status', message: 'Waiting 3s buffer...', progress: 1 })
|
emitChainJob(job, { type: 'status', message: 'Waiting 3s buffer...', progress: 1 })
|
||||||
await sleep(3000)
|
await sleep(3000)
|
||||||
@@ -269,14 +316,17 @@ export async function continueQueuedExtensions(job: Job, params: VideoChainParam
|
|||||||
fps: params.fps,
|
fps: params.fps,
|
||||||
samplerName: params.samplerName,
|
samplerName: params.samplerName,
|
||||||
scheduler: params.scheduler,
|
scheduler: params.scheduler,
|
||||||
persist: isLast,
|
persist,
|
||||||
workflow: params.workflow,
|
workflow: params.workflow,
|
||||||
duration: ext.duration,
|
duration: ext.duration,
|
||||||
useIdentityRefs: params.useIdentityRefs,
|
useIdentityRefs: params.useIdentityRefs,
|
||||||
referenceImages: params.useIdentityRefs ? params.referenceImages : []
|
referenceImages: params.useIdentityRefs ? params.referenceImages : []
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!isLast) {
|
shotsLeft -= 1
|
||||||
|
if (job.library) job.library.queueBudget = shotsLeft
|
||||||
|
|
||||||
|
if (!lastOfChain && !persist) {
|
||||||
if (!job.segmentBuffer?.length) {
|
if (!job.segmentBuffer?.length) {
|
||||||
throw new Error('Extension finished without a stitched video')
|
throw new Error('Extension finished without a stitched video')
|
||||||
}
|
}
|
||||||
@@ -284,9 +334,22 @@ export async function continueQueuedExtensions(job: Job, params: VideoChainParam
|
|||||||
job.segmentBuffer = undefined
|
job.segmentBuffer = undefined
|
||||||
job.library.extendPart1Path = undefined
|
job.library.extendPart1Path = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (persist && remainingAfter > 0) break
|
||||||
|
if (job.library.stopAfterCurrent) break
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (job.library) job.library.chainContinuing = false
|
if (job.library) job.library.chainContinuing = false
|
||||||
|
if (job.library?.queueId) {
|
||||||
|
const paused = job.status !== 'error' && job.status !== 'cancelled'
|
||||||
|
await finishQueueBurst(
|
||||||
|
job.library.ownerKey,
|
||||||
|
job.library.queueId,
|
||||||
|
paused && !job.library.queueAutoRun,
|
||||||
|
job.status === 'error' || job.status === 'cancelled' ? (job.error || 'Stopped') : undefined
|
||||||
|
).catch(() => null)
|
||||||
|
setQueueJob(job.library.queueId, null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +358,20 @@ export async function continueQueuedExtensionsIfNeeded(job: Job) {
|
|||||||
if (job.status === 'error' || job.status === 'cancelled' || job.status === 'complete') return
|
if (job.status === 'error' || job.status === 'cancelled' || job.status === 'complete') return
|
||||||
const remaining = remainingAfterCurrentShot(job.library)
|
const remaining = remainingAfterCurrentShot(job.library)
|
||||||
if (!remaining.length) return
|
if (!remaining.length) return
|
||||||
await continueQueuedExtensions(job, paramsFromJob(job))
|
const queue = job.library.queueId
|
||||||
|
? getShotQueue(job.library.ownerKey, job.library.queueId)
|
||||||
|
: null
|
||||||
|
const autoRun = queue ? queue.autoRun === true : job.library.queueAutoRun === true
|
||||||
|
const budget = job.library.queueBudget || 0
|
||||||
|
if (queue && !autoRun && budget <= 0) return
|
||||||
|
if (!queue && !autoRun && budget <= 0) {
|
||||||
|
// Legacy chains without a queue keep the old auto-continue behavior.
|
||||||
|
await continueQueuedExtensions(job, paramsFromJob(job), { autoRun: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const maxShots = autoRun ? remaining.length : budget
|
||||||
|
if (maxShots <= 0) return
|
||||||
|
await continueQueuedExtensions(job, paramsFromJob(job), { maxShots, autoRun })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runGeneration(job: Job, params: VideoChainParams) {
|
export async function runGeneration(job: Job, params: VideoChainParams) {
|
||||||
@@ -313,11 +389,95 @@ export async function runGeneration(job: Job, params: VideoChainParams) {
|
|||||||
await ensureComfyReady(ready)
|
await ensureComfyReady(ready)
|
||||||
|
|
||||||
const { extensions, ...base } = params
|
const { extensions, ...base } = params
|
||||||
|
const autoRun = job.library?.queueAutoRun === true
|
||||||
await queueMiniMax(job, {
|
await queueMiniMax(job, {
|
||||||
...base,
|
...base,
|
||||||
persist: extensions.length === 0
|
persist: extensions.length === 0 || !autoRun
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!extensions.length || !job.library) return
|
if (job.library?.queueId && !autoRun) {
|
||||||
await continueQueuedExtensions(job, params)
|
await finishQueueBurst(job.library.ownerKey, job.library.queueId, true).catch(() => null)
|
||||||
|
setQueueJob(job.library.queueId, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
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') {
|
||||||
|
const { queue, count: n } = await prepareQueueBurst(owner, queueId, count)
|
||||||
|
const lastIndex = lastCompletedIndex(queue)
|
||||||
|
const clipId = queue.currentClipId
|
||||||
|
if (!clipId) {
|
||||||
|
throw createError({ statusCode: 409, statusMessage: 'The previous clip is missing, so the next shot cannot start' })
|
||||||
|
}
|
||||||
|
const source = getClip(owner, clipId)
|
||||||
|
const destFolderLocked = false
|
||||||
|
const job = createJob()
|
||||||
|
job.kind = 'video'
|
||||||
|
job.maxStep = queue.steps
|
||||||
|
job.hideThumbnail = queue.hideThumbnail
|
||||||
|
job.clipId = clipId
|
||||||
|
job.library = {
|
||||||
|
ownerKey: owner,
|
||||||
|
folderId: queue.folderId,
|
||||||
|
hideThumbnail: queue.hideThumbnail,
|
||||||
|
hideInput: queue.hideInput,
|
||||||
|
folderLocked: destFolderLocked,
|
||||||
|
name: nextClipPartName(clipTitle(source)),
|
||||||
|
prompt: queue.segments[lastIndex]?.prompt || source.prompt,
|
||||||
|
aspect: queue.aspect,
|
||||||
|
width: queue.width,
|
||||||
|
height: queue.height,
|
||||||
|
steps: queue.steps,
|
||||||
|
turbo: queue.turbo,
|
||||||
|
seed: Math.floor(Math.random() * 2_147_483_647),
|
||||||
|
cfg: queue.cfg,
|
||||||
|
fps: queue.fps,
|
||||||
|
samplerName: queue.samplerName,
|
||||||
|
scheduler: queue.scheduler,
|
||||||
|
duration: queue.segments[lastIndex]?.duration || source.duration,
|
||||||
|
stillId: queue.stillId,
|
||||||
|
stillFilename: queue.stillFilename,
|
||||||
|
referenceStillIds: queue.referenceStillIds,
|
||||||
|
sound: queue.sound,
|
||||||
|
extensions: remainingFromQueue(queue),
|
||||||
|
chainIndex: Math.max(0, lastIndex),
|
||||||
|
chainStep: lastIndex + 1,
|
||||||
|
chainTotal: queue.segments.length,
|
||||||
|
chainLabel: lastIndex <= 0 ? 'Initial' : `Extension ${lastIndex}`,
|
||||||
|
familyId: queue.familyId,
|
||||||
|
parentClipId: clipId,
|
||||||
|
workflow: queue.workflow,
|
||||||
|
useIdentityRefs: queue.useIdentityRefs,
|
||||||
|
queueId: queue.id,
|
||||||
|
queueAutoRun: count === 'all',
|
||||||
|
queueBudget: n
|
||||||
|
}
|
||||||
|
setQueueJob(queue.id, job.id)
|
||||||
|
await updateShotQueue(owner, queue.id, (next) => {
|
||||||
|
next.currentJobId = job.id
|
||||||
|
next.status = 'running'
|
||||||
|
}).catch(() => null)
|
||||||
|
|
||||||
|
emitChainJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
|
||||||
|
void continueQueuedExtensions(job, paramsFromJob(job), {
|
||||||
|
maxShots: n,
|
||||||
|
autoRun: count === 'all'
|
||||||
|
}).catch(async (error) => {
|
||||||
|
removeExtendTemp(job.library?.extendTmpDir)
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
if (job.status === 'error' || job.status === 'cancelled') {
|
||||||
|
await finishQueueBurst(owner, queue.id, false, message).catch(() => null)
|
||||||
|
setQueueJob(queue.id, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
job.status = 'error'
|
||||||
|
job.error = message
|
||||||
|
emitJob(job, { type: 'error', error: message, message })
|
||||||
|
await finishQueueBurst(owner, queue.id, false, message).catch(() => null)
|
||||||
|
setQueueJob(queue.id, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { jobId: job.id, queueId: queue.id, count: n, chainTotal: queue.segments.length, chainStep: lastIndex + 1 }
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-3
@@ -2,7 +2,8 @@ import { existsSync } from 'node:fs'
|
|||||||
import type { Job, JobEvent } from '~/server/utils/jobs'
|
import type { Job, JobEvent } from '~/server/utils/jobs'
|
||||||
import { getJob, restoreJob } from '~/server/utils/jobs'
|
import { getJob, restoreJob } from '~/server/utils/jobs'
|
||||||
import type { PendingJob } from '~/server/utils/pending'
|
import type { PendingJob } from '~/server/utils/pending'
|
||||||
import { isLastChainShot, libraryFromPending, pendingFromJob, writePendingJob, deletePendingJob } from '~/server/utils/pending'
|
import { isLastChainShot, libraryFromPending, pendingFromJob, remainingAfterCurrentShot, writePendingJob, deletePendingJob } from '~/server/utils/pending'
|
||||||
|
import { syncQueueFromJob } from '~/server/utils/shotQueue'
|
||||||
|
|
||||||
function classifyError(message: string) {
|
function classifyError(message: string) {
|
||||||
const lower = message.toLowerCase()
|
const lower = message.toLowerCase()
|
||||||
@@ -132,6 +133,7 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
|
|||||||
job.status = job.status === 'cancelled' ? 'cancelled' : 'error'
|
job.status = job.status === 'cancelled' ? 'cancelled' : 'error'
|
||||||
job.error = classifyError(error)
|
job.error = classifyError(error)
|
||||||
emitChainJob(job, { type: 'error', error: job.error, message: job.error })
|
emitChainJob(job, { type: 'error', error: job.error, message: job.error })
|
||||||
|
await syncQueueFromJob(job).catch(() => null)
|
||||||
if (job.id) deletePendingJob(job.id)
|
if (job.id) deletePendingJob(job.id)
|
||||||
removeExtendTemp(job.library?.extendTmpDir)
|
removeExtendTemp(job.library?.extendTmpDir)
|
||||||
await purgeComfyArtifacts({
|
await purgeComfyArtifacts({
|
||||||
@@ -212,6 +214,7 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
|
|||||||
job.library.thumb = undefined
|
job.library.thumb = undefined
|
||||||
job.library.name = nextClipPartName(clip.name)
|
job.library.name = nextClipPartName(clip.name)
|
||||||
job.library.parentClipId = clip.id
|
job.library.parentClipId = clip.id
|
||||||
|
await syncQueueFromJob(job, clip.id).catch(() => null)
|
||||||
await purgeComfyArtifacts({
|
await purgeComfyArtifacts({
|
||||||
video,
|
video,
|
||||||
imageName: job.library.imageName,
|
imageName: job.library.imageName,
|
||||||
@@ -242,11 +245,14 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
|
|||||||
job.library.extendTmpDir = undefined
|
job.library.extendTmpDir = undefined
|
||||||
}
|
}
|
||||||
job.status = 'complete'
|
job.status = 'complete'
|
||||||
|
const queuedRemaining = job.library ? remainingAfterCurrentShot(job.library).length : 0
|
||||||
emitJob(job, {
|
emitJob(job, {
|
||||||
type: 'complete',
|
type: 'complete',
|
||||||
message: job.library?.folderLocked
|
message: job.library?.folderLocked
|
||||||
? 'Saved to the locked folder. Unlock it to view.'
|
? 'Saved to the locked folder. Unlock it to view.'
|
||||||
: (job.library?.extendSourceClipId || (job.library?.chainTotal || 1) > 1 ? 'Extended video ready' : 'Video ready'),
|
: queuedRemaining > 0
|
||||||
|
? `Shot ${chainMeta(job).step} of ${chainMeta(job).total} saved. ${queuedRemaining} remaining on Queue.`
|
||||||
|
: (job.library?.extendSourceClipId || (job.library?.chainTotal || 1) > 1 ? 'Extended video ready' : 'Video ready'),
|
||||||
progress: 100,
|
progress: 100,
|
||||||
filename: job.library?.folderLocked ? undefined : video.filename,
|
filename: job.library?.folderLocked ? undefined : video.filename,
|
||||||
subfolder: job.library?.folderLocked ? undefined : video.subfolder,
|
subfolder: job.library?.folderLocked ? undefined : video.subfolder,
|
||||||
@@ -256,7 +262,9 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
|
|||||||
folderLocked: job.library?.folderLocked,
|
folderLocked: job.library?.folderLocked,
|
||||||
chainStep: job.library?.chainStep,
|
chainStep: job.library?.chainStep,
|
||||||
chainTotal: job.library?.chainTotal,
|
chainTotal: job.library?.chainTotal,
|
||||||
chainLabel: job.library?.chainLabel
|
chainLabel: job.library?.chainLabel,
|
||||||
|
queuedRemaining,
|
||||||
|
queueId: job.library?.queueId
|
||||||
})
|
})
|
||||||
deletePendingJob(job.id)
|
deletePendingJob(job.id)
|
||||||
job.saving = false
|
job.saving = false
|
||||||
|
|||||||
Reference in New Issue
Block a user