Pause only the current shot batch so a new Generate can run when Beast is idle, without auto-resuming the held list.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-28 08:42:55 -05:00
co-authored by Cursor
parent 7d783bd97e
commit 8598dab516
5 changed files with 265 additions and 123 deletions
+145 -77
View File
@@ -52,11 +52,11 @@
</div> </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-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="pauseArmed && anythingGenerating" class="rounded-2xl border border-amber-300/30 bg-amber-400/10 px-4 py-3 text-sm text-amber-100"> <p v-else-if="willPauseLatch" class="rounded-2xl border border-amber-300/30 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">
Will pause after this video. The current clip keeps generating; the next queued job will not start until you resume. Will pause after this video. The current clip keeps generating; this batch will stop after it finishes.
</p> </p>
<p v-else-if="pauseArmed" class="rounded-2xl border border-amber-300/30 bg-amber-400/10 px-4 py-3 text-sm text-amber-100"> <p v-else-if="heldPaused" class="rounded-2xl border border-amber-300/30 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">
Paused after current. Waiting jobs will not start until you press Resume queue. This batch is paused. It will not continue until you resume.
</p> </p>
<p v-else-if="!jobs.length && !orphans.length" class="glass rounded-3xl px-5 py-10 text-center text-sm text-zinc-400"> <p v-else-if="!jobs.length && !orphans.length" class="glass rounded-3xl px-5 py-10 text-center text-sm text-zinc-400">
Nothing queued. Generate from Studio — a 30-shot script takes one slot, and you can line up more jobs behind it. Nothing queued. Generate from Studio — a 30-shot script takes one slot, and you can line up more jobs behind it.
@@ -86,30 +86,32 @@
{{ job.cutIn ? 'Next after current shot' : 'Run next' }} {{ job.cutIn ? 'Next after current shot' : 'Run next' }}
</button> </button>
<template v-if="job.shots"> <template v-if="job.shots">
<button <template v-if="canProcessShots(job)">
type="button" <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" type="button"
:disabled="busyId === job.shots.id || job.shots.status === 'running' || job.shots.status === 'complete' || job.shots.pendingCount === 0" 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"
@click="process(job.shots, 1)" :disabled="busyId === job.shots.id || job.shots.status === 'complete' || job.shots.pendingCount === 0"
> @click="process(job.shots, 1)"
{{ continueLabel(job.shots) }} >
</button> {{ continueLabel(job.shots) }}
<button </button>
type="button" <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" type="button"
:disabled="busyId === job.shots.id || job.shots.status === 'running' || job.shots.status === 'complete' || job.shots.pendingCount < 2" 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"
@click="process(job.shots, Math.min(3, job.shots.pendingCount))" :disabled="busyId === job.shots.id || job.shots.status === 'complete' || job.shots.pendingCount < 2"
> @click="process(job.shots, Math.min(3, job.shots.pendingCount))"
Next {{ Math.min(3, job.shots.pendingCount) || 3 }} >
</button> Next {{ Math.min(3, job.shots.pendingCount) || 3 }}
<button </button>
type="button" <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" type="button"
:disabled="busyId === job.shots.id || job.shots.status === 'running' || job.shots.status === 'complete' || job.shots.pendingCount === 0" 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"
@click="process(job.shots, 'all')" :disabled="busyId === job.shots.id || job.shots.status === 'complete' || job.shots.pendingCount === 0"
> @click="process(job.shots, 'all')"
Run remaining >
</button> Run remaining
</button>
</template>
<button <button
v-if="canPauseJob(job)" v-if="canPauseJob(job)"
type="button" type="button"
@@ -117,10 +119,10 @@
:class="pauseToggleClass(jobIsArmed(job))" :class="pauseToggleClass(jobIsArmed(job))"
:disabled="pauseBusy" :disabled="pauseBusy"
:aria-pressed="jobIsArmed(job)" :aria-pressed="jobIsArmed(job)"
:title="jobIsArmed(job) ? 'Click to keep going after this video' : 'Finish the current video, then stop'" :title="jobPauseTitle(job)"
@click="togglePause({ job })" @click="togglePause({ job })"
> >
{{ jobIsArmed(job) ? 'Will pause after this' : 'Pause after this' }} {{ jobPauseLabel(job) }}
</button> </button>
</template> </template>
<button <button
@@ -130,10 +132,10 @@
:class="pauseToggleClass(jobIsArmed(job))" :class="pauseToggleClass(jobIsArmed(job))"
:disabled="pauseBusy" :disabled="pauseBusy"
:aria-pressed="jobIsArmed(job)" :aria-pressed="jobIsArmed(job)"
:title="jobIsArmed(job) ? 'Click to keep going after this video' : 'Finish the current video, then stop'" :title="jobPauseTitle(job)"
@click="togglePause({ job })" @click="togglePause({ job })"
> >
{{ jobIsArmed(job) ? 'Will pause after this' : 'Pause after this' }} {{ jobPauseLabel(job) }}
</button> </button>
<button <button
v-if="job.status === 'waiting' || job.status === 'held'" v-if="job.status === 'waiting' || job.status === 'held'"
@@ -225,41 +227,43 @@
<p v-if="queue.lastError" class="mt-1 text-xs text-red-300">{{ queue.lastError }}</p> <p v-if="queue.lastError" class="mt-1 text-xs text-red-300">{{ queue.lastError }}</p>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <template v-if="canProcessQueue(queue)">
type="button" <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" type="button"
:disabled="busyId === queue.id || queue.status === 'running' || queue.status === 'complete' || queue.pendingCount === 0" 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"
@click="process(queue, 1)" :disabled="busyId === queue.id || queue.status === 'complete' || queue.pendingCount === 0"
> @click="process(queue, 1)"
{{ continueLabel(queue) }} >
</button> {{ continueLabel(queue) }}
<button </button>
type="button" <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" type="button"
:disabled="busyId === queue.id || queue.status === 'running' || queue.status === 'complete' || queue.pendingCount < 2" 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"
@click="process(queue, Math.min(3, queue.pendingCount))" :disabled="busyId === queue.id || queue.status === 'complete' || queue.pendingCount < 2"
> @click="process(queue, Math.min(3, queue.pendingCount))"
Next {{ Math.min(3, queue.pendingCount) || 3 }} >
</button> Next {{ Math.min(3, queue.pendingCount) || 3 }}
<button </button>
type="button" <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" type="button"
:disabled="busyId === queue.id || queue.status === 'running' || queue.status === 'complete' || queue.pendingCount === 0" 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"
@click="process(queue, 'all')" :disabled="busyId === queue.id || queue.status === 'complete' || queue.pendingCount === 0"
> @click="process(queue, 'all')"
Run remaining >
</button> Run remaining
</button>
</template>
<button <button
v-if="queue.status === 'running' || queue.stopAfterCurrent" v-if="queue.status === 'running' || queue.stopAfterCurrent"
type="button" type="button"
class="min-h-10 rounded-full px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-50" class="min-h-10 rounded-full px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-50"
:class="pauseToggleClass(Boolean(queue.stopAfterCurrent) || queuePaused)" :class="pauseToggleClass(queueIsArmed(queue))"
:disabled="pauseBusy || (queue.status !== 'running' && !queue.stopAfterCurrent && !queuePaused)" :disabled="pauseBusy"
:aria-pressed="Boolean(queue.stopAfterCurrent) || queuePaused" :aria-pressed="queueIsArmed(queue)"
:title="(queue.stopAfterCurrent || queuePaused) ? 'Click to keep going after this video' : 'Finish the current video, then stop'" :title="queuePauseTitle(queue)"
@click="togglePause({ queue })" @click="togglePause({ queue })"
> >
{{ queue.stopAfterCurrent || queuePaused ? (queue.status === 'running' ? 'Will pause after this' : 'Paused after current') : 'Pause after this' }} {{ queuePauseLabel(queue) }}
</button> </button>
<button <button
v-if="queue.status !== 'running'" v-if="queue.status !== 'running'"
@@ -387,22 +391,26 @@ let pollTimer: ReturnType<typeof setInterval> | null = null
const canCutIn = computed(() => jobs.value.some(job => job.status === 'running' || job.status === 'held')) const canCutIn = computed(() => jobs.value.some(job => job.status === 'running' || job.status === 'held'))
const pauseBusy = computed(() => busyId.value === 'pause') const pauseBusy = computed(() => busyId.value === 'pause')
const anythingGenerating = computed(() => ( const anythingGenerating = computed(() => (
jobs.value.some(job => job.status === 'running' || job.shots?.status === 'running') jobs.value.some(job => jobIsGenerating(job))
|| orphans.value.some(queue => queue.status === 'running') || orphans.value.some(queue => queue.status === 'running')
)) ))
const pauseArmed = computed(() => ( const willPauseLatch = computed(() => (
queuePaused.value jobs.value.some(job => jobIsLatched(job))
|| jobs.value.some(job => job.pauseAfterCurrent || job.pausedByUser || job.shots?.stopAfterCurrent) || orphans.value.some(queue => queueIsLatched(queue))
|| orphans.value.some(queue => queue.stopAfterCurrent)
)) ))
const heldPaused = computed(() => (
jobs.value.some(job => jobIsHeldPaused(job))
|| orphans.value.some(queue => queueIsHeldPaused(queue))
))
const pauseArmed = computed(() => willPauseLatch.value || heldPaused.value)
const pauseToggleLabel = computed(() => { const pauseToggleLabel = computed(() => {
if (pauseArmed.value && anythingGenerating.value) return 'Will pause after this' if (willPauseLatch.value) return 'Will pause after this'
if (pauseArmed.value) return 'Resume queue' if (heldPaused.value) return 'Resume queue'
return 'Pause after current' return 'Pause after current'
}) })
const pauseToggleTitle = computed(() => { const pauseToggleTitle = computed(() => {
if (pauseArmed.value && anythingGenerating.value) return 'Click to keep going after this video' if (willPauseLatch.value) return 'Click to keep going after this video'
if (pauseArmed.value) return 'Start the next waiting job' if (heldPaused.value) return 'Resume the paused batch'
return 'Finish the current video, then stop. Does not cancel the one that’s generating.' return 'Finish the current video, then stop. Does not cancel the one that’s generating.'
}) })
const pauseDisabledReason = computed(() => { const pauseDisabledReason = computed(() => {
@@ -435,6 +443,9 @@ function progressLine(queue: Queue) {
const pauseNote = queue.stopAfterCurrent ? ' Will pause after this shot.' : '' const pauseNote = queue.stopAfterCurrent ? ' Will pause after this shot.' : ''
return `Generating shot ${next.index + 1} of ${queue.totalCount}. ${queue.completedCount} saved.${pauseNote}` return `Generating shot ${next.index + 1} of ${queue.totalCount}. ${queue.completedCount} saved.${pauseNote}`
} }
if (queueIsHeldPaused(queue) && next) {
return `Paused. ${queue.completedCount} of ${queue.totalCount} saved. Next is shot ${next.index + 1}.`
}
if (queue.completedCount === 0) return `Not started · ${queue.totalCount} shots` if (queue.completedCount === 0) return `Not started · ${queue.totalCount} shots`
if (next) return `Left off after shot ${queue.completedCount} of ${queue.totalCount}. Next is shot ${next.index + 1}.` if (next) return `Left off after shot ${queue.completedCount} of ${queue.totalCount}. Next is shot ${next.index + 1}.`
return `Shot ${queue.completedCount} of ${queue.totalCount}` return `Shot ${queue.completedCount} of ${queue.totalCount}`
@@ -448,18 +459,17 @@ function jobLine(job: StudioJobRow) {
} }
if (job.shots) { if (job.shots) {
const line = progressLine(job.shots) const line = progressLine(job.shots)
if (job.pauseAfterCurrent && job.shots.status === 'running' && !job.shots.stopAfterCurrent) { if (jobIsLatched(job) && !job.shots.stopAfterCurrent) {
return `${line} Will pause after this shot.` return `${line} Will pause after this shot.`
} }
if (job.pausedByUser) return `${line} Paused after current.`
return line return line
} }
if (job.status === 'held') { if (job.status === 'held') {
if (job.pausedByUser) return 'Paused after current. Next jobs will not start until you resume.' if (jobIsHeldPaused(job)) return 'Paused. This batch will not continue until you resume.'
return job.holdForCutIn ? 'Paused so another job can cut in' : 'Paused' return job.holdForCutIn ? 'Paused so another job can cut in' : 'Paused'
} }
if (job.status === 'running') { if (job.status === 'running') {
if (job.pauseAfterCurrent) return `Generating · will pause after this · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'}` if (jobIsLatched(job)) return `Generating · will pause after this · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'}`
return `Generating · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'}` return `Generating · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'}`
} }
return job.status return job.status
@@ -630,24 +640,82 @@ function pauseToggleClass(pressed: boolean) {
: 'border border-amber-300/50 text-amber-100 hover:border-amber-200 hover:bg-amber-400/10' : 'border border-amber-300/50 text-amber-100 hover:border-amber-200 hover:bg-amber-400/10'
} }
function canPauseJob(job: StudioJobRow) { function jobIsGenerating(job: StudioJobRow) {
return job.status === 'running' || job.shots?.status === 'running' return job.status === 'running' || job.shots?.status === 'running'
} }
function jobIsLatched(job: StudioJobRow) {
return jobIsGenerating(job) && Boolean(job.pauseAfterCurrent || job.shots?.stopAfterCurrent)
}
function jobIsHeldPaused(job: StudioJobRow) {
if (jobIsGenerating(job)) return false
return Boolean(job.pausedByUser || job.shots?.stopAfterCurrent)
}
function queueIsLatched(queue: Queue) {
return queue.status === 'running' && Boolean(queue.stopAfterCurrent)
}
function queueIsHeldPaused(queue: Queue) {
return queue.status !== 'running' && Boolean(queue.stopAfterCurrent)
}
function queueIsArmed(queue: Queue) {
return Boolean(queue.stopAfterCurrent)
}
function canProcessShots(job: StudioJobRow) {
return Boolean(job.shots) && !jobIsGenerating(job) && !jobIsHeldPaused(job)
}
function canProcessQueue(queue: Queue) {
return queue.status !== 'running' && !queueIsHeldPaused(queue)
}
function canPauseJob(job: StudioJobRow) {
return jobIsGenerating(job) || jobIsHeldPaused(job)
}
function jobIsArmed(job: StudioJobRow) { function jobIsArmed(job: StudioJobRow) {
return Boolean(job.pauseAfterCurrent || job.shots?.stopAfterCurrent || (queuePaused.value && job.status === 'running')) return jobIsLatched(job) || jobIsHeldPaused(job)
}
function jobPauseLabel(job: StudioJobRow) {
if (jobIsLatched(job)) return 'Will pause after this'
if (jobIsHeldPaused(job)) return 'Paused'
return 'Pause after this'
}
function jobPauseTitle(job: StudioJobRow) {
if (jobIsLatched(job)) return 'Click to keep going after this video'
if (jobIsHeldPaused(job)) return 'Resume this batch'
return 'Finish the current video, then stop'
}
function queuePauseLabel(queue: Queue) {
if (queueIsLatched(queue)) return 'Will pause after this'
if (queueIsHeldPaused(queue)) return 'Paused'
return 'Pause after this'
}
function queuePauseTitle(queue: Queue) {
if (queueIsLatched(queue)) return 'Click to keep going after this video'
if (queueIsHeldPaused(queue)) return 'Resume this batch'
return 'Finish the current video, then stop'
} }
async function togglePause(target?: { job?: StudioJobRow; queue?: Queue }) { async function togglePause(target?: { job?: StudioJobRow; queue?: Queue }) {
const armed = target?.job const armed = target?.job
? jobIsArmed(target.job) ? jobIsArmed(target.job)
: target?.queue : target?.queue
? Boolean(target.queue.stopAfterCurrent || queuePaused.value) ? queueIsArmed(target.queue)
: pauseArmed.value : pauseArmed.value
const resume = armed const resume = armed
if (target?.job) { if (target?.job) {
target.job.pauseAfterCurrent = !resume target.job.pauseAfterCurrent = !resume
if (target.job.shots) target.job.shots.stopAfterCurrent = !resume if (target.job.shots) target.job.shots.stopAfterCurrent = !resume
if (resume) target.job.pausedByUser = false
} }
if (target?.queue) target.queue.stopAfterCurrent = !resume if (target?.queue) target.queue.stopAfterCurrent = !resume
if (!target?.job && !target?.queue) queuePaused.value = !resume if (!target?.job && !target?.queue) queuePaused.value = !resume
+13 -7
View File
@@ -1,5 +1,5 @@
import { listShotQueues, summarizeQueue } from '~/server/utils/shotQueue' import { listShotQueues, summarizeQueue } from '~/server/utils/shotQueue'
import { isStudioQueuePaused, listStudioJobs, summarizeStudioJob } from '~/server/utils/studioQueue' import { listStudioJobs, summarizeStudioJob } from '~/server/utils/studioQueue'
export default defineEventHandler((event) => { export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event) const { owner } = assertLibraryOwner(event)
@@ -28,14 +28,20 @@ export default defineEventHandler((event) => {
pendingCount: shotSummary?.pendingCount ?? job.shotCount pendingCount: shotSummary?.pendingCount ?? job.shotCount
} }
}) })
const orphans = full
? queues
.filter(queue => !claimed.has(queue.id) && queue.status !== 'complete')
.map(summarizeQueue)
: []
return { return {
jobs, jobs,
orphans: full orphans,
? queues
.filter(queue => !claimed.has(queue.id) && queue.status !== 'complete')
.map(summarizeQueue)
: [],
waitingCount: jobs.filter(job => job.status === 'waiting').length, waitingCount: jobs.filter(job => job.status === 'waiting').length,
paused: isStudioQueuePaused(owner) paused: jobs.some(job => job.pausedByUser === true)
|| orphans.some(queue => queue.stopAfterCurrent === true && queue.status !== 'running'),
willPause: jobs.some(job => (
job.pauseAfterCurrent === true
|| (job.status === 'running' && Boolean(job.shotQueueId && byId.get(job.shotQueueId)?.stopAfterCurrent))
))
} }
}) })
+1 -2
View File
@@ -1,6 +1,6 @@
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 { isStudioQueuePaused, kickStudioQueue, listOwnersWithStudioQueues, listStudioJobs } from '~/server/utils/studioQueue' 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(() => {
@@ -15,7 +15,6 @@ export default defineNitroPlugin(() => {
} }
} }
for (const owner of listOwnersWithQueues()) { for (const owner of listOwnersWithQueues()) {
if (isStudioQueuePaused(owner)) continue
for (const queue of listShotQueues(owner)) { for (const queue of listShotQueues(owner)) {
if (claimedQueueIds.has(queue.id)) continue if (claimedQueueIds.has(queue.id)) continue
if (!queue.autoRun) continue if (!queue.autoRun) continue
+105 -35
View File
@@ -139,8 +139,28 @@ function mutateStore<T>(owner: string, fn: (store: StudioQueueStore) => T): Prom
return run return run
} }
function jobIsUserPaused(job: StudioJob) {
return job.pauseAfterCurrent === true || job.pausedByUser === true
}
function syncPausedFlag(store: StudioQueueStore) {
store.paused = store.jobs.some(jobIsUserPaused)
}
function migrateGlobalPause(store: StudioQueueStore) {
const anyJobFlag = store.jobs.some(jobIsUserPaused)
if (store.paused && !anyJobFlag) {
for (const job of store.jobs) {
if (job.status === 'running') job.pauseAfterCurrent = true
else if (job.status === 'held' && !job.holdForCutIn) job.pausedByUser = true
}
}
syncPausedFlag(store)
}
export function isStudioQueuePaused(owner: string) { export function isStudioQueuePaused(owner: string) {
return readStore(owner).paused === true const store = readStore(owner)
return store.jobs.some(jobIsUserPaused)
} }
export function listOwnersWithStudioQueues() { export function listOwnersWithStudioQueues() {
@@ -196,11 +216,9 @@ export function applyPersistedPauseToJob(job: Job) {
const row = store.jobs.find(item => item.liveJobId === job.id) const row = store.jobs.find(item => item.liveJobId === job.id)
|| (library.queueId ? store.jobs.find(item => item.shotQueueId === library.queueId) : undefined) || (library.queueId ? store.jobs.find(item => item.shotQueueId === library.queueId) : undefined)
const queue = library.queueId ? getShotQueue(library.ownerKey, library.queueId) : null const queue = library.queueId ? getShotQueue(library.ownerKey, library.queueId) : null
const paused = store.paused === true const paused = row?.pauseAfterCurrent === true
|| row?.pauseAfterCurrent === true
|| row?.pausedByUser === true || row?.pausedByUser === true
|| queue?.stopAfterCurrent === true || queue?.stopAfterCurrent === true
|| queue?.status === 'paused'
if (paused) { if (paused) {
library.stopAfterCurrent = true library.stopAfterCurrent = true
library.queueAutoRun = false library.queueAutoRun = false
@@ -385,7 +403,12 @@ function pickHeld(jobs: StudioJob[]) {
} }
function pickWaiting(jobs: StudioJob[]) { function pickWaiting(jobs: StudioJob[]) {
return jobs.find(job => job.status === 'waiting') || null const waiting = jobs.filter(job => job.status === 'waiting')
if (!waiting.length) return null
if (jobs.some(job => job.pausedByUser === true)) {
return waiting.reduce((latest, job) => job.createdAt >= latest.createdAt ? job : latest)
}
return waiting[0] || null
} }
export function kickStudioQueue() { export function kickStudioQueue() {
@@ -404,13 +427,13 @@ function pendingAlive(job: StudioJob) {
)) ))
} }
function repairStaleJobs(jobs: StudioJob[], paused: boolean) { function repairStaleJobs(jobs: StudioJob[]) {
for (const job of jobs) { for (const job of jobs) {
if (job.status !== 'running') continue if (job.status !== 'running') continue
const live = job.liveJobId ? getJob(job.liveJobId) : undefined const live = job.liveJobId ? getJob(job.liveJobId) : undefined
const liveBusy = live && (live.status === 'queued' || live.status === 'uploading' || live.status === 'running') const liveBusy = live && (live.status === 'queued' || live.status === 'uploading' || live.status === 'running')
if (liveBusy || pendingAlive(job)) continue if (liveBusy || pendingAlive(job)) continue
const holdPause = paused || job.pauseAfterCurrent === true || job.pausedByUser === true const holdPause = job.pauseAfterCurrent === true || job.pausedByUser === true
if (job.shotQueueId) { if (job.shotQueueId) {
job.status = 'held' job.status = 'held'
job.liveJobId = undefined job.liveJobId = undefined
@@ -439,15 +462,16 @@ async function dispatchStudioQueue() {
if (videoJobsBusy()) return if (videoJobsBusy()) return
for (const owner of listOwnersWithStudioQueues()) { for (const owner of listOwnersWithStudioQueues()) {
const store = await mutateStore(owner, (current) => { const store = await mutateStore(owner, (current) => {
repairStaleJobs(current.jobs, current.paused === true) migrateGlobalPause(current)
repairStaleJobs(current.jobs)
const next = pruneDone(current.jobs) const next = pruneDone(current.jobs)
current.jobs.splice(0, current.jobs.length, ...next) current.jobs.splice(0, current.jobs.length, ...next)
syncPausedFlag(current)
return { return {
paused: current.paused === true, paused: current.paused === true,
jobs: current.jobs.map(item => structuredClone(item)) jobs: current.jobs.map(item => structuredClone(item))
} }
}) })
if (store.paused) return
const cutIn = pickCutIn(store.jobs) const cutIn = pickCutIn(store.jobs)
if (cutIn) { if (cutIn) {
await startStudioJob(cutIn) await startStudioJob(cutIn)
@@ -654,10 +678,10 @@ export async function onLiveVideoSettled(job: Job) {
const rowNow = snapshot.jobs.find(item => item.liveJobId === job.id) const rowNow = snapshot.jobs.find(item => item.liveJobId === job.id)
|| snapshot.jobs.find(item => item.status === 'running' && item.shotQueueId && item.shotQueueId === job.library?.queueId) || snapshot.jobs.find(item => item.status === 'running' && item.shotQueueId && item.shotQueueId === job.library?.queueId)
|| snapshot.jobs.find(item => item.status === 'running' && !job.library?.queueId) || snapshot.jobs.find(item => item.status === 'running' && !job.library?.queueId)
const userPause = !failed && ( const userPause = !failed && rowNow?.holdForCutIn !== true && (
rowNow?.pauseAfterCurrent === true rowNow?.pauseAfterCurrent === true
|| (snapshot.paused && !rowNow?.holdForCutIn) || rowNow?.pausedByUser === true
|| (job.library?.stopAfterCurrent === true && !rowNow?.holdForCutIn) || job.library?.stopAfterCurrent === true
) )
const cutInHold = !failed && remaining > 0 && rowNow?.holdForCutIn === true && !userPause const cutInHold = !failed && remaining > 0 && rowNow?.holdForCutIn === true && !userPause
const interrupted = remaining > 0 && (userPause || cutInHold) const interrupted = remaining > 0 && (userPause || cutInHold)
@@ -666,9 +690,11 @@ export async function onLiveVideoSettled(job: Job) {
} }
if (userPause && remaining > 0) { if (userPause && remaining > 0) {
await mutateStore(owner, (store) => { await mutateStore(owner, (store) => {
store.paused = true
const row = store.jobs.find(item => item.liveJobId === job.id) || store.jobs.find(item => item.status === 'running') const row = store.jobs.find(item => item.liveJobId === job.id) || store.jobs.find(item => item.status === 'running')
if (!row) return if (!row) {
syncPausedFlag(store)
return
}
row.status = 'held' row.status = 'held'
row.liveJobId = undefined row.liveJobId = undefined
row.pauseAfterCurrent = false row.pauseAfterCurrent = false
@@ -676,6 +702,7 @@ export async function onLiveVideoSettled(job: Job) {
row.holdForCutIn = false row.holdForCutIn = false
row.resumeAutoRun = row.payload.queueAutoRun === true || row.resumeAutoRun === true row.resumeAutoRun = row.payload.queueAutoRun === true || row.resumeAutoRun === true
row.updatedAt = Date.now() row.updatedAt = Date.now()
syncPausedFlag(store)
}) })
} else if (cutInHold) { } else if (cutInHold) {
await mutate(owner, (jobs) => { await mutate(owner, (jobs) => {
@@ -689,9 +716,11 @@ export async function onLiveVideoSettled(job: Job) {
}) })
} else if (userPause) { } else if (userPause) {
await mutateStore(owner, (store) => { await mutateStore(owner, (store) => {
store.paused = true
const row = store.jobs.find(item => item.liveJobId === job.id) || store.jobs.find(item => item.status === 'running') const row = store.jobs.find(item => item.liveJobId === job.id) || store.jobs.find(item => item.status === 'running')
if (!row) return if (!row) {
syncPausedFlag(store)
return
}
row.status = job.status === 'cancelled' row.status = job.status === 'cancelled'
? 'cancelled' ? 'cancelled'
: job.status === 'complete' : job.status === 'complete'
@@ -704,6 +733,7 @@ export async function onLiveVideoSettled(job: Job) {
row.pausedByUser = false row.pausedByUser = false
row.lastError = job.error row.lastError = job.error
row.updatedAt = Date.now() row.updatedAt = Date.now()
syncPausedFlag(store)
}) })
} else { } else {
const status = job.status === 'cancelled' const status = job.status === 'cancelled'
@@ -737,11 +767,17 @@ export async function toggleStudioQueuePause(owner: string, options: {
const running = storeNow.jobs.find(job => job.status === 'running') const running = storeNow.jobs.find(job => job.status === 'running')
const target = options.jobId const target = options.jobId
? storeNow.jobs.find(job => job.id === options.jobId) ? storeNow.jobs.find(job => job.id === options.jobId)
: running || storeNow.jobs.find(job => job.pauseAfterCurrent || job.pausedByUser) : options.queueId
? storeNow.jobs.find(job => job.shotQueueId === options.queueId)
: running || storeNow.jobs.find(job => job.pauseAfterCurrent || job.pausedByUser)
const queueArmed = options.queueId const queueArmed = options.queueId
? listShotQueues(owner).some(queue => queue.id === options.queueId && queue.stopAfterCurrent === true) ? listShotQueues(owner).some(queue => queue.id === options.queueId && queue.stopAfterCurrent === true)
: false : target?.shotQueueId
const armed = target?.pauseAfterCurrent === true || queueArmed || (storeNow.paused && !running) ? listShotQueues(owner).some(queue => queue.id === target.shotQueueId && queue.stopAfterCurrent === true)
: false
const armed = target?.pauseAfterCurrent === true
|| target?.pausedByUser === true
|| queueArmed
const shouldPause = options.resume === true ? false : options.resume === false ? true : !armed const shouldPause = options.resume === true ? false : options.resume === false ? true : !armed
if (!shouldPause) { if (!shouldPause) {
@@ -750,55 +786,75 @@ export async function toggleStudioQueuePause(owner: string, options: {
&& Boolean(job.shotQueueId) && Boolean(job.shotQueueId)
&& job.pausedByUser === true && job.pausedByUser === true
&& (!options.jobId || job.id === options.jobId) && (!options.jobId || job.id === options.jobId)
&& (!options.queueId || job.shotQueueId === options.queueId)
)) ))
const restored = await mutateStore(owner, (store) => { const restored = await mutateStore(owner, (store) => {
store.paused = false
for (const job of store.jobs) { for (const job of store.jobs) {
if (options.jobId && job.id !== options.jobId) continue if (options.jobId && job.id !== options.jobId) continue
if (options.queueId && job.shotQueueId !== options.queueId) continue
job.pauseAfterCurrent = false job.pauseAfterCurrent = false
job.pausedByUser = false job.pausedByUser = false
} }
if (!options.jobId) { syncPausedFlag(store)
for (const job of store.jobs) {
job.pauseAfterCurrent = false
job.pausedByUser = false
}
}
return structuredClone(store) return structuredClone(store)
}) })
const rows = options.jobId const rows = options.jobId
? restored.jobs.filter(job => job.id === options.jobId) ? restored.jobs.filter(job => job.id === options.jobId)
: restored.jobs : options.queueId
? restored.jobs.filter(job => job.shotQueueId === options.queueId)
: restored.jobs
for (const row of rows) { for (const row of rows) {
if (row.liveJobId) applyLiveJobPause(getJob(row.liveJobId), false, row.payload.queueAutoRun === true || row.resumeAutoRun === true) if (row.liveJobId) applyLiveJobPause(getJob(row.liveJobId), false, row.payload.queueAutoRun === true || row.resumeAutoRun === true)
if (row.shotQueueId) await setShotQueuePause(owner, row.shotQueueId, false).catch(() => null) if (row.shotQueueId) await setShotQueuePause(owner, row.shotQueueId, false).catch(() => null)
} }
if (!options.jobId) { if (!options.jobId && !options.queueId) {
for (const queue of listShotQueues(owner)) { for (const queue of listShotQueues(owner)) {
if (queue.stopAfterCurrent) await setShotQueuePause(owner, queue.id, false).catch(() => null) if (queue.stopAfterCurrent) await setShotQueuePause(owner, queue.id, false).catch(() => null)
} }
} else if (options.queueId) {
const rowHasQueue = rows.some(job => job.shotQueueId === options.queueId)
if (!rowHasQueue) await setShotQueuePause(owner, options.queueId, false).catch(() => null)
} }
if (heldPaused[0] && !videoJobsBusy()) { if (heldPaused[0] && !videoJobsBusy()) {
await resumeHeldStudioJob(heldPaused[0]).catch(() => null) await resumeHeldStudioJob(heldPaused[0]).catch(() => null)
} else { } else {
if (heldPaused.length && videoJobsBusy()) {
await mutate(owner, (jobs) => {
for (const job of jobs) {
if (!heldPaused.some(item => item.id === job.id)) continue
job.holdForCutIn = true
job.pausedByUser = false
job.pauseAfterCurrent = false
job.updatedAt = Date.now()
}
}).catch(() => null)
}
kickStudioQueue() kickStudioQueue()
} }
return { paused: false, willPause: false } return { paused: false, willPause: false }
} }
const updated = await mutateStore(owner, (store) => { const updated = await mutateStore(owner, (store) => {
store.paused = true
const row = options.jobId const row = options.jobId
? store.jobs.find(job => job.id === options.jobId) ? store.jobs.find(job => job.id === options.jobId)
: store.jobs.find(job => job.status === 'running') : options.queueId
? store.jobs.find(job => job.shotQueueId === options.queueId)
: store.jobs.find(job => job.status === 'running')
if (row) { if (row) {
if (row.status !== 'running' && row.status !== 'held') { if (row.status !== 'running' && row.status !== 'held') {
throw createError({ statusCode: 409, statusMessage: 'Nothing is generating on that job' }) throw createError({ statusCode: 409, statusMessage: 'Nothing is generating on that job' })
} }
row.pauseAfterCurrent = row.status === 'running' if (row.status === 'running') {
row.pauseAfterCurrent = true
} else {
row.pausedByUser = true
row.pauseAfterCurrent = false
row.holdForCutIn = false
}
row.resumeAutoRun = row.payload.queueAutoRun === true || row.resumeAutoRun === true row.resumeAutoRun = row.payload.queueAutoRun === true || row.resumeAutoRun === true
row.updatedAt = Date.now() row.updatedAt = Date.now()
} }
syncPausedFlag(store)
return { return {
paused: store.paused, paused: store.paused,
job: row ? structuredClone(row) : null job: row ? structuredClone(row) : null
@@ -812,7 +868,13 @@ export async function toggleStudioQueuePause(owner: string, options: {
} }
for (const pending of listPendingJobs()) { for (const pending of listPendingJobs()) {
if (pending.ownerKey !== owner) continue if (pending.ownerKey !== owner) continue
if (queueId && pending.queueId && pending.queueId !== queueId) continue if (queueId) {
if (pending.queueId !== queueId) continue
} else if (updated.job?.liveJobId) {
if (pending.jobId !== updated.job.liveJobId) continue
} else {
continue
}
patchPendingJob(pending.jobId, { stopAfterCurrent: true, queueAutoRun: false }) patchPendingJob(pending.jobId, { stopAfterCurrent: true, queueAutoRun: false })
} }
const queued = queueId ? getShotQueue(owner, queueId) : null const queued = queueId ? getShotQueue(owner, queueId) : null
@@ -832,19 +894,27 @@ export async function toggleStudioQueuePause(owner: string, options: {
if (!updated.job && !queueId && !anyGenerating) { if (!updated.job && !queueId && !anyGenerating) {
throw createError({ statusCode: 409, statusMessage: 'Nothing is generating, so there is nothing to pause after' }) throw createError({ statusCode: 409, statusMessage: 'Nothing is generating, so there is nothing to pause after' })
} }
return { paused: true, willPause: true, jobId: updated.job?.id, queueId } return {
paused: true,
willPause: updated.job?.status === 'running' || Boolean(queued?.status === 'running') || anyGenerating,
jobId: updated.job?.id,
queueId
}
} }
export async function onStudioQueueBurstStarted(owner: string, queueId: string, liveJobId: string) { export async function onStudioQueueBurstStarted(owner: string, queueId: string, liveJobId: string) {
await mutateStore(owner, (store) => { await mutateStore(owner, (store) => {
const row = store.jobs.find(job => job.shotQueueId === queueId) const row = store.jobs.find(job => job.shotQueueId === queueId)
if (!row) return if (!row) {
store.paused = false syncPausedFlag(store)
return
}
row.status = 'running' row.status = 'running'
row.liveJobId = liveJobId row.liveJobId = liveJobId
row.pauseAfterCurrent = false row.pauseAfterCurrent = false
row.pausedByUser = false row.pausedByUser = false
row.holdForCutIn = false row.holdForCutIn = false
row.updatedAt = Date.now() row.updatedAt = Date.now()
syncPausedFlag(store)
}) })
} }
+1 -2
View File
@@ -9,7 +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, onStudioQueueBurstStarted, isStudioQueuePaused } from '~/server/utils/studioQueue' import { onLiveVideoSettled, onStudioQueueBurstStarted } from '~/server/utils/studioQueue'
import { import {
finishQueueBurst, finishQueueBurst,
getShotQueue, getShotQueue,
@@ -241,7 +241,6 @@ function seedCurrentVideo(job: Job) {
function chainShouldHold(job: Job) { function chainShouldHold(job: Job) {
if (!job.library) return false if (!job.library) return false
if (job.library.stopAfterCurrent) return true if (job.library.stopAfterCurrent) return true
if (isStudioQueuePaused(job.library.ownerKey)) return true
const queue = job.library.queueId const queue = job.library.queueId
? getShotQueue(job.library.ownerKey, job.library.queueId) ? getShotQueue(job.library.ownerKey, job.library.queueId)
: null : null