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>
<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">
Will pause after this video. The current clip keeps generating; the next queued job will not start until you resume.
<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; this batch will stop after it finishes.
</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">
Paused after current. Waiting jobs will not start until you press Resume queue.
<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">
This batch is paused. It will not continue until you resume.
</p>
<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.
@@ -86,30 +86,32 @@
{{ job.cutIn ? 'Next after current shot' : 'Run next' }}
</button>
<template v-if="job.shots">
<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 === job.shots.id || job.shots.status === 'running' || job.shots.status === 'complete' || job.shots.pendingCount === 0"
@click="process(job.shots, 1)"
>
{{ continueLabel(job.shots) }}
</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 === job.shots.id || job.shots.status === 'running' || 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>
<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 === job.shots.id || job.shots.status === 'running' || job.shots.status === 'complete' || job.shots.pendingCount === 0"
@click="process(job.shots, 'all')"
>
Run remaining
</button>
<template v-if="canProcessShots(job)">
<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 === job.shots.id || job.shots.status === 'complete' || job.shots.pendingCount === 0"
@click="process(job.shots, 1)"
>
{{ continueLabel(job.shots) }}
</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 === 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>
<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 === job.shots.id || job.shots.status === 'complete' || job.shots.pendingCount === 0"
@click="process(job.shots, 'all')"
>
Run remaining
</button>
</template>
<button
v-if="canPauseJob(job)"
type="button"
@@ -117,10 +119,10 @@
:class="pauseToggleClass(jobIsArmed(job))"
:disabled="pauseBusy"
: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 })"
>
{{ jobIsArmed(job) ? 'Will pause after this' : 'Pause after this' }}
{{ jobPauseLabel(job) }}
</button>
</template>
<button
@@ -130,10 +132,10 @@
:class="pauseToggleClass(jobIsArmed(job))"
:disabled="pauseBusy"
: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 })"
>
{{ jobIsArmed(job) ? 'Will pause after this' : 'Pause after this' }}
{{ jobPauseLabel(job) }}
</button>
<button
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>
</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)"
>
{{ continueLabel(queue) }}
</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>
<template v-if="canProcessQueue(queue)">
<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 === 'complete' || queue.pendingCount === 0"
@click="process(queue, 1)"
>
{{ continueLabel(queue) }}
</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 === '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 === 'complete' || queue.pendingCount === 0"
@click="process(queue, 'all')"
>
Run remaining
</button>
</template>
<button
v-if="queue.status === 'running' || queue.stopAfterCurrent"
type="button"
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)"
:disabled="pauseBusy || (queue.status !== 'running' && !queue.stopAfterCurrent && !queuePaused)"
:aria-pressed="Boolean(queue.stopAfterCurrent) || queuePaused"
:title="(queue.stopAfterCurrent || queuePaused) ? 'Click to keep going after this video' : 'Finish the current video, then stop'"
:class="pauseToggleClass(queueIsArmed(queue))"
:disabled="pauseBusy"
:aria-pressed="queueIsArmed(queue)"
:title="queuePauseTitle(queue)"
@click="togglePause({ queue })"
>
{{ queue.stopAfterCurrent || queuePaused ? (queue.status === 'running' ? 'Will pause after this' : 'Paused after current') : 'Pause after this' }}
{{ queuePauseLabel(queue) }}
</button>
<button
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 pauseBusy = computed(() => busyId.value === 'pause')
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')
))
const pauseArmed = computed(() => (
queuePaused.value
|| jobs.value.some(job => job.pauseAfterCurrent || job.pausedByUser || job.shots?.stopAfterCurrent)
|| orphans.value.some(queue => queue.stopAfterCurrent)
const willPauseLatch = computed(() => (
jobs.value.some(job => jobIsLatched(job))
|| orphans.value.some(queue => queueIsLatched(queue))
))
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(() => {
if (pauseArmed.value && anythingGenerating.value) return 'Will pause after this'
if (pauseArmed.value) return 'Resume queue'
if (willPauseLatch.value) return 'Will pause after this'
if (heldPaused.value) return 'Resume queue'
return 'Pause after current'
})
const pauseToggleTitle = computed(() => {
if (pauseArmed.value && anythingGenerating.value) return 'Click to keep going after this video'
if (pauseArmed.value) return 'Start the next waiting job'
if (willPauseLatch.value) return 'Click to keep going after this video'
if (heldPaused.value) return 'Resume the paused batch'
return 'Finish the current video, then stop. Does not cancel the one that’s generating.'
})
const pauseDisabledReason = computed(() => {
@@ -435,6 +443,9 @@ function progressLine(queue: Queue) {
const pauseNote = queue.stopAfterCurrent ? ' Will pause after this shot.' : ''
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 (next) return `Left off after shot ${queue.completedCount} of ${queue.totalCount}. Next is shot ${next.index + 1}.`
return `Shot ${queue.completedCount} of ${queue.totalCount}`
@@ -448,18 +459,17 @@ function jobLine(job: StudioJobRow) {
}
if (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.`
}
if (job.pausedByUser) return `${line} Paused after current.`
return line
}
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'
}
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 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'
}
function canPauseJob(job: StudioJobRow) {
function jobIsGenerating(job: StudioJobRow) {
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) {
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 }) {
const armed = target?.job
? jobIsArmed(target.job)
: target?.queue
? Boolean(target.queue.stopAfterCurrent || queuePaused.value)
? queueIsArmed(target.queue)
: pauseArmed.value
const resume = armed
if (target?.job) {
target.job.pauseAfterCurrent = !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?.job && !target?.queue) queuePaused.value = !resume
+13 -7
View File
@@ -1,5 +1,5 @@
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) => {
const { owner } = assertLibraryOwner(event)
@@ -28,14 +28,20 @@ export default defineEventHandler((event) => {
pendingCount: shotSummary?.pendingCount ?? job.shotCount
}
})
const orphans = full
? queues
.filter(queue => !claimed.has(queue.id) && queue.status !== 'complete')
.map(summarizeQueue)
: []
return {
jobs,
orphans: full
? queues
.filter(queue => !claimed.has(queue.id) && queue.status !== 'complete')
.map(summarizeQueue)
: [],
orphans,
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 { 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'
export default defineNitroPlugin(() => {
@@ -15,7 +15,6 @@ export default defineNitroPlugin(() => {
}
}
for (const owner of listOwnersWithQueues()) {
if (isStudioQueuePaused(owner)) continue
for (const queue of listShotQueues(owner)) {
if (claimedQueueIds.has(queue.id)) 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
}
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) {
return readStore(owner).paused === true
const store = readStore(owner)
return store.jobs.some(jobIsUserPaused)
}
export function listOwnersWithStudioQueues() {
@@ -196,11 +216,9 @@ export function applyPersistedPauseToJob(job: Job) {
const row = store.jobs.find(item => item.liveJobId === job.id)
|| (library.queueId ? store.jobs.find(item => item.shotQueueId === library.queueId) : undefined)
const queue = library.queueId ? getShotQueue(library.ownerKey, library.queueId) : null
const paused = store.paused === true
|| row?.pauseAfterCurrent === true
const paused = row?.pauseAfterCurrent === true
|| row?.pausedByUser === true
|| queue?.stopAfterCurrent === true
|| queue?.status === 'paused'
if (paused) {
library.stopAfterCurrent = true
library.queueAutoRun = false
@@ -385,7 +403,12 @@ function pickHeld(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() {
@@ -404,13 +427,13 @@ function pendingAlive(job: StudioJob) {
))
}
function repairStaleJobs(jobs: StudioJob[], paused: boolean) {
function repairStaleJobs(jobs: StudioJob[]) {
for (const job of jobs) {
if (job.status !== 'running') continue
const live = job.liveJobId ? getJob(job.liveJobId) : undefined
const liveBusy = live && (live.status === 'queued' || live.status === 'uploading' || live.status === 'running')
if (liveBusy || pendingAlive(job)) continue
const holdPause = paused || job.pauseAfterCurrent === true || job.pausedByUser === true
const holdPause = job.pauseAfterCurrent === true || job.pausedByUser === true
if (job.shotQueueId) {
job.status = 'held'
job.liveJobId = undefined
@@ -439,15 +462,16 @@ async function dispatchStudioQueue() {
if (videoJobsBusy()) return
for (const owner of listOwnersWithStudioQueues()) {
const store = await mutateStore(owner, (current) => {
repairStaleJobs(current.jobs, current.paused === true)
migrateGlobalPause(current)
repairStaleJobs(current.jobs)
const next = pruneDone(current.jobs)
current.jobs.splice(0, current.jobs.length, ...next)
syncPausedFlag(current)
return {
paused: current.paused === true,
jobs: current.jobs.map(item => structuredClone(item))
}
})
if (store.paused) return
const cutIn = pickCutIn(store.jobs)
if (cutIn) {
await startStudioJob(cutIn)
@@ -654,10 +678,10 @@ export async function onLiveVideoSettled(job: Job) {
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' && !job.library?.queueId)
const userPause = !failed && (
const userPause = !failed && rowNow?.holdForCutIn !== true && (
rowNow?.pauseAfterCurrent === true
|| (snapshot.paused && !rowNow?.holdForCutIn)
|| (job.library?.stopAfterCurrent === true && !rowNow?.holdForCutIn)
|| rowNow?.pausedByUser === true
|| job.library?.stopAfterCurrent === true
)
const cutInHold = !failed && remaining > 0 && rowNow?.holdForCutIn === true && !userPause
const interrupted = remaining > 0 && (userPause || cutInHold)
@@ -666,9 +690,11 @@ export async function onLiveVideoSettled(job: Job) {
}
if (userPause && remaining > 0) {
await mutateStore(owner, (store) => {
store.paused = true
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.liveJobId = undefined
row.pauseAfterCurrent = false
@@ -676,6 +702,7 @@ export async function onLiveVideoSettled(job: Job) {
row.holdForCutIn = false
row.resumeAutoRun = row.payload.queueAutoRun === true || row.resumeAutoRun === true
row.updatedAt = Date.now()
syncPausedFlag(store)
})
} else if (cutInHold) {
await mutate(owner, (jobs) => {
@@ -689,9 +716,11 @@ export async function onLiveVideoSettled(job: Job) {
})
} else if (userPause) {
await mutateStore(owner, (store) => {
store.paused = true
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'
? 'cancelled'
: job.status === 'complete'
@@ -704,6 +733,7 @@ export async function onLiveVideoSettled(job: Job) {
row.pausedByUser = false
row.lastError = job.error
row.updatedAt = Date.now()
syncPausedFlag(store)
})
} else {
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 target = 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
? listShotQueues(owner).some(queue => queue.id === options.queueId && queue.stopAfterCurrent === true)
: false
const armed = target?.pauseAfterCurrent === true || queueArmed || (storeNow.paused && !running)
: target?.shotQueueId
? 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
if (!shouldPause) {
@@ -750,55 +786,75 @@ export async function toggleStudioQueuePause(owner: string, options: {
&& Boolean(job.shotQueueId)
&& job.pausedByUser === true
&& (!options.jobId || job.id === options.jobId)
&& (!options.queueId || job.shotQueueId === options.queueId)
))
const restored = await mutateStore(owner, (store) => {
store.paused = false
for (const job of store.jobs) {
if (options.jobId && job.id !== options.jobId) continue
if (options.queueId && job.shotQueueId !== options.queueId) continue
job.pauseAfterCurrent = false
job.pausedByUser = false
}
if (!options.jobId) {
for (const job of store.jobs) {
job.pauseAfterCurrent = false
job.pausedByUser = false
}
}
syncPausedFlag(store)
return structuredClone(store)
})
const rows = 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) {
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 (!options.jobId) {
if (!options.jobId && !options.queueId) {
for (const queue of listShotQueues(owner)) {
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()) {
await resumeHeldStudioJob(heldPaused[0]).catch(() => null)
} 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()
}
return { paused: false, willPause: false }
}
const updated = await mutateStore(owner, (store) => {
store.paused = true
const row = 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.status !== 'running' && row.status !== 'held') {
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.updatedAt = Date.now()
}
syncPausedFlag(store)
return {
paused: store.paused,
job: row ? structuredClone(row) : null
@@ -812,7 +868,13 @@ export async function toggleStudioQueuePause(owner: string, options: {
}
for (const pending of listPendingJobs()) {
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 })
}
const queued = queueId ? getShotQueue(owner, queueId) : null
@@ -832,19 +894,27 @@ export async function toggleStudioQueuePause(owner: string, options: {
if (!updated.job && !queueId && !anyGenerating) {
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) {
await mutateStore(owner, (store) => {
const row = store.jobs.find(job => job.shotQueueId === queueId)
if (!row) return
store.paused = false
if (!row) {
syncPausedFlag(store)
return
}
row.status = 'running'
row.liveJobId = liveJobId
row.pauseAfterCurrent = false
row.pausedByUser = false
row.holdForCutIn = false
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 { extractLastFrame, probeHasAudio } from '~/server/utils/ffmpeg'
import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
import { onLiveVideoSettled, onStudioQueueBurstStarted, isStudioQueuePaused } from '~/server/utils/studioQueue'
import { onLiveVideoSettled, onStudioQueueBurstStarted } from '~/server/utils/studioQueue'
import {
finishQueueBurst,
getShotQueue,
@@ -241,7 +241,6 @@ function seedCurrentVideo(job: Job) {
function chainShouldHold(job: Job) {
if (!job.library) return false
if (job.library.stopAfterCurrent) return true
if (isStudioQueuePaused(job.library.ownerKey)) return true
const queue = job.library.queueId
? getShotQueue(job.library.ownerKey, job.library.queueId)
: null