Put ACE-Step music jobs on the same studio queue as video and image.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-03 00:08:07 -05:00
co-authored by Cursor
parent 9cb8b9bda3
commit 53990192e4
8 changed files with 233 additions and 34 deletions
+5 -1
View File
@@ -1195,7 +1195,8 @@
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-zinc-100">{{ job.name }}</p>
<p class="mt-0.5 text-xs text-zinc-500">
<span v-if="job.kind === 'edit'" class="mr-1 text-amber-200/80">{{ job.imagePipeline === 'v2' ? 'IMAGE V2' : ((job.shotCount || 1) > 1 ? 'EDIT' : 'IMAGE') }} ·</span>
<span v-if="job.kind === 'music'" class="mr-1 text-amber-200/80">MUSIC ·</span>
<span v-else-if="job.kind === 'edit'" class="mr-1 text-amber-200/80">{{ job.imagePipeline === 'v2' ? 'IMAGE V2' : ((job.shotCount || 1) > 1 ? 'EDIT' : 'IMAGE') }} ·</span>
{{ studioJobStatusLabel(job) }}
· {{ studioJobShotLine(job) }}
</p>
@@ -5170,6 +5171,7 @@ function studioJobStatusLabel(job: { status: string; cutIn?: boolean; holdForCut
}
function studioJobShotLine(job: { kind?: string; shotCount: number; completedCount?: number; status: string }) {
if (job.kind === 'music') return 'ACE-Step'
if (job.kind === 'edit') {
const total = job.shotCount || 1
return total <= 1 ? 'image edit' : `${total} passes`
@@ -5223,6 +5225,8 @@ async function refreshStudioQueue() {
} else if (running.id && running.liveJobId === editJobId.value) {
outputStudioJobId.value = running.id
}
} else if (running?.kind === 'music') {
return
} else if (running?.liveJobId && running.liveJobId !== videoJobId.value && !videoBusy.value && !watchingLiveVideo() && !extendOpen.value) {
outputStudioJobId.value = running.id
videoJobId.value = running.liveJobId
+84 -2
View File
@@ -3,6 +3,7 @@
<AppHeader
subtitle="ACE-Step · vocals"
current="music"
:queue-count="queueCount"
/>
<main class="mx-auto grid min-w-0 max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-stretch">
@@ -121,6 +122,10 @@
{{ busy ? (status || 'Generating…') : 'Generate' }}
</button>
<p v-if="blockReason" class="text-xs text-zinc-400">{{ blockReason }}</p>
<p v-else-if="queued" class="text-xs text-zinc-400">
Waiting in the job queue.
<NuxtLink to="/queue" class="text-amber-200 hover:text-amber-100">Open Queue</NuxtLink>
</p>
</section>
<section class="glass min-w-0 max-w-full space-y-4 rounded-3xl p-5 sm:p-6">
@@ -188,10 +193,14 @@ const progress = ref(0)
const audioUrl = ref('')
const trackId = ref('')
const jobId = ref('')
const studioJobId = ref('')
const queued = ref(false)
const queueCount = ref(0)
const downloadName = ref('track.flac')
let source: EventSource | null = null
let listenGen = 0
let settled = false
let queuePoll: ReturnType<typeof setInterval> | null = null
const blocked = computed(() => !tags.value.trim() || (!instrumental.value && !lyrics.value.trim()) || !folderId.value)
const blockReason = computed(() => {
@@ -232,6 +241,55 @@ function stopListen() {
source = null
}
function stopQueuePoll() {
if (queuePoll) {
clearInterval(queuePoll)
queuePoll = null
}
}
async function refreshStudioQueue() {
const data = await $fetch<{
jobs?: Array<{
id: string
status: string
liveJobId?: string
lastError?: string
}>
waitingCount?: number
}>('/api/studio-queue').catch(() => ({ jobs: [] as Array<{ id: string; status: string; liveJobId?: string; lastError?: string }>, waitingCount: 0 }))
const rows = data.jobs || []
queueCount.value = data.waitingCount || rows.filter(job => job.status === 'waiting').length
if (!studioJobId.value) return
const row = rows.find(job => job.id === studioJobId.value)
if (!row) {
if (queued.value && !jobId.value) {
queued.value = false
}
return
}
if (row.status === 'waiting' || (row.status === 'held' && !row.liveJobId)) {
queued.value = true
busy.value = true
status.value = row.status === 'held' ? 'Paused in the job queue' : 'Waiting in the job queue…'
return
}
if (row.status === 'error') {
queued.value = false
busy.value = false
error.value = row.lastError || 'Generation failed'
status.value = error.value
stopQueuePoll()
return
}
if (row.liveJobId && row.liveJobId !== jobId.value) {
queued.value = false
jobId.value = row.liveJobId
status.value = status.value || 'Generating…'
listen(row.liveJobId)
}
}
function applyEvent(payload: Record<string, any>) {
if (payload.message) status.value = payload.message
if (typeof payload.progress === 'number') progress.value = payload.progress
@@ -253,6 +311,10 @@ function applyEvent(payload: Record<string, any>) {
audioUrl.value = `/api/media?${params.toString()}`
}
stopListen()
queued.value = false
studioJobId.value = ''
stopQueuePoll()
queuePoll = setInterval(() => { void refreshStudioQueue() }, 8000)
void notifyGenerationComplete('music')
}
if (payload.type === 'error' || payload.status === 'error') {
@@ -260,7 +322,10 @@ function applyEvent(payload: Record<string, any>) {
busy.value = false
error.value = payload.error || payload.message || 'Generation failed'
status.value = error.value
queued.value = false
stopListen()
stopQueuePoll()
queuePoll = setInterval(() => { void refreshStudioQueue() }, 8000)
}
}
@@ -293,12 +358,15 @@ async function generate() {
await ensureGenerationNotifyPermission()
busy.value = true
settled = false
queued.value = false
progress.value = 2
audioUrl.value = ''
trackId.value = ''
jobId.value = ''
studioJobId.value = ''
status.value = 'Queueing ACE-Step…'
try {
const started = await $fetch<{ jobId: string; name?: string }>(
const started = await $fetch<{ jobId: string; studioJobId?: string; queued?: boolean; name?: string }>(
'/api/generate/music',
{
method: 'POST',
@@ -314,11 +382,21 @@ async function generate() {
}
}
)
jobId.value = started.jobId
studioJobId.value = started.studioJobId || ''
if (started.name) downloadName.value = `${started.name}.flac`
await refreshStudioQueue()
stopQueuePoll()
queuePoll = setInterval(() => { void refreshStudioQueue() }, 2500)
if (started.queued) {
queued.value = true
status.value = 'Waiting in the job queue…'
return
}
jobId.value = started.jobId
listen(started.jobId)
} catch (err: any) {
busy.value = false
queued.value = false
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not queue music generation'
status.value = error.value
}
@@ -330,6 +408,9 @@ watch(instrumental, (on) => {
onMounted(async () => {
await loadLibrary()
await refreshStudioQueue()
stopQueuePoll()
queuePoll = setInterval(() => { void refreshStudioQueue() }, 8000)
const query = useRoute().query
if (typeof query.track === 'string' && query.track) {
audioUrl.value = `/api/library/tracks/${query.track}/audio`
@@ -340,5 +421,6 @@ onMounted(async () => {
onBeforeUnmount(() => {
stopListen()
stopQueuePoll()
})
</script>
+24 -4
View File
@@ -11,7 +11,7 @@
<div>
<h2 class="font-display text-2xl font-bold">Queue</h2>
<p class="mt-1 max-w-2xl text-sm text-zinc-400">
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.
Jobs you submitted from Studio or Music. 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-2 sm:gap-3">
@@ -47,7 +47,7 @@
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.
Nothing queued. Generate from Studio or Music — a 30-shot script takes one slot, and you can line up more jobs behind it.
</p>
<section
@@ -572,6 +572,22 @@ function progressLine(queue: Queue) {
}
function jobLine(job: StudioJobRow) {
if (job.kind === 'music') {
if (job.status === 'waiting') {
return job.cutIn
? 'Next after the current shot · ACE-Step'
: 'Waiting · ACE-Step'
}
if (job.status === 'held') {
if (jobIsHeldPaused(job)) return 'Paused. This track will not continue until you resume.'
return job.holdForCutIn ? 'Paused so another job can cut in' : 'Paused'
}
if (job.status === 'running') {
if (jobIsLatched(job)) return 'Generating track · will pause after this'
return 'Generating track'
}
return job.status
}
if (job.kind === 'edit') {
const passes = job.shotCount || 1
const passLine = passes > 1 ? `${passes} passes` : 'image edit'
@@ -790,7 +806,7 @@ function listenJob(job: StudioJobRow) {
void loadQueues()
}
if (payload.type === 'complete' && !(Number(payload.queuedRemaining) > 0)) {
void notifyGenerationComplete(job.kind === 'edit' || payload.stillId ? 'image' : 'video')
void notifyGenerationComplete(job.kind === 'music' ? 'music' : job.kind === 'edit' || payload.stillId ? 'image' : 'video')
}
if (payload.type === 'complete' || payload.type === 'error') {
source.close()
@@ -801,6 +817,7 @@ function listenJob(job: StudioJobRow) {
}
function jobKindLabel(job: StudioJobRow) {
if (job.kind === 'music') return 'MUSIC'
if (job.kind === 'edit') {
if (job.imagePipeline === 'v2') return 'IMAGE V2'
return (job.shotCount || 1) > 1 ? 'EDIT' : 'IMAGE'
@@ -824,6 +841,7 @@ function promptSnippet(text?: string) {
}
function shotProgressDetail(job: StudioJobRow) {
if (job.kind === 'music') return promptSnippet(job.prompt)
const queue = job.shots
if (!queue) return job.kind === 'edit' ? promptSnippet(job.prompt) : ''
const total = queue.totalCount || job.shotCount || 1
@@ -841,7 +859,9 @@ function askRemoveJob(job: StudioJobRow) {
name: job.name || 'Untitled job',
detail: shotProgressDetail(job),
consequence: generating
? 'This stops the job that’s generating on Beast and removes only this row. Other queue items stay. Saved clips stay in the library.'
? (job.kind === 'music'
? 'This stops the track that’s generating on Beast and removes only this row. Other queue items stay. Saved tracks stay in the library.'
: 'This stops the job that’s generating on Beast and removes only this row. Other queue items stay. Saved clips stay in the library.')
: pausedBatch
? 'This drops the rest of this shot list only. It will not resume, and it will not stop a different job that’s generating. Saved clips stay in the library.'
: 'This removes only this job from the queue. Other items stay. Saved clips stay in the library.',