Tighten YuE lyrics validation and Comfy music workflow wiring.

Require section tags before queueing YuE and keep the ACE/YuE path changes that were already in progress.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-03 20:32:41 -05:00
co-authored by Cursor
parent 47f14d7be2
commit 17d4d4ffdc
8 changed files with 114 additions and 29 deletions
+64 -15
View File
@@ -390,6 +390,35 @@ async function tryRecover() {
} }
} }
async function attachLiveMusic(opts: {
studioId?: string
liveId: string
message?: string
progress?: number
}) {
const liveId = String(opts.liveId || '').trim()
if (!liveId) return
if (opts.studioId) studioJobId.value = opts.studioId
liveMusicId.value = opts.studioId || liveMusicId.value || liveId
queued.value = false
busy.value = true
settled = false
if (typeof opts.progress === 'number') progress.value = opts.progress
if (opts.message) status.value = opts.message
else if (!status.value || /waiting for a generate|waiting in the job queue|waiting for gpu/i.test(status.value)) {
status.value = selectedEngine.value === 'yue'
? 'YuE running on Comfy — Stage A can take 10–20+ minutes'
: 'Generating…'
}
if (jobId.value !== liveId) {
jobId.value = liveId
listen(liveId)
}
if (!recoverPoll) {
recoverPoll = setInterval(() => { void tryRecover() }, 8000)
}
}
async function refreshStudioQueue() { async function refreshStudioQueue() {
const data = await $fetch<{ const data = await $fetch<{
jobs?: Array<{ jobs?: Array<{
@@ -405,6 +434,23 @@ async function refreshStudioQueue() {
const musicRow = rows.find(job => job.kind === 'music' && (job.status === 'waiting' || job.status === 'running' || job.status === 'held')) const musicRow = rows.find(job => job.kind === 'music' && (job.status === 'waiting' || job.status === 'running' || job.status === 'held'))
liveMusicId.value = musicRow?.id || '' liveMusicId.value = musicRow?.id || ''
queueCount.value = data.waitingCount || rows.filter(job => job.status === 'waiting').length queueCount.value = data.waitingCount || rows.filter(job => job.status === 'waiting').length
if (musicRow?.status === 'running' && musicRow.liveJobId) {
await attachLiveMusic({
studioId: musicRow.id,
liveId: musicRow.liveJobId
})
} else if (musicRow && (musicRow.status === 'waiting' || (musicRow.status === 'held' && !musicRow.liveJobId))) {
studioJobId.value = musicRow.id
queued.value = true
busy.value = true
status.value = musicRow.status === 'held'
? 'Paused in the job queue'
: (selectedEngine.value === 'yue'
? 'Waiting for GPU… YuE is heavy; do not spam Generate'
: 'Waiting in the job queue…')
}
if (!studioJobId.value) return if (!studioJobId.value) return
const row = rows.find(job => job.id === studioJobId.value) const row = rows.find(job => job.id === studioJobId.value)
if (!row) { if (!row) {
@@ -429,16 +475,7 @@ async function refreshStudioQueue() {
return return
} }
if (row.status === 'running' && row.liveJobId) { if (row.status === 'running' && row.liveJobId) {
queued.value = false await attachLiveMusic({ studioId: row.id, liveId: row.liveJobId })
if (!jobId.value || jobId.value !== row.liveJobId) {
jobId.value = row.liveJobId
listen(row.liveJobId)
}
if (/waiting/i.test(status.value)) {
status.value = selectedEngine.value === 'yue'
? 'YuE running on Comfy — Stage A can take 10–20+ minutes'
: 'Generating…'
}
} }
if (row.status === 'error') { if (row.status === 'error') {
queued.value = false queued.value = false
@@ -449,13 +486,24 @@ async function refreshStudioQueue() {
return return
} }
if (row.liveJobId && row.liveJobId !== jobId.value) { if (row.liveJobId && row.liveJobId !== jobId.value) {
queued.value = false await attachLiveMusic({ studioId: row.id, liveId: row.liveJobId })
jobId.value = row.liveJobId
status.value = status.value || 'Generating…'
listen(row.liveJobId)
} }
} }
async function resumeActiveMusic() {
const active = await $fetch<{
music?: { jobId?: string; message?: string; progress?: number; status?: string }
} | null>('/api/generate/active').catch(() => null)
const liveId = active?.music?.jobId
if (!liveId) return
if (active?.music?.status === 'error' || active?.music?.status === 'complete') return
await attachLiveMusic({
liveId,
message: active?.music?.message,
progress: typeof active?.music?.progress === 'number' ? active.music.progress : undefined
})
}
function applyEvent(payload: Record<string, any>) { function applyEvent(payload: Record<string, any>) {
if (payload.message) status.value = payload.message if (payload.message) status.value = payload.message
if (typeof payload.progress === 'number') progress.value = payload.progress if (typeof payload.progress === 'number') progress.value = payload.progress
@@ -660,6 +708,7 @@ onMounted(async () => {
await loadLibrary() await loadLibrary()
await loadMusicPresets() await loadMusicPresets()
applyIncomingMusic() applyIncomingMusic()
await resumeActiveMusic()
await refreshStudioQueue() await refreshStudioQueue()
stopQueuePoll() stopQueuePoll()
queuePoll = setInterval(() => { void refreshStudioQueue() }, 8000) queuePoll = setInterval(() => { void refreshStudioQueue() }, 8000)
@@ -670,7 +719,7 @@ onMounted(async () => {
status.value = 'Loaded from library' status.value = 'Loaded from library'
return return
} }
if (!audioUrl.value) { if (!audioUrl.value && !busy.value) {
try { try {
const recovered = await $fetch<Record<string, any>>('/api/generate/recover', { method: 'POST', body: {} }) const recovered = await $fetch<Record<string, any>>('/api/generate/recover', { method: 'POST', body: {} })
if (recovered?.trackId && /recovered/i.test(String(recovered.message || ''))) { if (recovered?.trackId && /recovered/i.test(String(recovered.message || ''))) {
+1 -1
View File
@@ -72,7 +72,7 @@ export default defineEventHandler(async (event) => {
} }
return { return {
jobId: video?.jobId || edit?.jobId || music?.jobId || null, jobId: video?.jobId || edit?.jobId || null,
video, video,
edit, edit,
music music
+6 -1
View File
@@ -12,7 +12,8 @@ import {
parseMusicEngine, parseMusicEngine,
parseMusicSeed, parseMusicSeed,
sanitizeMusicLyrics, sanitizeMusicLyrics,
sanitizeMusicTags sanitizeMusicTags,
yueLyricsProblem
} from '~/utils/music' } from '~/utils/music'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@@ -62,6 +63,10 @@ export default defineEventHandler(async (event) => {
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked) const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
const engine = parseMusicEngine(body.engine) const engine = parseMusicEngine(body.engine)
if (engine === 'yue' && !instrumental) {
const problem = yueLyricsProblem(lyrics)
if (problem) throw createError({ statusCode: 400, statusMessage: problem })
}
const duration = clampMusicDuration(body.duration) const duration = clampMusicDuration(body.duration)
const steps = clampMusicSteps( const steps = clampMusicSteps(
body.steps, body.steps,
+2 -2
View File
@@ -19,10 +19,10 @@
"genres_prompt": "inspiring female uplifting pop airy vocal electronic bright", "genres_prompt": "inspiring female uplifting pop airy vocal electronic bright",
"lyrics_prompt": "[verse]\n\n[chorus]\n", "lyrics_prompt": "[verse]\n\n[chorus]\n",
"seed": 0, "seed": 0,
"run_n_segment": 2, "run_n_segment": 1,
"repetition_penalty": 1.1, "repetition_penalty": 1.1,
"prompt_start_time": 0, "prompt_start_time": 0,
"prompt_end_time": 45, "prompt_end_time": 30,
"max_new_tokens": 2944, "max_new_tokens": 2944,
"use_dual_tracks_prompt": false, "use_dual_tracks_prompt": false,
"use_audio_prompt": false, "use_audio_prompt": false,
+10
View File
@@ -195,6 +195,16 @@ export async function interruptComfy() {
return Boolean(res?.ok) return Boolean(res?.ok)
} }
/** Ask Comfy to drop cached models so a heavy YuE job starts with a clean 16GB card. */
export async function freeComfyVram() {
await comfyFetch('/free', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ unload_models: true, free_memory: true }),
signal: AbortSignal.timeout(15000)
}).catch(() => null)
}
export async function fetchHistory(promptId: string) { export async function fetchHistory(promptId: string) {
const res = await comfyFetch(`/history/${encodeURIComponent(promptId)}`) const res = await comfyFetch(`/history/${encodeURIComponent(promptId)}`)
if (!res.ok) return null if (!res.ok) return null
+6 -2
View File
@@ -1,5 +1,5 @@
import { createJob, emitJob, type Job } from '~/server/utils/jobs' import { createJob, emitJob, type Job } from '~/server/utils/jobs'
import { extractAudio, fetchHistory, fetchHistoryAll, findHistoryAudio, purgeComfyArtifacts, queuePrompt } from '~/server/utils/comfy' import { extractAudio, fetchHistory, fetchHistoryAll, findHistoryAudio, freeComfyVram, purgeComfyArtifacts, queuePrompt } from '~/server/utils/comfy'
import { comfyWsUrl } from '~/server/utils/comfy' import { comfyWsUrl } from '~/server/utils/comfy'
import { ensureComfyReady } from '~/server/utils/comfyLifecycle' import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
import { downloadComfyAudio, saveTrack } from '~/server/utils/library' import { downloadComfyAudio, saveTrack } from '~/server/utils/library'
@@ -33,7 +33,7 @@ function sleep(ms: number) {
function classifyError(message: string) { function classifyError(message: string) {
const lower = message.toLowerCase() const lower = message.toLowerCase()
if (lower.includes('out of memory') || lower.includes('exceed allowed memory') || (lower.includes('cuda') && lower.includes('alloc')) || lower.includes('vram')) { if (lower.includes('out of memory') || lower.includes('exceed allowed memory') || (lower.includes('cuda') && lower.includes('alloc')) || lower.includes('vram')) {
return 'ComfyUI ran out of VRAM. For YuE use shorter lyrics (fewer sections) or ACE-Step instead.' return 'ComfyUI ran out of VRAM on YuE. Sleep→Poke Comfy, don’t run video/image first, use one short [Chorus] only — or use ACE-Step.'
} }
if (lower.includes('econnrefused') || lower.includes('unreachable') || lower.includes('fetch failed')) { if (lower.includes('econnrefused') || lower.includes('unreachable') || lower.includes('fetch failed')) {
return 'ComfyUI host connection dropped. Confirm the desktop instance is running.' return 'ComfyUI host connection dropped. Confirm the desktop instance is running.'
@@ -372,6 +372,10 @@ export async function startMusicJob(params: MusicJobParams) {
emitJob(job, { type: 'status', message: `Queueing ${engineLabel}...`, progress: 6 }) emitJob(job, { type: 'status', message: `Queueing ${engineLabel}...`, progress: 6 })
await waitForSocket(job, 4000) await waitForSocket(job, 4000)
await assertMusicEngineNodes(params.engine) await assertMusicEngineNodes(params.engine)
if (params.engine === 'yue') {
emitJob(job, { type: 'status', message: 'Freeing Comfy VRAM for YuE…', progress: 5 })
await freeComfyVram()
}
const graph = buildMusicWorkflow({ const graph = buildMusicWorkflow({
tags: params.tags, tags: params.tags,
lyrics: params.instrumental ? '' : params.lyrics, lyrics: params.instrumental ? '' : params.lyrics,
+9 -8
View File
@@ -26,8 +26,8 @@ type WorkflowGraph = Record<string, WorkflowNode>
const ACE_V1_CKPT = 'ace_step_v1_3.5b.safetensors' const ACE_V1_CKPT = 'ace_step_v1_3.5b.safetensors'
const ACE_15_CKPT = 'ace_step_1.5_turbo_aio.safetensors' const ACE_15_CKPT = 'ace_step_1.5_turbo_aio.safetensors'
const YUE_S1_DEFAULT = 'C:\\Users\\ianjm\\AppData\\Local\\Comfy-Desktop\\ComfyUI-Shared\\models\\YuE\\YuE-s1-7B-anneal-en-cot' const YUE_S1_DEFAULT = 'C:\\Users\\ianjm\\AppData\\Local\\Comfy-Desktop\\ComfyUI-Shared\\models\\yue\\YuE-s1-7B-anneal-en-cot'
const YUE_S2_DEFAULT = 'C:\\Users\\ianjm\\AppData\\Local\\Comfy-Desktop\\ComfyUI-Shared\\models\\YuE\\YuE-s2-1B-general' const YUE_S2_DEFAULT = 'C:\\Users\\ianjm\\AppData\\Local\\Comfy-Desktop\\ComfyUI-Shared\\models\\yue\\YuE-s2-1B-general'
const YUE_NODE = 'YUE_Stage_A_Loader' const YUE_NODE = 'YUE_Stage_A_Loader'
export function musicFilenamePrefix() { export function musicFilenamePrefix() {
@@ -115,14 +115,14 @@ function buildYueWorkflow(params: MusicWorkflowParams): WorkflowGraph {
const graph = structuredClone(workflowYue as WorkflowGraph) const graph = structuredClone(workflowYue as WorkflowGraph)
const prefix = params.filenamePrefix || musicFilenamePrefix() const prefix = params.filenamePrefix || musicFilenamePrefix()
const tags = params.tags const tags = params.tags
const lyrics = params.lyrics || '[instrumental]\n' const lyrics = params.lyrics || '[Instrumental]\n'
// Beast 5080 16GB recipe (keep in sync with workflow_yue.json): // Beast 5080 16GB recipe (keep in sync with workflow_yue.json):
// - mmgp profile 2 only (3–5 enable quanto and crash under Comfy InferenceMode) // - mmgp profile 2 only (3–5 enable quanto and crash under Comfy InferenceMode)
// - max 2 Stage-A segments (3+ OOMs) // - exactly 1 Stage-A lyric section (2+ still OOMs beside xcodec)
// - max_new_tokens floor is 2944 (node min); do not go lower // - max_new_tokens floor is 2944 (node min); do not go lower
// - Triton/torch.compile off on Windows // - Triton/torch.compile off on Windows
const sections = lyrics.split(/\[[^\]]+\]/g).filter(part => part.trim()).length || 2 // - xcodec stays on CPU during Stage A (node patch)
const segments = Math.max(1, Math.min(2, sections)) const segments = 1
for (const node of Object.values(graph)) { for (const node of Object.values(graph)) {
if (node.class_type === 'YUE_Stage_A_Loader') { if (node.class_type === 'YUE_Stage_A_Loader') {
node.inputs.stage_A_repo = yueStageARepo() node.inputs.stage_A_repo = yueStageARepo()
@@ -138,10 +138,11 @@ function buildYueWorkflow(params: MusicWorkflowParams): WorkflowGraph {
node.inputs.stage2_batch_size = 1 node.inputs.stage2_batch_size = 1
} }
if (node.class_type === 'YUE_Stage_A_Sampler') { if (node.class_type === 'YUE_Stage_A_Sampler') {
node.inputs.genres_prompt = tags.replace(/,/g, ' ').replace(/\s+/g, ' ').trim() // Genres go to the model prompt only. Stage-A npy filenames are short (Windows MAX_PATH).
node.inputs.genres_prompt = tags.replace(/,/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 400)
node.inputs.lyrics_prompt = lyrics node.inputs.lyrics_prompt = lyrics
node.inputs.seed = params.seed node.inputs.seed = params.seed
node.inputs.prompt_end_time = Math.min(params.duration, 45) node.inputs.prompt_end_time = Math.min(params.duration, 30)
node.inputs.run_n_segment = segments node.inputs.run_n_segment = segments
node.inputs.max_new_tokens = 2944 node.inputs.max_new_tokens = 2944
node.inputs.use_dual_tracks_prompt = false node.inputs.use_dual_tracks_prompt = false
+16
View File
@@ -94,6 +94,22 @@ export function sanitizeMusicLyrics(raw: unknown, instrumental = false) {
return String(raw || '').replace(/\r\n/g, '\n').trim().slice(0, MUSIC_LYRICS_MAX) return String(raw || '').replace(/\r\n/g, '\n').trim().slice(0, MUSIC_LYRICS_MAX)
} }
/** YuE splits on [Section] headers. Returns how many sections are present. */
export function countYueLyricSections(lyrics: unknown) {
const text = String(lyrics || '')
const matches = text.match(/\[[^\]]+\]/g)
return matches ? matches.length : 0
}
export function yueLyricsProblem(lyrics: unknown): string | null {
const text = String(lyrics || '').trim()
if (!text) return 'YuE needs lyrics with section tags like [Verse 1] and [Chorus].'
if (countYueLyricSections(text) < 1) {
return 'YuE lyrics must include at least one [Section] tag (use the chips under Lyrics).'
}
return null
}
export function parseMusicSeed(raw: unknown) { export function parseMusicSeed(raw: unknown) {
const text = String(raw ?? '').trim().toLowerCase() const text = String(raw ?? '').trim().toLowerCase()
if (!text || text === 'random') return Math.floor(Math.random() * 2_147_483_647) if (!text || text === 'random') return Math.floor(Math.random() * 2_147_483_647)