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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 19:42:14 +10:00
parent b7e113ca5b
commit 12a8cf1fd2
6 changed files with 213 additions and 0 deletions

10
scripts/install_acestep.sh Executable file
View File

@ -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"

54
scripts/install_momask.sh Executable file
View File

@ -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"

View File

@ -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"}
}
}
}

View File

@ -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)

View File

@ -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}
}
}
}

View File

@ -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)