Replace Comfy YuE with standalone YuEGP profile 1
This commit is contained in:
@@ -84,6 +84,15 @@ function liveJob(id, kind = 'edit', extra = {}) {
|
||||
return { id, kind, status: 'running', promptId: `prompt-${id}`, startedAt: Date.now() - 300_000,
|
||||
library: { ownerKey: 'test-owner' }, ...extra }
|
||||
}
|
||||
|
||||
test('standalone YuEGP retains its queue slot while Comfy is empty for longer than the zombie timeout', async () => {
|
||||
const live = liveJob('yuegp-active', 'music', { yueGp: true, promptId: undefined, startedAt: Date.now() - 3600000 })
|
||||
const f = fixture({ rows: [row('yuegp-active'), row('next', 'waiting', undefined)], lives: [live] })
|
||||
await f.api.kickStudioQueue()
|
||||
assert.equal(live.status, 'running')
|
||||
assert.equal(f.started.length, 0)
|
||||
assert.equal(f.rows()[1].status, 'waiting')
|
||||
})
|
||||
async function finishes(promise) {
|
||||
let timer
|
||||
try {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import ast
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
|
||||
spec = importlib.util.spec_from_file_location('patcher', pathlib.Path(__file__).parents[1] / 'scripts/patch_yue_cancellation.py')
|
||||
patcher = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(patcher)
|
||||
|
||||
class CancellationTests(unittest.TestCase):
|
||||
def test_interrupt_propagates_before_sampling(self):
|
||||
source = 'class BlockTokenRangeProcessor:\n def __call__(self, input_ids, scores):\n return scores\n'
|
||||
result = patcher.patch(source)
|
||||
self.assertEqual(result, patcher.patch(result))
|
||||
cancelled = False
|
||||
class Interrupted(Exception):
|
||||
pass
|
||||
def check():
|
||||
if cancelled:
|
||||
raise Interrupted()
|
||||
module = types.ModuleType('comfy.model_management')
|
||||
module.throw_exception_if_processing_interrupted = check
|
||||
sys.modules['comfy.model_management'] = module
|
||||
namespace = {}
|
||||
exec(compile(ast.parse(result), '<patched>', 'exec'), namespace)
|
||||
callback = namespace['BlockTokenRangeProcessor']()
|
||||
self.assertEqual(callback(None, 42), 42)
|
||||
cancelled = True
|
||||
with self.assertRaises(Interrupted):
|
||||
callback(None, 42)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""CPU-only adapter regression tests; never import torch or run inference."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
spec = importlib.util.spec_from_file_location('worker', Path(__file__).parents[1] / 'scripts/yuegp-worker.py')
|
||||
worker = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(worker)
|
||||
|
||||
|
||||
class WorkerTests(unittest.TestCase):
|
||||
def test_lyrics_preserve_words_and_normalize_ui_headings(self):
|
||||
self.assertEqual(worker.normalize_lyrics('[Pre-Chorus]\nEvery word stays\n[Outro]\n'), '[prechorus]\nEvery word stays\n\n')
|
||||
self.assertEqual(worker.normalize_lyrics('[Verse 1]\nHello'), '[verse1]\nHello\n\n')
|
||||
for lyrics in ['', 'No heading', '[Verse]\nA\n[Chorus]\nB']:
|
||||
with self.assertRaises(ValueError):
|
||||
worker.normalize_lyrics(lyrics)
|
||||
|
||||
def test_codec_construction_and_weight_loading_are_explicitly_cpu(self):
|
||||
active = []
|
||||
class Device:
|
||||
def __enter__(self): active.append('cpu')
|
||||
def __exit__(self, *args): active.pop()
|
||||
codec = Mock()
|
||||
def construct(**kwargs):
|
||||
self.assertEqual(active, ['cpu'])
|
||||
return codec
|
||||
def load(path, **kwargs):
|
||||
self.assertEqual(active, ['cpu'])
|
||||
self.assertEqual(kwargs['map_location'], 'cpu')
|
||||
return {'codec_model': 'weights'}
|
||||
torch = SimpleNamespace(device=lambda name: Device() if name == 'cpu' else self.fail('GPU context'), load=load)
|
||||
ns = {'SoundStream': construct, 'OmegaConf': SimpleNamespace(load=lambda _: SimpleNamespace(generator=SimpleNamespace(config={}))) }
|
||||
self.assertIs(worker.load_codec_on_cpu(ns, torch), codec)
|
||||
codec.load_state_dict.assert_called_once_with('weights')
|
||||
codec.eval.assert_called_once()
|
||||
self.assertEqual(active, [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,90 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { createYueGpHost, validateYueGpRequest } from '../scripts/yuegp-host.mjs'
|
||||
|
||||
const request = { id: 'test-song-123456', tags: 'pop, warm vocals', lyrics: '[Verse 1]\nA quiet morning, a new day', seed: 0 }
|
||||
test('YuEGP defaults are profile 1, 60 seconds, and no silent fallback', () => {
|
||||
assert.deepEqual(validateYueGpRequest(request), { ...request, profile: 1, duration: 60 })
|
||||
assert.equal(validateYueGpRequest({ ...request, profile: 3 }).profile, 3)
|
||||
for (const profile of [0, 2, 4, 5, '1']) assert.throws(() => validateYueGpRequest({ ...request, profile }))
|
||||
assert.throws(() => validateYueGpRequest({ ...request, lyrics: '[Verse]\nA\n[Chorus]\nB' }))
|
||||
assert.throws(() => validateYueGpRequest({ ...request, id: '../escape' }))
|
||||
})
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'aigen-yuegp-'))
|
||||
const python = join(root, 'python.exe')
|
||||
writeFileSync(python, '')
|
||||
writeFileSync(join(root, 'aigen-ready.json'), '{}')
|
||||
const child = new EventEmitter()
|
||||
child.stdout = new PassThrough(); child.stderr = new PassThrough()
|
||||
child.kill = () => { setImmediate(() => child.emit('close', 1)); return true }
|
||||
const calls = []
|
||||
const dataDir = join(root, 'jobs')
|
||||
const host = createYueGpHost({ root, python, dataDir, prepare: async () => {}, leaseValid: () => true,
|
||||
spawnProcess: (...args) => { calls.push(args); return child } })
|
||||
return { host, child, calls, root, dataDir }
|
||||
}
|
||||
test('host launches only the standalone worker, preserves seed 0, and deduplicates POST retries', async () => {
|
||||
const f = fixture()
|
||||
await f.host.start(request, 'lease')
|
||||
await f.host.start(request, 'lease')
|
||||
assert.equal(f.calls.length, 1)
|
||||
const [, args, opts] = f.calls[0]
|
||||
assert.equal(args.at(-1), '1')
|
||||
assert.equal(args.includes('--compile'), false)
|
||||
assert.equal(opts.windowsHide, true)
|
||||
assert.equal(opts.shell, false)
|
||||
const posted = JSON.parse(readFileSync(join(f.dataDir, request.id, 'request.json')))
|
||||
assert.equal(posted.seed, 0)
|
||||
assert.equal(posted.stage1Model, 'm-a-p/YuE-s1-7B-anneal-en-cot')
|
||||
assert.equal(posted.stage2Model, 'm-a-p/YuE-s2-1B-general')
|
||||
await assert.rejects(f.host.start({ ...request, id: 'second-song-12345' }, 'lease'), /already running/)
|
||||
await f.host.cancel(request.id)
|
||||
assert.equal(f.host.busy(), false)
|
||||
assert.equal(f.host.read(request.id).status, 'cancelled')
|
||||
})
|
||||
test('partial log chunks report progress, and audio is unavailable until process exit', async () => {
|
||||
const f = fixture()
|
||||
await f.host.start(request, 'lease')
|
||||
f.child.stdout.write('AIGEN_EVENT {"stage":"stage1",')
|
||||
f.child.stdout.write('"step":100,"maxStep":6000,"progress":1.7}\n')
|
||||
assert.equal(f.host.read(request.id).step, 100)
|
||||
writeFileSync(join(f.dataDir, request.id, 'audio.wav'), 'audio')
|
||||
assert.equal(f.host.audio(request.id), null)
|
||||
f.child.emit('close', 0)
|
||||
assert.equal(f.host.read(request.id).status, 'complete')
|
||||
assert.equal(f.host.busy(), false)
|
||||
assert.ok(f.host.audio(request.id).endsWith('audio.wav'))
|
||||
})
|
||||
test('worker failure is durable and never launches an alternate profile', async () => {
|
||||
const f = fixture()
|
||||
await f.host.start(request, 'lease')
|
||||
f.child.stdout.write('AIGEN_EVENT {"stage":"error","error":"CUDA out of memory"}\n')
|
||||
f.child.emit('close', 1)
|
||||
assert.match(f.host.read(request.id).error, /out of memory/)
|
||||
assert.equal(f.calls.length, 1)
|
||||
assert.equal(f.host.read(request.id).profile, 1)
|
||||
})
|
||||
test('the Comfy workflow builder refuses YuE while Ace graphs still build', () => {
|
||||
const source = readFileSync(new URL('../server/utils/musicWorkflow.ts', import.meta.url), 'utf8')
|
||||
const code = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
|
||||
const exports = {}
|
||||
new Function('require', 'exports', 'useRuntimeConfig', code)(id => {
|
||||
if (id.endsWith('.json')) return { default: JSON.parse(readFileSync(new URL(`../server/assets/${id.split('/').pop()}`, import.meta.url))) }
|
||||
if (id === '~/utils/music') return { parseMusicBpm: () => 120 }
|
||||
throw new Error(id)
|
||||
}, exports, () => ({}))
|
||||
const params = { tags: 'pop', lyrics: '[Verse]\nhello', duration: 60, steps: 8, seed: 0, cfg: 1, lyricsStrength: 0.9 }
|
||||
assert.throws(() => exports.buildMusicWorkflow({ ...params, engine: 'yue' }), /standalone YuEGP/)
|
||||
for (const engine of ['ace-step', 'ace-step-1.5']) {
|
||||
const graph = exports.buildMusicWorkflow({ ...params, engine })
|
||||
assert.ok(Object.values(graph).some(n => n.class_type === 'KSampler' && n.inputs.seed === 0))
|
||||
}
|
||||
assert.equal(existsSync(new URL('../server/assets/workflow_yue.json', import.meta.url)), false)
|
||||
})
|
||||
Reference in New Issue
Block a user