From 53990192e4db439a4a6f5fac78fabefbb37439cb Mon Sep 17 00:00:00 2001 From: Towsty Date: Thu, 3 Sep 2026 00:08:07 -0500 Subject: [PATCH] Put ACE-Step music jobs on the same studio queue as video and image. Co-authored-by: Cursor --- pages/index.vue | 6 ++- pages/music.vue | 86 ++++++++++++++++++++++++++++++- pages/queue.vue | 28 ++++++++-- server/api/generate/music.post.ts | 63 +++++++++++++++------- server/api/interrupt.post.ts | 5 +- server/api/studio-queue.get.ts | 6 ++- server/utils/musicChain.ts | 12 +++++ server/utils/studioQueue.ts | 61 +++++++++++++++++++--- 8 files changed, 233 insertions(+), 34 deletions(-) diff --git a/pages/index.vue b/pages/index.vue index 2054a8c..2e6a3ff 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -1195,7 +1195,8 @@

{{ job.name }}

- {{ job.imagePipeline === 'v2' ? 'IMAGE V2' : ((job.shotCount || 1) > 1 ? 'EDIT' : 'IMAGE') }} · + MUSIC · + {{ job.imagePipeline === 'v2' ? 'IMAGE V2' : ((job.shotCount || 1) > 1 ? 'EDIT' : 'IMAGE') }} · {{ studioJobStatusLabel(job) }} · {{ studioJobShotLine(job) }}

@@ -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 diff --git a/pages/music.vue b/pages/music.vue index c6d578e..8d0d1a3 100644 --- a/pages/music.vue +++ b/pages/music.vue @@ -3,6 +3,7 @@
@@ -121,6 +122,10 @@ {{ busy ? (status || 'Generating…') : 'Generate' }}

{{ blockReason }}

+

+ Waiting in the job queue. + Open Queue +

@@ -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 | 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) { if (payload.message) status.value = payload.message if (typeof payload.progress === 'number') progress.value = payload.progress @@ -253,6 +311,10 @@ function applyEvent(payload: Record) { 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) { 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() }) diff --git a/pages/queue.vue b/pages/queue.vue index 90ef684..c7db14e 100644 --- a/pages/queue.vue +++ b/pages/queue.vue @@ -11,7 +11,7 @@

Queue

- 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.

@@ -47,7 +47,7 @@ This batch is paused. It will not continue until you resume.

- 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.

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.', diff --git a/server/api/generate/music.post.ts b/server/api/generate/music.post.ts index 3f52742..ad2ebf3 100644 --- a/server/api/generate/music.post.ts +++ b/server/api/generate/music.post.ts @@ -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, - name: clipName, - tags, - lyrics, - duration, - steps, - seed, - cfg, - lyricsStrength, - instrumental, - folderLocked, - engine, - samplerName: String(body.sampler_name || 'euler'), - scheduler: String(body.scheduler || 'simple') + familyId, + kind: 'music', + payload: { + prompt: tags, + name: clipName, + folderId, + aspect: 'audio', + width: 0, + height: 0, + steps, + turbo: false, + seed, + cfg, + fps: 0, + samplerName, + scheduler, + duration, + sound: !instrumental, + workflow: 'v1', + useIdentityRefs: false, + hideThumbnail: false, + folderLocked, + 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, diff --git a/server/api/interrupt.post.ts b/server/api/interrupt.post.ts index cd3f9f6..785782f 100644 --- a/server/api/interrupt.post.ts +++ b/server/api/interrupt.post.ts @@ -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() diff --git a/server/api/studio-queue.get.ts b/server/api/studio-queue.get.ts index 1b4b0c3..139089d 100644 --- a/server/api/studio-queue.get.ts +++ b/server/api/studio-queue.get.ts @@ -16,11 +16,13 @@ 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 === 'edit' + : kind === 'music' + ? undefined + : kind === 'edit' ? (job.payload.passes?.length ? [ { prompt: job.payload.prompt, duration: 0 }, diff --git a/server/utils/musicChain.ts b/server/utils/musicChain.ts index 947e281..53baaf8 100644 --- a/server/utils/musicChain.ts +++ b/server/utils/musicChain.ts @@ -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 { 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 { 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) } } })() diff --git a/server/utils/studioQueue.ts b/server/utils/studioQueue.ts index b63d251..3e2dd81 100644 --- a/server/utils/studioQueue.ts +++ b/server/utils/studioQueue.ts @@ -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 | { 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,10 +323,12 @@ export async function addStudioJob(params: { kind?: StudioJobKind }) { const now = Date.now() - const kind = params.kind === 'edit' ? 'edit' : 'video' - const shotCount = kind === 'edit' - ? 1 + (params.payload.passes?.length || 0) - : 1 + (params.payload.extensions?.length || 0) + 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 = { id: crypto.randomUUID(), ownerKey: params.ownerKey, @@ -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