Replace Comfy YuE with standalone YuEGP profile 1
This commit is contained in:
@@ -43,7 +43,7 @@
|
|||||||
<textarea v-model="draft.tags" rows="3" class="max-h-32 w-full overflow-y-auto rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 [overflow-wrap:anywhere] focus:ring-2 disabled:opacity-70" :disabled="!editable" />
|
<textarea v-model="draft.tags" rows="3" class="max-h-32 w-full overflow-y-auto rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 [overflow-wrap:anywhere] focus:ring-2 disabled:opacity-70" :disabled="!editable" />
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2 text-sm text-zinc-300">
|
<label class="flex items-center gap-2 text-sm text-zinc-300">
|
||||||
<input v-model="draft.instrumental" type="checkbox" class="accent-amber-400" :disabled="!editable">
|
<input v-model="draft.instrumental" type="checkbox" class="accent-amber-400" :disabled="!editable || isYueGp">
|
||||||
Instrumental
|
Instrumental
|
||||||
</label>
|
</label>
|
||||||
<label v-if="!draft.instrumental" class="block text-sm">
|
<label v-if="!draft.instrumental" class="block text-sm">
|
||||||
@@ -97,17 +97,24 @@
|
|||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
<label v-if="job.kind !== 'edit' || draft.duration" class="block text-sm">
|
<label v-if="job.kind !== 'edit' || draft.duration" class="block text-sm">
|
||||||
<span class="mb-1 block font-medium text-zinc-300">Duration</span>
|
<span class="mb-1 block font-medium text-zinc-300">Duration</span>
|
||||||
<input v-model.number="draft.duration" type="number" min="0.5" max="180" step="0.1" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm disabled:opacity-70" :disabled="!editable">
|
<input v-model.number="draft.duration" type="number" :min="isYueGp ? 30 : 0.5" :max="isYueGp ? 150 : 180" :step="isYueGp ? 1 : 0.1" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm disabled:opacity-70" :disabled="!editable">
|
||||||
</label>
|
</label>
|
||||||
<label class="block text-sm">
|
<label class="block text-sm">
|
||||||
<span class="mb-1 block font-medium text-zinc-300">Seed</span>
|
<span class="mb-1 block font-medium text-zinc-300">Seed</span>
|
||||||
<input v-model="draft.seed" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm disabled:opacity-70" :disabled="!editable">
|
<input v-model="draft.seed" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm disabled:opacity-70" :disabled="!editable">
|
||||||
</label>
|
</label>
|
||||||
<label class="block text-sm">
|
<label v-if="isYueGp" class="block text-sm">
|
||||||
|
<span class="mb-1 block font-medium text-zinc-300">YuEGP profile</span>
|
||||||
|
<select v-model.number="draft.yueProfile" :disabled="!editable" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2">
|
||||||
|
<option :value="1">1 · Full quality · 16GB</option>
|
||||||
|
<option :value="3">3 · Manual lower-memory fallback</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label v-if="!isYueGp" class="block text-sm">
|
||||||
<span class="mb-1 block font-medium text-zinc-300">Steps</span>
|
<span class="mb-1 block font-medium text-zinc-300">Steps</span>
|
||||||
<input v-model.number="draft.steps" type="number" min="1" max="100" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm disabled:opacity-70" :disabled="!editable">
|
<input v-model.number="draft.steps" type="number" min="1" max="100" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm disabled:opacity-70" :disabled="!editable">
|
||||||
</label>
|
</label>
|
||||||
<label class="block text-sm">
|
<label v-if="!isYueGp" class="block text-sm">
|
||||||
<span class="mb-1 block font-medium text-zinc-300">CFG</span>
|
<span class="mb-1 block font-medium text-zinc-300">CFG</span>
|
||||||
<input v-model.number="draft.cfg" type="number" min="0" max="20" step="0.1" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm disabled:opacity-70" :disabled="!editable">
|
<input v-model.number="draft.cfg" type="number" min="0" max="20" step="0.1" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm disabled:opacity-70" :disabled="!editable">
|
||||||
</label>
|
</label>
|
||||||
@@ -206,6 +213,7 @@ const draft = reactive({
|
|||||||
tags: '',
|
tags: '',
|
||||||
lyrics: '',
|
lyrics: '',
|
||||||
instrumental: false,
|
instrumental: false,
|
||||||
|
yueProfile: 1 as 1 | 3,
|
||||||
duration: 5,
|
duration: 5,
|
||||||
seed: '',
|
seed: '',
|
||||||
steps: 20,
|
steps: 20,
|
||||||
@@ -218,6 +226,7 @@ const draft = reactive({
|
|||||||
const extraShots = ref<QueuedShotDraft[]>([])
|
const extraShots = ref<QueuedShotDraft[]>([])
|
||||||
|
|
||||||
const kindLabel = computed(() => job.value ? queuedJobKindLabel(job.value) : 'Job')
|
const kindLabel = computed(() => job.value ? queuedJobKindLabel(job.value) : 'Job')
|
||||||
|
const isYueGp = computed(() => job.value?.kind === 'music' && (job.value.payload.musicEngine || job.value.payload.engine) === 'yue')
|
||||||
const editable = computed(() => job.value ? queuedJobEditable(job.value) : false)
|
const editable = computed(() => job.value ? queuedJobEditable(job.value) : false)
|
||||||
const shots = computed(() => job.value ? queuedJobShots(currentPayload(), job.value.kind) : [])
|
const shots = computed(() => job.value ? queuedJobShots(currentPayload(), job.value.kind) : [])
|
||||||
const thumbUrl = computed(() => {
|
const thumbUrl = computed(() => {
|
||||||
@@ -233,6 +242,7 @@ const loraEngine = computed(() => {
|
|||||||
const metaLine = computed(() => {
|
const metaLine = computed(() => {
|
||||||
const payload = job.value?.payload
|
const payload = job.value?.payload
|
||||||
if (!payload) return ''
|
if (!payload) return ''
|
||||||
|
if (isYueGp.value) return 'Standalone YuEGP · one section · compile off'
|
||||||
return [
|
return [
|
||||||
payload.workflow,
|
payload.workflow,
|
||||||
payload.v2Mode,
|
payload.v2Mode,
|
||||||
@@ -262,6 +272,7 @@ function currentPayload(): QueuedInspectPayload {
|
|||||||
base.tags = draft.tags
|
base.tags = draft.tags
|
||||||
base.lyrics = draft.lyrics
|
base.lyrics = draft.lyrics
|
||||||
base.instrumental = draft.instrumental
|
base.instrumental = draft.instrumental
|
||||||
|
if (isYueGp.value) base.yueProfile = draft.yueProfile
|
||||||
base.duration = draft.duration
|
base.duration = draft.duration
|
||||||
base.steps = draft.steps
|
base.steps = draft.steps
|
||||||
base.cfg = draft.cfg
|
base.cfg = draft.cfg
|
||||||
@@ -296,6 +307,7 @@ function hydrate(next: QueuedInspectJob) {
|
|||||||
draft.tags = payload.tags || (next.kind === 'music' ? (payload.prompt || next.prompt) : '') || ''
|
draft.tags = payload.tags || (next.kind === 'music' ? (payload.prompt || next.prompt) : '') || ''
|
||||||
draft.lyrics = payload.lyrics || ''
|
draft.lyrics = payload.lyrics || ''
|
||||||
draft.instrumental = payload.instrumental === true
|
draft.instrumental = payload.instrumental === true
|
||||||
|
draft.yueProfile = payload.yueProfile === 3 ? 3 : 1
|
||||||
draft.duration = Number(payload.duration) || 5
|
draft.duration = Number(payload.duration) || 5
|
||||||
draft.seed = payload.seed != null ? String(payload.seed) : ''
|
draft.seed = payload.seed != null ? String(payload.seed) : ''
|
||||||
draft.steps = Number(payload.steps) || 20
|
draft.steps = Number(payload.steps) || 20
|
||||||
@@ -385,6 +397,7 @@ async function saveChanges() {
|
|||||||
tags: payload.tags,
|
tags: payload.tags,
|
||||||
lyrics: payload.lyrics,
|
lyrics: payload.lyrics,
|
||||||
instrumental: payload.instrumental,
|
instrumental: payload.instrumental,
|
||||||
|
...(isYueGp.value ? { yueProfile: payload.yueProfile } : {}),
|
||||||
promptPre: payload.promptPre,
|
promptPre: payload.promptPre,
|
||||||
promptPost: payload.promptPost,
|
promptPost: payload.promptPost,
|
||||||
negative: payload.negative,
|
negative: payload.negative,
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Standalone YuEGP replacement
|
||||||
|
|
||||||
|
The app's `yue` music engine now means **standalone deepbeepmeep/YuEGP**. It never submits a Comfy graph. ACE-Step and ACE-Step 1.5 retain their existing Comfy workflows. No image/video model or extension workflow was changed.
|
||||||
|
|
||||||
|
## Defaults and controls
|
||||||
|
|
||||||
|
- Profile **1**: unquantized YuEGP path. Upstream loads Stage 1 in BF16 and Stage 2 in FP16; the UI does not mislabel these as a single FP16 mode.
|
||||||
|
- Profile **3** is a manual setting only. No automatic OOM fallback, retry into a different profile, or profile 2 path.
|
||||||
|
- **60 seconds**, **one non-empty lyric section**, **6,000 maximum new tokens**. Duration maps to 100 tokens/second; actual audio duration depends on the model's end token. Supported single-section range is 30–150 seconds (below the 16,384-token context limit).
|
||||||
|
- English CoT Stage 1: `m-a-p/YuE-s1-7B-anneal-en-cot`.
|
||||||
|
- Stage 2: `m-a-p/YuE-s2-1B-general`.
|
||||||
|
- ICL and dual-track prompting off. Instrumental-only generation remains available through ACE.
|
||||||
|
- Compile **off**. The app never passes `--compile`. Explicit command-line opt-in checks `import triton` before enabling it.
|
||||||
|
- Lyrics, genre tags, seed (including zero), destination folder/name, duration, and profile are carried through the music job. More than one non-empty lyric section is rejected rather than silently dropping lyrics. Existing numbered/hyphenated headings are normalized for upstream tokenization.
|
||||||
|
|
||||||
|
## Exact call path
|
||||||
|
|
||||||
|
The UI sends `POST /api/generate/music` with:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"engine":"yue","yueProfile":1,"duration":60,"seed":42,"tags":"pop, warm vocals","lyrics":"[Verse 1]\nYour lyrics here","folderId":"<library-folder>","name":"My song"}
|
||||||
|
```
|
||||||
|
|
||||||
|
The studio queue calls `startMusicJob(params)`, which routes `engine === 'yue'` directly to `startYueGpJob(params)`. Under the existing shared GPU reservation, that sends:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST <COMFY_CONTROL_URL>/yuegp/jobs
|
||||||
|
{ id, profile: 1, duration: 60, seed, tags, lyrics }
|
||||||
|
```
|
||||||
|
|
||||||
|
The device-local host launches this argument array (no shell, hidden Windows process):
|
||||||
|
|
||||||
|
```text
|
||||||
|
<YUEGP_ROOT>/.venv/Scripts/python.exe -u <aigen>/scripts/yuegp-worker.py --root <YUEGP_ROOT> --request <job-dir>/request.json --profile 1
|
||||||
|
```
|
||||||
|
|
||||||
|
The worker calls the pinned YuEGP functions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
offload.profile({'transformer': model, 'stage2': model2},
|
||||||
|
profile_no=1, quantizeTransformer=False, compile=False, verboseLevel=1)
|
||||||
|
stage1_inference(tags, lyrics, 1, 6000, seed, state, callback)
|
||||||
|
stage2_inference(model2, stems, output_dir, batch_size=20, state=state, callback=callback)
|
||||||
|
```
|
||||||
|
|
||||||
|
The inference functions are loaded from upstream `inference/gradio_server.py` using an explicit AST function list and source checksum. No Gradio import, UI, server, CLI initialization, or upstream default profile executes. The upstream standalone `infer.py` is not used: its missing `sdpa` argument and single-section loop differ from the maintained functions.
|
||||||
|
|
||||||
|
## Host setup
|
||||||
|
|
||||||
|
Run `scripts/setup-yuegp.ps1 -Uv <path-to-uv.exe>` on the GPU computer. The default sibling checkout is `C:\Users\ianjm\Development\YuEGP`, pinned to revision `2d72ff734b7a127324353c0dcd0f95ca4cc0b797`. Setup creates its own Python 3.10 environment with PyTorch 2.7.1/CUDA 12.8 for the RTX 5080. It installs YuEGP's own Transformers optimization files only into that environment. It does not modify Comfy's Python, install Gradio, or commit weights.
|
||||||
|
|
||||||
|
Existing Stage 1/2 weights and codec weights are reused from the local model directory recorded in `aigen-ready.json`. Missing model weights use their HF IDs. Optional host environment variables:
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `YUEGP_ROOT` | Standalone YuEGP checkout |
|
||||||
|
| `YUEGP_PYTHON` | Isolated worker Python executable |
|
||||||
|
| `YUEGP_JOBS_DIR` | Durable per-job logs, requests, intermediates, and WAV output |
|
||||||
|
| `YUEGP_MODELS_DIR` | Parent directory containing Stage 1/2 model folders |
|
||||||
|
| `YUEGP_STAGE1_MODEL` / `YUEGP_STAGE2_MODEL` | Explicit local model paths or HF IDs |
|
||||||
|
|
||||||
|
The deployed Nuxt app uses its existing `COMFY_CONTROL_URL` and token. Restart the host connector after installing the new scripts. This does not require changing any image/video workflow.
|
||||||
|
|
||||||
|
## Queue, cancellation, and output
|
||||||
|
|
||||||
|
The host verifies Comfy's queue is empty, stops the idle Comfy process to release its cached VRAM, and launches YuEGP under the same shared GPU reservation. Comfy mutations and wake requests are blocked while YuEGP owns the GPU. Cancellation waits for worker exit. Expired reservations stop the worker; a parent-process watchdog prevents orphan GPU jobs after a host crash.
|
||||||
|
|
||||||
|
Status is read from `/yuegp/jobs/<id>` and audio from `/yuegp/jobs/<id>/audio`. Token counts and stage names are real YuEGP callbacks; the percentage describes the current stage, not an ETA. The app saves the final WAV into its normal library and releases the studio queue slot. Duplicate submissions use the same ID. Durable app records restore pending jobs before queue repair on restart, and saved audio is deduplicated by job ID. Errors preserve intermediates and never fall back to Comfy.
|
||||||
|
|
||||||
|
## Removed
|
||||||
|
|
||||||
|
- `server/assets/workflow_yue.json`
|
||||||
|
- `scripts/patch_yue_cancellation.py`
|
||||||
|
- `tests/test_yue_cancellation.py`
|
||||||
|
- Local untracked `scripts/patch_comfyui_yue_windows.ps1`
|
||||||
|
- YuE graph builder/node checks, hardcoded MMGP profile 2 payloads, obsolete model environment settings, and the Comfy-node model-copy installer branch.
|
||||||
|
|
||||||
|
Historical library metadata may still recognize old YuE outputs. That is read-only compatibility, not a generation backend. Old host custom-node files are not used by this integration.
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- `scripts/yuegp-worker.py`
|
||||||
|
- `scripts/yuegp-host.mjs`
|
||||||
|
- `scripts/setup-yuegp.ps1`
|
||||||
|
- `server/utils/yueGp.ts`
|
||||||
|
- `server/plugins/00-resume-yuegp.ts`
|
||||||
|
- `tests/yuegp.test.mjs`
|
||||||
|
- `tests/test_yuegp_worker.py`
|
||||||
|
- This document.
|
||||||
|
|
||||||
|
UI, queue, host reservation/proxy, library metadata, and music API files were updated to connect these pieces. Existing music engine key `yue` is preserved for compatibility.
|
||||||
|
|
||||||
|
Upstream reference: [deepbeepmeep/YuEGP](https://github.com/deepbeepmeep/YuEGP/tree/2d72ff734b7a127324353c0dcd0f95ca4cc0b797). Its advertised timings are not a benchmark of this 5080; local validation is recorded below.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- 45 JavaScript tests passed, including shared GPU coordination, YuEGP process lifecycle, no fallback, progress parsing, and unchanged ACE graph construction.
|
||||||
|
- Two CPU-only Python regression tests passed for lyric preservation and explicit CPU decoder construction/loading. These do not import PyTorch or run inference.
|
||||||
|
- Production build passed.
|
||||||
|
- Source scan finds no live Comfy YuE generation graph. `server/utils/comfy.ts` still recognizes `YUE_Stage_A_Sampler` only when reading historical metadata.
|
||||||
|
- The earlier test failed during decoder construction: MMGP sets the default PyTorch device to CUDA. The adapter now defers decoder creation until the transformer stages finish, releases their VRAM, and constructs the decoder in an explicit CPU context. It also writes WAV directly instead of depending on a Windows MP3 backend.
|
||||||
|
- No generation test was run after that fix, at the user's request. Successful song generation, peak VRAM, and wall time remain unverified.
|
||||||
|
- This isolated environment currently has no FlashAttention or Triton. It uses upstream SDPA with compilation off. The optimized FlashAttention cache path in YuEGP is therefore not active; profile 1 remains selected and unquantized, but advertised four-minute timings must not be assumed for this installation.
|
||||||
@@ -59,8 +59,6 @@ export default defineNuxtConfig({
|
|||||||
purgeComfyOutputs: process.env.PURGE_COMFY_OUTPUTS !== 'false',
|
purgeComfyOutputs: process.env.PURGE_COMFY_OUTPUTS !== 'false',
|
||||||
comfyFilenamePrefix: process.env.COMFY_FILENAME_PREFIX || 'video/MiniMax_H3',
|
comfyFilenamePrefix: process.env.COMFY_FILENAME_PREFIX || 'video/MiniMax_H3',
|
||||||
comfyAudioPrefix: process.env.COMFY_AUDIO_PREFIX || 'audio/AIGen',
|
comfyAudioPrefix: process.env.COMFY_AUDIO_PREFIX || 'audio/AIGen',
|
||||||
yueStageA: process.env.YUE_STAGE_A || '',
|
|
||||||
yueStageB: process.env.YUE_STAGE_B || '',
|
|
||||||
kreaConceptLora: process.env.NUXT_KREA_CONCEPT_LORA || '',
|
kreaConceptLora: process.env.NUXT_KREA_CONCEPT_LORA || '',
|
||||||
public: {
|
public: {
|
||||||
authEnabled: process.env.NUXT_PUBLIC_AUTH_ENABLED === 'true',
|
authEnabled: process.env.NUXT_PUBLIC_AUTH_ENABLED === 'true',
|
||||||
|
|||||||
+36
-15
@@ -26,6 +26,14 @@
|
|||||||
|
|
||||||
<GenerationSettings :advanced="advanced">
|
<GenerationSettings :advanced="advanced">
|
||||||
<label class="block space-y-2 text-sm"><span>Model</span><select :value="engineFamily" class="w-full rounded-xl border border-white/15 bg-zinc-900 p-3" @change="selectEngineFamily(($event.target as HTMLSelectElement).value as 'ace' | 'yue')"><option value="ace">ACE-Step</option><option value="yue">YuE</option></select></label>
|
<label class="block space-y-2 text-sm"><span>Model</span><select :value="engineFamily" class="w-full rounded-xl border border-white/15 bg-zinc-900 p-3" @change="selectEngineFamily(($event.target as HTMLSelectElement).value as 'ace' | 'yue')"><option value="ace">ACE-Step</option><option value="yue">YuE</option></select></label>
|
||||||
|
<label v-if="engineFamily === 'yue'" class="block space-y-2 text-sm">
|
||||||
|
<span>YuEGP profile</span>
|
||||||
|
<select v-model.number="yueProfile" class="w-full rounded-xl border border-white/15 bg-zinc-900 p-3">
|
||||||
|
<option :value="1">1 · Full quality · 16GB (default)</option>
|
||||||
|
<option :value="3">3 · Lower memory · Manual fallback</option>
|
||||||
|
</select>
|
||||||
|
<span class="block text-xs text-zinc-400">One lyric section. Compile off. Profile 3 is never selected automatically.</span>
|
||||||
|
</label>
|
||||||
<div v-if="!advanced" class="space-y-4">
|
<div v-if="!advanced" class="space-y-4">
|
||||||
<label v-if="engineFamily !== 'yue'" class="block text-sm">Steps<input v-model.number="steps" type="number" :min="stepsMin" :max="stepsMax" class="mt-2 w-full rounded-xl border border-white/15 bg-zinc-900 p-3"></label>
|
<label v-if="engineFamily !== 'yue'" class="block text-sm">Steps<input v-model.number="steps" type="number" :min="stepsMin" :max="stepsMax" class="mt-2 w-full rounded-xl border border-white/15 bg-zinc-900 p-3"></label>
|
||||||
<label class="block text-sm">Seed<input v-model="seed" placeholder="random" class="mt-2 w-full rounded-xl border border-white/15 bg-zinc-900 p-3"></label>
|
<label class="block text-sm">Seed<input v-model="seed" placeholder="random" class="mt-2 w-full rounded-xl border border-white/15 bg-zinc-900 p-3"></label>
|
||||||
@@ -65,7 +73,7 @@
|
|||||||
<div class="mb-1 flex flex-wrap items-center justify-between gap-2">
|
<div class="mb-1 flex flex-wrap items-center justify-between gap-2">
|
||||||
<span class="text-sm font-medium text-zinc-300">Lyrics</span>
|
<span class="text-sm font-medium text-zinc-300">Lyrics</span>
|
||||||
<label class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
|
<label class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
|
||||||
<input v-model="instrumental" type="checkbox" class="accent-amber-400">
|
<input v-model="instrumental" :disabled="engineFamily === 'yue'" type="checkbox" class="accent-amber-400">
|
||||||
Instrumental
|
Instrumental
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +108,7 @@
|
|||||||
:max="durationMax"
|
:max="durationMax"
|
||||||
step="5"
|
step="5"
|
||||||
>
|
>
|
||||||
<span class="mt-1 block text-[11px] text-zinc-500">{{ engineFamily === 'yue' ? 'YuE uses this as stage-A length' : `${durationMin}–${durationMax} seconds` }}</span>
|
<span class="mt-1 block text-[11px] text-zinc-500">{{ engineFamily === 'yue' ? 'Approximate length · one lyric section' : `${durationMin}–${durationMax} seconds` }}</span>
|
||||||
</label>
|
</label>
|
||||||
<label v-if="advanced && engineFamily !== 'yue'" class="block text-sm">
|
<label v-if="advanced && engineFamily !== 'yue'" class="block text-sm">
|
||||||
<span class="mb-1 block font-medium text-zinc-300">Steps · {{ steps }}</span>
|
<span class="mb-1 block font-medium text-zinc-300">Steps · {{ steps }}</span>
|
||||||
@@ -200,7 +208,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<p v-if="busy && !queued && jobId" class="text-xs text-zinc-400">
|
<p v-if="busy && !queued && jobId" class="text-xs text-zinc-400">
|
||||||
{{ musicElapsed }} elapsed · {{ musicActivityText }}
|
{{ musicElapsed }} elapsed · {{ musicActivityText }}
|
||||||
<span v-if="indeterminateMusic" class="mt-1 block">YuE does not report a completion percentage during this stage.</span>
|
<span v-if="activeMusicEngine === 'yue' && !indeterminateMusic" class="mt-1 block">Progress is for the current stage.</span>
|
||||||
|
<span v-if="indeterminateMusic" class="mt-1 block">Waiting for a progress report.</span>
|
||||||
</p>
|
</p>
|
||||||
<AudioPlayer
|
<AudioPlayer
|
||||||
v-if="audioUrl"
|
v-if="audioUrl"
|
||||||
@@ -237,7 +246,8 @@ import {
|
|||||||
MUSIC_STEPS_MIN,
|
MUSIC_STEPS_MIN,
|
||||||
MUSIC_STRUCTURE_CHIPS,
|
MUSIC_STRUCTURE_CHIPS,
|
||||||
parseMusicEngine,
|
parseMusicEngine,
|
||||||
type MusicEngine
|
type MusicEngine,
|
||||||
|
yueLyricsProblem,
|
||||||
} from '~/utils/music'
|
} from '~/utils/music'
|
||||||
import { ensureGenerationNotifyPermission, notifyGenerationComplete } from '~/utils/generationNotify'
|
import { ensureGenerationNotifyPermission, notifyGenerationComplete } from '~/utils/generationNotify'
|
||||||
import type { LibraryFolder } from '~/utils/libraryBrowse'
|
import type { LibraryFolder } from '~/utils/libraryBrowse'
|
||||||
@@ -248,7 +258,7 @@ const tagsPlaceholder = DEFAULT_MUSIC_TAGS
|
|||||||
const lyricsPlaceholder = DEFAULT_MUSIC_LYRICS
|
const lyricsPlaceholder = DEFAULT_MUSIC_LYRICS
|
||||||
const chips = MUSIC_STRUCTURE_CHIPS
|
const chips = MUSIC_STRUCTURE_CHIPS
|
||||||
const durationMin = MUSIC_DURATION_MIN
|
const durationMin = MUSIC_DURATION_MIN
|
||||||
const durationMax = MUSIC_DURATION_MAX
|
const durationMax = computed(() => engineFamily.value === 'yue' ? 150 : MUSIC_DURATION_MAX)
|
||||||
const stepsMin = MUSIC_STEPS_MIN
|
const stepsMin = MUSIC_STEPS_MIN
|
||||||
const stepsMax = MUSIC_STEPS_MAX
|
const stepsMax = MUSIC_STEPS_MAX
|
||||||
|
|
||||||
@@ -266,6 +276,7 @@ const folders = ref<LibraryFolder[]>([])
|
|||||||
const lyricsEl = ref<HTMLTextAreaElement | null>(null)
|
const lyricsEl = ref<HTMLTextAreaElement | null>(null)
|
||||||
const ace15 = ref(false)
|
const ace15 = ref(false)
|
||||||
const engineFamily = ref<'ace' | 'yue'>('ace')
|
const engineFamily = ref<'ace' | 'yue'>('ace')
|
||||||
|
const yueProfile = ref<1 | 3>(1)
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const forceClearing = ref(false)
|
const forceClearing = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
@@ -276,17 +287,17 @@ const musicStartedAt = ref(0)
|
|||||||
const activeMusicEngine = ref('')
|
const activeMusicEngine = ref('')
|
||||||
const musicActivity = ref<{ checkedAt: number; running: boolean } | null>(null)
|
const musicActivity = ref<{ checkedAt: number; running: boolean } | null>(null)
|
||||||
let musicClockTimer: ReturnType<typeof setInterval> | null = null
|
let musicClockTimer: ReturnType<typeof setInterval> | null = null
|
||||||
const indeterminateMusic = computed(() => !queued.value && activeMusicEngine.value === 'yue' && progress.value < 98)
|
const indeterminateMusic = computed(() => !queued.value && activeMusicEngine.value === 'yue' && progress.value < 98 && !musicActivity.value?.running)
|
||||||
const recentMusicCheck = computed(() => Boolean(musicActivity.value?.running && musicClock.value - musicActivity.value.checkedAt < 15000))
|
const recentMusicCheck = computed(() => Boolean(musicActivity.value?.running && musicClock.value - musicActivity.value.checkedAt < 15000))
|
||||||
const musicElapsed = computed(() => {
|
const musicElapsed = computed(() => {
|
||||||
const seconds = musicStartedAt.value ? Math.max(0, Math.floor((musicClock.value - musicStartedAt.value) / 1000)) : 0
|
const seconds = musicStartedAt.value ? Math.max(0, Math.floor((musicClock.value - musicStartedAt.value) / 1000)) : 0
|
||||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
||||||
})
|
})
|
||||||
const musicActivityText = computed(() => {
|
const musicActivityText = computed(() => {
|
||||||
if (!musicActivity.value) return 'Waiting for a Comfy activity check…'
|
if (!musicActivity.value) return 'Waiting for a generator activity check…'
|
||||||
const age = Math.max(0, Math.floor((musicClock.value - musicActivity.value.checkedAt) / 1000))
|
const age = Math.max(0, Math.floor((musicClock.value - musicActivity.value.checkedAt) / 1000))
|
||||||
if (age >= 15) return `Activity check delayed · last response ${age}s ago`
|
if (age >= 15) return `Activity check delayed · last response ${age}s ago`
|
||||||
return musicActivity.value.running ? `Comfy confirms this job is running · checked ${age}s ago` : 'Comfy responded · checking for the result…'
|
return musicActivity.value.running ? `Generator confirms this job is running · checked ${age}s ago` : 'Generator responded · checking for the result…'
|
||||||
})
|
})
|
||||||
const audioUrl = ref('')
|
const audioUrl = ref('')
|
||||||
const trackId = ref('')
|
const trackId = ref('')
|
||||||
@@ -305,7 +316,7 @@ let settled = false
|
|||||||
let queuePoll: ReturnType<typeof setInterval> | null = null
|
let queuePoll: ReturnType<typeof setInterval> | null = null
|
||||||
let recoverPoll: 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 blocked = computed(() => Boolean(blockReason.value))
|
||||||
const headerSubtitle = computed(() => {
|
const headerSubtitle = computed(() => {
|
||||||
if (engineFamily.value === 'yue') return 'YuE · lyrics-to-song'
|
if (engineFamily.value === 'yue') return 'YuE · lyrics-to-song'
|
||||||
return ace15.value ? 'ACE-Step 1.5 · vocals' : 'ACE-Step · vocals'
|
return ace15.value ? 'ACE-Step 1.5 · vocals' : 'ACE-Step · vocals'
|
||||||
@@ -316,13 +327,18 @@ const selectedEngine = computed<MusicEngine>(() => {
|
|||||||
})
|
})
|
||||||
const blockReason = computed(() => {
|
const blockReason = computed(() => {
|
||||||
if (!folderId.value) return 'Choose a library folder before generating.'
|
if (!folderId.value) return 'Choose a library folder before generating.'
|
||||||
|
if (engineFamily.value === 'yue' && instrumental.value) return 'YuEGP requires lyrics. Use ACE for instrumental music.'
|
||||||
|
if (engineFamily.value === 'yue' && duration.value > 150) return 'YuEGP supports up to 150 seconds per section.'
|
||||||
if (!tags.value.trim()) return 'Add genre and style tags.'
|
if (!tags.value.trim()) return 'Add genre and style tags.'
|
||||||
|
if (engineFamily.value === 'yue') { const problem = yueLyricsProblem(lyrics.value); if (problem) return problem }
|
||||||
if (!instrumental.value && !lyrics.value.trim()) return 'Write lyrics, or turn on Instrumental.'
|
if (!instrumental.value && !lyrics.value.trim()) return 'Write lyrics, or turn on Instrumental.'
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
function selectEngineFamily(family: 'ace' | 'yue') {
|
function selectEngineFamily(family: 'ace' | 'yue') {
|
||||||
engineFamily.value = family
|
engineFamily.value = family
|
||||||
|
if (family === 'yue') { instrumental.value = false; duration.value = Math.min(150, duration.value) }
|
||||||
|
if (family === 'yue' && lyrics.value === DEFAULT_MUSIC_LYRICS) lyrics.value = '[Verse 1]\n'
|
||||||
if (family === 'ace' && ace15.value && steps.value === MUSIC_STEPS_DEFAULT) {
|
if (family === 'ace' && ace15.value && steps.value === MUSIC_STEPS_DEFAULT) {
|
||||||
steps.value = MUSIC_STEPS_DEFAULT_15
|
steps.value = MUSIC_STEPS_DEFAULT_15
|
||||||
}
|
}
|
||||||
@@ -437,7 +453,7 @@ async function attachLiveMusic(opts: {
|
|||||||
if (opts.message) status.value = opts.message
|
if (opts.message) status.value = opts.message
|
||||||
else if (!status.value || /queueing|waiting for a generate|waiting in the job queue|waiting for gpu/i.test(status.value)) {
|
else if (!status.value || /queueing|waiting for a generate|waiting in the job queue|waiting for gpu/i.test(status.value)) {
|
||||||
status.value = selectedEngine.value === 'yue'
|
status.value = selectedEngine.value === 'yue'
|
||||||
? 'YuE running on Comfy — Stage A can take 10–20+ minutes'
|
? 'YuEGP is generating your song'
|
||||||
: 'Generating…'
|
: 'Generating…'
|
||||||
}
|
}
|
||||||
if (jobId.value !== liveId) {
|
if (jobId.value !== liveId) {
|
||||||
@@ -482,7 +498,7 @@ async function refreshStudioQueue() {
|
|||||||
status.value = musicRow.status === 'held'
|
status.value = musicRow.status === 'held'
|
||||||
? 'Paused in the job queue'
|
? 'Paused in the job queue'
|
||||||
: (selectedEngine.value === 'yue'
|
: (selectedEngine.value === 'yue'
|
||||||
? 'Waiting for GPU… YuE is heavy; do not spam Generate'
|
? 'Waiting for the GPU to run YuEGP'
|
||||||
: 'Waiting in the job queue…')
|
: 'Waiting in the job queue…')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,7 +510,7 @@ async function refreshStudioQueue() {
|
|||||||
}
|
}
|
||||||
if (jobId.value && /waiting in the job queue/i.test(status.value)) {
|
if (jobId.value && /waiting in the job queue/i.test(status.value)) {
|
||||||
status.value = selectedEngine.value === 'yue'
|
status.value = selectedEngine.value === 'yue'
|
||||||
? 'YuE running on Comfy — Stage A can take 10–20+ minutes'
|
? 'YuEGP is generating your song'
|
||||||
: 'Generating…'
|
: 'Generating…'
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -505,7 +521,7 @@ async function refreshStudioQueue() {
|
|||||||
status.value = row.status === 'held'
|
status.value = row.status === 'held'
|
||||||
? 'Paused in the job queue'
|
? 'Paused in the job queue'
|
||||||
: (selectedEngine.value === 'yue'
|
: (selectedEngine.value === 'yue'
|
||||||
? 'Waiting for GPU… YuE is heavy; do not spam Generate'
|
? 'Waiting for the GPU to run YuEGP'
|
||||||
: 'Waiting in the job queue…')
|
: 'Waiting in the job queue…')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -553,7 +569,7 @@ function applyEvent(payload: Record<string, any>) {
|
|||||||
if (payload.trackId) {
|
if (payload.trackId) {
|
||||||
trackId.value = payload.trackId
|
trackId.value = payload.trackId
|
||||||
audioUrl.value = `/api/library/tracks/${payload.trackId}/audio`
|
audioUrl.value = `/api/library/tracks/${payload.trackId}/audio`
|
||||||
downloadName.value = `${(name.value || 'track').replace(/[^\w.-]+/g, '-')}.flac`
|
downloadName.value = `${(name.value || 'track').replace(/[^\w.-]+/g, '-')}.${payload.audioExt === 'wav' ? 'wav' : 'flac'}`
|
||||||
} else if (payload.filename) {
|
} else if (payload.filename) {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
filename: payload.filename,
|
filename: payload.filename,
|
||||||
@@ -669,7 +685,8 @@ async function generate() {
|
|||||||
instrumental: instrumental.value,
|
instrumental: instrumental.value,
|
||||||
folderId: folderId.value,
|
folderId: folderId.value,
|
||||||
name: name.value.trim(),
|
name: name.value.trim(),
|
||||||
engine: selectedEngine.value
|
engine: selectedEngine.value,
|
||||||
|
...(engineFamily.value === 'yue' ? { yueProfile: yueProfile.value } : {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -715,6 +732,7 @@ function applyIncomingMusic() {
|
|||||||
if (payload.seed != null) seed.value = String(payload.seed)
|
if (payload.seed != null) seed.value = String(payload.seed)
|
||||||
if (typeof payload.name === 'string') name.value = payload.name
|
if (typeof payload.name === 'string') name.value = payload.name
|
||||||
if (payload.folderId && folders.value.some(folder => folder.id === payload.folderId)) folderId.value = payload.folderId
|
if (payload.folderId && folders.value.some(folder => folder.id === payload.folderId)) folderId.value = payload.folderId
|
||||||
|
if (payload.yueProfile === 1 || payload.yueProfile === 3) yueProfile.value = payload.yueProfile
|
||||||
if (payload.musicEngine || payload.engine) applyEngine(parseMusicEngine(payload.musicEngine || payload.engine))
|
if (payload.musicEngine || payload.engine) applyEngine(parseMusicEngine(payload.musicEngine || payload.engine))
|
||||||
status.value = 'Loaded from generation log'
|
status.value = 'Loaded from generation log'
|
||||||
return
|
return
|
||||||
@@ -736,6 +754,7 @@ function applyIncomingMusic() {
|
|||||||
seed?: string
|
seed?: string
|
||||||
folderId?: string
|
folderId?: string
|
||||||
engine?: string
|
engine?: string
|
||||||
|
yueProfile?: 1 | 3
|
||||||
}
|
}
|
||||||
if (typeof loaded.tags === 'string') tags.value = loaded.tags
|
if (typeof loaded.tags === 'string') tags.value = loaded.tags
|
||||||
if (typeof loaded.lyrics === 'string') lyrics.value = loaded.lyrics
|
if (typeof loaded.lyrics === 'string') lyrics.value = loaded.lyrics
|
||||||
@@ -746,6 +765,7 @@ function applyIncomingMusic() {
|
|||||||
if (typeof loaded.name === 'string') name.value = loaded.name
|
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.folderId && folders.value.some(folder => folder.id === loaded.folderId)) folderId.value = loaded.folderId
|
||||||
if (loaded.engine) applyEngine(parseMusicEngine(loaded.engine))
|
if (loaded.engine) applyEngine(parseMusicEngine(loaded.engine))
|
||||||
|
yueProfile.value = loaded.yueProfile === 3 ? 3 : 1
|
||||||
status.value = 'Loaded from the queue'
|
status.value = 'Loaded from the queue'
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
@@ -766,6 +786,7 @@ function applyPreset() {
|
|||||||
if (typeof settings.steps === 'number') steps.value = settings.steps
|
if (typeof settings.steps === 'number') steps.value = settings.steps
|
||||||
if (typeof settings.seed === 'number') seed.value = String(settings.seed)
|
if (typeof settings.seed === 'number') seed.value = String(settings.seed)
|
||||||
if (settings.engine) applyEngine(settings.engine)
|
if (settings.engine) applyEngine(settings.engine)
|
||||||
|
yueProfile.value = settings.yueProfile === 3 ? 3 : 1
|
||||||
name.value = preset.name
|
name.value = preset.name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createGpuReservation } from './gpu-reservation.mjs'
|
import { createGpuReservation } from './gpu-reservation.mjs'
|
||||||
import { createGpuProxy } from './gpu-proxy.mjs'
|
import { createGpuProxy } from './gpu-proxy.mjs'
|
||||||
|
import { createYueGpHost } from './yuegp-host.mjs'
|
||||||
import http from 'node:http'
|
import http from 'node:http'
|
||||||
import net from 'node:net'
|
import net from 'node:net'
|
||||||
import { execFile, spawn } from 'node:child_process'
|
import { execFile, spawn } from 'node:child_process'
|
||||||
@@ -143,7 +144,7 @@ let proxyTarget = 0
|
|||||||
|
|
||||||
function ensureProxyListening() {
|
function ensureProxyListening() {
|
||||||
if (proxyServer) return
|
if (proxyServer) return
|
||||||
proxyServer = createGpuProxy({ target: () => proxyTarget, reservation: gpuReservation, authorized, markWork })
|
proxyServer = createGpuProxy({ target: () => proxyTarget, reservation: gpuReservation, authorized, markWork, externalBusy: () => yueGp.busy() })
|
||||||
proxyServer.on('error', (error) => {
|
proxyServer.on('error', (error) => {
|
||||||
console.log(JSON.stringify({ src: 'comfy-host-agent', event: 'proxy-error', error: String(error.message || error) }))
|
console.log(JSON.stringify({ src: 'comfy-host-agent', event: 'proxy-error', error: String(error.message || error) }))
|
||||||
})
|
})
|
||||||
@@ -812,6 +813,7 @@ function purgeDesktopFiles(body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gpuReservation = createGpuReservation({ idle: async () => {
|
const gpuReservation = createGpuReservation({ idle: async () => {
|
||||||
|
if (yueGp.busy()) return false
|
||||||
if ((await trainingLock()).busy) return false
|
if ((await trainingLock()).busy) return false
|
||||||
const healthy = await syncProxy()
|
const healthy = await syncProxy()
|
||||||
if (healthy) {
|
if (healthy) {
|
||||||
@@ -822,9 +824,39 @@ const gpuReservation = createGpuReservation({ idle: async () => {
|
|||||||
return !(await processUp()) && !(await pythonMainUp().catch(() => true))
|
return !(await processUp()) && !(await pythonMainUp().catch(() => true))
|
||||||
} })
|
} })
|
||||||
|
|
||||||
|
const yueGp = createYueGpHost({
|
||||||
|
leaseValid: lease => gpuReservation.isOwner(lease),
|
||||||
|
prepare: async () => {
|
||||||
|
if ((await trainingLock()).busy) throw new Error('GPU is busy with training.')
|
||||||
|
const healthy = await syncProxy()
|
||||||
|
if (healthy) {
|
||||||
|
const queue = await fetchLocalQueue(healthy)
|
||||||
|
if (!queue.ok || queue.running || queue.pending) throw new Error('Comfy is busy; YuEGP cannot start.')
|
||||||
|
await stopComfyProcesses()
|
||||||
|
markAsleep()
|
||||||
|
}
|
||||||
|
if (await pythonMainUp()) throw new Error('Comfy has not stopped; retry after the GPU is free.')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
async function handleControl(req, res) {
|
async function handleControl(req, res) {
|
||||||
if (!authorized(req)) return json(res, 401, { ok: false, error: 'unauthorized' })
|
if (!authorized(req)) return json(res, 401, { ok: false, error: 'unauthorized' })
|
||||||
const url = new URL(req.url || '/', 'http://localhost')
|
const url = new URL(req.url || '/', 'http://localhost')
|
||||||
|
if (url.pathname.startsWith('/yuegp/')) {
|
||||||
|
const match = url.pathname.match(/^\/yuegp\/jobs\/([a-zA-Z0-9-]{12,80})(\/audio|\/cancel)?$/)
|
||||||
|
if (req.method === 'GET' && url.pathname === '/yuegp/status') return json(res, 200, { configured: yueGp.configured(), busy: yueGp.busy(), backend: 'yuegp' })
|
||||||
|
if (req.method === 'POST' && url.pathname === '/yuegp/jobs') return json(res, 200, await yueGp.start(await readJson(req), String(req.headers['x-aigen-gpu-lease'] || '')))
|
||||||
|
if (match && req.method === 'POST' && match[2] === '/cancel') return json(res, 200, await yueGp.cancel(match[1]))
|
||||||
|
if (match && req.method === 'GET' && match[2] === '/audio') {
|
||||||
|
const path = yueGp.audio(match[1])
|
||||||
|
return path ? streamFile(res, path) : json(res, 404, { error: 'Audio not ready' })
|
||||||
|
}
|
||||||
|
if (match && req.method === 'GET' && !match[2]) {
|
||||||
|
const job = yueGp.read(match[1])
|
||||||
|
return json(res, job ? 200 : 404, job || { error: 'YuEGP job not found' })
|
||||||
|
}
|
||||||
|
return json(res, 404, { error: 'Unknown YuEGP endpoint' })
|
||||||
|
}
|
||||||
if (req.method === 'POST' && url.pathname.startsWith('/gpu/')) {
|
if (req.method === 'POST' && url.pathname.startsWith('/gpu/')) {
|
||||||
const body = await readJson(req)
|
const body = await readJson(req)
|
||||||
let result
|
let result
|
||||||
@@ -855,10 +887,12 @@ async function handleControl(req, res) {
|
|||||||
port: healthyPort || null,
|
port: healthyPort || null,
|
||||||
proxyPort,
|
proxyPort,
|
||||||
gpu: gpuReservation.availability(),
|
gpu: gpuReservation.availability(),
|
||||||
training: { busy: lastTraining.busy }
|
training: { busy: lastTraining.busy },
|
||||||
|
yuegp: { busy: yueGp.busy(), configured: yueGp.configured() }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (req.method === 'POST' && url.pathname === '/start') {
|
if (req.method === 'POST' && url.pathname === '/start') {
|
||||||
|
if (yueGp.busy()) return json(res, 409, { message: 'YuEGP is using the GPU.' })
|
||||||
const training = await trainingLock()
|
const training = await trainingLock()
|
||||||
if (training.busy) {
|
if (training.busy) {
|
||||||
return json(res, 409, {
|
return json(res, 409, {
|
||||||
@@ -954,6 +988,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
} else await handleControl(req, res)
|
} else await handleControl(req, res)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
req.resume()
|
req.resume()
|
||||||
|
if (String(req.url || '').startsWith('/yuegp/') && !res.headersSent) return json(res, error.statusCode || 400, { error: error.message || 'YuEGP request failed' })
|
||||||
if (!res.headersSent) json(res, error.statusCode || 400, { ok: false, message: error.statusCode === 409 ? 'GPU is in use. Waiting for availability.' : 'GPU coordination request failed.' })
|
if (!res.headersSent) json(res, error.statusCode || 400, { ok: false, message: error.statusCode === 409 ? 'GPU is in use. Waiting for availability.' : 'GPU coordination request failed.' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -177,7 +177,6 @@ $vae = Join-Path $root 'models\vae'
|
|||||||
$ttsAce = Join-Path $root 'models\TTS\ACE-Step-v1-3.5B'
|
$ttsAce = Join-Path $root 'models\TTS\ACE-Step-v1-3.5B'
|
||||||
$customAce = Join-Path $root 'custom_nodes\ComfyUI-ACE-Step\models'
|
$customAce = Join-Path $root 'custom_nodes\ComfyUI-ACE-Step\models'
|
||||||
$yueRoot = Join-Path $root 'models\YuE'
|
$yueRoot = Join-Path $root 'models\YuE'
|
||||||
$yueCustom = Join-Path $root 'custom_nodes\ComfyUI-YuE\models'
|
|
||||||
|
|
||||||
Write-Step "ComfyUI root"
|
Write-Step "ComfyUI root"
|
||||||
Write-Host " $root"
|
Write-Host " $root"
|
||||||
@@ -283,30 +282,7 @@ if (-not $SkipAceStep15) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not $SkipYue) {
|
if (-not $SkipYue) { Write-Host 'YuE now uses standalone YuEGP. Run scripts/setup-yuegp.ps1.' }
|
||||||
$s1 = Join-Path $yueRoot 'YuE-s1-7B-anneal-en-cot'
|
|
||||||
$s2 = Join-Path $yueRoot 'YuE-s2-1B-general'
|
|
||||||
$codec = Join-Path $yueRoot 'xcodec_mini_infer'
|
|
||||||
|
|
||||||
Pull-IfMissing -Label 'YuE stage-1 (s1 7B anneal EN CoT)' -Target $s1 -MinBytes 1GB -Download {
|
|
||||||
Invoke-HfDownload -Cli $cli -Repo 'm-a-p/YuE-s1-7B-anneal-en-cot' -LocalDir $s1 -Include @('*.safetensors', '*.bin', '*.json', '*.model', '*.txt')
|
|
||||||
}
|
|
||||||
Pull-IfMissing -Label 'YuE stage-2 (s2 1B general)' -Target $s2 -MinBytes 200MB -Download {
|
|
||||||
Invoke-HfDownload -Cli $cli -Repo 'm-a-p/YuE-s2-1B-general' -LocalDir $s2 -Include @('*.safetensors', '*.bin', '*.json', '*.model', '*.txt')
|
|
||||||
}
|
|
||||||
Pull-IfMissing -Label 'YuE vocoder (xcodec_mini_infer)' -Target $codec -MinBytes 20MB -Download {
|
|
||||||
Invoke-HfDownload -Cli $cli -Repo 'm-a-p/xcodec_mini_infer' -LocalDir $codec -Include @('*.safetensors', '*.pth', '*.pt', '*.json', '*.yaml', '*.txt')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Test-Path (Split-Path $yueCustom -Parent)) {
|
|
||||||
Pull-IfMissing -Label 'YuE custom-node models copy' -Target $yueCustom -MinBytes 1GB -Download {
|
|
||||||
Ensure-Dir $yueCustom
|
|
||||||
Copy-Item -Recurse -Force (Join-Path $yueRoot '*') $yueCustom
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Write-Skip "ComfyUI-YuE custom node not installed — YuE weights stay in models\YuE"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$after = Get-DirBytes (Join-Path $root 'models')
|
$after = Get-DirBytes (Join-Path $root 'models')
|
||||||
$delta = [Math]::Max(0, $after - $before)
|
$delta = [Math]::Max(0, $after - $before)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ function reply(res, status, body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Streams Comfy HTTP/WebSocket traffic; all mutations require the current reservation. */
|
/** Streams Comfy HTTP/WebSocket traffic; all mutations require the current reservation. */
|
||||||
export function createGpuProxy({ target, reservation, authorized = () => true, markWork = () => {} }) {
|
export function createGpuProxy({ target, reservation, authorized = () => true, markWork = () => {}, externalBusy = () => false }) {
|
||||||
const server = http.createServer(async (req, res) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
const path = new URL(req.url || '/', 'http://localhost').pathname.replace(/\/+$/, '') || '/'
|
const path = new URL(req.url || '/', 'http://localhost').pathname.replace(/\/+$/, '') || '/'
|
||||||
// A global history list can expose another studio's prompts and outputs.
|
// A global history list can expose another studio's prompts and outputs.
|
||||||
@@ -55,6 +55,7 @@ export function createGpuProxy({ target, reservation, authorized = () => true, m
|
|||||||
try {
|
try {
|
||||||
if (req.method === 'GET' || req.method === 'HEAD') await forward()
|
if (req.method === 'GET' || req.method === 'HEAD') await forward()
|
||||||
else {
|
else {
|
||||||
|
if (externalBusy()) { req.resume(); return reply(res, 409, { error: { message: 'YuEGP is using the GPU.' }, code: 'GPU_BUSY' }) }
|
||||||
if (!authorized(req)) { req.resume(); return reply(res, 401, { error: 'Unauthorized' }) }
|
if (!authorized(req)) { req.resume(); return reply(res, 401, { error: 'Unauthorized' }) }
|
||||||
await reservation.permit(String(req.headers['x-aigen-gpu-lease'] || ''), async () => { markWork(); await forward() })
|
await reservation.permit(String(req.headers['x-aigen-gpu-lease'] || ''), async () => { markWork(); await forward() })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function createGpuReservation({ idle, now = Date.now, ttlMs = 60_000, tic
|
|||||||
for (const [ticket, seen] of waiting) if (now() - seen >= ticketTtlMs) waiting.delete(ticket)
|
for (const [ticket, seen] of waiting) if (now() - seen >= ticketTtlMs) waiting.delete(ticket)
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
isOwner: valid,
|
||||||
acquire(ticket) {
|
acquire(ticket) {
|
||||||
return serialize(async () => {
|
return serialize(async () => {
|
||||||
if (typeof ticket !== 'string' || !/^[a-zA-Z0-9-]{16,80}$/.test(ticket)) throw new Error('Invalid reservation ticket')
|
if (typeof ticket !== 'string' || !/^[a-zA-Z0-9-]{16,80}$/.test(ticket)) throw new Error('Invalid reservation ticket')
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
"""Make YuE's HF token samplers honor Comfy's cancel flag. Run with the node directory."""
|
|
||||||
import ast
|
|
||||||
import pathlib
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
|
|
||||||
CHECK = ' from comfy.model_management import throw_exception_if_processing_interrupted\n throw_exception_if_processing_interrupted()\n'
|
|
||||||
|
|
||||||
def patch(source):
|
|
||||||
tree = ast.parse(source)
|
|
||||||
cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'BlockTokenRangeProcessor')
|
|
||||||
method = next(n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name == '__call__')
|
|
||||||
if 'throw_exception_if_processing_interrupted' in ast.get_source_segment(source, method):
|
|
||||||
return source
|
|
||||||
lines = source.splitlines(keepends=True)
|
|
||||||
lines.insert(method.body[0].lineno - 1, CHECK)
|
|
||||||
result = ''.join(lines)
|
|
||||||
ast.parse(result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
root = pathlib.Path(sys.argv[1]).resolve(strict=True)
|
|
||||||
plans = []
|
|
||||||
for name in ('infer.py', 'common.py'):
|
|
||||||
path = root / 'inference' / name
|
|
||||||
source = path.read_text(encoding='utf-8')
|
|
||||||
plans.append((path, source, patch(source)))
|
|
||||||
for path, source, result in plans:
|
|
||||||
if source == result:
|
|
||||||
print(f'Already patched: {path.name}')
|
|
||||||
continue
|
|
||||||
backup = path.with_suffix('.py.before-aigen-cancel')
|
|
||||||
if backup.exists():
|
|
||||||
raise RuntimeError(f'Backup already exists: {backup}')
|
|
||||||
shutil.copy2(path, backup)
|
|
||||||
path.write_text(result, encoding='utf-8')
|
|
||||||
print(f'Patched {path.name}; original backed up.')
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Standalone YuEGP only. Never install into Comfy's Python environment.
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$Root = (Join-Path (Split-Path $PSScriptRoot -Parent) '..\YuEGP'),
|
||||||
|
[string]$Uv = 'uv',
|
||||||
|
[string]$Models = (Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Shared\models\yue')
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$Root = [IO.Path]::GetFullPath($Root)
|
||||||
|
$revision = '2d72ff734b7a127324353c0dcd0f95ca4cc0b797'
|
||||||
|
function Run([string]$Exe, [string[]]$Arguments) {
|
||||||
|
& $Exe @Arguments
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "$Exe failed ($LASTEXITCODE)" }
|
||||||
|
}
|
||||||
|
if (!(Test-Path -LiteralPath $Root)) {
|
||||||
|
Run git @('clone', 'https://github.com/deepbeepmeep/YuEGP.git', $Root)
|
||||||
|
Run git @('-C', $Root, 'checkout', '--detach', $revision)
|
||||||
|
}
|
||||||
|
$head = & git -C $Root rev-parse HEAD
|
||||||
|
if ($head.Trim() -ne $revision) { throw "YuEGP must be checked out at $revision. No existing checkout was changed." }
|
||||||
|
Run $Uv @('venv', '--allow-existing', '--python', '3.10', (Join-Path $Root '.venv'))
|
||||||
|
$python = Join-Path $Root '.venv\Scripts\python.exe'
|
||||||
|
Run $Uv @('pip', 'install', '--python', $python, 'torch==2.7.1', 'torchaudio==2.7.1', '--index-url', 'https://download.pytorch.org/whl/cu128')
|
||||||
|
Run $Uv @('pip', 'install', '--python', $python, 'torch==2.7.1', 'torchaudio==2.7.1', 'numpy<2', 'scipy==1.10.1', 'transformers==4.48.1', 'mmgp==3.1.4-post15', 'peft==0.14.0', 'omegaconf', 'einops', 'sentencepiece', 'tqdm', 'tensorboard', 'descript-audiotools==0.7.2', 'descript-audio-codec', 'soundfile', 'psutil')
|
||||||
|
# Apply YuEGP's own upstream optimization files to its own environment only.
|
||||||
|
Copy-Item -LiteralPath (Join-Path $Root 'transformers\generation\utils.py') -Destination (Join-Path $Root '.venv\Lib\site-packages\transformers\generation\utils.py')
|
||||||
|
Copy-Item -LiteralPath (Join-Path $Root 'transformers\models\llama\modeling_llama.py') -Destination (Join-Path $Root '.venv\Lib\site-packages\transformers\models\llama\modeling_llama.py')
|
||||||
|
$codec = Join-Path $Root 'inference\xcodec_mini_infer'
|
||||||
|
if (!(Test-Path -LiteralPath $codec)) {
|
||||||
|
$previousSmudge = $env:GIT_LFS_SKIP_SMUDGE
|
||||||
|
try { $env:GIT_LFS_SKIP_SMUDGE = '1'; Run git @('clone', '--depth', '1', 'https://huggingface.co/m-a-p/xcodec_mini_infer', $codec) }
|
||||||
|
finally { $env:GIT_LFS_SKIP_SMUDGE = $previousSmudge }
|
||||||
|
}
|
||||||
|
# Reuse local codec weights. Download missing assets explicitly from their HF repo.
|
||||||
|
$weights = @('final_ckpt\ckpt_00360000.pth', 'decoders\decoder_131000.pth', 'decoders\decoder_151000.pth', 'semantic_ckpts\hf_1_325000\pytorch_model.bin')
|
||||||
|
foreach ($weight in $weights) {
|
||||||
|
$target = Join-Path $codec $weight
|
||||||
|
if ((Test-Path -LiteralPath $target) -and (Get-Item -LiteralPath $target).Length -gt 1024) { continue }
|
||||||
|
$source = Join-Path (Join-Path $Models 'xcodec_mini_infer') $weight
|
||||||
|
if ((Test-Path -LiteralPath $source) -and (Get-Item -LiteralPath $source).Length -gt 1024) { Copy-Item -LiteralPath $source -Destination $target }
|
||||||
|
else {
|
||||||
|
Run $python @('-c', 'from huggingface_hub import hf_hub_download; import sys; hf_hub_download("m-a-p/xcodec_mini_infer",sys.argv[1],local_dir=sys.argv[2])', $weight.Replace('\','/'), $codec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Run $python @('-c', 'import torch,transformers; from mmgp import offload; assert torch.cuda.is_available(); print(torch.__version__,transformers.__version__,torch.cuda.get_device_name(0))')
|
||||||
|
@{ revision=$revision; modelsDir=$Models; profile=1; compile=$false } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Root 'aigen-ready.json') -Encoding utf8
|
||||||
|
Write-Host "YuEGP ready at $Root. Set YUEGP_ROOT on the GPU host if using a different location. No Comfy environment was modified."
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, appendFileSync, readdirSync } from 'node:fs'
|
||||||
|
import { join, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
export function validateYueGpRequest(body) {
|
||||||
|
const profile = body.profile ?? 1
|
||||||
|
const duration = body.duration ?? 60
|
||||||
|
if (![1, 3].includes(profile)) throw new Error('YuEGP profile must be 1 or manual fallback 3.')
|
||||||
|
if (!Number.isInteger(duration) || duration < 30 || duration > 150) throw new Error('YuEGP duration must be 30–150 seconds.')
|
||||||
|
if (!Number.isInteger(body.seed) || body.seed < 0 || body.seed > 2147483647) throw new Error('Invalid seed.')
|
||||||
|
if (!/^[a-zA-Z0-9-]{12,80}$/.test(body.id || '')) throw new Error('Invalid job ID.')
|
||||||
|
const tags = String(body.tags || '').trim()
|
||||||
|
const lyrics = String(body.lyrics || '').trim()
|
||||||
|
if (!tags || tags.length > 2000 || !lyrics || lyrics.length > 8000) throw new Error('Genre tags and one lyric section are required.')
|
||||||
|
const sections = [...lyrics.matchAll(/\[([^\]]+)\]\s*([^\[]*)/gs)].filter(m => m[2].trim())
|
||||||
|
if (sections.length !== 1) throw new Error('YuEGP requires one non-empty lyric section.')
|
||||||
|
return { id: body.id, profile, duration, seed: body.seed, tags, lyrics }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One isolated Python process per song. A process exit is the GPU release boundary. */
|
||||||
|
export function createYueGpHost({ prepare, leaseValid, spawnProcess = spawn, root, python, dataDir } = {}) {
|
||||||
|
const repo = resolve(root || process.env.YUEGP_ROOT || join(fileURLToPath(new URL('../..', import.meta.url)), 'YuEGP'))
|
||||||
|
const executable = python || process.env.YUEGP_PYTHON || join(repo, '.venv', 'Scripts', 'python.exe')
|
||||||
|
const data = resolve(dataDir || process.env.YUEGP_JOBS_DIR || join(repo, 'aigen-jobs'))
|
||||||
|
const worker = fileURLToPath(new URL('./yuegp-worker.py', import.meta.url))
|
||||||
|
let active = null
|
||||||
|
// A worker whose parent died exits within two seconds. Block new GPU owners
|
||||||
|
// across a host restart until that watchdog has had time to run.
|
||||||
|
const restartHoldUntil = existsSync(data) && readdirSync(data).some(id => {
|
||||||
|
try { return ['running', 'starting', 'cancelling'].includes(JSON.parse(readFileSync(join(data, id, 'status.json'), 'utf8')).status) }
|
||||||
|
catch { return false }
|
||||||
|
}) ? Date.now() + 10000 : 0
|
||||||
|
const dir = id => {
|
||||||
|
if (!/^[a-zA-Z0-9-]{12,80}$/.test(id || '')) throw new Error('Invalid job ID.')
|
||||||
|
return join(data, id)
|
||||||
|
}
|
||||||
|
const persist = job => {
|
||||||
|
const target = join(dir(job.id), 'status.json')
|
||||||
|
writeFileSync(target + '.tmp', JSON.stringify(job))
|
||||||
|
renameSync(target + '.tmp', target)
|
||||||
|
}
|
||||||
|
const read = id => {
|
||||||
|
if (active?.job.id === id) return { ...active.job }
|
||||||
|
const path = join(dir(id), 'status.json')
|
||||||
|
if (!existsSync(path)) return null
|
||||||
|
const job = JSON.parse(readFileSync(path, 'utf8'))
|
||||||
|
if (['running', 'starting', 'cancelling'].includes(job.status)) {
|
||||||
|
job.status = 'error'; job.error = 'YuEGP host restarted. Intermediate files are preserved.'
|
||||||
|
}
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
busy: () => Boolean(active) || Date.now() < restartHoldUntil,
|
||||||
|
configured: () => existsSync(executable) && existsSync(join(repo, 'aigen-ready.json')),
|
||||||
|
read,
|
||||||
|
audio(id) {
|
||||||
|
return read(id)?.status === 'complete' ? join(dir(id), 'audio.wav') : null
|
||||||
|
},
|
||||||
|
async start(body, lease) {
|
||||||
|
const request = validateYueGpRequest(body)
|
||||||
|
const previous = read(request.id)
|
||||||
|
if (previous) return previous // POST retries cannot generate the same song twice.
|
||||||
|
if (active || Date.now() < restartHoldUntil) throw Object.assign(new Error('YuEGP is already running or releasing a previous worker.'), { statusCode: 409 })
|
||||||
|
if (!existsSync(executable) || !existsSync(join(repo, 'aigen-ready.json'))) throw new Error('YuEGP is not installed. Run scripts/setup-yuegp.ps1 on the GPU host.')
|
||||||
|
const job = { id: request.id, status: 'starting', message: 'Preparing GPU for YuEGP', profile: request.profile, progress: 0, startedAt: Date.now(), checkedAt: Date.now() }
|
||||||
|
active = { job, child: null, cancelled: false }
|
||||||
|
const run = active
|
||||||
|
mkdirSync(dir(job.id), { recursive: true })
|
||||||
|
persist(job)
|
||||||
|
try {
|
||||||
|
await prepare()
|
||||||
|
if (run.cancelled || !leaseValid(lease)) throw new Error('YuEGP start cancelled or GPU reservation expired.')
|
||||||
|
const installation = JSON.parse(readFileSync(join(repo, 'aigen-ready.json'), 'utf8').replace(/^\uFEFF/, ''))
|
||||||
|
const localModels = process.env.YUEGP_MODELS_DIR || installation.modelsDir
|
||||||
|
const model = (key, name) => process.env[key] || (localModels && existsSync(join(localModels, name)) ? join(localModels, name) : `m-a-p/${name}`)
|
||||||
|
writeFileSync(join(dir(job.id), 'request.json'), JSON.stringify({ ...request,
|
||||||
|
stage1Model: model('YUEGP_STAGE1_MODEL', 'YuE-s1-7B-anneal-en-cot'),
|
||||||
|
stage2Model: model('YUEGP_STAGE2_MODEL', 'YuE-s2-1B-general') }))
|
||||||
|
const child = spawnProcess(executable, ['-u', worker, '--root', repo, '--request', join(dir(job.id), 'request.json'), '--profile', String(request.profile)],
|
||||||
|
{ cwd: join(repo, 'inference'), windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, PYTHONUTF8: '1', TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD: '1' } })
|
||||||
|
run.child = child
|
||||||
|
job.status = 'running'; job.message = 'Loading YuEGP'; persist(job)
|
||||||
|
let tail = ''
|
||||||
|
let lines = ''
|
||||||
|
const log = chunk => { appendFileSync(join(dir(job.id), 'worker.log'), chunk); tail = (tail + chunk.toString()).slice(-6000) }
|
||||||
|
child.stderr.on('data', log)
|
||||||
|
child.stdout.on('data', chunk => {
|
||||||
|
log(chunk); lines += chunk.toString()
|
||||||
|
const parts = lines.split(/\r?\n/); lines = parts.pop().slice(-64000)
|
||||||
|
for (const line of parts) {
|
||||||
|
if (!line.startsWith('AIGEN_EVENT ')) continue
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(line.slice(12))
|
||||||
|
for (const key of ['stage', 'message', 'progress', 'step', 'maxStep', 'error', 'duration']) if (event[key] !== undefined) job[key] = event[key]
|
||||||
|
job.checkedAt = Date.now(); persist(job)
|
||||||
|
} catch { /* Invalid log lines are not state transitions. */ }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// The worker also exits if this host dies, using its parent PID watchdog.
|
||||||
|
const watchdog = setInterval(() => {
|
||||||
|
if (!leaseValid(lease)) { job.error = 'GPU reservation expired; YuEGP stopped.'; child.kill() }
|
||||||
|
}, 5000)
|
||||||
|
let finished = false
|
||||||
|
const finish = (code, error) => {
|
||||||
|
if (finished) return
|
||||||
|
finished = true
|
||||||
|
clearInterval(watchdog)
|
||||||
|
job.status = run.cancelled ? 'cancelled' : !error && code === 0 && existsSync(join(dir(job.id), 'audio.wav')) ? 'complete' : 'error'
|
||||||
|
if (job.status === 'error') job.error ||= error?.message || tail || `YuEGP exited with code ${code}`
|
||||||
|
job.message = job.status === 'complete' ? 'Audio ready' : job.status === 'cancelled' ? 'Cancelled' : job.error
|
||||||
|
job.checkedAt = Date.now(); persist(job)
|
||||||
|
if (active === run) active = null
|
||||||
|
}
|
||||||
|
child.once('error', error => finish(null, error))
|
||||||
|
child.once('close', code => finish(code))
|
||||||
|
return { ...job }
|
||||||
|
} catch (error) {
|
||||||
|
job.status = run.cancelled ? 'cancelled' : 'error'; job.error = error.message; persist(job)
|
||||||
|
if (active === run) active = null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async cancel(id) {
|
||||||
|
if (active?.job.id !== id) return read(id)
|
||||||
|
const run = active
|
||||||
|
run.cancelled = true
|
||||||
|
run.job.status = 'cancelling'
|
||||||
|
persist(run.job)
|
||||||
|
if (run.child) {
|
||||||
|
const child = run.child
|
||||||
|
await new Promise(resolve => { child.once('close', resolve); child.kill() })
|
||||||
|
}
|
||||||
|
return read(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""Headless adapter for pinned deepbeepmeep/YuEGP inference functions.
|
||||||
|
|
||||||
|
No Comfy imports, no Gradio import/server, and no automatic profile fallback.
|
||||||
|
Stdout AIGEN_EVENT lines are consumed by the device-local host agent.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
import hashlib
|
||||||
|
import gc
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from types import SimpleNamespace, FunctionType
|
||||||
|
|
||||||
|
YUEGP_REVISION = '2d72ff734b7a127324353c0dcd0f95ca4cc0b797'
|
||||||
|
|
||||||
|
|
||||||
|
def emit(**event):
|
||||||
|
print('AIGEN_EVENT ' + json.dumps(event), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_lyrics(text):
|
||||||
|
# Accept our existing chips (Verse 1, Pre-Chorus); upstream only accepts \w+.
|
||||||
|
sections = re.findall(r'\[([^\]]+)\]\s*([^\[]*)', text.strip(), re.S)
|
||||||
|
sections = [(name, words.strip()) for name, words in sections if words.strip()]
|
||||||
|
if len(sections) != 1:
|
||||||
|
raise ValueError('YuEGP requires one non-empty lyric section. Combine the lyrics under one heading.')
|
||||||
|
name, words = sections[0]
|
||||||
|
return '[' + re.sub(r'\W+', '', name).lower() + ']\n' + words + '\n\n'
|
||||||
|
|
||||||
|
|
||||||
|
def load_functions(root):
|
||||||
|
source = root / 'inference' / 'gradio_server.py'
|
||||||
|
text = source.read_text(encoding='utf-8')
|
||||||
|
if hashlib.sha256(text.encode()).hexdigest() != '567b91b3100c4fb2c95496e83b135e9d55fd03714e83912f9586037805fdecc9':
|
||||||
|
raise RuntimeError('YuEGP inference source differs from the validated revision.')
|
||||||
|
tree = ast.parse(text)
|
||||||
|
names = {'BlockTokenRangeProcessor', 'load_audio_mono', 'encode_audio',
|
||||||
|
'stage1_inference', 'stage2_generate', 'stage2_inference'}
|
||||||
|
# Load the library functions only. Never execute upstream CLI/UI startup.
|
||||||
|
nodes = [n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.ClassDef)) and n.name in names]
|
||||||
|
if {n.name for n in nodes} != names:
|
||||||
|
raise RuntimeError('Unsupported YuEGP source: reinstall the pinned revision.')
|
||||||
|
ns = {'__file__': str(source)}
|
||||||
|
imports = [n for n in tree.body if isinstance(n, (ast.Import, ast.ImportFrom))
|
||||||
|
and not (isinstance(n, ast.Import) and any(a.name == 'gradio' for a in n.names))]
|
||||||
|
exec(compile(ast.Module(body=imports + nodes, type_ignores=[]), str(source), 'exec'), ns)
|
||||||
|
return ns
|
||||||
|
|
||||||
|
|
||||||
|
def load_codec_on_cpu(ns, torch):
|
||||||
|
with torch.device('cpu'):
|
||||||
|
config = ns['OmegaConf'].load('xcodec_mini_infer/final_ckpt/config.yaml')
|
||||||
|
codec = ns['SoundStream'](**config.generator.config)
|
||||||
|
codec.load_state_dict(torch.load('xcodec_mini_infer/final_ckpt/ckpt_00360000.pth', map_location='cpu', weights_only=False)['codec_model'])
|
||||||
|
codec.eval()
|
||||||
|
return codec
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import psutil
|
||||||
|
parent = psutil.Process(os.getppid())
|
||||||
|
def parent_watchdog():
|
||||||
|
while parent.is_running():
|
||||||
|
time.sleep(2)
|
||||||
|
os._exit(2) # The host died: never leave an orphan consuming the GPU.
|
||||||
|
threading.Thread(target=parent_watchdog, daemon=True).start()
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--root', required=True)
|
||||||
|
parser.add_argument('--request', required=True)
|
||||||
|
parser.add_argument('--profile', type=int, choices=[1, 3], default=1)
|
||||||
|
parser.add_argument('--compile', action='store_true')
|
||||||
|
cli = parser.parse_args()
|
||||||
|
request = json.loads(Path(cli.request).read_text(encoding='utf-8-sig'))
|
||||||
|
root = Path(cli.root).resolve()
|
||||||
|
output = Path(cli.request).resolve().parent
|
||||||
|
duration = int(request.get('duration', 60))
|
||||||
|
if not 30 <= duration <= 150:
|
||||||
|
raise ValueError('Duration must be 30–150 seconds.')
|
||||||
|
lyrics = normalize_lyrics(request['lyrics'])
|
||||||
|
tags = ' '.join(str(request['tags']).split())
|
||||||
|
seed = int(request['seed'])
|
||||||
|
max_tokens = duration * 100
|
||||||
|
if max_tokens >= 16000:
|
||||||
|
raise ValueError('YuEGP supports at most 150 seconds per section.')
|
||||||
|
compile_enabled = False
|
||||||
|
if cli.compile:
|
||||||
|
import triton # Explicit opt-in AND an actual successful import required.
|
||||||
|
compile_enabled = True
|
||||||
|
os.chdir(root / 'inference')
|
||||||
|
sys.path[:0] = [str(root / 'inference'), str(root / 'inference/xcodec_mini_infer'),
|
||||||
|
str(root / 'inference/xcodec_mini_infer/descriptaudiocodec')]
|
||||||
|
emit(stage='loading', progress=1, message=f'Loading YuEGP profile {cli.profile}', profile=cli.profile,
|
||||||
|
compile=compile_enabled, sections=1, maxNewTokens=max_tokens)
|
||||||
|
ns = load_functions(root)
|
||||||
|
torch, np, sf = ns['torch'], ns['np'], ns['sf']
|
||||||
|
if not torch.cuda.is_available():
|
||||||
|
raise RuntimeError('YuEGP requires a CUDA GPU; CPU fallback is disabled.')
|
||||||
|
# The upstream transformer patch is part of YuEGP, in its own environment.
|
||||||
|
import transformers.generation.utils as generation
|
||||||
|
if 'callback' not in Path(generation.__file__).read_text(encoding='utf-8'):
|
||||||
|
raise RuntimeError('YuEGP transformer functions are missing. Run setup-yuegp.ps1.')
|
||||||
|
attention = 'sdpa'
|
||||||
|
try:
|
||||||
|
import flash_attn
|
||||||
|
attention = 'flash_attention_2'
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
ns['random'].seed(seed)
|
||||||
|
np.random.seed(seed)
|
||||||
|
torch.manual_seed(seed)
|
||||||
|
torch.cuda.manual_seed_all(seed)
|
||||||
|
device = torch.device('cuda:0')
|
||||||
|
stage1 = request.get('stage1Model') or 'm-a-p/YuE-s1-7B-anneal-en-cot'
|
||||||
|
stage2 = request.get('stage2Model') or 'm-a-p/YuE-s2-1B-general'
|
||||||
|
model = ns['AutoModelForCausalLM'].from_pretrained(stage1, torch_dtype=torch.bfloat16, attn_implementation=attention).eval()
|
||||||
|
model2 = ns['AutoModelForCausalLM'].from_pretrained(stage2, torch_dtype=torch.float16, attn_implementation=attention).eval()
|
||||||
|
if not compile_enabled:
|
||||||
|
# Transformers 4.48 selects DynamicCache with None; the literal
|
||||||
|
# string "dynamic" is not an accepted configuration value.
|
||||||
|
model.generation_config.cache_implementation = None
|
||||||
|
model2.generation_config.cache_implementation = None
|
||||||
|
model._validate_model_kwargs = lambda _: None
|
||||||
|
model2._validate_model_kwargs = lambda _: None
|
||||||
|
offloader = ns['offload'].profile({'transformer': model, 'stage2': model2}, profile_no=cli.profile,
|
||||||
|
quantizeTransformer=cli.profile == 3, compile=compile_enabled, verboseLevel=1)
|
||||||
|
args = SimpleNamespace(use_audio_prompt=False, use_dual_tracks_prompt=False, rescale=True,
|
||||||
|
output_dir=str(output), cuda_idx=0)
|
||||||
|
ns.update(model=model, model_stage2=model2, device=device, codec_model=None, args=args,
|
||||||
|
mmtokenizer=ns['_MMSentencePieceTokenizer']('./mm_tokenizer_v0.2_hf/tokenizer.model'),
|
||||||
|
codectool=ns['CodecManipulator']('xcodec', 0, 1), codectool_stage2=ns['CodecManipulator']('xcodec', 0, 8),
|
||||||
|
stage1_output_dir=str(output / 'stage1'),
|
||||||
|
split_lyrics=lambda _: [lyrics], get_song_id=lambda *a: 'song')
|
||||||
|
(output / 'stage1').mkdir(exist_ok=True)
|
||||||
|
(output / 'stage2').mkdir(exist_ok=True)
|
||||||
|
state = {}
|
||||||
|
last = [0.0]
|
||||||
|
def callback(done, total):
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - last[0] < 1 and done < total:
|
||||||
|
return
|
||||||
|
last[0] = now
|
||||||
|
stage = state.get('stage', 'Generating')
|
||||||
|
# Percent is explicitly local to the reported stage, never a song ETA.
|
||||||
|
emit(stage=stage, message=stage, step=int(done), maxStep=int(total),
|
||||||
|
progress=round(100 * done / max(1, total), 1))
|
||||||
|
emit(stage='stage1', message='Generating song tokens', progress=0)
|
||||||
|
stems = ns['stage1_inference'](tags, lyrics, 1, max_tokens, seed, state, callback)
|
||||||
|
emit(stage='stage2', message='Generating audio detail', progress=0)
|
||||||
|
results = ns['stage2_inference'](model2, stems, str(output / 'stage2'),
|
||||||
|
batch_size=20 if cli.profile == 1 else 4, state=state, callback=callback)
|
||||||
|
# Release transformer allocations before codec/vocoder decode.
|
||||||
|
offloader.unload_all()
|
||||||
|
gc.collect()
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
# MMGP sets the default device to CUDA. Explicit CPU construction prevents
|
||||||
|
# decoder initialization from competing with the language models for VRAM.
|
||||||
|
emit(stage='decoding', message='Loading audio decoder', progress=0)
|
||||||
|
codec = load_codec_on_cpu(ns, torch)
|
||||||
|
codec.to(device)
|
||||||
|
emit(stage='decoding', message='Decoding and mixing audio', progress=0)
|
||||||
|
low_tracks = []
|
||||||
|
for path in results:
|
||||||
|
codes = np.load(path)
|
||||||
|
with torch.no_grad():
|
||||||
|
wave = codec.decode(torch.as_tensor(codes.astype(np.int16), dtype=torch.long).unsqueeze(0).permute(1, 0, 2).to(device))
|
||||||
|
low_tracks.append(wave.cpu().squeeze().numpy())
|
||||||
|
sf.write(str(output / 'mix16.wav'), low_tracks[0] + low_tracks[1], 16000)
|
||||||
|
with torch.device('cpu'):
|
||||||
|
vocal_decoder, inst_decoder = ns['build_codec_model']('xcodec_mini_infer/decoders/config.yaml',
|
||||||
|
'xcodec_mini_infer/decoders/decoder_131000.pth', 'xcodec_mini_infer/decoders/decoder_151000.pth')
|
||||||
|
# Keep upstream neural decoding, but write WAV directly: no MP3/FFmpeg
|
||||||
|
# backend dependency in the isolated Windows environment.
|
||||||
|
process_audio = ns['process_audio']
|
||||||
|
audio_globals = dict(process_audio.__globals__)
|
||||||
|
def save_wave(wave, path, sample_rate, rescale=False):
|
||||||
|
data = wave.detach().cpu().numpy()
|
||||||
|
peak = float(np.max(np.abs(data)))
|
||||||
|
if rescale and peak > 0.99:
|
||||||
|
data = data * (0.99 / peak)
|
||||||
|
sf.write(str(path), data.T, sample_rate, subtype='PCM_16')
|
||||||
|
audio_globals['save_audio'] = save_wave
|
||||||
|
decode_audio = FunctionType(process_audio.__code__, audio_globals)
|
||||||
|
tracks = []
|
||||||
|
for path in results:
|
||||||
|
instrumental = '_itrack' in path
|
||||||
|
decoder = inst_decoder if instrumental else vocal_decoder
|
||||||
|
with torch.no_grad():
|
||||||
|
tracks.append(decode_audio(path, str(output / ('instrumental.wav' if instrumental else 'vocal.wav')),
|
||||||
|
True, args, decoder, codec))
|
||||||
|
decoder.to('cpu')
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
mixed = (tracks[0] + tracks[1]).detach().cpu().squeeze().numpy()
|
||||||
|
sf.write(str(output / 'mix44.wav'), mixed, 44100)
|
||||||
|
ns['replace_low_freq_with_energy_matched'](a_file=str(output / 'mix16.wav'), b_file=str(output / 'mix44.wav'),
|
||||||
|
c_file=str(output / 'audio.wav'), cutoff_freq=5500.0)
|
||||||
|
info = sf.info(str(output / 'audio.wav'))
|
||||||
|
if info.frames <= 0:
|
||||||
|
raise RuntimeError('YuEGP produced empty audio.')
|
||||||
|
emit(stage='complete', message='Audio ready', progress=100, duration=info.duration)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except Exception as error:
|
||||||
|
emit(stage='error', message=str(error), error=str(error))
|
||||||
|
raise
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { yueGpConfigured } from '~/server/utils/yueGp'
|
||||||
import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/server/utils/studioQueue'
|
import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/server/utils/studioQueue'
|
||||||
import { comfyConfigured } from '~/server/utils/comfy'
|
import { comfyConfigured } from '~/server/utils/comfy'
|
||||||
import {
|
import {
|
||||||
@@ -28,12 +29,15 @@ export default defineEventHandler(async (event) => {
|
|||||||
instrumental?: unknown
|
instrumental?: unknown
|
||||||
folderId?: unknown
|
folderId?: unknown
|
||||||
name?: unknown
|
name?: unknown
|
||||||
|
yueProfile?: unknown
|
||||||
engine?: unknown
|
engine?: unknown
|
||||||
sampler_name?: unknown
|
sampler_name?: unknown
|
||||||
scheduler?: unknown
|
scheduler?: unknown
|
||||||
}>(event).catch(() => ({} as Record<string, unknown>))
|
}>(event).catch(() => ({} as Record<string, unknown>))
|
||||||
|
|
||||||
if (!comfyConfigured()) {
|
const engine = parseMusicEngine(body.engine)
|
||||||
|
if (engine === 'yue' && !yueGpConfigured()) throw createError({ statusCode: 503, statusMessage: 'YuEGP host is not configured. Set COMFY_CONTROL_URL.' })
|
||||||
|
if (engine !== 'yue' && !comfyConfigured()) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 503,
|
statusCode: 503,
|
||||||
statusMessage: 'Beast ComfyUI is not configured. Set COMFY_HOST.'
|
statusMessage: 'Beast ComfyUI is not configured. Set COMFY_HOST.'
|
||||||
@@ -62,12 +66,15 @@ export default defineEventHandler(async (event) => {
|
|||||||
const destFolder = library.folders.find(folder => folder.id === folderId)
|
const destFolder = library.folders.find(folder => folder.id === folderId)
|
||||||
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
||||||
|
|
||||||
const engine = parseMusicEngine(body.engine)
|
if (engine === 'yue' && instrumental) throw createError({ statusCode: 400, statusMessage: 'YuEGP currently requires lyrics. Use ACE for instrumental music.' })
|
||||||
|
const yueProfile = body.yueProfile ?? 1
|
||||||
|
if (engine === 'yue' && yueProfile !== 1 && yueProfile !== 3) throw createError({ statusCode: 400, statusMessage: 'Choose YuEGP profile 1 or manual fallback 3.' })
|
||||||
if (engine === 'yue' && !instrumental) {
|
if (engine === 'yue' && !instrumental) {
|
||||||
const problem = yueLyricsProblem(lyrics)
|
const problem = yueLyricsProblem(lyrics)
|
||||||
if (problem) throw createError({ statusCode: 400, statusMessage: problem })
|
if (problem) throw createError({ statusCode: 400, statusMessage: problem })
|
||||||
}
|
}
|
||||||
const duration = clampMusicDuration(body.duration)
|
const duration = clampMusicDuration(body.duration)
|
||||||
|
if (engine === 'yue' && duration > 150) throw createError({ statusCode: 400, statusMessage: 'YuEGP supports up to 150 seconds per section.' })
|
||||||
const steps = clampMusicSteps(
|
const steps = clampMusicSteps(
|
||||||
body.steps,
|
body.steps,
|
||||||
engine === 'ace-step-1.5' ? MUSIC_STEPS_DEFAULT_15 : MUSIC_STEPS_DEFAULT
|
engine === 'ace-step-1.5' ? MUSIC_STEPS_DEFAULT_15 : MUSIC_STEPS_DEFAULT
|
||||||
@@ -115,7 +122,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
lyrics,
|
lyrics,
|
||||||
instrumental,
|
instrumental,
|
||||||
lyricsStrength,
|
lyricsStrength,
|
||||||
musicEngine: engine
|
musicEngine: engine,
|
||||||
|
...(engine === 'yue' ? { yueProfile: yueProfile as 1 | 3 } : {})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await kickStudioQueue()
|
await kickStudioQueue()
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (live.status === 'complete' || live.status === 'error' || live.trackId || live.clipId) {
|
if (live.status === 'complete' || live.status === 'error' || live.trackId || live.clipId) {
|
||||||
return jobSnapshot(live)
|
return jobSnapshot(live)
|
||||||
}
|
}
|
||||||
|
if (live.yueGp) return jobSnapshot(live)
|
||||||
if (live.kind === 'music' && Date.now() - live.startedAt > 15_000) {
|
if (live.kind === 'music' && Date.now() - live.startedAt > 15_000) {
|
||||||
const recovered = await recoverFinishedMedia(event, { tags: live.library?.tags })
|
const recovered = await recoverFinishedMedia(event, { tags: live.library?.tags })
|
||||||
if (recovered?.trackId) return recovered
|
if (recovered?.trackId) return recovered
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
payload.prompt = text(body.tags, 2000)
|
payload.prompt = text(body.tags, 2000)
|
||||||
row.prompt = payload.prompt
|
row.prompt = payload.prompt
|
||||||
}
|
}
|
||||||
|
if (body.yueProfile != null && row.kind === 'music' && payload.musicEngine === 'yue') {
|
||||||
|
if (body.yueProfile !== 1 && body.yueProfile !== 3) throw createError({ statusCode: 400, statusMessage: 'Choose YuEGP profile 1 or 3.' })
|
||||||
|
payload.yueProfile = body.yueProfile
|
||||||
|
}
|
||||||
if (body.lyrics != null) payload.lyrics = text(body.lyrics)
|
if (body.lyrics != null) payload.lyrics = text(body.lyrics)
|
||||||
const instrumental = optionalBool(body.instrumental)
|
const instrumental = optionalBool(body.instrumental)
|
||||||
if (instrumental != null) payload.instrumental = instrumental
|
if (instrumental != null) payload.instrumental = instrumental
|
||||||
@@ -57,7 +61,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (body.promptPost != null) payload.promptPost = text(body.promptPost)
|
if (body.promptPost != null) payload.promptPost = text(body.promptPost)
|
||||||
if (body.negative != null) payload.negative = text(body.negative, 2000)
|
if (body.negative != null) payload.negative = text(body.negative, 2000)
|
||||||
const duration = optionalNumber(body.duration)
|
const duration = optionalNumber(body.duration)
|
||||||
if (duration != null) payload.duration = Math.min(120, Math.max(0.5, duration))
|
if (duration != null) {
|
||||||
|
if (row.kind === 'music' && payload.musicEngine === 'yue') {
|
||||||
|
if (!Number.isInteger(duration) || duration < 30 || duration > 150) throw createError({ statusCode: 400, statusMessage: 'YuEGP duration must be 30–150 seconds.' })
|
||||||
|
payload.duration = duration
|
||||||
|
} else payload.duration = Math.min(120, Math.max(0.5, duration))
|
||||||
|
}
|
||||||
const steps = optionalNumber(body.steps)
|
const steps = optionalNumber(body.steps)
|
||||||
if (steps != null) payload.steps = Math.min(100, Math.max(1, Math.round(steps)))
|
if (steps != null) payload.steps = Math.min(100, Math.max(1, Math.round(steps)))
|
||||||
const cfg = optionalNumber(body.cfg)
|
const cfg = optionalNumber(body.cfg)
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
{
|
|
||||||
"15": {
|
|
||||||
"inputs": {
|
|
||||||
"stage_A_repo": "m-a-p/YuE-s1-7B-anneal-en-cot",
|
|
||||||
"xcodec_ckpt": "ckpt_00360000.pth",
|
|
||||||
"quantization_model": "fp16",
|
|
||||||
"use_mmgp": true,
|
|
||||||
"stage1_cache_size": 8192,
|
|
||||||
"exllamav2_cache_mode": "FP16",
|
|
||||||
"mmgp_profile": 2
|
|
||||||
},
|
|
||||||
"class_type": "YUE_Stage_A_Loader",
|
|
||||||
"_meta": {
|
|
||||||
"title": "YuE Stage A Loader"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"8": {
|
|
||||||
"inputs": {
|
|
||||||
"genres_prompt": "inspiring female uplifting pop airy vocal electronic bright",
|
|
||||||
"lyrics_prompt": "[verse]\n\n[chorus]\n",
|
|
||||||
"seed": 0,
|
|
||||||
"run_n_segment": 1,
|
|
||||||
"repetition_penalty": 1.1,
|
|
||||||
"prompt_start_time": 0,
|
|
||||||
"prompt_end_time": 30,
|
|
||||||
"max_new_tokens": 2944,
|
|
||||||
"use_dual_tracks_prompt": false,
|
|
||||||
"use_audio_prompt": false,
|
|
||||||
"offload_model": true,
|
|
||||||
"stage1_no_guidance": true,
|
|
||||||
"model": ["15", 0]
|
|
||||||
},
|
|
||||||
"class_type": "YUE_Stage_A_Sampler",
|
|
||||||
"_meta": {
|
|
||||||
"title": "YuE Stage A"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"16": {
|
|
||||||
"inputs": {
|
|
||||||
"stage_B_repo": "m-a-p/YuE-s2-1B-general",
|
|
||||||
"stage2_cache_size": 4096,
|
|
||||||
"stage2_batch_size": 1,
|
|
||||||
"exllamav2_cache_mode": "FP16",
|
|
||||||
"use_mmgp": true,
|
|
||||||
"info": ["8", 1]
|
|
||||||
},
|
|
||||||
"class_type": "YUE_Stage_B_Loader",
|
|
||||||
"_meta": {
|
|
||||||
"title": "YuE Stage B Loader"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"9": {
|
|
||||||
"inputs": {
|
|
||||||
"vocal_decoder_ckpt": "decoder_131000.pth",
|
|
||||||
"inst_decoder_ckpt": "decoder_151000.pth",
|
|
||||||
"rescale": true,
|
|
||||||
"stage1_set": ["8", 0],
|
|
||||||
"model": ["16", 0]
|
|
||||||
},
|
|
||||||
"class_type": "YUE_Stage_B_Sampler",
|
|
||||||
"_meta": {
|
|
||||||
"title": "YuE Stage B"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"19": {
|
|
||||||
"inputs": {
|
|
||||||
"filename_prefix": "audio/AIGen",
|
|
||||||
"audio": ["9", 0]
|
|
||||||
},
|
|
||||||
"class_type": "SaveAudio",
|
|
||||||
"_meta": {
|
|
||||||
"title": "Save Audio"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { resumeYueGpJobs } from '~/server/utils/yueGp'
|
||||||
|
|
||||||
|
// Restore live music IDs before queue repair examines their durable studio rows.
|
||||||
|
export default defineNitroPlugin(() => { resumeYueGpJobs() })
|
||||||
@@ -175,7 +175,8 @@ function sanitizeMusicSettings(raw: unknown): MusicPresetSettings {
|
|||||||
steps: clampMusicSteps(rec.steps),
|
steps: clampMusicSteps(rec.steps),
|
||||||
seed: typeof rec.seed === 'number' && Number.isFinite(rec.seed) ? Math.max(0, Math.floor(rec.seed)) : undefined,
|
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),
|
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 type JobStatus = 'queued' | 'uploading' | 'running' | 'complete' | 'error' | 'cancelled' | 'deferred'
|
||||||
|
|
||||||
export interface JobEvent {
|
export interface JobEvent {
|
||||||
|
audioExt?: string
|
||||||
type: string
|
type: string
|
||||||
message?: string
|
message?: string
|
||||||
progress?: number
|
progress?: number
|
||||||
@@ -31,6 +32,7 @@ export interface JobEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Job {
|
export interface Job {
|
||||||
|
yueGp?: boolean
|
||||||
musicActivity?: { checkedAt: number; running: boolean }
|
musicActivity?: { checkedAt: number; running: boolean }
|
||||||
id: string
|
id: string
|
||||||
kind?: 'video' | 'edit' | 'music'
|
kind?: 'video' | 'edit' | 'music'
|
||||||
@@ -50,6 +52,7 @@ export interface Job {
|
|||||||
hideThumbnail?: boolean
|
hideThumbnail?: boolean
|
||||||
imageComfyHost?: string
|
imageComfyHost?: string
|
||||||
library?: {
|
library?: {
|
||||||
|
yueProfile?: 1 | 3
|
||||||
ownerKey: string
|
ownerKey: string
|
||||||
folderId: string
|
folderId: string
|
||||||
hideThumbnail: boolean
|
hideThumbnail: boolean
|
||||||
@@ -150,6 +153,12 @@ export function createJob(kind: Job['kind'] = 'video'): Job {
|
|||||||
return 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) {
|
export function getJob(id: string) {
|
||||||
return jobs.get(id)
|
return jobs.get(id)
|
||||||
}
|
}
|
||||||
@@ -195,6 +204,7 @@ export function jobSnapshot(job: Job) {
|
|||||||
kind: job.kind || 'video',
|
kind: job.kind || 'video',
|
||||||
engine: job.library?.engine,
|
engine: job.library?.engine,
|
||||||
musicActivity: job.musicActivity,
|
musicActivity: job.musicActivity,
|
||||||
|
audioExt: job.library?.audioExt,
|
||||||
type: 'snapshot' as const,
|
type: 'snapshot' as const,
|
||||||
status: job.status,
|
status: job.status,
|
||||||
message: job.message,
|
message: job.message,
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export interface LibraryTrack {
|
|||||||
createdAt: number
|
createdAt: number
|
||||||
filename: string
|
filename: string
|
||||||
ext: string
|
ext: string
|
||||||
|
yueProfile?: 1 | 3
|
||||||
engine?: string
|
engine?: string
|
||||||
samplerName?: string
|
samplerName?: string
|
||||||
scheduler?: string
|
scheduler?: string
|
||||||
@@ -1767,6 +1768,10 @@ export function getTrack(owner: string, id: string) {
|
|||||||
return track
|
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: {
|
export async function saveTrack(params: {
|
||||||
ownerKey: string
|
ownerKey: string
|
||||||
folderId: string
|
folderId: string
|
||||||
@@ -1780,6 +1785,7 @@ export async function saveTrack(params: {
|
|||||||
instrumental: boolean
|
instrumental: boolean
|
||||||
audio: Buffer
|
audio: Buffer
|
||||||
ext?: string
|
ext?: string
|
||||||
|
yueProfile?: 1 | 3
|
||||||
engine?: string
|
engine?: string
|
||||||
samplerName?: string
|
samplerName?: string
|
||||||
scheduler?: string
|
scheduler?: string
|
||||||
@@ -1805,6 +1811,7 @@ export async function saveTrack(params: {
|
|||||||
filename: `audio.${ext}`,
|
filename: `audio.${ext}`,
|
||||||
ext,
|
ext,
|
||||||
engine: params.engine,
|
engine: params.engine,
|
||||||
|
yueProfile: params.yueProfile,
|
||||||
samplerName: params.samplerName,
|
samplerName: params.samplerName,
|
||||||
scheduler: params.scheduler,
|
scheduler: params.scheduler,
|
||||||
lyricsStrength: params.lyricsStrength,
|
lyricsStrength: params.lyricsStrength,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { startYueGpJob } from './yueGp'
|
||||||
import { createJob, emitJob, type Job } from '~/server/utils/jobs'
|
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 { comfyWsUrl, comfyFetch } 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'
|
||||||
@@ -21,6 +22,7 @@ export type MusicJobParams = {
|
|||||||
lyricsStrength: number
|
lyricsStrength: number
|
||||||
instrumental: boolean
|
instrumental: boolean
|
||||||
folderLocked?: boolean
|
folderLocked?: boolean
|
||||||
|
yueProfile?: 1 | 3
|
||||||
engine?: MusicEngine
|
engine?: MusicEngine
|
||||||
samplerName?: string
|
samplerName?: string
|
||||||
scheduler?: string
|
scheduler?: string
|
||||||
@@ -33,7 +35,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 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')) {
|
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.'
|
||||||
@@ -338,6 +340,7 @@ function watchMusicJob(job: Job): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function startMusicJob(params: MusicJobParams) {
|
export async function startMusicJob(params: MusicJobParams) {
|
||||||
|
if (params.engine === 'yue') return startYueGpJob(params)
|
||||||
const job = createJob('music')
|
const job = createJob('music')
|
||||||
job.library = {
|
job.library = {
|
||||||
ownerKey: params.ownerKey,
|
ownerKey: params.ownerKey,
|
||||||
@@ -381,14 +384,10 @@ export async function startMusicJob(params: MusicJobParams) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
job.status = 'running'
|
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 })
|
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,
|
||||||
@@ -406,9 +405,7 @@ export async function startMusicJob(params: MusicJobParams) {
|
|||||||
try {
|
try {
|
||||||
const queued = await queuePrompt(graph, job.clientId, { timeoutMs: 25_000 })
|
const queued = await queuePrompt(graph, job.clientId, { timeoutMs: 25_000 })
|
||||||
if (queued?.prompt_id) job.promptId = queued.prompt_id
|
if (queued?.prompt_id) job.promptId = queued.prompt_id
|
||||||
const runningMessage = params.engine === 'yue'
|
const runningMessage = 'Job queued on ComfyUI'
|
||||||
? 'YuE Stage A on Comfy — 16GB job, often 10–20+ minutes. Leave it alone.'
|
|
||||||
: 'Job queued on ComfyUI'
|
|
||||||
emitJob(job, { type: 'status', message: runningMessage, progress: 8 })
|
emitJob(job, { type: 'status', message: runningMessage, progress: 8 })
|
||||||
} catch (queueError: unknown) {
|
} catch (queueError: unknown) {
|
||||||
const statusCode = Number((queueError as { statusCode?: number; status?: number })?.statusCode
|
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
|
if (statusCode >= 400) throw queueError
|
||||||
emitJob(job, {
|
emitJob(job, {
|
||||||
type: 'status',
|
type: 'status',
|
||||||
message: params.engine === 'yue'
|
message: 'Waiting for ComfyUI to pick up the job...',
|
||||||
? 'YuE submitted — waiting for Comfy to start Stage A…'
|
|
||||||
: 'Waiting for ComfyUI to pick up the job...',
|
|
||||||
progress: 8
|
progress: 8
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import workflowAceStep from '../assets/workflow_ace_step.json'
|
import workflowAceStep from '../assets/workflow_ace_step.json'
|
||||||
import workflowAceStep15 from '../assets/workflow_ace_step_15.json'
|
import workflowAceStep15 from '../assets/workflow_ace_step_15.json'
|
||||||
import workflowYue from '../assets/workflow_yue.json'
|
|
||||||
import type { MusicEngine } from '~/utils/music'
|
import type { MusicEngine } from '~/utils/music'
|
||||||
import { parseMusicBpm } 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_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_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() {
|
export function musicFilenamePrefix() {
|
||||||
return String(useRuntimeConfig().comfyAudioPrefix || process.env.COMFY_AUDIO_PREFIX || 'audio/AIGen')
|
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) {
|
function applyPrefix(graph: WorkflowGraph, prefix: string) {
|
||||||
for (const node of Object.values(graph)) {
|
for (const node of Object.values(graph)) {
|
||||||
if ((node.class_type === 'SaveAudio' || node.class_type === 'SaveAudioMP3') && 'filename_prefix' in node.inputs) {
|
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
|
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 {
|
export function buildMusicWorkflow(params: MusicWorkflowParams): WorkflowGraph {
|
||||||
const engine = params.engine || 'ace-step'
|
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)
|
if (engine === 'ace-step-1.5') return buildAce15Workflow(params)
|
||||||
return buildAceV1Workflow(params)
|
return buildAceV1Workflow(params)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assertMusicEngineNodes(engine: MusicEngine | undefined) {
|
export async function assertMusicEngineNodes(engine: MusicEngine | undefined) {
|
||||||
const { comfyHasClassType } = await import('~/server/utils/comfy')
|
const { comfyHasClassType } = await import('~/server/utils/comfy')
|
||||||
if (engine === 'yue') {
|
if (engine === 'yue') throw new Error('YuE cannot run on Comfy.')
|
||||||
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 === 'ace-step-1.5') {
|
if (engine === 'ace-step-1.5') {
|
||||||
const present = await comfyHasClassType('TextEncodeAceStepAudio1.5')
|
const present = await comfyHasClassType('TextEncodeAceStepAudio1.5')
|
||||||
if (present === false) {
|
if (present === false) {
|
||||||
@@ -186,6 +121,5 @@ export async function assertMusicEngineNodes(engine: MusicEngine | undefined) {
|
|||||||
|
|
||||||
export const MUSIC_NODE_LABELS: Record<string, string> = Object.fromEntries([
|
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(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(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}`])
|
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export function inspectLiveMusicJob(job: Job) {
|
|||||||
seed: lib?.seed,
|
seed: lib?.seed,
|
||||||
cfg: lib?.cfg,
|
cfg: lib?.cfg,
|
||||||
engine: lib?.engine,
|
engine: lib?.engine,
|
||||||
|
yueProfile: lib?.yueProfile,
|
||||||
lyricsStrength: lib?.lyricsStrength,
|
lyricsStrength: lib?.lyricsStrength,
|
||||||
hideThumbnail: true,
|
hideThumbnail: true,
|
||||||
folderLocked: lib?.folderLocked === 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) {
|
if (!live || live.kind !== 'music' || live.library?.ownerKey !== owner) {
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
|
throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
|
||||||
}
|
}
|
||||||
|
if (live.yueGp) {
|
||||||
|
const { cancelYueGpJob } = await import('./yueGp')
|
||||||
|
await cancelYueGpJob(live)
|
||||||
|
return
|
||||||
|
}
|
||||||
live.status = 'cancelled'
|
live.status = 'cancelled'
|
||||||
if (live.library) live.library.stopAfterCurrent = true
|
if (live.library) live.library.stopAfterCurrent = true
|
||||||
emitJob(live, { type: 'error', error: 'Job interrupted.', message: 'Job interrupted.' })
|
emitJob(live, { type: 'error', error: 'Job interrupted.', message: 'Job interrupted.' })
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export interface StudioJobPayload {
|
|||||||
lyrics?: string
|
lyrics?: string
|
||||||
instrumental?: boolean
|
instrumental?: boolean
|
||||||
lyricsStrength?: number
|
lyricsStrength?: number
|
||||||
|
yueProfile?: 1 | 3
|
||||||
musicEngine?: import('~/utils/music').MusicEngine
|
musicEngine?: import('~/utils/music').MusicEngine
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,6 +284,7 @@ function failZombieLiveJob(job: Job, error: string) {
|
|||||||
function sweepStaleLiveJobs() {
|
function sweepStaleLiveJobs() {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
for (const job of listJobs()) {
|
for (const job of listJobs()) {
|
||||||
|
if (job.yueGp) continue
|
||||||
if (job.saving) continue
|
if (job.saving) continue
|
||||||
if (job.status === 'queued' && !job.promptId && now - job.startedAt >= QUEUED_GRACE_MS) {
|
if (job.status === 'queued' && !job.promptId && now - job.startedAt >= QUEUED_GRACE_MS) {
|
||||||
failZombieLiveJob(job, 'Job never started')
|
failZombieLiveJob(job, 'Job never started')
|
||||||
@@ -302,6 +304,7 @@ async function reapZombieLiveJobs() {
|
|||||||
const { fetchHistory } = await import('~/server/utils/comfy')
|
const { fetchHistory } = await import('~/server/utils/comfy')
|
||||||
for (const job of listJobs()) {
|
for (const job of listJobs()) {
|
||||||
// Never interrupt download/stitch/library save — Comfy is idle then by design.
|
// Never interrupt download/stitch/library save — Comfy is idle then by design.
|
||||||
|
if (job.yueGp) continue
|
||||||
if (job.saving) continue
|
if (job.saving) continue
|
||||||
if (job.library?.chainContinuing) continue
|
if (job.library?.chainContinuing) continue
|
||||||
if (job.status !== 'running' && job.status !== 'uploading' && job.status !== 'queued') continue
|
if (job.status !== 'running' && job.status !== 'uploading' && job.status !== 'queued') continue
|
||||||
@@ -346,6 +349,7 @@ async function reapZombieLiveJobs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function liveJobOwnsGpu(job: Job) {
|
function liveJobOwnsGpu(job: Job) {
|
||||||
|
if (job.yueGp && ['running', 'queued', 'uploading'].includes(job.status)) return true
|
||||||
if (job.library?.stopAfterCurrent) return false
|
if (job.library?.stopAfterCurrent) return false
|
||||||
if (job.library?.chainContinuing) return true
|
if (job.library?.chainContinuing) return true
|
||||||
if (job.saving) return true
|
if (job.saving) return true
|
||||||
@@ -365,6 +369,7 @@ function liveJobOwnsGpu(job: Job) {
|
|||||||
*/
|
*/
|
||||||
function clearDeadGpuClaimsForForceStart() {
|
function clearDeadGpuClaimsForForceStart() {
|
||||||
for (const live of listJobs()) {
|
for (const live of listJobs()) {
|
||||||
|
if (live.yueGp) continue
|
||||||
if (live.saving) continue
|
if (live.saving) continue
|
||||||
if (jobIsLocallySubmitting(live)) continue
|
if (jobIsLocallySubmitting(live)) continue
|
||||||
if (live.status !== 'running' && live.status !== 'queued' && live.status !== 'uploading') {
|
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.library?.ownerKey !== owner) continue
|
||||||
if (job.status === 'complete' || job.status === 'error' || job.status === 'cancelled') continue
|
if (job.status === 'complete' || job.status === 'error' || job.status === 'cancelled') continue
|
||||||
liveIds.add(job.id)
|
liveIds.add(job.id)
|
||||||
|
if (job.yueGp) {
|
||||||
|
const { cancelYueGpJob } = await import('./yueGp')
|
||||||
|
await cancelYueGpJob(job)
|
||||||
|
}
|
||||||
job.status = 'cancelled'
|
job.status = 'cancelled'
|
||||||
job.error = 'Cleared by force reset'
|
job.error = 'Cleared by force reset'
|
||||||
if (job.library) {
|
if (job.library) {
|
||||||
@@ -647,6 +656,11 @@ async function stopLiveGeneration(liveJobId?: string, shotQueueId?: string) {
|
|||||||
}
|
}
|
||||||
if (!liveJobId) return
|
if (!liveJobId) return
|
||||||
const live = getJob(liveJobId)
|
const live = getJob(liveJobId)
|
||||||
|
if (live?.yueGp) {
|
||||||
|
const { cancelYueGpJob } = await import('./yueGp')
|
||||||
|
await cancelYueGpJob(live)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (live) {
|
if (live) {
|
||||||
live.status = 'cancelled'
|
live.status = 'cancelled'
|
||||||
if (live.library) {
|
if (live.library) {
|
||||||
@@ -896,7 +910,7 @@ function repairStaleJobs(jobs: StudioJob[]) {
|
|||||||
job.updatedAt = Date.now()
|
job.updatedAt = Date.now()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (job.status === 'error' && isTransientComfyError(job.lastError)) {
|
if (job.payload.musicEngine !== 'yue' && job.status === 'error' && isTransientComfyError(job.lastError)) {
|
||||||
job.status = 'waiting'
|
job.status = 'waiting'
|
||||||
job.liveJobId = undefined
|
job.liveJobId = undefined
|
||||||
job.lastError = undefined
|
job.lastError = undefined
|
||||||
@@ -905,7 +919,7 @@ function repairStaleJobs(jobs: StudioJob[]) {
|
|||||||
job.updatedAt = Date.now()
|
job.updatedAt = Date.now()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (job.status === 'held' && isTransientComfyError(job.lastError)) {
|
if (job.payload.musicEngine !== 'yue' && job.status === 'held' && isTransientComfyError(job.lastError)) {
|
||||||
job.status = 'waiting'
|
job.status = 'waiting'
|
||||||
job.liveJobId = undefined
|
job.liveJobId = undefined
|
||||||
job.lastError = undefined
|
job.lastError = undefined
|
||||||
@@ -1285,7 +1299,7 @@ async function startStudioEditJob(item: StudioJob) {
|
|||||||
height: payload.height,
|
height: payload.height,
|
||||||
steps: payload.steps,
|
steps: payload.steps,
|
||||||
turbo: true,
|
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,
|
cfg: payload.cfg,
|
||||||
stillId: payload.stillId,
|
stillId: payload.stillId,
|
||||||
stillFilename: payload.stillFilename,
|
stillFilename: payload.stillFilename,
|
||||||
@@ -1375,12 +1389,13 @@ async function startStudioMusicJob(item: StudioJob) {
|
|||||||
lyrics: payload.instrumental ? '' : (payload.lyrics || ''),
|
lyrics: payload.instrumental ? '' : (payload.lyrics || ''),
|
||||||
duration: payload.duration,
|
duration: payload.duration,
|
||||||
steps: payload.steps,
|
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,
|
cfg: payload.cfg,
|
||||||
lyricsStrength: payload.instrumental ? 0 : (payload.lyricsStrength ?? 0.9),
|
lyricsStrength: payload.instrumental ? 0 : (payload.lyricsStrength ?? 0.9),
|
||||||
instrumental: payload.instrumental === true,
|
instrumental: payload.instrumental === true,
|
||||||
folderLocked: payload.folderLocked,
|
folderLocked: payload.folderLocked,
|
||||||
engine: payload.musicEngine || 'ace-step',
|
engine: payload.musicEngine || 'ace-step',
|
||||||
|
yueProfile: payload.yueProfile ?? 1,
|
||||||
samplerName: payload.samplerName || 'euler',
|
samplerName: payload.samplerName || 'euler',
|
||||||
scheduler: payload.scheduler || 'simple'
|
scheduler: payload.scheduler || 'simple'
|
||||||
})
|
})
|
||||||
@@ -1650,7 +1665,7 @@ export async function onLiveVideoSettled(job: Job) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const remaining = remainingStudioShots(job)
|
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
|
const failed = (job.status === 'error' || job.status === 'cancelled') && !wakeFail
|
||||||
|
|
||||||
await mutateStore(owner, (store) => {
|
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. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,6 +84,15 @@ function liveJob(id, kind = 'edit', extra = {}) {
|
|||||||
return { id, kind, status: 'running', promptId: `prompt-${id}`, startedAt: Date.now() - 300_000,
|
return { id, kind, status: 'running', promptId: `prompt-${id}`, startedAt: Date.now() - 300_000,
|
||||||
library: { ownerKey: 'test-owner' }, ...extra }
|
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) {
|
async function finishes(promise) {
|
||||||
let timer
|
let timer
|
||||||
try {
|
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)
|
||||||
|
})
|
||||||
@@ -5,6 +5,7 @@ import type { ImageV2PresetSettings } from '~/utils/imageV2'
|
|||||||
export type GenerationPresetKind = 'video' | 'image' | 'imagev2' | 'music'
|
export type GenerationPresetKind = 'video' | 'image' | 'imagev2' | 'music'
|
||||||
|
|
||||||
export type MusicPresetSettings = {
|
export type MusicPresetSettings = {
|
||||||
|
yueProfile?: 1 | 3
|
||||||
tags?: string
|
tags?: string
|
||||||
lyrics?: string
|
lyrics?: string
|
||||||
instrumental?: boolean
|
instrumental?: boolean
|
||||||
|
|||||||
@@ -284,6 +284,7 @@ export function clipSettingsRows(clip: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function trackSettingsRows(track: {
|
export function trackSettingsRows(track: {
|
||||||
|
yueProfile?: 1 | 3
|
||||||
tags?: string
|
tags?: string
|
||||||
lyrics?: string
|
lyrics?: string
|
||||||
duration?: number
|
duration?: number
|
||||||
@@ -310,8 +311,9 @@ export function trackSettingsRows(track: {
|
|||||||
const rounded = Math.round(track.duration * 10) / 10
|
const rounded = Math.round(track.duration * 10) / 10
|
||||||
rows.push({ label: 'Duration', value: Number.isInteger(rounded) ? `${rounded}s` : `${rounded.toFixed(1)}s` })
|
rows.push({ label: 'Duration', value: Number.isInteger(rounded) ? `${rounded}s` : `${rounded.toFixed(1)}s` })
|
||||||
}
|
}
|
||||||
if (track.steps != null) rows.push({ label: 'Steps', value: String(track.steps) })
|
if (track.yueProfile != null) rows.push({ label: 'YuEGP profile', value: String(track.yueProfile) })
|
||||||
if (track.cfg != null) rows.push({ label: 'CFG', value: formatNum(track.cfg) })
|
if (track.yueProfile == null && track.steps != null) rows.push({ label: 'Steps', value: String(track.steps) })
|
||||||
|
if (track.yueProfile == null && track.cfg != null) rows.push({ label: 'CFG', value: formatNum(track.cfg) })
|
||||||
if (track.samplerName) rows.push({ label: 'Sampler', value: track.samplerName })
|
if (track.samplerName) rows.push({ label: 'Sampler', value: track.samplerName })
|
||||||
if (track.scheduler) rows.push({ label: 'Scheduler', value: track.scheduler })
|
if (track.scheduler) rows.push({ label: 'Scheduler', value: track.scheduler })
|
||||||
if (track.lyricsStrength != null) rows.push({ label: 'Lyric strength', value: formatNum(track.lyricsStrength) })
|
if (track.lyricsStrength != null) rows.push({ label: 'Lyric strength', value: formatNum(track.lyricsStrength) })
|
||||||
|
|||||||
+2
-3
@@ -104,9 +104,8 @@ export function countYueLyricSections(lyrics: unknown) {
|
|||||||
export function yueLyricsProblem(lyrics: unknown): string | null {
|
export function yueLyricsProblem(lyrics: unknown): string | null {
|
||||||
const text = String(lyrics || '').trim()
|
const text = String(lyrics || '').trim()
|
||||||
if (!text) return 'YuE needs lyrics with section tags like [Verse 1] and [Chorus].'
|
if (!text) return 'YuE needs lyrics with section tags like [Verse 1] and [Chorus].'
|
||||||
if (countYueLyricSections(text) < 1) {
|
const sections = [...text.matchAll(/\[([^\]]+)\]\s*([^\[]*)/gs)].filter(match => match[2].trim())
|
||||||
return 'YuE lyrics must include at least one [Section] tag (use the chips under Lyrics).'
|
if (sections.length !== 1) return 'YuEGP needs exactly one non-empty lyric section. Combine the lyrics under one heading.'
|
||||||
}
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -22,6 +22,7 @@ export type QueuedShotDraft = import('~/utils/imageIterations').ImageIteration &
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type QueuedInspectPayload = {
|
export type QueuedInspectPayload = {
|
||||||
|
yueProfile?: 1 | 3
|
||||||
prompt?: string
|
prompt?: string
|
||||||
promptMid?: string
|
promptMid?: string
|
||||||
promptPre?: string
|
promptPre?: string
|
||||||
@@ -227,7 +228,8 @@ export function queuedJobPresetBody(job: QueuedInspectJob, name: string) {
|
|||||||
steps: clampMusicSteps(payload.steps),
|
steps: clampMusicSteps(payload.steps),
|
||||||
seed: payload.seed,
|
seed: payload.seed,
|
||||||
cfg: payload.cfg,
|
cfg: payload.cfg,
|
||||||
engine: payload.musicEngine || payload.engine
|
engine: payload.musicEngine || payload.engine,
|
||||||
|
yueProfile: payload.yueProfile
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user