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.',
+40 -13
View File
@@ -1,4 +1,4 @@
import { startMusicJob } from '~/server/utils/musicChain'
import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/server/utils/studioQueue'
import { comfyConfigured } from '~/server/utils/comfy'
import {
clampLyricsStrength,
@@ -67,27 +67,54 @@ export default defineEventHandler(async (event) => {
engine: engine === 'ace-step-1.5' ? 'ace15' : 'ace'
})
const job = await startMusicJob({
const samplerName = String(body.sampler_name || 'euler')
const scheduler = String(body.scheduler || 'simple')
const familyId = crypto.randomUUID()
const studio = await addStudioJob({
ownerKey,
folderId,
familyId,
kind: 'music',
payload: {
prompt: tags,
name: clipName,
tags,
lyrics,
duration,
folderId,
aspect: 'audio',
width: 0,
height: 0,
steps,
turbo: false,
seed,
cfg,
lyricsStrength,
instrumental,
fps: 0,
samplerName,
scheduler,
duration,
sound: !instrumental,
workflow: 'v1',
useIdentityRefs: false,
hideThumbnail: false,
folderLocked,
engine,
samplerName: String(body.sampler_name || 'euler'),
scheduler: String(body.scheduler || 'simple')
referenceStillIds: [null, null, null, null],
extensions: [],
queueAutoRun: false,
lyrics,
instrumental,
lyricsStrength,
musicEngine: engine
}
})
await kickStudioQueue()
let latest = listStudioJobs(ownerKey).find(item => item.id === studio.id)
if (!latest?.liveJobId && !(await videoJobsBusy())) {
await kickStudioQueue()
latest = listStudioJobs(ownerKey).find(item => item.id === studio.id)
}
const liveJobId = latest?.liveJobId || ''
return {
jobId: job.id,
queued: job.status === 'queued',
jobId: liveJobId || studio.id,
studioJobId: studio.id,
queued: !liveJobId,
seed,
duration,
steps,
+4 -1
View File
@@ -33,7 +33,8 @@ export default defineEventHandler(async (event) => {
: listJobs().filter((job) => {
if (job.status === 'complete' || job.status === 'error' || job.status === 'cancelled') return false
if (kind === 'edit') return job.kind === 'edit'
if (kind === 'video') return job.kind !== 'edit'
if (kind === 'music') return job.kind === 'music'
if (kind === 'video') return job.kind !== 'edit' && job.kind !== 'music'
return true
})
for (const job of targets) {
@@ -47,7 +48,9 @@ export default defineEventHandler(async (event) => {
for (const row of listStudioJobs(owner)) {
if (row.status !== 'running' && row.status !== 'held') continue
if (kind === 'edit' && row.kind !== 'edit') continue
if (kind === 'music' && row.kind !== 'music') continue
if (kind === 'video' && row.kind === 'edit') continue
if (kind === 'video' && row.kind === 'music') continue
await cancelStudioJob(owner, row.id).catch(() => null)
}
const ok = await interruptComfy()
+3 -1
View File
@@ -16,9 +16,11 @@ export default defineEventHandler((event) => {
const byId = new Map(queues.map(queue => [queue.id, queue]))
const jobs = active.map((job) => {
const shots = job.shotQueueId ? byId.get(job.shotQueueId) || null : null
const kind = job.kind === 'edit' ? 'edit' : 'video'
const kind = job.kind === 'edit' ? 'edit' : job.kind === 'music' ? 'music' : 'video'
const shotSummary = shots ? summarizeQueue(shots) : null
const plannedShots = shotSummary
? undefined
: kind === 'music'
? undefined
: kind === 'edit'
? (job.payload.passes?.length
+12
View File
@@ -44,6 +44,15 @@ function classifyError(message: string) {
return message
}
async function settleMusicStudio(job: Job) {
try {
const { onLiveVideoSettled } = await import('~/server/utils/studioQueue')
await onLiveVideoSettled(job)
} catch {
/* queue kick is best-effort */
}
}
function nodeLabel(node: string) {
if (!node) return ''
return NODE_LABELS[node] || ''
@@ -118,6 +127,7 @@ function watchMusicJob(job: Job): Promise<void> {
job.status = job.status === 'cancelled' ? 'cancelled' : 'error'
job.error = classifyError(error)
emitJob(job, { type: 'error', error: job.error, message: job.error })
await settleMusicStudio(job)
resolve()
}
@@ -183,6 +193,7 @@ function watchMusicJob(job: Job): Promise<void> {
job.error = `Audio generated but library save failed: ${message}`
emitJob(job, { type: 'error', error: job.error, message: job.error })
}
await settleMusicStudio(job)
job.saving = false
resolve()
return true
@@ -344,6 +355,7 @@ export async function startMusicJob(params: MusicJobParams) {
job.status = 'error'
job.error = classifyError(message)
emitJob(job, { type: 'error', error: job.error, message: job.error })
await settleMusicStudio(job)
}
}
})()
+53 -4
View File
@@ -11,7 +11,7 @@ import { imageV2StackSpecials } from '~/utils/imageV2'
import { allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks'
export type StudioJobStatus = 'waiting' | 'running' | 'held' | 'complete' | 'error' | 'cancelled'
export type StudioJobKind = 'video' | 'edit'
export type StudioJobKind = 'video' | 'edit' | 'music'
export interface StudioJobPayload {
prompt: string
@@ -68,6 +68,10 @@ export interface StudioJobPayload {
maskStillFilename?: string
refineStrength?: number
extendFromClipId?: string
lyrics?: string
instrumental?: boolean
lyricsStrength?: number
musicEngine?: import('~/utils/music').MusicEngine
}
export interface StudioJob {
@@ -209,7 +213,9 @@ export function listStudioJobs(owner: string) {
}
export function studioJobKind(job: Pick<StudioJob, 'kind'> | { kind?: string }) {
return job.kind === 'edit' ? 'edit' : 'video'
if (job.kind === 'edit') return 'edit'
if (job.kind === 'music') return 'music'
return 'video'
}
export function summarizeStudioJob(job: StudioJob) {
@@ -317,8 +323,10 @@ export async function addStudioJob(params: {
kind?: StudioJobKind
}) {
const now = Date.now()
const kind = params.kind === 'edit' ? 'edit' : 'video'
const shotCount = kind === 'edit'
const kind = params.kind === 'edit' ? 'edit' : params.kind === 'music' ? 'music' : 'video'
const shotCount = kind === 'music'
? 1
: kind === 'edit'
? 1 + (params.payload.passes?.length || 0)
: 1 + (params.payload.extensions?.length || 0)
const job: StudioJob = {
@@ -976,7 +984,48 @@ async function startStudioExtendJob(item: StudioJob) {
}
}
async function startStudioMusicJob(item: StudioJob) {
let live: import('~/server/utils/jobs').Job | undefined
try {
const { startMusicJob } = await import('~/server/utils/musicChain')
const payload = item.payload
live = await startMusicJob({
ownerKey: item.ownerKey,
folderId: payload.folderId,
name: payload.name,
tags: payload.prompt,
lyrics: payload.instrumental ? '' : (payload.lyrics || ''),
duration: payload.duration,
steps: payload.steps,
seed: payload.seed || Math.floor(Math.random() * 2_147_483_647),
cfg: payload.cfg,
lyricsStrength: payload.instrumental ? 0 : (payload.lyricsStrength ?? 0.9),
instrumental: payload.instrumental === true,
folderLocked: payload.folderLocked,
engine: payload.musicEngine || 'ace-step',
samplerName: payload.samplerName || 'euler',
scheduler: payload.scheduler || 'simple'
})
await markStudioLive(item.ownerKey, item.id, live.id)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (live && (live.status === 'queued' || live.status === 'uploading' || live.status === 'running')) {
live.status = 'error'
live.error = message
}
await patchStudioJob(item.ownerKey, item.id, (job) => {
job.status = 'error'
job.lastError = message
}).catch(() => null)
kickStudioQueue()
}
}
export async function startStudioJob(item: StudioJob) {
if (studioJobKind(item) === 'music') {
await startStudioMusicJob(item)
return
}
if (studioJobKind(item) === 'edit') {
await startStudioEditJob(item)
return