Add ACE-Step 1.5 and YuE on Music, and put Video/Image/Music cards back.

Recover finished Comfy audio when queueing hangs so a completed track is not stuck on Queueing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-03 07:42:54 -05:00
co-authored by Cursor
parent ce85e7f513
commit 78b158471b
17 changed files with 951 additions and 110 deletions
+15 -42
View File
@@ -7,50 +7,11 @@
@gpu="pollComfyHealth"
/>
<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">
<main class="mx-auto min-w-0 max-w-7xl space-y-6 px-4 py-6 sm:px-6">
<StudioKindCards :current="imageStudio ? 'image' : 'video'" />
<div class="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-stretch">
<section id="studio-input" class="glass min-w-0 max-w-full space-y-5 rounded-3xl p-5 sm:p-6">
<div>
<div class="mb-4 grid grid-cols-3 gap-3">
<button
type="button"
class="rounded-3xl border px-4 py-4 text-left transition"
:class="studioMode === 'video' ? 'border-amber-300 bg-amber-400/10 text-amber-50' : 'border-white/10 text-zinc-400 hover:border-white/20 hover:text-zinc-200'"
@click="selectStudioKind('video')"
>
<svg class="h-10 w-10" viewBox="0 0 40 40" fill="none" aria-hidden="true">
<rect x="4" y="10" width="24" height="20" rx="3" stroke="currentColor" stroke-width="2" />
<path d="M28 16 36 12v16l-8-4V16Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
<circle cx="12" cy="20" r="2" fill="currentColor" />
</svg>
<span class="mt-3 block font-display text-lg font-bold text-zinc-100">Video</span>
<span class="mt-0.5 block text-xs text-zinc-500">MiniMax H3 · LTX</span>
</button>
<button
type="button"
class="rounded-3xl border px-4 py-4 text-left transition"
:class="imageStudio ? 'border-amber-300 bg-amber-400/10 text-amber-50' : 'border-white/10 text-zinc-400 hover:border-white/20 hover:text-zinc-200'"
@click="selectStudioKind('image')"
>
<svg class="h-10 w-10" viewBox="0 0 40 40" fill="none" aria-hidden="true">
<rect x="6" y="8" width="28" height="24" rx="3" stroke="currentColor" stroke-width="2" />
<path d="m10 26 7-8 6 7 3-3 4 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="15" cy="15" r="2.5" fill="currentColor" />
</svg>
<span class="mt-3 block font-display text-lg font-bold text-zinc-100">Image</span>
<span class="mt-0.5 block text-xs text-zinc-500">Flux · Krea</span>
</button>
<NuxtLink
to="/music"
class="rounded-3xl border border-white/10 px-4 py-4 text-left text-zinc-400 transition hover:border-white/20 hover:text-zinc-200"
>
<svg class="h-10 w-10" viewBox="0 0 40 40" fill="none" aria-hidden="true">
<circle cx="20" cy="20" r="12" stroke="currentColor" stroke-width="2" />
<path d="M18 14v12l10-4V12l-10 2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
</svg>
<span class="mt-3 block font-display text-lg font-bold text-zinc-100">Music</span>
<span class="mt-0.5 block text-xs text-zinc-500">ACE-Step · vocals</span>
</NuxtLink>
</div>
<h2 class="font-display text-2xl font-bold">Input</h2>
<p class="text-sm text-zinc-400">{{ studioMode === 'editv2'
? (v2Mode === 'generate'
@@ -2108,6 +2069,7 @@
</template>
</div>
</section>
</div>
</main>
<ImageBatchModal
@@ -2611,6 +2573,12 @@ function selectStudioKind(kind: 'video' | 'image') {
studioMode.value = 'editv2'
outputFocus.value = 'edit'
}
function applyStudioQueryFromRoute() {
const studio = String(route.query.studio || '').toLowerCase()
if (studio === 'image') selectStudioKind('image')
else if (studio === 'video') selectStudioKind('video')
}
const imageComfyOk = ref(false)
const imageComfyBusy = ref(false)
const imageComfyConfigured = ref(false)
@@ -3990,6 +3958,7 @@ onMounted(async () => {
if (route.query.clip || route.query.still) applyIncomingLibraryItem()
else applyIncomingDirectorScript()
await applyIncomingQueuedJob()
applyStudioQueryFromRoute()
window.addEventListener('keydown', onStudioKey)
})
@@ -4020,6 +3989,10 @@ watch(studioMode, () => {
}
})
watch(() => route.query.studio, () => {
applyStudioQueryFromRoute()
})
watch([browseFolderId, libraryFilter], () => {
selectedKeys.value = []
lastSelectedKey.value = ''
+152 -8
View File
@@ -1,12 +1,14 @@
<template>
<div class="min-h-dvh min-w-0">
<AppHeader
subtitle="ACE-Step · vocals"
:subtitle="headerSubtitle"
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">
<main class="mx-auto min-w-0 max-w-7xl space-y-6 px-4 py-6 sm:px-6">
<StudioKindCards current="music" />
<div class="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-stretch">
<section class="glass min-w-0 max-w-full space-y-5 rounded-3xl p-5 sm:p-6">
<div>
<h2 class="font-display text-2xl font-bold">Music Generator</h2>
@@ -15,6 +17,56 @@
</p>
</div>
<div>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Engine</p>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm"
:class="engineFamily === 'ace' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="selectEngineFamily('ace')"
>
<span class="block font-semibold">ACE-Step</span>
<span class="text-xs text-zinc-400">Native Comfy checkpoint. Fast vocals.</span>
</button>
<div
class="cursor-pointer rounded-xl border px-3 py-2 text-left text-sm"
:class="engineFamily === 'yue' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
role="button"
tabindex="0"
@click="selectEngineFamily('yue')"
@keydown.enter.prevent="selectEngineFamily('yue')"
>
<span class="flex items-center gap-2">
<span class="font-semibold">YuE</span>
<InfoTip
aria-label="What YuE does"
text="YuE is a separate lyrics-to-song engine (stage-1 7B + stage-2 1B + xcodec). It needs the ComfyUI_YuE custom node on Beast and is slower and heavier than ACE-Step. Use it when you want a full sung track from lyrics rather than ACE-Step’s tag-conditioned sampler."
/>
</span>
<span class="text-xs text-zinc-400">Lyrics-to-song. Custom node.</span>
</div>
</div>
</div>
<div v-if="engineFamily === 'ace'" class="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm">
<span class="min-w-0">
<span class="flex items-center gap-2">
<span class="block font-medium text-zinc-200">ACE-Step 1.5</span>
<InfoTip
aria-label="What ACE-Step 1.5 does"
:text="ace15Info"
/>
</span>
<span class="mt-0.5 block text-xs text-zinc-500">Turbo AIO checkpoint. Different graph than v1, usually 8 steps.</span>
</span>
<label class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center" title="Toggle ACE-Step 1.5">
<input v-model="ace15" type="checkbox" class="peer sr-only" role="switch">
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
</label>
</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>
<label class="block text-sm">
@@ -66,9 +118,9 @@
:max="durationMax"
step="5"
>
<span class="mt-1 block text-[11px] text-zinc-500">{{ durationMin }}–{{ durationMax }} seconds</span>
<span class="mt-1 block text-[11px] text-zinc-500">{{ engineFamily === 'yue' ? 'YuE uses this as stage-A length' : `${durationMin}–${durationMax} seconds` }}</span>
</label>
<label class="block text-sm">
<label v-if="engineFamily !== 'yue'" class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">Steps · {{ steps }}</span>
<input
v-model.number="steps"
@@ -78,7 +130,7 @@
:max="stepsMax"
step="1"
>
<span class="mt-1 block text-[11px] text-zinc-500">30–50 is the usual range</span>
<span class="mt-1 block text-[11px] text-zinc-500">{{ ace15 ? '8 is the usual 1.5 turbo range' : '30–50 is the usual range' }}</span>
</label>
</div>
@@ -167,6 +219,7 @@
<NuxtLink to="/library" class="text-amber-200 hover:text-amber-100">Open Library</NuxtLink>
</p>
</section>
</div>
</main>
<QueuedJobModal :job-id="inspectJobId" @close="inspectJobId = ''" />
</div>
@@ -176,13 +229,18 @@
import {
DEFAULT_MUSIC_LYRICS,
DEFAULT_MUSIC_TAGS,
MUSIC_CFG_DEFAULT,
MUSIC_CFG_DEFAULT_15,
MUSIC_DURATION_DEFAULT,
MUSIC_DURATION_MAX,
MUSIC_DURATION_MIN,
MUSIC_STEPS_DEFAULT,
MUSIC_STEPS_DEFAULT_15,
MUSIC_STEPS_MAX,
MUSIC_STEPS_MIN,
MUSIC_STRUCTURE_CHIPS
MUSIC_STRUCTURE_CHIPS,
parseMusicEngine,
type MusicEngine
} from '~/utils/music'
import { ensureGenerationNotifyPermission, notifyGenerationComplete } from '~/utils/generationNotify'
import type { LibraryFolder } from '~/utils/libraryBrowse'
@@ -197,6 +255,8 @@ const durationMax = MUSIC_DURATION_MAX
const stepsMin = MUSIC_STEPS_MIN
const stepsMax = MUSIC_STEPS_MAX
const ace15Info = 'Turns on ACE-Step 1.5 Turbo, the all-in-one checkpoint already on Beast. 1.5 uses a different Comfy graph than v1: a 1.5 text encoder (tags, BPM, key, duration) and EmptyAceStep1.5LatentAudio, usually with 8 sampler steps and CFG 1. Songs often come back faster and cleaner. Lyrics-strength from v1 does not apply. Leave this off to stay on ACE-Step v1 3.5B, which is the graph that already generated on this GPU.'
const tags = ref(DEFAULT_MUSIC_TAGS)
const lyrics = ref(DEFAULT_MUSIC_LYRICS)
const instrumental = ref(false)
@@ -207,6 +267,8 @@ const name = ref('')
const folderId = ref('')
const folders = ref<LibraryFolder[]>([])
const lyricsEl = ref<HTMLTextAreaElement | null>(null)
const ace15 = ref(false)
const engineFamily = ref<'ace' | 'yue'>('ace')
const busy = ref(false)
const error = ref('')
const status = ref('')
@@ -226,8 +288,17 @@ let source: EventSource | null = null
let listenGen = 0
let settled = false
let queuePoll: ReturnType<typeof setInterval> | null = null
let recoverPoll: ReturnType<typeof setInterval> | null = null
const blocked = computed(() => !tags.value.trim() || (!instrumental.value && !lyrics.value.trim()) || !folderId.value)
const headerSubtitle = computed(() => {
if (engineFamily.value === 'yue') return 'YuE · lyrics-to-song'
return ace15.value ? 'ACE-Step 1.5 · vocals' : 'ACE-Step · vocals'
})
const selectedEngine = computed<MusicEngine>(() => {
if (engineFamily.value === 'yue') return 'yue'
return ace15.value ? 'ace-step-1.5' : 'ace-step'
})
const blockReason = computed(() => {
if (!folderId.value) return 'Choose a library folder before generating.'
if (!tags.value.trim()) return 'Add genre and style tags.'
@@ -235,6 +306,32 @@ const blockReason = computed(() => {
return ''
})
function selectEngineFamily(family: 'ace' | 'yue') {
engineFamily.value = family
if (family === 'ace' && ace15.value && steps.value === MUSIC_STEPS_DEFAULT) {
steps.value = MUSIC_STEPS_DEFAULT_15
}
if (family === 'ace' && !ace15.value && steps.value === MUSIC_STEPS_DEFAULT_15) {
steps.value = MUSIC_STEPS_DEFAULT
}
}
function applyEngine(engine: MusicEngine) {
if (engine === 'yue') {
engineFamily.value = 'yue'
ace15.value = false
return
}
engineFamily.value = 'ace'
ace15.value = engine === 'ace-step-1.5'
}
watch(ace15, (on) => {
if (engineFamily.value !== 'ace') return
if (on && (steps.value === MUSIC_STEPS_DEFAULT || steps.value >= 30)) steps.value = MUSIC_STEPS_DEFAULT_15
if (!on && steps.value === MUSIC_STEPS_DEFAULT_15) steps.value = MUSIC_STEPS_DEFAULT
})
function insertChip(chip: string) {
const el = lyricsEl.value
const token = `${chip}\n`
@@ -273,6 +370,26 @@ function stopQueuePoll() {
}
}
function stopRecoverPoll() {
if (recoverPoll) {
clearInterval(recoverPoll)
recoverPoll = null
}
}
async function tryRecover() {
if (settled || !busy.value || queued.value) return
try {
const recovered = await $fetch<Record<string, any>>('/api/generate/recover', {
method: 'POST',
body: { jobId: jobId.value || undefined }
})
if (recovered?.trackId) applyEvent(recovered)
} catch {
/* still running */
}
}
async function refreshStudioQueue() {
const data = await $fetch<{
jobs?: Array<{
@@ -341,6 +458,7 @@ function applyEvent(payload: Record<string, any>) {
stopListen()
queued.value = false
studioJobId.value = ''
stopRecoverPoll()
stopQueuePoll()
queuePoll = setInterval(() => { void refreshStudioQueue() }, 8000)
void notifyGenerationComplete('music')
@@ -352,6 +470,7 @@ function applyEvent(payload: Record<string, any>) {
status.value = error.value
queued.value = false
stopListen()
stopRecoverPoll()
stopQueuePoll()
queuePoll = setInterval(() => { void refreshStudioQueue() }, 8000)
}
@@ -375,6 +494,9 @@ function listen(id: string) {
listen(id)
}, 2000)
}
if (!recoverPoll) {
recoverPoll = setInterval(() => { void tryRecover() }, 8000)
}
}
async function generate() {
@@ -392,7 +514,11 @@ async function generate() {
trackId.value = ''
jobId.value = ''
studioJobId.value = ''
status.value = 'Queueing ACE-Step…'
status.value = engineFamily.value === 'yue'
? 'Queueing YuE…'
: ace15.value
? 'Queueing ACE-Step 1.5…'
: 'Queueing ACE-Step…'
try {
const started = await $fetch<{ jobId: string; studioJobId?: string; queued?: boolean; name?: string }>(
'/api/generate/music',
@@ -404,9 +530,11 @@ async function generate() {
duration: duration.value,
steps: steps.value,
seed: seed.value || 'random',
cfg: selectedEngine.value === 'ace-step-1.5' ? MUSIC_CFG_DEFAULT_15 : MUSIC_CFG_DEFAULT,
instrumental: instrumental.value,
folderId: folderId.value,
name: name.value.trim()
name: name.value.trim(),
engine: selectedEngine.value
}
}
)
@@ -422,6 +550,9 @@ async function generate() {
}
jobId.value = started.jobId
listen(started.jobId)
stopRecoverPoll()
recoverPoll = setInterval(() => { void tryRecover() }, 8000)
window.setTimeout(() => { void tryRecover() }, 12000)
} catch (err: any) {
busy.value = false
queued.value = false
@@ -448,6 +579,7 @@ function applyIncomingMusic() {
steps?: number
seed?: string
folderId?: string
engine?: string
}
if (typeof loaded.tags === 'string') tags.value = loaded.tags
if (typeof loaded.lyrics === 'string') lyrics.value = loaded.lyrics
@@ -457,6 +589,7 @@ function applyIncomingMusic() {
if (typeof loaded.seed === 'string') seed.value = loaded.seed
if (typeof loaded.name === 'string') name.value = loaded.name
if (loaded.folderId && folders.value.some(folder => folder.id === loaded.folderId)) folderId.value = loaded.folderId
if (loaded.engine) applyEngine(parseMusicEngine(loaded.engine))
status.value = 'Loaded from the queue'
} catch { /* ignore */ }
}
@@ -476,6 +609,7 @@ function applyPreset() {
if (typeof settings.duration === 'number') duration.value = settings.duration
if (typeof settings.steps === 'number') steps.value = settings.steps
if (typeof settings.seed === 'number') seed.value = String(settings.seed)
if (settings.engine) applyEngine(settings.engine)
name.value = preset.name
}
@@ -491,11 +625,21 @@ onMounted(async () => {
audioUrl.value = `/api/library/tracks/${query.track}/audio`
trackId.value = query.track
status.value = 'Loaded from library'
return
}
if (!audioUrl.value) {
try {
const recovered = await $fetch<Record<string, any>>('/api/generate/recover', { method: 'POST', body: {} })
if (recovered?.trackId && /recovered/i.test(String(recovered.message || ''))) {
applyEvent({ ...recovered, type: 'complete' })
}
} catch { /* nothing waiting in ComfyUI */ }
}
})
onBeforeUnmount(() => {
stopListen()
stopRecoverPoll()
stopQueuePoll()
})
</script>