Replace Comfy YuE with standalone YuEGP profile 1

This commit is contained in:
Towsty
2026-09-07 20:24:47 -05:00
parent eeff12cdab
commit c2b6032e49
33 changed files with 976 additions and 294 deletions
+2 -1
View File
@@ -175,7 +175,8 @@ function sanitizeMusicSettings(raw: unknown): MusicPresetSettings {
steps: clampMusicSteps(rec.steps),
seed: typeof rec.seed === 'number' && Number.isFinite(rec.seed) ? Math.max(0, Math.floor(rec.seed)) : undefined,
cfg: rec.cfg == null ? undefined : clampMusicCfg(rec.cfg),
engine: parseMusicEngine(rec.engine)
engine: parseMusicEngine(rec.engine),
...(parseMusicEngine(rec.engine) === 'yue' ? { yueProfile: rec.yueProfile === 3 ? 3 as const : 1 as const } : {})
}
}
+10
View File
@@ -1,6 +1,7 @@
export type JobStatus = 'queued' | 'uploading' | 'running' | 'complete' | 'error' | 'cancelled' | 'deferred'
export interface JobEvent {
audioExt?: string
type: string
message?: string
progress?: number
@@ -31,6 +32,7 @@ export interface JobEvent {
}
export interface Job {
yueGp?: boolean
musicActivity?: { checkedAt: number; running: boolean }
id: string
kind?: 'video' | 'edit' | 'music'
@@ -50,6 +52,7 @@ export interface Job {
hideThumbnail?: boolean
imageComfyHost?: string
library?: {
yueProfile?: 1 | 3
ownerKey: string
folderId: string
hideThumbnail: boolean
@@ -150,6 +153,12 @@ export function createJob(kind: Job['kind'] = 'video'): Job {
return job
}
export function restoreMusicJob(record: Pick<Job, 'id' | 'clientId' | 'startedAt' | 'library' | 'trackId'>) {
const job: Job = { ...record, yueGp: true, kind: 'music', status: 'running', message: 'Reconnecting to YuEGP', progress: 0, step: 0, maxStep: 0, events: [], listeners: new Set() }
jobs.set(job.id, job)
return job
}
export function getJob(id: string) {
return jobs.get(id)
}
@@ -195,6 +204,7 @@ export function jobSnapshot(job: Job) {
kind: job.kind || 'video',
engine: job.library?.engine,
musicActivity: job.musicActivity,
audioExt: job.library?.audioExt,
type: 'snapshot' as const,
status: job.status,
message: job.message,
+7
View File
@@ -81,6 +81,7 @@ export interface LibraryTrack {
createdAt: number
filename: string
ext: string
yueProfile?: 1 | 3
engine?: string
samplerName?: string
scheduler?: string
@@ -1767,6 +1768,10 @@ export function getTrack(owner: string, id: string) {
return track
}
export function findYueGpTrack(owner: string, jobId: string) {
return readCatalog(owner).tracks.find(track => track.comfyFilename === `yuegp-${jobId}.wav`)
}
export async function saveTrack(params: {
ownerKey: string
folderId: string
@@ -1780,6 +1785,7 @@ export async function saveTrack(params: {
instrumental: boolean
audio: Buffer
ext?: string
yueProfile?: 1 | 3
engine?: string
samplerName?: string
scheduler?: string
@@ -1805,6 +1811,7 @@ export async function saveTrack(params: {
filename: `audio.${ext}`,
ext,
engine: params.engine,
yueProfile: params.yueProfile,
samplerName: params.samplerName,
scheduler: params.scheduler,
lyricsStrength: params.lyricsStrength,
+8 -13
View File
@@ -1,5 +1,6 @@
import { startYueGpJob } from './yueGp'
import { createJob, emitJob, type Job } from '~/server/utils/jobs'
import { extractAudio, fetchHistory, fetchHistoryAll, findHistoryAudio, freeComfyVram, purgeComfyArtifacts, queuePrompt } from '~/server/utils/comfy'
import { extractAudio, fetchHistory, fetchHistoryAll, findHistoryAudio, purgeComfyArtifacts, queuePrompt } from '~/server/utils/comfy'
import { comfyWsUrl, comfyFetch } from '~/server/utils/comfy'
import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
import { downloadComfyAudio, saveTrack } from '~/server/utils/library'
@@ -21,6 +22,7 @@ export type MusicJobParams = {
lyricsStrength: number
instrumental: boolean
folderLocked?: boolean
yueProfile?: 1 | 3
engine?: MusicEngine
samplerName?: string
scheduler?: string
@@ -33,7 +35,7 @@ function sleep(ms: number) {
function classifyError(message: string) {
const lower = message.toLowerCase()
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 on YuE. Sleep→Poke Comfy, don’t run video/image first, use one short [Chorus] only — or use ACE-Step.'
return 'ComfyUI ran out of VRAM.'
}
if (lower.includes('econnrefused') || lower.includes('unreachable') || lower.includes('fetch failed')) {
return 'ComfyUI host connection dropped. Confirm the desktop instance is running.'
@@ -338,6 +340,7 @@ function watchMusicJob(job: Job): Promise<void> {
}
export async function startMusicJob(params: MusicJobParams) {
if (params.engine === 'yue') return startYueGpJob(params)
const job = createJob('music')
job.library = {
ownerKey: params.ownerKey,
@@ -381,14 +384,10 @@ export async function startMusicJob(params: MusicJobParams) {
})
})
job.status = 'running'
const engineLabel = params.engine === 'yue' ? 'YuE' : params.engine === 'ace-step-1.5' ? 'ACE-Step 1.5' : 'ACE-Step'
const engineLabel = params.engine === 'ace-step-1.5' ? 'ACE-Step 1.5' : 'ACE-Step'
emitJob(job, { type: 'status', message: `Queueing ${engineLabel}...`, progress: 6 })
await waitForSocket(job, 4000)
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({
tags: params.tags,
lyrics: params.instrumental ? '' : params.lyrics,
@@ -406,9 +405,7 @@ export async function startMusicJob(params: MusicJobParams) {
try {
const queued = await queuePrompt(graph, job.clientId, { timeoutMs: 25_000 })
if (queued?.prompt_id) job.promptId = queued.prompt_id
const runningMessage = params.engine === 'yue'
? 'YuE Stage A on Comfy — 16GB job, often 10–20+ minutes. Leave it alone.'
: 'Job queued on ComfyUI'
const runningMessage = 'Job queued on ComfyUI'
emitJob(job, { type: 'status', message: runningMessage, progress: 8 })
} catch (queueError: unknown) {
const statusCode = Number((queueError as { statusCode?: number; status?: number })?.statusCode
@@ -416,9 +413,7 @@ export async function startMusicJob(params: MusicJobParams) {
if (statusCode >= 400) throw queueError
emitJob(job, {
type: 'status',
message: params.engine === 'yue'
? 'YuE submitted — waiting for Comfy to start Stage A…'
: 'Waiting for ComfyUI to pick up the job...',
message: 'Waiting for ComfyUI to pick up the job...',
progress: 8
})
}
+3 -69
View File
@@ -1,6 +1,5 @@
import workflowAceStep from '../assets/workflow_ace_step.json'
import workflowAceStep15 from '../assets/workflow_ace_step_15.json'
import workflowYue from '../assets/workflow_yue.json'
import type { MusicEngine } from '~/utils/music'
import { parseMusicBpm } from '~/utils/music'
@@ -26,22 +25,10 @@ type WorkflowGraph = Record<string, WorkflowNode>
const ACE_V1_CKPT = 'ace_step_v1_3.5b.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_S2_DEFAULT = 'C:\\Users\\ianjm\\AppData\\Local\\Comfy-Desktop\\ComfyUI-Shared\\models\\yue\\YuE-s2-1B-general'
const YUE_NODE = 'YUE_Stage_A_Loader'
export function musicFilenamePrefix() {
return String(useRuntimeConfig().comfyAudioPrefix || process.env.COMFY_AUDIO_PREFIX || 'audio/AIGen')
}
function yueStageARepo() {
return String(process.env.YUE_STAGE_A || useRuntimeConfig().yueStageA || YUE_S1_DEFAULT)
}
function yueStageBRepo() {
return String(process.env.YUE_STAGE_B || useRuntimeConfig().yueStageB || YUE_S2_DEFAULT)
}
function applyPrefix(graph: WorkflowGraph, prefix: string) {
for (const node of Object.values(graph)) {
if ((node.class_type === 'SaveAudio' || node.class_type === 'SaveAudioMP3') && 'filename_prefix' in node.inputs) {
@@ -111,68 +98,16 @@ function buildAce15Workflow(params: MusicWorkflowParams): WorkflowGraph {
return graph
}
function buildYueWorkflow(params: MusicWorkflowParams): WorkflowGraph {
const graph = structuredClone(workflowYue as WorkflowGraph)
const prefix = params.filenamePrefix || musicFilenamePrefix()
const tags = params.tags
const lyrics = params.lyrics || '[Instrumental]\n'
// Beast 5080 16GB recipe (keep in sync with workflow_yue.json):
// - mmgp profile 2 only (3–5 enable quanto and crash under Comfy InferenceMode)
// - exactly 1 Stage-A lyric section (2+ still OOMs beside xcodec)
// - max_new_tokens floor is 2944 (node min); do not go lower
// - Triton/torch.compile off on Windows
// - xcodec stays on CPU during Stage A (node patch)
const segments = 1
for (const node of Object.values(graph)) {
if (node.class_type === 'YUE_Stage_A_Loader') {
node.inputs.stage_A_repo = yueStageARepo()
node.inputs.quantization_model = 'fp16'
node.inputs.use_mmgp = true
node.inputs.stage1_cache_size = 8192
node.inputs.mmgp_profile = 2
}
if (node.class_type === 'YUE_Stage_B_Loader') {
node.inputs.stage_B_repo = yueStageBRepo()
node.inputs.use_mmgp = true
node.inputs.stage2_cache_size = 4096
node.inputs.stage2_batch_size = 1
}
if (node.class_type === 'YUE_Stage_A_Sampler') {
// 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.seed = params.seed
node.inputs.prompt_end_time = Math.min(params.duration, 30)
node.inputs.run_n_segment = segments
node.inputs.max_new_tokens = 2944
node.inputs.use_dual_tracks_prompt = false
node.inputs.use_audio_prompt = false
node.inputs.offload_model = true
node.inputs.stage1_no_guidance = true
}
}
applyPrefix(graph, prefix)
return graph
}
export function buildMusicWorkflow(params: MusicWorkflowParams): WorkflowGraph {
const engine = params.engine || 'ace-step'
if (engine === 'yue') return buildYueWorkflow(params)
if (engine === 'yue') throw new Error('YuE requires the standalone YuEGP backend.')
if (engine === 'ace-step-1.5') return buildAce15Workflow(params)
return buildAceV1Workflow(params)
}
export async function assertMusicEngineNodes(engine: MusicEngine | undefined) {
const { comfyHasClassType } = await import('~/server/utils/comfy')
if (engine === 'yue') {
const present = await comfyHasClassType(YUE_NODE)
if (present === false) {
throw createError({
statusCode: 400,
statusMessage: 'YuE is not installed on Beast. Clone ComfyUI_YuE into custom_nodes and restart Comfy, then try again.'
})
}
}
if (engine === 'yue') throw new Error('YuE cannot run on Comfy.')
if (engine === 'ace-step-1.5') {
const present = await comfyHasClassType('TextEncodeAceStepAudio1.5')
if (present === false) {
@@ -186,6 +121,5 @@ export async function assertMusicEngineNodes(engine: MusicEngine | undefined) {
export const MUSIC_NODE_LABELS: Record<string, string> = Object.fromEntries([
...Object.entries(workflowAceStep as WorkflowGraph).map(([id, node]) => [id, node._meta?.title || `Node ${id}`]),
...Object.entries(workflowAceStep15 as WorkflowGraph).map(([id, node]) => [id, node._meta?.title || `Node ${id}`]),
...Object.entries(workflowYue as WorkflowGraph).map(([id, node]) => [id, node._meta?.title || `Node ${id}`])
...Object.entries(workflowAceStep15 as WorkflowGraph).map(([id, node]) => [id, node._meta?.title || `Node ${id}`])
])
+6
View File
@@ -90,6 +90,7 @@ export function inspectLiveMusicJob(job: Job) {
seed: lib?.seed,
cfg: lib?.cfg,
engine: lib?.engine,
yueProfile: lib?.yueProfile,
lyricsStrength: lib?.lyricsStrength,
hideThumbnail: true,
folderLocked: lib?.folderLocked === true
@@ -110,6 +111,11 @@ export async function cancelLiveMusicJob(owner: string, id: string) {
if (!live || live.kind !== 'music' || live.library?.ownerKey !== owner) {
throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
}
if (live.yueGp) {
const { cancelYueGpJob } = await import('./yueGp')
await cancelYueGpJob(live)
return
}
live.status = 'cancelled'
if (live.library) live.library.stopAfterCurrent = true
emitJob(live, { type: 'error', error: 'Job interrupted.', message: 'Job interrupted.' })
+20 -5
View File
@@ -76,6 +76,7 @@ export interface StudioJobPayload {
lyrics?: string
instrumental?: boolean
lyricsStrength?: number
yueProfile?: 1 | 3
musicEngine?: import('~/utils/music').MusicEngine
}
@@ -283,6 +284,7 @@ function failZombieLiveJob(job: Job, error: string) {
function sweepStaleLiveJobs() {
const now = Date.now()
for (const job of listJobs()) {
if (job.yueGp) continue
if (job.saving) continue
if (job.status === 'queued' && !job.promptId && now - job.startedAt >= QUEUED_GRACE_MS) {
failZombieLiveJob(job, 'Job never started')
@@ -302,6 +304,7 @@ async function reapZombieLiveJobs() {
const { fetchHistory } = await import('~/server/utils/comfy')
for (const job of listJobs()) {
// Never interrupt download/stitch/library save — Comfy is idle then by design.
if (job.yueGp) continue
if (job.saving) continue
if (job.library?.chainContinuing) continue
if (job.status !== 'running' && job.status !== 'uploading' && job.status !== 'queued') continue
@@ -346,6 +349,7 @@ async function reapZombieLiveJobs() {
}
function liveJobOwnsGpu(job: Job) {
if (job.yueGp && ['running', 'queued', 'uploading'].includes(job.status)) return true
if (job.library?.stopAfterCurrent) return false
if (job.library?.chainContinuing) return true
if (job.saving) return true
@@ -365,6 +369,7 @@ function liveJobOwnsGpu(job: Job) {
*/
function clearDeadGpuClaimsForForceStart() {
for (const live of listJobs()) {
if (live.yueGp) continue
if (live.saving) continue
if (jobIsLocallySubmitting(live)) continue
if (live.status !== 'running' && live.status !== 'queued' && live.status !== 'uploading') {
@@ -586,6 +591,10 @@ export async function clearStuckStudioWork(owner: string) {
if (job.library?.ownerKey !== owner) continue
if (job.status === 'complete' || job.status === 'error' || job.status === 'cancelled') continue
liveIds.add(job.id)
if (job.yueGp) {
const { cancelYueGpJob } = await import('./yueGp')
await cancelYueGpJob(job)
}
job.status = 'cancelled'
job.error = 'Cleared by force reset'
if (job.library) {
@@ -647,6 +656,11 @@ async function stopLiveGeneration(liveJobId?: string, shotQueueId?: string) {
}
if (!liveJobId) return
const live = getJob(liveJobId)
if (live?.yueGp) {
const { cancelYueGpJob } = await import('./yueGp')
await cancelYueGpJob(live)
return
}
if (live) {
live.status = 'cancelled'
if (live.library) {
@@ -896,7 +910,7 @@ function repairStaleJobs(jobs: StudioJob[]) {
job.updatedAt = Date.now()
continue
}
if (job.status === 'error' && isTransientComfyError(job.lastError)) {
if (job.payload.musicEngine !== 'yue' && job.status === 'error' && isTransientComfyError(job.lastError)) {
job.status = 'waiting'
job.liveJobId = undefined
job.lastError = undefined
@@ -905,7 +919,7 @@ function repairStaleJobs(jobs: StudioJob[]) {
job.updatedAt = Date.now()
continue
}
if (job.status === 'held' && isTransientComfyError(job.lastError)) {
if (job.payload.musicEngine !== 'yue' && job.status === 'held' && isTransientComfyError(job.lastError)) {
job.status = 'waiting'
job.liveJobId = undefined
job.lastError = undefined
@@ -1285,7 +1299,7 @@ async function startStudioEditJob(item: StudioJob) {
height: payload.height,
steps: payload.steps,
turbo: true,
seed: payload.seed || Math.floor(Math.random() * 2_147_483_647),
seed: payload.seed ?? Math.floor(Math.random() * 2_147_483_647),
cfg: payload.cfg,
stillId: payload.stillId,
stillFilename: payload.stillFilename,
@@ -1375,12 +1389,13 @@ async function startStudioMusicJob(item: StudioJob) {
lyrics: payload.instrumental ? '' : (payload.lyrics || ''),
duration: payload.duration,
steps: payload.steps,
seed: payload.seed || Math.floor(Math.random() * 2_147_483_647),
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',
yueProfile: payload.yueProfile ?? 1,
samplerName: payload.samplerName || 'euler',
scheduler: payload.scheduler || 'simple'
})
@@ -1650,7 +1665,7 @@ export async function onLiveVideoSettled(job: Job) {
return
}
const remaining = remainingStudioShots(job)
const wakeFail = job.status === 'error' && remaining > 0 && isTransientComfyError(job.error)
const wakeFail = !job.yueGp && job.status === 'error' && remaining > 0 && isTransientComfyError(job.error)
const failed = (job.status === 'error' || job.status === 'cancelled') && !wakeFail
await mutateStore(owner, (store) => {
+149
View File
@@ -0,0 +1,149 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
import { join } from 'node:path'
import { createJob, emitJob, getJob, restoreMusicJob, type Job } from './jobs'
import { saveTrack, findYueGpTrack } from './library'
import { sharedGpuHeaders } from './sharedGpu'
import type { MusicJobParams } from './musicChain'
function settings() {
const config = useRuntimeConfig()
return { url: String(config.comfyControlUrl || process.env.COMFY_CONTROL_URL || '').replace(/\/$/, ''),
token: String(config.comfyControlToken || process.env.COMFY_CONTROL_TOKEN || '') }
}
export function yueGpConfigured() { return Boolean(settings().url) }
async function request(path: string, body?: unknown) {
const { url, token } = settings()
if (!url) throw new Error('YuEGP host is not configured. Set COMFY_CONTROL_URL.')
const response = await fetch(`${url}/yuegp/${path}`, {
method: body === undefined ? 'GET' : 'POST',
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}),
...(body === undefined ? {} : { ...sharedGpuHeaders(), 'Content-Type': 'application/json' }) },
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000)
})
if (!response.ok) {
const detail = await response.json().catch(() => ({})) as { message?: string; error?: string }
throw Object.assign(new Error(detail.message || detail.error || `YuEGP host returned ${response.status}`), { statusCode: response.status })
}
return response
}
function pendingRoot() { return join(String(useRuntimeConfig().libraryDir || process.env.LIBRARY_DIR || '/data/library'), 'yuegp-pending') }
function persist(job: Job) {
mkdirSync(pendingRoot(), { recursive: true })
const path = join(pendingRoot(), `${job.id}.json`)
writeFileSync(path + '.tmp', JSON.stringify({ id: job.id, clientId: job.clientId, startedAt: job.startedAt, library: job.library, trackId: job.trackId }))
renameSync(path + '.tmp', path)
}
async function settle(job: Job) {
const { onLiveVideoSettled } = await import('./studioQueue')
await onLiveVideoSettled(job)
const path = join(pendingRoot(), `${job.id}.json`)
if (existsSync(path)) unlinkSync(path)
}
export async function cancelYueGpJob(job: Job) {
await request(`jobs/${job.id}/cancel`, {})
job.status = 'cancelled'
emitJob(job, { type: 'error', error: 'Cancelled', message: 'Cancelled' })
}
async function watch(job: Job) {
let failures = 0
while (job.status !== 'cancelled') {
try {
const state = await (await request(`jobs/${job.id}`)).json() as {
status: string; message: string; error?: string; stage?: string; progress?: number; step?: number; maxStep?: number; duration?: number
}
if (job.status === 'cancelled') { await settle(job); return }
job.musicActivity = { checkedAt: Date.now(), running: ['starting', 'running', 'cancelling'].includes(state.status) }
if (state.status === 'error' || state.status === 'cancelled') {
job.status = state.status === 'cancelled' ? 'cancelled' : 'error'
job.error = state.error || state.message
if (/out of memory/i.test(job.error)) job.error += ' Select profile 3 manually to retry; profile was not changed automatically.'
emitJob(job, { type: 'error', error: job.error, message: job.error })
await settle(job)
return
}
if (state.status === 'complete') {
job.saving = true
emitJob(job, { type: 'status', message: 'Saving audio to library…', progress: 98 })
const lib = job.library!
lib.audioExt = 'wav'
job.trackId ||= findYueGpTrack(lib.ownerKey, job.id)?.id
if (!job.trackId) {
const audio = Buffer.from(await (await request(`jobs/${job.id}/audio`)).arrayBuffer())
const track = await saveTrack({ ownerKey: lib.ownerKey, folderId: lib.folderId, name: lib.name,
tags: lib.tags || lib.prompt, lyrics: lib.lyrics || '', duration: state.duration || lib.duration || 60,
seed: lib.seed, steps: 0, cfg: 0, instrumental: lib.instrumental === true,
engine: 'yue', yueProfile: lib.yueProfile ?? 1, audio, ext: 'wav', comfyFilename: `yuegp-${job.id}.wav` })
job.trackId = track.id
persist(job)
}
job.status = 'complete'; job.saving = false
emitJob(job, { type: 'complete', message: lib.folderLocked ? 'Saved to the locked folder.' : 'Track ready',
progress: 100, trackId: job.trackId, folderLocked: lib.folderLocked, audioExt: 'wav' })
await settle(job)
return
}
failures = 0
job.status = 'running'
const percent = Number(state.progress || 0)
emitJob(job, { type: 'progress', message: `${state.message}${state.maxStep ? ` · ${state.step}/${state.maxStep} tokens` : ''}`,
progress: Math.min(95, Math.max(1, percent)), step: state.step || 0, maxStep: state.maxStep || 0 })
} catch (error) {
failures++
// Retain the durable record, even on network/save failure, for restart recovery.
job.saving = false
emitJob(job, { type: 'status', message: `YuEGP connection/save check failed; retrying: ${error instanceof Error ? error.message : String(error)}` })
if (failures >= 10) {
job.status = 'error'; job.error = 'YuEGP host unreachable or audio save failed. Job files are retained for recovery.'
emitJob(job, { type: 'error', error: job.error, message: job.error })
const { onLiveVideoSettled } = await import('./studioQueue')
await onLiveVideoSettled(job)
return
}
}
await new Promise(resolve => setTimeout(resolve, 2000))
}
await settle(job)
}
export function startYueGpJob(params: MusicJobParams) {
const job = createJob('music')
job.yueGp = true
job.library = { ...params, prompt: params.tags, engine: 'yue', aspect: 'audio', width: 0, height: 0,
hideThumbnail: false, turbo: false, sound: true, yueProfile: params.yueProfile ?? 1 }
persist(job)
// Defer execution until the studio row has attached this live ID.
setTimeout(() => { void (async () => {
try {
if (job.status === 'cancelled') { await settle(job); return }
await request('jobs', { id: job.id, tags: params.tags, lyrics: params.lyrics,
duration: params.duration, seed: params.seed, profile: params.yueProfile ?? 1 })
await watch(job)
} catch (error) {
const statusCode = (error as { statusCode?: number })?.statusCode
if (statusCode && statusCode >= 400 && statusCode < 500) {
job.status = 'error'; job.error = error instanceof Error ? error.message : String(error)
emitJob(job, { type: 'error', error: job.error, message: job.error })
await settle(job)
return
}
// Submission may have reached the host despite an HTTP timeout. Read by ID;
// never resubmit into a different backend or generate a duplicate song.
emitJob(job, { type: 'status', message: `Checking YuEGP submission: ${error instanceof Error ? error.message : String(error)}` })
await watch(job)
}
})() }, 0)
return job
}
export function resumeYueGpJobs() {
if (!existsSync(pendingRoot())) return
for (const file of readdirSync(pendingRoot()).filter(file => /^[a-zA-Z0-9-]+\.json$/.test(file))) {
try {
const record = JSON.parse(readFileSync(join(pendingRoot(), file), 'utf8'))
if (getJob(record.id)) continue
const job = restoreMusicJob(record)
void watch(job)
} catch { /* Preserve invalid records for diagnosis. */ }
}
}