From 12a8cf1fd26c2f9d96e4961b95baf8b655c26766 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 19:42:14 +1000 Subject: [PATCH] music_local (ACE-Step) + motion_local (MoMask) operators with install scripts install_momask.sh encodes the compat patches: numpy<2 + umath_tests shim, matplotlib axes-clearing fix, np.float alias wrapper (mb_gen.py) Co-Authored-By: Claude Fable 5 --- scripts/install_acestep.sh | 10 ++++ scripts/install_momask.sh | 54 ++++++++++++++++++ server/operators/motion_local/manifest.json | 18 ++++++ server/operators/motion_local/run.py | 61 +++++++++++++++++++++ server/operators/music_local/manifest.json | 21 +++++++ server/operators/music_local/run.py | 49 +++++++++++++++++ 6 files changed, 213 insertions(+) create mode 100755 scripts/install_acestep.sh create mode 100755 scripts/install_momask.sh create mode 100644 server/operators/motion_local/manifest.json create mode 100644 server/operators/motion_local/run.py create mode 100644 server/operators/music_local/manifest.json create mode 100644 server/operators/music_local/run.py diff --git a/scripts/install_acestep.sh b/scripts/install_acestep.sh new file mode 100755 index 0000000..8542cfa --- /dev/null +++ b/scripts/install_acestep.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Install ACE-Step (text → music) into vendor/acestep + venvs/acestep. +# Weights (~7GB) auto-download from HF on first generation. +set -euo pipefail +cd "$(dirname "$0")/.." +[ -d vendor/acestep ] || git clone -q https://github.com/ace-step/ACE-Step.git vendor/acestep +uv venv venvs/acestep --python 3.11 2>/dev/null || true +cd vendor/acestep +uv pip install --python ../../venvs/acestep/bin/python -e . +echo "acestep installed" diff --git a/scripts/install_momask.sh b/scripts/install_momask.sh new file mode 100755 index 0000000..493e1b2 --- /dev/null +++ b/scripts/install_momask.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Install MoMask (text → motion BVH) into vendor/momask + venvs/momask. +# Research code: needs numpy<2 + matplotlib 3.6 + two small compat patches. +set -euo pipefail +cd "$(dirname "$0")/.." + +[ -d vendor/momask ] || git clone -q https://github.com/EricGuo5513/momask-codes.git vendor/momask +uv venv venvs/momask --python 3.11 2>/dev/null || true +uv pip install --python venvs/momask/bin/python \ + torch "numpy<2" scipy "matplotlib==3.6.3" tqdm einops gdown ftfy regex \ + "git+https://github.com/openai/CLIP.git" + +cd vendor/momask + +# HumanML3D checkpoints (188MB, Google Drive) +if [ ! -d checkpoints/t2m/t2m_nlayer8_nhead6_ld384_ff1024_cdp0.1_rvq6ns ]; then + mkdir -p checkpoints/t2m && cd checkpoints/t2m + ../../../../venvs/momask/bin/gdown 1vXS7SHJBgWPt59wupQ5UUzhFObrnGkQ0 + unzip -q humanml3d_models.zip && rm humanml3d_models.zip + cd ../.. +fi + +# Patch 1: numpy.core.umath_tests was removed — np.matmul is the same op +python3 - <<'PYEOF' +import pathlib +SHIM = ("from types import SimpleNamespace as _SN; " + "import numpy as _np; ut = _SN(matrix_multiply=_np.matmul)") +for f in ("visualization/Animation.py", "visualization/Quaternions.py"): + p = pathlib.Path(f); s = p.read_text() + if "umath_tests" in s: + p.write_text(s.replace("import numpy.core.umath_tests as ut", SHIM)) +PYEOF + +# Patch 2: matplotlib removed the ax.lines/ax.collections setters +python3 - <<'PYEOF' +import pathlib +p = pathlib.Path("utils/plot_script.py"); s = p.read_text() +s = s.replace("ax.lines = []", "[l.remove() for l in list(ax.lines)]") +s = s.replace("ax.collections = []", "[c.remove() for c in list(ax.collections)]") +p.write_text(s) +PYEOF + +# Wrapper: restore np.float/np.int/... aliases the old code expects +cat > mb_gen.py <<'PYEOF' +"""MODELBEAST wrapper: numpy>=1.24 compat aliases, then run gen_t2m.py.""" +import numpy as np +for _n, _v in {"float": float, "int": int, "object": object, "bool": bool, "str": str}.items(): + if not hasattr(np, _n): + setattr(np, _n, _v) +import runpy +runpy.run_path("gen_t2m.py", run_name="__main__") +PYEOF + +echo "momask installed" diff --git a/server/operators/motion_local/manifest.json b/server/operators/motion_local/manifest.json new file mode 100644 index 0000000..490aef0 --- /dev/null +++ b/server/operators/motion_local/manifest.json @@ -0,0 +1,18 @@ +{ + "id": "motion_local", + "name": "Motion (local, MoMask)", + "category": "motion", + "description": "Text → character motion as BVH (+ mp4 preview) via MoMask (HumanML3D). For the bespoke clips Mixamo doesn't have — describe the action in plain words. Output BVH retargets onto rigged characters in Blender (import BVH, retarget to the character_kit skeleton). CPU-only, ~1-2 min. Research-grade: expect roughly right, polish in Blender. Install: scripts/install_momask.sh", + "accepts": [], + "produces": ["motion"], + "resources": "cpu", + "entry": "run.py", + "python": "venvs/momask/bin/python", + "params_schema": { + "type": "object", + "properties": { + "text": {"type": "string", "default": "", "description": "The action: 'a person riffles through a crate of records, pulls one out and admires it'"}, + "length": {"type": "integer", "default": -1, "minimum": -1, "maximum": 196, "description": "Frames at 20fps (196 max ≈ 10s); -1 = let the model estimate"} + } + } +} diff --git a/server/operators/motion_local/run.py b/server/operators/motion_local/run.py new file mode 100644 index 0000000..706d642 --- /dev/null +++ b/server/operators/motion_local/run.py @@ -0,0 +1,61 @@ +import argparse +import json +import os +import shutil +import subprocess +import sys +import time +import uuid +from pathlib import Path + +os.environ["PATH"] = "/opt/homebrew/bin:/usr/local/bin:" + os.environ.get("PATH", "") + +ap = argparse.ArgumentParser() +ap.add_argument("--input", action="append", default=[]) +ap.add_argument("--outdir", required=True) +ap.add_argument("--params", default="{}") +a = ap.parse_args() +p = json.loads(a.params) + +text = (p.get("text") or "").strip() +if not text: + print("ERROR: text is required") + sys.exit(1) + +ROOT = Path(__file__).resolve().parents[3] +MOMASK = ROOT / "vendor" / "momask" +if not (MOMASK / "mb_gen.py").exists(): + print("ERROR: momask not installed — run scripts/install_momask.sh") + sys.exit(1) + +ext = f"mb_{uuid.uuid4().hex[:10]}" # unique run dir inside the shared vendor tree +cmd = [sys.executable, "mb_gen.py", "--gpu_id", "-1", "--ext", ext, "--text_prompt", text] +length = int(p.get("length", -1)) +if length > 0: + cmd += ["--motion_length", str(length)] + +t0 = time.time() +r = subprocess.run(cmd, cwd=str(MOMASK), capture_output=True, text=True) +gen_dir = MOMASK / "generation" / ext +files = sorted(gen_dir.rglob("*")) if gen_dir.exists() else [] +bvh = [f for f in files if f.suffix == ".bvh"] +if r.returncode != 0 or not bvh: + print(r.stdout[-1000:]) + print(r.stderr[-1500:], file=sys.stderr) + print("ERROR: momask generation failed") + sys.exit(1) + +outdir = Path(a.outdir) +slug = "".join(c if c.isalnum() else "_" for c in text[:40]).strip("_") +n = 0 +for f in files: + if f.suffix == ".bvh": + # _ik = foot-contact cleaned; that's the one to retarget + kind = "ik" if "_ik" in f.stem else "raw" + shutil.copy(f, outdir / f"motion_{slug}_{kind}.bvh") + n += 1 + elif f.suffix == ".mp4" and "_ik" in f.stem: + shutil.copy(f, outdir / f"motion_{slug}_preview.mp4") + n += 1 +shutil.rmtree(gen_dir, ignore_errors=True) +print(f"done in {time.time() - t0:.0f}s: {n} files for '{text[:60]}'", flush=True) diff --git a/server/operators/music_local/manifest.json b/server/operators/music_local/manifest.json new file mode 100644 index 0000000..cc0ae3c --- /dev/null +++ b/server/operators/music_local/manifest.json @@ -0,0 +1,21 @@ +{ + "id": "music_local", + "name": "Music (local, ACE-Step)", + "category": "audio", + "description": "Style tags → music WAV via ACE-Step 3.5B on MPS (Apache 2.0, ~7GB weights). Jingles, menu loops, shop muzak, boss themes; add lyrics for actual songs ([verse]/[chorus] markers work, '[instrumental]' for none). Runs on the primary only. Install: scripts/install_acestep.sh", + "accepts": [], + "produces": ["audio"], + "resources": "gpu", + "entry": "run.py", + "python": "venvs/acestep/bin/python", + "params_schema": { + "type": "object", + "properties": { + "prompt": {"type": "string", "default": "", "description": "Style tags, comma-separated: 'lo-fi synth funk, playful, toy robot bleeps, 90 bpm'"}, + "lyrics": {"type": "string", "default": "[instrumental]", "description": "Lyrics with [verse]/[chorus] markers, or [instrumental]"}, + "duration": {"type": "number", "default": 30, "minimum": 5, "maximum": 240, "description": "Seconds"}, + "steps": {"type": "integer", "default": 27, "minimum": 10, "maximum": 100, "description": "27 = fast/good, 60 = best"}, + "seed": {"type": "integer", "default": 42} + } + } +} diff --git a/server/operators/music_local/run.py b/server/operators/music_local/run.py new file mode 100644 index 0000000..43eca69 --- /dev/null +++ b/server/operators/music_local/run.py @@ -0,0 +1,49 @@ +import argparse +import json +import os +import sys +import time +from pathlib import Path + +os.environ.setdefault("TQDM_DISABLE", "1") +os.environ["PATH"] = "/opt/homebrew/bin:/usr/local/bin:" + os.environ.get("PATH", "") + +ap = argparse.ArgumentParser() +ap.add_argument("--input", action="append", default=[]) +ap.add_argument("--outdir", required=True) +ap.add_argument("--params", default="{}") +a = ap.parse_args() +p = json.loads(a.params) + +prompt = (p.get("prompt") or "").strip() +if not prompt: + print("ERROR: prompt (style tags) is required") + sys.exit(1) + +from acestep.pipeline_ace_step import ACEStepPipeline # noqa: E402 + +seed = int(p.get("seed", 42)) +out = Path(a.outdir) / f"music_{seed}.wav" +t0 = time.time() +pipe = ACEStepPipeline(dtype="float32") # bfloat16 unsupported on MPS +print(f"pipeline ready in {time.time() - t0:.1f}s", flush=True) + +t1 = time.time() +pipe( + prompt=prompt, + lyrics=(p.get("lyrics") or "[instrumental]").strip(), + audio_duration=float(p.get("duration", 30)), + infer_step=int(p.get("steps", 27)), + guidance_scale=15.0, + omega_scale=10.0, + manual_seeds=[seed], + save_path=str(out), +) +if not out.exists(): + # some versions append suffixes; register whatever wav landed in outdir + wavs = list(Path(a.outdir).glob("*.wav")) + if not wavs: + print("ERROR: no wav produced") + sys.exit(1) + out = wavs[0] +print(f"done in {time.time() - t1:.1f}s: {out.name}", flush=True)