Pause the queue after the current clip for real, and carry global locks plus labeled permanence stills through generate and shot prompts.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+140
-17
@@ -26,12 +26,23 @@
|
||||
Jobs you submitted from Studio. A long shot script is one job; its shots are the list underneath. Run next inserts a waiting job after the current shot as its own clip.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<p v-if="waitingCount" class="text-sm text-amber-100">{{ waitingCount }} job{{ waitingCount === 1 ? '' : 's' }} waiting</p>
|
||||
<p v-else-if="pendingCount" class="text-sm text-amber-100">{{ pendingCount }} shot{{ pendingCount === 1 ? '' : 's' }} waiting</p>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-red-400/40 px-3 py-1.5 text-xs text-red-200 hover:border-red-300 hover:text-red-100 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(pauseArmed)"
|
||||
:disabled="pauseBusy || (!anythingGenerating && !pauseArmed)"
|
||||
:aria-pressed="pauseArmed"
|
||||
:title="pauseDisabledReason || pauseToggleTitle"
|
||||
@click="togglePause()"
|
||||
>
|
||||
{{ pauseToggleLabel }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="min-h-10 rounded-full border border-red-400/40 px-3 py-1.5 text-xs text-red-200 hover:border-red-300 hover:text-red-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!clearableCount || Boolean(busyId)"
|
||||
@click="clearAll"
|
||||
>
|
||||
@@ -41,6 +52,12 @@
|
||||
</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>
|
||||
<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>
|
||||
<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.
|
||||
</p>
|
||||
@@ -94,14 +111,30 @@
|
||||
Run remaining
|
||||
</button>
|
||||
<button
|
||||
v-if="job.shots.status === 'running'"
|
||||
v-if="canPauseJob(job)"
|
||||
type="button"
|
||||
class="rounded-full border border-red-400/40 px-3 py-1.5 text-xs text-red-200"
|
||||
@click="pause(job.shots)"
|
||||
class="min-h-10 rounded-full px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-50"
|
||||
: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'"
|
||||
@click="togglePause({ job })"
|
||||
>
|
||||
Pause after this shot
|
||||
{{ jobIsArmed(job) ? 'Will pause after this' : 'Pause after this' }}
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
v-else-if="canPauseJob(job)"
|
||||
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(jobIsArmed(job))"
|
||||
:disabled="pauseBusy"
|
||||
:aria-pressed="jobIsArmed(job)"
|
||||
:title="jobIsArmed(job) ? 'Click to keep going after this video' : 'Finish the current video, then stop'"
|
||||
@click="togglePause({ job })"
|
||||
>
|
||||
{{ jobIsArmed(job) ? 'Will pause after this' : 'Pause after this' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="job.status === 'waiting' || job.status === 'held'"
|
||||
type="button"
|
||||
@@ -217,12 +250,16 @@
|
||||
Run remaining
|
||||
</button>
|
||||
<button
|
||||
v-if="queue.status === 'running'"
|
||||
v-if="queue.status === 'running' || queue.stopAfterCurrent"
|
||||
type="button"
|
||||
class="rounded-full border border-red-400/40 px-3 py-1.5 text-xs text-red-200"
|
||||
@click="pause(queue)"
|
||||
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'"
|
||||
@click="togglePause({ queue })"
|
||||
>
|
||||
Pause after this shot
|
||||
{{ queue.stopAfterCurrent || queuePaused ? (queue.status === 'running' ? 'Will pause after this' : 'Paused after current') : 'Pause after this' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="queue.status !== 'running'"
|
||||
@@ -312,6 +349,7 @@ type Queue = {
|
||||
completedCount: number
|
||||
totalCount: number
|
||||
currentJobId?: string
|
||||
stopAfterCurrent?: boolean
|
||||
segments: Segment[]
|
||||
}
|
||||
|
||||
@@ -324,6 +362,8 @@ type StudioJobRow = {
|
||||
shotQueueId?: string
|
||||
cutIn?: boolean
|
||||
holdForCutIn?: boolean
|
||||
pauseAfterCurrent?: boolean
|
||||
pausedByUser?: boolean
|
||||
lastError?: string
|
||||
shots?: Queue | null
|
||||
plannedShots?: { prompt: string; duration: number }[]
|
||||
@@ -336,6 +376,7 @@ const jobs = ref<StudioJobRow[]>([])
|
||||
const orphans = ref<Queue[]>([])
|
||||
const waitingCount = ref(0)
|
||||
const pendingCount = ref(0)
|
||||
const queuePaused = ref(false)
|
||||
const error = ref('')
|
||||
const busyId = ref('')
|
||||
const liveMessage = ref<Record<string, string>>({})
|
||||
@@ -344,6 +385,31 @@ const sources = new Map<string, EventSource>()
|
||||
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')
|
||||
|| 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 pauseToggleLabel = computed(() => {
|
||||
if (pauseArmed.value && anythingGenerating.value) return 'Will pause after this'
|
||||
if (pauseArmed.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'
|
||||
return 'Finish the current video, then stop. Does not cancel the one that’s generating.'
|
||||
})
|
||||
const pauseDisabledReason = computed(() => {
|
||||
if (pauseBusy.value) return 'Updating pause…'
|
||||
if (!anythingGenerating.value && !pauseArmed.value) return 'Nothing is generating, so there is nothing to pause after'
|
||||
return ''
|
||||
})
|
||||
const clearableCount = computed(() => (
|
||||
jobs.value.filter(job => job.status === 'waiting' || job.status === 'held').length
|
||||
+ orphans.value.filter(queue => queue.status !== 'running').length
|
||||
@@ -366,7 +432,8 @@ function progressLine(queue: Queue) {
|
||||
const next = nextSegment(queue)
|
||||
if (queue.status === 'complete') return `All ${queue.totalCount} shots saved.`
|
||||
if (queue.status === 'running' && next) {
|
||||
return `Generating shot ${next.index + 1} of ${queue.totalCount}. ${queue.completedCount} saved.`
|
||||
const pauseNote = queue.stopAfterCurrent ? ' Will pause after this shot.' : ''
|
||||
return `Generating shot ${next.index + 1} of ${queue.totalCount}. ${queue.completedCount} saved.${pauseNote}`
|
||||
}
|
||||
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}.`
|
||||
@@ -379,9 +446,22 @@ function jobLine(job: StudioJobRow) {
|
||||
? `Next after the current shot · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'} · own clip, not part of the current sequence`
|
||||
: `Waiting · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'}`
|
||||
}
|
||||
if (job.shots) return progressLine(job.shots)
|
||||
if (job.status === 'held') return job.holdForCutIn ? 'Paused so another job can cut in' : 'Paused'
|
||||
if (job.status === 'running') return `Generating · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'}`
|
||||
if (job.shots) {
|
||||
const line = progressLine(job.shots)
|
||||
if (job.pauseAfterCurrent && job.shots.status === 'running' && !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.'
|
||||
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'}`
|
||||
return `Generating · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'}`
|
||||
}
|
||||
return job.status
|
||||
}
|
||||
|
||||
@@ -453,6 +533,7 @@ async function loadQueues() {
|
||||
jobs?: StudioJobRow[]
|
||||
orphans?: Queue[]
|
||||
waitingCount?: number
|
||||
paused?: boolean
|
||||
}>('/api/studio-queue?full=1')
|
||||
const nextJobs = (data.jobs || []).map((job) => (
|
||||
job.shots ? { ...job, shots: mergeQueues([job.shots])[0] } : job
|
||||
@@ -460,6 +541,7 @@ async function loadQueues() {
|
||||
jobs.value = nextJobs
|
||||
orphans.value = mergeQueues(data.orphans || [])
|
||||
waitingCount.value = data.waitingCount || 0
|
||||
queuePaused.value = data.paused === true
|
||||
pendingCount.value = [
|
||||
...nextJobs.flatMap(job => job.shots ? [job.shots] : []),
|
||||
...orphans.value
|
||||
@@ -542,9 +624,50 @@ async function process(queue: Queue, count: number | 'all') {
|
||||
}
|
||||
}
|
||||
|
||||
async function pause(queue: Queue) {
|
||||
await $fetch(`/api/queues/${queue.id}/pause`, { method: 'POST' }).catch(() => null)
|
||||
await loadQueues()
|
||||
function pauseToggleClass(pressed: boolean) {
|
||||
return pressed
|
||||
? 'bg-amber-400 text-zinc-950 shadow-[0_0_0_2px_rgba(251,191,36,0.55)]'
|
||||
: 'border border-amber-300/50 text-amber-100 hover:border-amber-200 hover:bg-amber-400/10'
|
||||
}
|
||||
|
||||
function canPauseJob(job: StudioJobRow) {
|
||||
return job.status === 'running' || job.shots?.status === 'running'
|
||||
}
|
||||
|
||||
function jobIsArmed(job: StudioJobRow) {
|
||||
return Boolean(job.pauseAfterCurrent || job.shots?.stopAfterCurrent || (queuePaused.value && job.status === 'running'))
|
||||
}
|
||||
|
||||
async function togglePause(target?: { job?: StudioJobRow; queue?: Queue }) {
|
||||
const armed = target?.job
|
||||
? jobIsArmed(target.job)
|
||||
: target?.queue
|
||||
? Boolean(target.queue.stopAfterCurrent || queuePaused.value)
|
||||
: pauseArmed.value
|
||||
const resume = armed
|
||||
if (target?.job) {
|
||||
target.job.pauseAfterCurrent = !resume
|
||||
if (target.job.shots) target.job.shots.stopAfterCurrent = !resume
|
||||
}
|
||||
if (target?.queue) target.queue.stopAfterCurrent = !resume
|
||||
if (!target?.job && !target?.queue) queuePaused.value = !resume
|
||||
busyId.value = 'pause'
|
||||
error.value = ''
|
||||
try {
|
||||
if (target?.job) {
|
||||
await $fetch(`/api/studio-queue/${target.job.id}/pause`, { method: 'POST', body: { resume } })
|
||||
} else if (target?.queue) {
|
||||
await $fetch(`/api/queues/${target.queue.id}/pause`, { method: 'POST', body: { resume } })
|
||||
} else {
|
||||
await $fetch('/api/studio-queue/pause', { method: 'POST', body: { resume } })
|
||||
}
|
||||
await loadQueues()
|
||||
} catch (err: any) {
|
||||
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not update pause'
|
||||
await loadQueues().catch(() => null)
|
||||
} finally {
|
||||
busyId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(queue: Queue) {
|
||||
|
||||
Reference in New Issue
Block a user