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