Replace Comfy YuE with standalone YuEGP profile 1
This commit is contained in:
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}`])
|
||||
])
|
||||
|
||||
@@ -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.' })
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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. */ }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user