make the MuseTalk install reach a working venv

Four pins and one probe, each found by building it rather than reading about it.

mmpose's RTMO head imports mmdet unconditionally although mmpose lists it as
optional, so `import mmpose.apis` dies without it; mmdet then asserts
mmcv < 2.2.0 at import; openmmlab publishes a cp311 mmcv 2.1.0 wheel on exactly
one index, cu121/torch2.1. So the torch pin is 2.1.2 and it is dictated by mmcv,
not by MuseTalk. mmengine imports pkg_resources to find mmpose's config
directory, which setuptools dropped in 81, making setuptools<81 a runtime pin.

chumpy, which mmpose depends on, has a setup.py that does `import pip` and
cannot build in an isolated environment. Named per package rather than turning
isolation off wholesale, which would let everything else build against a
half-installed venv.

The venv probe was wrong, not the install: it ran a bare interpreter, so
face_detection -- vendored under musetalk/utils, not a package -- came back
missing and a complete install was reported broken. It runs against the install
root with the engine's own PYTHONPATH now, as a real run does, and asks for
mmpose.apis rather than mmpose, since the package imports fine and the submodule
is what fails.

Also: uv's HTTP timeout is too short for hundred-megabyte wheels off third-party
indexes, and the abort reads as a corrupt wheel rather than a timeout. And a
per-run directory is swept after a day -- a failed run keeps its task file and
frames, which is what you read to find out why, but not for ever.

Verified live: MuseTalk v15 lip-synced a 2s 480x480 clip in 92s, 48 frames with
the audio muxed, and the successful run removed its own directory.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T3rZnLhydX9QqUcfBbqLu
parent d94b144e
......@@ -90,6 +90,10 @@ class EngineSpec:
probe_imports: Tuple[str, ...]
#: Extra directories (relative to the install root) to put on PYTHONPATH.
pythonpath: Tuple[str, ...] = ()
#: Packages whose sdist cannot be built in an isolated environment. Named
#: one at a time rather than disabling isolation wholesale, which would let
#: every other package see this venv's half-installed state.
no_build_isolation: Tuple[str, ...] = ()
#: Builds the argv tail and returns (argv, finish) where `finish` moves the
#: engine's output to the requested path. Set below, after the builders.
argv: Optional[Callable] = None
......@@ -163,6 +167,27 @@ def _latentsync_argv(root: Path, video: str, audio: str, out: str,
return argv, _finish
def _sweep_run_dirs(parent: Path, keep: Path = None,
max_age_s: float = 24 * 3600) -> int:
"""Drop leftover per-run directories older than a day. Returns how many."""
import time
gone = 0
try:
entries = list(parent.iterdir())
except OSError:
return 0
for d in entries:
if keep is not None and d == keep:
continue
try:
if d.is_dir() and (time.time() - d.stat().st_mtime) > max_age_s:
shutil.rmtree(d, ignore_errors=True)
gone += 1
except OSError:
continue
return gone
def _musetalk_argv(root: Path, video: str, audio: str, out: str,
options: dict) -> Tuple[List[str], Callable[[], str]]:
"""MuseTalk takes a YAML task list, not paths, and writes into a result dir.
......@@ -183,6 +208,11 @@ def _musetalk_argv(root: Path, video: str, audio: str, out: str,
run_id = uuid.uuid4().hex[:10]
work = root / "coderai-runs" / run_id
work.mkdir(parents=True, exist_ok=True)
# A successful run removes its own directory; a failed one is left behind on
# purpose, because the frames and the task file are what you look at to find
# out why. Sweep the stale ones rather than keeping them for ever, so a
# misconfigured engine cannot quietly fill the install root.
_sweep_run_dirs(work.parent, keep=work)
task = work / "task.yaml"
# The loader is OmegaConf, so a quoted scalar is enough; abspath keeps it
# independent of the cwd the subprocess runs in.
......@@ -279,10 +309,16 @@ ENGINES: Dict[str, EngineSpec] = {
"models/whisper/config.json",
"models/dwpose/dw-ll_ucoco_384.pth",
"models/face-parse-bisent/79999_iter.pth"),
probe_imports=("torch", "diffusers", "mmpose", "face_detection"),
probe_imports=("torch", "diffusers", "mmpose.apis", "mmdet",
"face_detection"),
# preprocessing.py does `from face_detection import …` — a top-level
# import of a package that lives under musetalk/utils.
pythonpath=(".", "musetalk/utils"),
# mmpose depends on chumpy, whose setup.py does `import pip` — which an
# isolated build environment has not got, so the sdist fails to build
# with "ModuleNotFoundError: No module named 'pip'". Building it against
# the venv (which is seeded with pip below) is the way through.
no_build_isolation=("chumpy",),
argv=_musetalk_argv,
),
}
......@@ -403,16 +439,35 @@ def _venv_python(venv: Path) -> Path:
"python.exe" if os.name == "nt" else "python")
def _engine_env(sp: EngineSpec, root: Path) -> dict:
"""The environment this engine's interpreter runs under.
clean_py_env() is load-bearing rather than hygiene — see the module
docstring. PYTHONPATH carries the engine's own tree, because part of what
each one imports is vendored inside it rather than installed: MuseTalk's
preprocessing does `from face_detection import …`, a top-level import of a
package that lives under musetalk/utils.
"""
env = clean_py_env()
paths = [str((root / p).resolve()) for p in (sp.pythonpath or (".",))]
env["PYTHONPATH"] = os.pathsep.join(paths)
return env
def _pip_ok(name: str, py: Path, quiet: bool = False) -> bool:
"""Does this venv import what the engine needs?
"""Can this venv import everything the engine needs, vendored parts included?
clean_py_env() is load-bearing rather than hygiene — see the module docstring.
Run against the install root, exactly as a real run is. Probing with a bare
interpreter instead reported a complete install as broken, because the
vendored modules are not on any default path.
"""
sp = spec(name)
root = install_dir(name)
code = "import " + ", ".join(sp.probe_imports)
try:
r = subprocess.run([str(py), "-c", code], capture_output=True,
env=clean_py_env())
cwd=str(root) if root.is_dir() else None,
env=_engine_env(sp, root))
if r.returncode != 0 and not quiet:
why = (r.stderr or r.stdout or b"").decode(errors="replace").strip()
print(f"[{name}] venv at {py} is not usable: {why[-400:]}", flush=True)
......@@ -501,9 +556,23 @@ def _install_requirements(name: str, py: Path, log=None) -> None:
# uv's default is first-index-wins, so with the PyTorch index in these
# requirements it resolves unrelated packages off it and pins the wrong
# versions of things like requests.
subprocess.run([uv, "pip", "install", "--python", str(py),
"--index-strategy", "unsafe-best-match", "-r", str(req)],
check=True, env=clean_py_env())
env = clean_py_env()
# These are hundred-megabyte wheels off third-party indexes -- the
# PyTorch one, and openmmlab's for mmcv -- and uv's default timeout is
# short enough that a slow CDN aborts mid-unzip. The failure reads as a
# corrupt wheel ("Failed to unzip wheel … error decoding response body"),
# which sends you looking for a build problem that is not there.
env.setdefault("UV_HTTP_TIMEOUT", "900")
cmd = [uv, "pip", "install", "--python", str(py),
"--index-strategy", "unsafe-best-match"]
if sp.no_build_isolation:
# Those packages build against the venv, so it needs the build tools
# a uv venv does not ship.
subprocess.run([uv, "pip", "install", "--python", str(py),
"pip", "setuptools", "wheel"], check=True, env=env)
for pkg in sp.no_build_isolation:
cmd += ["--no-build-isolation-package", pkg]
subprocess.run(cmd + ["-r", str(req)], check=True, env=env)
if not _pip_ok(name, py):
raise RuntimeError(
f"the {name} venv installed but does not import "
......@@ -615,9 +684,7 @@ def run(name: str, video_path: str, audio_path: str, out_path: str,
tail, finish = sp.argv(root, video_path, audio_path, out_path, options)
cmd = [str(py), "-m", sp.entrypoint] + tail
env = clean_py_env()
paths = [str((root / p).resolve()) for p in (sp.pythonpath or (".",))]
env["PYTHONPATH"] = os.pathsep.join(paths)
env = _engine_env(sp, root)
if log:
log(f"[{sp.name}] {' '.join(cmd[1:])}")
try:
......
......@@ -13,15 +13,16 @@
# download_weights.sh to pull the BiSeNet face-parsing checkpoint off a Google
# Drive id; coderai takes that file from a Hub mirror instead.
#
# mmcv is the fragile one. openmmlab publishes prebuilt wheels for a handful of
# torch/CUDA pairs and torch 2.4.1+cu121 is one of them, which is why the torch
# pin here is older than LatentSync's. If this host has no matching wheel, mmcv
# builds from source and needs nvcc; ensure_installed() reports that rather than
# leaving a half-built venv behind.
# The torch pin is dictated by mmcv, not by MuseTalk. mmpose's RTMO head
# imports mmdet unconditionally, mmdet asserts mmcv < 2.2.0 at import time, and
# openmmlab publishes a cp311 wheel for mmcv 2.1.0 on exactly one index:
# cu121/torch2.1. Every other index it publishes has 2.2.0 only. So torch 2.1.2
# it is -- which diffusers 0.30 and transformers 4.39 are contemporaries of
# anyway. Moving torch up here means building mmcv from source against nvcc.
--extra-index-url https://download.pytorch.org/whl/cu121
torch==2.4.1+cu121
torchvision==0.19.1+cu121
torch==2.1.2+cu121
torchvision==0.16.2+cu121
diffusers==0.30.2
transformers==4.39.2
......@@ -40,8 +41,17 @@ ffmpeg-python
requests
# DWPose landmarks. preprocessing.py calls mmpose.apis at import time, so these
# are not optional.
-f https://download.openmmlab.com/mmcv/dist/cu121/torch2.4/index.html
mmcv==2.2.0
# are not optional -- and neither is mmdet: mmpose lists it as optional but
# models/heads/hybrid_heads/rtmo_head.py imports it unconditionally, so
# `import mmpose.apis` dies with ModuleNotFoundError without it.
-f https://download.openmmlab.com/mmcv/dist/cu121/torch2.1/index.html
mmcv==2.1.0
mmengine>=0.10.3
mmpose==1.3.2
mmdet==3.3.0
# mmengine.utils.package_utils imports pkg_resources to locate mmpose's config
# directory, and setuptools dropped pkg_resources in 81. It is a RUNTIME
# dependency here, not a build one, so it has to be pinned rather than left to
# whatever the venv was seeded with.
setuptools<81
......@@ -277,3 +277,96 @@ def test_the_presenter_still_ranks_them_ahead_of_wav2lip():
presenter._AUTO_WITH_BASE.index("wav2lip")
assert presenter._AUTO_WITH_BASE.index("musetalk") < \
presenter._AUTO_WITH_BASE.index("wav2lip")
# ── the install environment ──────────────────────────────────────────────────
def test_a_package_that_cannot_build_isolated_is_named_one_at_a_time(le):
"""mmpose depends on chumpy, whose setup.py does `import pip` — which an
isolated build environment has not got. Disabling isolation wholesale would
let every other package build against a half-installed venv, so the
exception is per package."""
assert le.spec("musetalk").no_build_isolation == ("chumpy",)
assert le.spec("latentsync").no_build_isolation == ()
def test_the_uv_install_seeds_build_tools_only_when_something_needs_them(le):
"""A uv venv ships no pip, so a package building against it finds none."""
import inspect
src = inspect.getsource(le._install_requirements)
seed = src[src.index("if sp.no_build_isolation:"):]
assert '"pip", "setuptools", "wheel"' in seed
assert "--no-build-isolation-package" in seed
def test_large_wheels_get_a_timeout_that_fits_them(le):
"""uv's default aborts mid-unzip on a slow third-party index, and reports it
as a corrupt wheel rather than a timeout."""
import inspect
src = inspect.getsource(le._install_requirements)
assert "UV_HTTP_TIMEOUT" in src
def test_musetalk_keeps_the_runtime_pins_that_look_like_cruft():
"""Three pins in requirements-musetalk.txt invite a tidy-up and each one
breaks the engine at import time if it goes.
mmcv 2.1.0 (not 2.2.0) because mmdet asserts mmcv < 2.2.0 and openmmlab
publishes a cp311 2.1.0 wheel on exactly one index; torch 2.1.2 because that
index is cu121/torch2.1; setuptools < 81 because mmengine still imports
pkg_resources, which 81 removed.
"""
req = (ROOT / "requirements-musetalk.txt").read_text(encoding="utf-8")
assert "mmcv==2.1.0" in req
assert "mmdet==3.3.0" in req
assert "torch==2.1.2+cu121" in req
assert "setuptools<81" in req
assert "cu121/torch2.1/index.html" in req
def test_the_probe_imports_the_submodule_that_actually_fails(le):
"""`import mmpose` succeeds without mmdet; `import mmpose.apis` is what
reaches rtmo_head and dies. Probing the package would call a venv that
cannot run the engine usable."""
assert "mmpose.apis" in le.spec("musetalk").probe_imports
assert "mmpose" not in le.spec("musetalk").probe_imports
def test_the_venv_probe_runs_where_the_engine_runs(le):
"""face_detection is vendored under musetalk/utils, not installed, so a
bare-interpreter probe reports a complete install as broken."""
import inspect
src = inspect.getsource(le._pip_ok)
assert "_engine_env" in src
assert "cwd=" in src
def test_a_failed_run_keeps_its_evidence_but_not_for_ever(le, tmp_path):
"""The frames and the task file are what you read to find out why a run
failed, so a failure leaves them; a day later they are only clutter in the
install root."""
import os
import time
parent = tmp_path / "coderai-runs"
old = parent / "old"
recent = parent / "recent"
for d in (old, recent):
d.mkdir(parents=True)
os.utime(old, (time.time() - 48 * 3600, time.time() - 48 * 3600))
assert le._sweep_run_dirs(parent) == 1
assert recent.is_dir() and not old.exists()
def test_the_sweep_never_removes_the_run_being_set_up(le, tmp_path):
import os
import time
parent = tmp_path / "coderai-runs"
mine = parent / "mine"
mine.mkdir(parents=True)
os.utime(mine, (time.time() - 48 * 3600, time.time() - 48 * 3600))
assert le._sweep_run_dirs(parent, keep=mine) == 0
assert mine.is_dir()
def test_the_sweep_is_silent_when_there_is_nothing_to_sweep(le, tmp_path):
assert le._sweep_run_dirs(tmp_path / "never-made") == 0
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment