From cff9f098b4dd61287c789e96795d5997adeac706 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:07:44 +0000 Subject: [PATCH 1/2] Move media backends into a single 'media' extra; base keeps the pure modules (#54) moviepy, opencv-contrib-python, scipy, pydub, burns, numpy, pillow and looks move from the base dependencies to the 'media' extra. mixing.audio and mixing.video raise an ImportError naming "pip install 'mixing[media]'" when the extra is missing. No public name is removed or renamed. Co-Authored-By: Claude Sonnet 5.5 --- .claude/CLAUDE.md | 9 +- .claude/skills/mixing/SKILL.md | 5 +- README.md | 8 +- mixing/audio/__init__.py | 4 + mixing/tests/test_guard_base_install.py | 132 ++++++++++++++++++++++++ mixing/util.py | 49 ++++++++- mixing/video/__init__.py | 4 + pyproject.toml | 38 ++++--- 8 files changed, 227 insertions(+), 22 deletions(-) create mode 100644 mixing/tests/test_guard_base_install.py diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4bb31c3..f36ec17 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -123,7 +123,14 @@ Use the helpers, don't reinvent them: ## Optional extras -`pip install mixing[]`: `audio` (pydub/ffmpeg-python/soundfile), +The base install carries only the pure modules (`srt`, `chapters`, `errors`, +`util`, `egress`, `transcript`, TTS). **`media`** supplies the backends +`mixing.audio` / `mixing.video` import (moviepy, opencv-contrib-python, scipy, +pydub, numpy, pillow, burns, looks); importing either without it raises an +`ImportError` naming `pip install 'mixing[media]'` (`util.require_media_extra`, +called at the top of both package `__init__`s). Tests install it via `testing`. + +`pip install mixing[]`: `media` (see above), `audio` (ffmpeg-python/soundfile, on top of `media`), `widget` (Jupyter audio widget), `gen` (Veo/`google-genai`), `llm` (`aix`, for chapter titling + SRT translation), `clipboard` (`pyclip`), `beats` (`librosa`, for `mixing.audio.beats.beat_grid`), `dev`/`testing`. diff --git a/.claude/skills/mixing/SKILL.md b/.claude/skills/mixing/SKILL.md index 4768194..197fc47 100644 --- a/.claude/skills/mixing/SKILL.md +++ b/.claude/skills/mixing/SKILL.md @@ -83,7 +83,10 @@ with Video("clip.mp4") as v: ## Optional extras & keys -- `pip install mixing[audio]` (pydub/soundfile), `[gen]` (Veo), `[llm]` (`aix` +- `pip install 'mixing[media]'` is required for `mixing.audio` / `mixing.video` + (moviepy, opencv, scipy, pydub); the base install has only the pure modules + and the media modules raise an `ImportError` naming this extra. +- `pip install mixing[audio]` (ffmpeg-python/soundfile), `[gen]` (Veo), `[llm]` (`aix` for chapter titles + SRT translation), `[widget]`, `[clipboard]`. - ElevenLabs features (`mixing.transcript`, `mixing.dubbing`) need `ELEVENLABS_API_KEY` (or pass `api_key=`). Responses are cached on disk, so a diff --git a/README.md b/README.md index a9b6cd4..02e83c2 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,12 @@ replace/mix, Ken Burns, thumbnails, subtitles, speech-to-text + filler removal, TTS dubbing, and chapter detection. ```bash -pip install mixing +pip install 'mixing[media]' # audio + video editing (moviepy, opencv, scipy, pydub) +pip install mixing # base: only the pure modules (srt, chapters, errors, util, egress, transcript, TTS) ``` +The base install is light on purpose: it carries no media backend (and so no bundled ffmpeg binary). `mixing.audio` and `mixing.video` need the `media` extra; importing them without it raises an `ImportError` that says so. + --- ## 🤖 AI-first: start with the skills & agents @@ -123,7 +126,8 @@ logging.basicConfig(level=logging.INFO) # or level=logging.ERROR for errors onl ### Optional extras ```bash -pip install mixing[audio] # pydub / ffmpeg-python / soundfile +pip install mixing[media] # moviepy / opencv / scipy / pydub: mixing.audio + mixing.video +pip install mixing[audio] # ffmpeg-python / soundfile (on top of media) pip install mixing[widget] # interactive Jupyter audio widget pip install mixing[gen] # Google Vertex AI Veo generation pip install mixing[llm] # aix — chapter titling + SRT translation diff --git a/mixing/audio/__init__.py b/mixing/audio/__init__.py index 3141e72..06a66b4 100644 --- a/mixing/audio/__init__.py +++ b/mixing/audio/__init__.py @@ -27,6 +27,10 @@ >>> paths = extract_segments("concert.wav", segs, output="songs/") # doctest: +SKIP """ +from ..util import AUDIO_IMPORT_NAMES, require_media_extra + +require_media_extra("mixing.audio", modules=AUDIO_IMPORT_NAMES) + from .audio_ops import ( Audio, AudioSamples, diff --git a/mixing/tests/test_guard_base_install.py b/mixing/tests/test_guard_base_install.py new file mode 100644 index 0000000..66f42fa --- /dev/null +++ b/mixing/tests/test_guard_base_install.py @@ -0,0 +1,132 @@ +"""Guardrail: the base install (no ``media`` extra) keeps the pure modules importable. + +The media backends (moviepy, opencv, scipy, pydub, burns, numpy, pillow, looks) +are only declared by the ``media`` extra. These tests hide them in a fresh +interpreter (``sys.modules[name] = None`` makes ``import name`` fail and +``find_spec`` return ``None``, exactly as on a machine without the wheel) and +check that + +- the pure modules import and work, and load none of the hidden libraries; +- ``mixing.audio`` / ``mixing.video`` (and the lazy facade names that resolve to + them) raise an ``ImportError`` naming ``mixing[media]``; +- the packaging metadata declares the split. +""" + +import json +import subprocess +import sys +import textwrap + +import pytest + +from mixing.util import MEDIA_IMPORT_NAMES, MEDIA_INSTALL_HINT + +_PROLOGUE = f""" +import sys +for _name in {list(MEDIA_IMPORT_NAMES)!r}: + sys.modules[_name] = None +""" + + +def _run_hidden(body: str) -> dict: + """Run ``body`` in a fresh interpreter with the media libs hidden; return its JSON.""" + code = _PROLOGUE + textwrap.dedent(body) + proc = subprocess.run( + [sys.executable, "-c", code], capture_output=True, text=True, timeout=120 + ) + assert proc.returncode == 0, proc.stderr[-2000:] + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +def test_pure_modules_import_without_media_libs(): + out = _run_hidden( + """ + import json, sys + import mixing + import mixing.srt, mixing.chapters, mixing.errors, mixing.util, mixing.egress + import mixing.transcript, mixing.dubbing + from mixing import Cue, parse_srt, dump_srt, MixingError, Chapter, has_ffmpeg + from mixing.dubbing import text_to_speech, translate_srt + from mixing import transcribe, remove_fillers + cues = parse_srt("1\\n00:00:00,000 --> 00:00:01,500\\nhi\\n") + print(json.dumps({ + "cues": len(cues), + "loaded": [n for n in sys.modules + if n.split(".")[0] in ("moviepy", "cv2", "scipy", "pydub", "numpy", "PIL") + and sys.modules[n] is not None], + })) + """ + ) + assert out["cues"] == 1 + assert out["loaded"] == [] + + +@pytest.mark.parametrize( + "statement", + [ + "import mixing.audio", + "import mixing.video", + "from mixing.video import replace_audio", + "from mixing.audio import fade_in", + "import mixing.video.video_ops", + "import mixing.audio.segmentation", + "import mixing; mixing.Video", + "import mixing; mixing.Audio", + "from mixing import dub_video_from_srt", + ], +) +def test_media_modules_raise_import_error_naming_the_extra(statement): + out = _run_hidden( + f""" + import json + try: + {statement} + except ImportError as e: + print(json.dumps({{"message": str(e)}})) + else: + print(json.dumps({{"message": None}})) + """ + ) + assert out["message"] is not None, "expected an ImportError" + assert "pip install 'mixing[media]'" in out["message"] + assert MEDIA_INSTALL_HINT == "pip install 'mixing[media]'" + + +def test_require_package_hint_names_the_extra_for_media_libs(): + out = _run_hidden( + """ + import json + from mixing.util import require_package + try: + require_package("scipy.signal") + except ImportError as e: + print(json.dumps({"message": str(e)})) + """ + ) + assert "mixing[media]" in out["message"] + + +def test_require_package_hint_unchanged_for_other_packages(): + from mixing.util import require_package + + with pytest.raises(ImportError, match="pip install nonexistent_package_xyz123"): + require_package("nonexistent_package_xyz123") + + +def test_pyproject_declares_the_media_extra(): + tomllib = pytest.importorskip("tomllib" if sys.version_info >= (3, 11) else "tomli") + from pathlib import Path + + path = Path(__file__).resolve().parents[2] / "pyproject.toml" + if not path.exists(): + pytest.skip("pyproject.toml not available (installed distribution)") + project = tomllib.loads(path.read_text())["project"] + + def names(requirements): + import re + + return {re.split(r"[<>=!~\[ ;]", r, maxsplit=1)[0].lower() for r in requirements} + + heavy = {"moviepy", "opencv-contrib-python", "scipy", "pydub", "burns", "numpy"} + assert not heavy & names(project["dependencies"]) + assert heavy <= names(project["optional-dependencies"]["media"]) diff --git a/mixing/util.py b/mixing/util.py index a5c12d3..afedc78 100644 --- a/mixing/util.py +++ b/mixing/util.py @@ -2,6 +2,7 @@ from typing import Literal import importlib +import importlib.util import logging import shutil @@ -58,10 +59,52 @@ def ffmpeg_exe() -> str: ) +MEDIA_EXTRA = "media" +MEDIA_INSTALL_HINT = f"pip install 'mixing[{MEDIA_EXTRA}]'" + +# Import names (top-level) that the ``media`` extra provides, per media area. +AUDIO_IMPORT_NAMES = ("numpy", "pydub", "scipy") +VIDEO_IMPORT_NAMES = ("numpy", "moviepy", "cv2", "PIL", "burns", "looks") +# Everything the extra supplies; ``require_package`` uses it to pick its hint. +MEDIA_IMPORT_NAMES = tuple(dict.fromkeys(AUDIO_IMPORT_NAMES + VIDEO_IMPORT_NAMES)) + + +def _is_importable(name: str) -> bool: + """True if top-level module ``name`` can be found, without importing it.""" + try: + return importlib.util.find_spec(name) is not None + except (ImportError, ValueError): + return False + + +def require_media_extra(feature: str, *, modules: tuple[str, ...] = MEDIA_IMPORT_NAMES): + """Raise an informative ``ImportError`` if the ``media`` extra is not installed. + + The base ``pip install mixing`` ships only the pure modules (``srt``, + ``chapters``, ``errors``, ``util``, ``egress``, ``transcript``, TTS); the + moviepy / opencv / scipy / pydub backends come with ``mixing[media]``. + + >>> require_media_extra("demo", modules=("math",)) + >>> require_media_extra("demo", modules=("no_such_module_xyz",)) + Traceback (most recent call last): + ... + ImportError: demo requires the 'media' extra ... + """ + missing = [name for name in modules if not _is_importable(name)] + if missing: + raise ImportError( + f"{feature} requires the {MEDIA_EXTRA!r} extra " + f"(missing: {', '.join(missing)}). Install it with " + f"{MEDIA_INSTALL_HINT}." + ) + + def require_package(package_name: str): """ Import a package, raising an informative error if not installed. + Packages supplied by the ``media`` extra get a hint naming that extra. + >>> math = require_package('math') # doctest: +SKIP >>> math.pi # doctest: +SKIP 3.141592653589793 @@ -69,9 +112,13 @@ def require_package(package_name: str): try: return importlib.import_module(package_name) except ImportError as e: + if package_name.split(".")[0] in MEDIA_IMPORT_NAMES: + hint = MEDIA_INSTALL_HINT + else: + hint = f"pip install {package_name}" raise ImportError( f"Package '{package_name}' is required for this functionality. " - f"Please install it via 'pip install {package_name}'." + f"Please install it via '{hint}'." ) from e diff --git a/mixing/video/__init__.py b/mixing/video/__init__.py index bb2e77a..b7a832f 100644 --- a/mixing/video/__init__.py +++ b/mixing/video/__init__.py @@ -3,6 +3,10 @@ """ +from mixing.util import VIDEO_IMPORT_NAMES, require_media_extra + +require_media_extra("mixing.video", modules=VIDEO_IMPORT_NAMES) + from mixing.video.video_subtitles import ( generate_subtitle_clips, to_srt_time, diff --git a/pyproject.toml b/pyproject.toml index 045a8a6..2f2923e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,10 +15,25 @@ authors = [ { name = "Thor Whalen" }, ] dependencies = [ - "moviepy", + # Base install = the pure modules (srt, chapters, errors, util, egress, + # transcript, dubbing TTS): no media backend, no bundled ffmpeg binary. + # moviepy / opencv / scipy / pydub / burns live in the ``media`` extra. "config2py", "dol", "xdol", +] + +[project.license] +text = "MIT" + +[project.urls] +Homepage = "https://github.com/thorwhalen/mixing" + +[project.optional-dependencies] +# The media backends: everything mixing.audio and mixing.video import. +# Importing either without this extra raises an ImportError naming it. +media = [ + "moviepy", # opencv-contrib-python (superset of opencv-python; MIT/Apache) is the single cv2 provider # across the ecosystem — mediapipe + syncnet require it, so using it here avoids the # unsupported double-install (both provide `cv2`) when muvid[scoring] is co-installed. @@ -27,31 +42,19 @@ dependencies = [ "pydub", "numpy", # scipy backs mixing.audio's cross-correlation alignment (find_audio_offset / - # find_audio_offset_detailed / align_clips_to_reference). It is imported lazily - # (require_package("scipy.signal")) so `import mixing` stays light, but it must be - # installed for alignment to run — a core mixing.audio capability, so base deps - # (documented as a mixing.audio dep in .claude/CLAUDE.md). + # find_audio_offset_detailed / align_clips_to_reference) and segmentation. "scipy", "burns", # The geometry VOCABULARY (SOCIAL_SIZES, the fit/fill/stretch mode names, # the social backdrop's blur sigma and dim factor) is owned by `looks` and # imported by mixing.video.video_util — one place says what a "shorts" is. - # The implementations stay here: video_util resizes with moviepy, and the - # `social` branch is a composite, not a formula. `looks` declares no - # dependencies of its own, so this edge costs nothing. The floor is the - # first release carrying SOCIAL_SIZES / FitMode / DFLT_BACKDROP_*. + # `looks` declares no dependencies of its own. The floor is the first + # release carrying SOCIAL_SIZES / FitMode / DFLT_BACKDROP_*. "looks>=0.0.11", ] - -[project.license] -text = "MIT" - -[project.urls] -Homepage = "https://github.com/thorwhalen/mixing" - -[project.optional-dependencies] testing = [ "pytest", + "mixing[media]", ] clipboard = [ "pyclip", @@ -81,6 +84,7 @@ llm = [ dev = [ "pytest", "ruff", + "mixing[media]", ] [tool.ruff] From b15dc26967a1911fdf84e5d95f52363252bc23e4 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:09:59 +0000 Subject: [PATCH 2/2] ci: install the media extra so the suite can import mixing.audio/video Co-Authored-By: Claude Sonnet 5.5 --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2f2923e..457508a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -153,6 +153,10 @@ install.windows = "choco install ffmpeg -y --no-progress" [tool.wads.ci] project_name = "mixing" +# The suite exercises mixing.audio / mixing.video, which need the media backends. +[tool.wads.ci.install] +extras = "media" + [tool.wads.ci.commands] pre_test = [] test = []