Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,14 @@ Use the helpers, don't reinvent them:

## Optional extras

`pip install mixing[<extra>]`: `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[<extra>]`: `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`.
Expand Down
5 changes: 4 additions & 1 deletion .claude/skills/mixing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions mixing/audio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
132 changes: 132 additions & 0 deletions mixing/tests/test_guard_base_install.py
Original file line number Diff line number Diff line change
@@ -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"])
49 changes: 48 additions & 1 deletion mixing/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from typing import Literal
import importlib
import importlib.util
import logging
import shutil

Expand Down Expand Up @@ -58,20 +59,66 @@ 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
"""
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


Expand Down
4 changes: 4 additions & 0 deletions mixing/video/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
42 changes: 25 additions & 17 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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",
Expand Down Expand Up @@ -81,6 +84,7 @@ llm = [
dev = [
"pytest",
"ruff",
"mixing[media]",
]

[tool.ruff]
Expand Down Expand Up @@ -149,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 = []
Expand Down
Loading