muvid.visualize

Turn a song (+ optional cover) into a visualizer music video.

The lightweight, deterministic, ffmpeg-only half of muvid: given audio and usually a cover image, it produces a 16:9, H.264, loudness-normalized mp4 — a still cover, a Ken Burns pan, or an audio-reactive visualizer (CQT, spectrogram, waveform, bars, vectorscope) — plus a matching thumbnail. No AI, no network; the narrative pipeline (muvid.facade, muvid.renderers) is a separate concern and this subpackage stands on its own.

The one call most people need:

>>> from muvid.visualize import render_audio_video
>>> render_audio_video("song.wav", image="cover.png")

Everything else is a knob on that: list_visuals() names the built-in looks, register_visual() adds your own, CoverLayout controls how the cover sits on the canvas, and thumbnail_image() derives a 16:9 thumbnail from that same composition. verify_video() checks a render against what a platform will actually accept.

Because the whole song is known before the first frame is drawn, a visual can also be driven by precomputed audio analysis: flash_filter() turns an onset envelope into an ffmpeg sendcmd script, which is how the spectrogram pulses on the beat.

Needs ffmpeg (and ffprobe) on the PATH. Every built-in visual is ffmpeg-native, except Ken Burns, which renders through burns (already a dependency of mixing, so it needs no extra).

class muvid.visualize.Check(name: str, ok: bool, detail: str)[source]

One verification result.

class muvid.visualize.CoverLayout(background: str = 'blur', blur_sigma: float = 30.0, dim: float = 0.25, saturation: float = 0.8, cover_fraction: float = 0.92, cover_alpha: float = 1.0, background_color: str = 'black')[source]

How a cover image is placed on the canvas.

background

"blur" (a blurred, darkened copy of the cover fills the frame) or "color" (a flat background_color).

Type:

str

blur_sigma

Gaussian blur strength for the "blur" background.

Type:

float

dim

How much to darken the background, 0 (unchanged) to 1 (black).

Type:

float

saturation

Background saturation (< 1 desaturates, so the sharp cover stays the focal point).

Type:

float

cover_fraction

How much of the frame the sharp cover fills. The cover is scaled up, keeping its aspect ratio, until it reaches this fraction of either the frame width or the frame height — whichever it hits first (so a wide cover is width-bound, a tall one height-bound). 1.0 touches the edges; below 1 leaves padding.

Type:

float

cover_alpha

Opacity of the sharp cover, 0 (invisible) to 1 (opaque). Below 1 lets whatever is behind the cover — a reactive visualizer, the blurred background — show through it.

Type:

float

background_color

Fill colour when background="color".

Type:

str

exception muvid.visualize.FfmpegError[source]

An ffmpeg/ffprobe invocation failed, or a needed tool/filter is absent.

class muvid.visualize.Loudness(integrated: float = -14.0, true_peak: float = -1.0, lra: float = 11.0, measured: dict | None = None)[source]

An EBU R128 loudness target, plus the measurement of a specific track.

measured is the loudnorm analysis pass output (None until measure_loudness() has run). Carrying both lets filter_spec() emit the accurate two-pass filter when a measurement exists and fall back to the (less accurate) single-pass form when it does not.

filter_spec() str[source]

The loudnorm filter string for this target.

class muvid.visualize.RenderResult(path: ~pathlib.Path, duration: float, size: tuple[int, int], fps: int, visual: str, loudness: ~muvid.visualize.ffmpeg.Loudness | None = None, canvas: ~pathlib.Path | None = None, extras: dict = <factory>)[source]

A rendered video and what is worth knowing about it.

Usable anywhere a path is (it implements os.PathLike).

path

The rendered mp4.

Type:

pathlib.Path

duration

Its duration in seconds.

Type:

float

size

Frame size.

Type:

tuple[int, int]

fps

Frame rate.

Type:

int

visual

The strategy that produced it.

Type:

str

loudness

The applied loudness target and measurement, if normalized.

Type:

muvid.visualize.ffmpeg.Loudness | None

canvas

The composed canvas image, when the strategy built one — reuse it as the thumbnail rather than re-deriving it.

Type:

pathlib.Path | None

class muvid.visualize.TitleStyle(size_fraction: float = 0.045, color: str = 'white', font: str | None = None, margin_fraction: float = 0.06, box: bool = True, box_color: str = 'black@0.45')[source]

How a burnt-in title is drawn (ffmpeg drawtext).

size_fraction

Font size as a fraction of canvas height.

Type:

float

color

Text colour.

Type:

str

font

Font file path, or None to auto-detect one.

Type:

str | None

margin_fraction

Distance from the bottom edge, as a fraction of height.

Type:

float

box

Draw a translucent plate behind the text (keeps it legible over busy artwork).

Type:

bool

box_color

Colour (with alpha) of that plate.

Type:

str

class muvid.visualize.VisualContext(audio: ~pathlib.Path, image: ~pathlib.Path | None, duration: float, size: tuple[int, int], fps: int, layout: ~muvid.visualize.canvas.CoverLayout = <factory>, title: str | None = None, title_style: ~muvid.visualize.canvas.TitleStyle | None = None, workdir: ~pathlib.Path = <factory>, options: dict = <factory>)[source]

Everything a visual strategy needs to know about the render.

audio

The audio file (ffmpeg input 0).

Type:

pathlib.Path

image

The cover art, if the caller supplied one.

Type:

pathlib.Path | None

duration

Audio duration in seconds.

Type:

float

size

Canvas size (width, height).

Type:

tuple[int, int]

fps

Output frame rate.

Type:

int

layout

How the cover sits on the canvas.

Type:

muvid.visualize.canvas.CoverLayout

title

Title to burn in, if any.

Type:

str | None

title_style

How to draw that title.

Type:

muvid.visualize.canvas.TitleStyle | None

workdir

A directory the strategy may write intermediate files into.

Type:

pathlib.Path

options

Strategy-specific knobs, passed straight through by the caller.

Type:

dict

require_image(visual: str) Path[source]

The cover image, or a ValueError naming what to do instead.

class muvid.visualize.VisualPlan(inputs: list[list[str]] = <factory>, filters: list[str] = <factory>, video: str = 'vbg', uses_audio: bool = False, has_cover: bool = False, has_title: bool = False, still: Path | None = None)[source]

The ffmpeg fragments that render one strategy’s video stream.

inputs

Extra ffmpeg input argument groups (each ends with -i PATH), numbered from input 1.

Type:

list[list[str]]

filters

filter_complex chains, joined with ; by the renderer.

Type:

list[str]

video

Label of the video stream the chains emit.

Type:

str

uses_audio

The plan consumes the [aviz] audio copy.

Type:

bool

has_cover

The plan already placed the cover; the renderer must not overlay it again.

Type:

bool

has_title

The plan already burnt in the title; the renderer must not draw it again.

Type:

bool

still

When set, the video is this static image — the renderer takes a much cheaper path (encode one short segment, then loop it) and ignores inputs/filters.

Type:

pathlib.Path | None

muvid.visualize.canvas_image(image: str | Path, *, saveas: str | Path | None = None, size: tuple[int, int] = (1920, 1080), layout: CoverLayout | None = None, title: str | None = None, title_style: TitleStyle | None = None) Path[source]

Render the composed canvas (background + centred cover + title) as a PNG.

Composing once into an image — rather than re-running a 1080p blur on every frame — is what makes a still-image music video cheap to render, and it gives the thumbnail and the video’s first frame a single source of truth.

Parameters:
  • image – The cover art.

  • saveas – Output PNG path (default: <image-stem>.canvas.png).

  • size – Canvas size.

  • layout – Placement/treatment of the cover (a default one when omitted).

  • title – Burn this title into the canvas (omit for no title).

  • title_style – How to draw that title.

Returns:

Path to the rendered PNG.

muvid.visualize.decode_pcm(audio: str | Path, *, sample_rate: int, channels: int = 1) bytes[source]

Decode audio to raw PCM_SAMPLE_FORMAT samples on stdout.

Analysis passes (loudness envelopes, onset detection) want samples, not a container. This is the single place muvid turns a media file into raw PCM, so $MUVID_FFMPEG_TIMEOUT_S bounds that decode like every other one.

Parameters:
  • audio – The media file to decode.

  • sample_rate – Resample to this rate. Analysis rarely needs full quality, and a low rate keeps a long track’s decode cheap.

  • channels – Downmix to this many channels (1 = mono).

Returns:

The raw PCM bytes — empty when ffmpeg could not decode audio. Returning empty rather than raising lets a caller treat “no usable audio” as “no effect” (see muvid.visualize.reactive.flash_filter()).

muvid.visualize.failures(checks: list[Check]) list[Check][source]

Just the checks that failed.

muvid.visualize.flash_filter(audio: str | Path, *, fps: int, duration: float | None, workdir: Path, label: str = 'flash', brightness: float = 0.25, saturation: float = 0.8, decay: float = 0.5) str[source]

A filter fragment that makes the stream it follows pulse with the beat.

Computes the envelope, writes the sendcmd script into workdir, and returns the chain ,sendcmd=f=…,eq@<label>=… to append after the visual filter (e.g. showspectrum).

Returns "" — a fragment that changes nothing — when the audio yields no envelope or this ffmpeg build lacks FLASH_FILTERS, so a caller can append it unconditionally and still render.

The eq starts as a no-op (brightness=0:saturation=1); the script drives it. eval=frame so it re-reads every frame.

Parameters:
  • audio – The track whose beats drive the flash.

  • fps – The render’s frame rate (one command pair per frame).

  • duration – Clamp the flash to this many seconds (None = whole track).

  • workdir – Directory to write the sendcmd script into.

  • labelsendcmd label for this flash’s eq.

  • brightness – Peak brightness boost on a beat.

  • saturation – Peak saturation boost on a beat.

  • decay – Per-frame afterglow of a pulse.

muvid.visualize.has_filter(name: str) bool[source]

Whether this ffmpeg build has the name filter compiled in.

muvid.visualize.list_visuals() list[str][source]

The names of every registered visual strategy.

muvid.visualize.measure_loudness(audio: str | Path, target: Loudness | None = None) Loudness[source]

Analyse audio (loudnorm pass 1) and return target with the result.

Two-pass loudnorm is the only accurate way to hit a loudness target: pass 1 measures the program loudness, pass 2 applies a linear gain from that measurement. Single-pass loudnorm is a dynamic normalizer and will both miss the target and squash the dynamics of music.

Parameters:
  • audio – The audio (or video) file to measure.

  • target – The loudness target; a default one is used when omitted.

Returns:

A new Loudness with measured populated.

muvid.visualize.media_duration(media: str | Path) float[source]

Duration of media in seconds.

Falls back to the longest stream duration when the container has none.

Raises:

FfmpegError – The duration could not be determined.

muvid.visualize.onset_envelope(audio: str | Path, *, fps: int, duration: float | None = None, sr: int = 22050, decay: float = 0.5) list[float][source]

Per-video-frame onset strength in [0, 1], with phosphor-style decay.

Decodes audio to mono, measures frame-wise loudness, takes the half-wave-rectified rise in loudness (an onset/transient measure, so sustained loud passages don’t stay lit — only attacks do), scales it robustly to [0, 1], then lets each pulse fade by decay per frame so a beat flashes and trails off rather than blinking for a single frame.

Parameters:
  • audio – The track to analyse.

  • fps – Video frame rate — one envelope value per frame.

  • duration – Clamp the envelope to this many seconds (defaults to the whole track).

  • sr – Analysis sample rate.

  • decay – Per-frame persistence of a pulse, 0 (no trail) to <1 (longer afterglow).

Returns:

One value per frame. Empty if the audio could not be decoded.

muvid.visualize.probe(media: str | Path) dict[source]

Return ffprobe’s format + streams JSON for media.

muvid.visualize.register_visual(name: str) Callable[[Callable[[VisualContext], VisualPlan | Path | str]], Callable[[VisualContext], VisualPlan | Path | str]][source]

Register a visual strategy under name (the open-closed seam).

Examples

>>> @register_visual("black")
... def _black(ctx):
...     w, h = ctx.size
...     return VisualPlan(filters=[f"color=c=black:s={w}x{h}[vbg]"])
>>> "black" in list_visuals()
True
>>> _ = _VISUALS.pop("black")  # (keep the registry tidy for the next doctest)
muvid.visualize.render_audio_video(audio: str | Path, image: str | Path | None = None, *, visual: str | Callable[[VisualContext], VisualPlan | Path | str] = 'auto', saveas: str | Path | None = None, size: tuple[int, int] = (1920, 1080), fps: int = 24, title: str | None = None, layout: CoverLayout | None = None, title_style: TitleStyle | None = None, normalize: bool = False, loudness: Loudness | None = None, crf: int = 18, preset: str = 'medium', audio_bitrate: str = '384k', gop_seconds: float = 2.0, options: dict | None = None, workdir: str | Path | None = None) RenderResult[source]

Render audio into a video, using visual for the picture.

The video is exactly as long as the audio, 16:9, H.264/yuv420p + AAC — what YouTube asks for. With normalize=True the audio is brought to a fixed EBU R128 loudness with a two-pass loudnorm, which is what makes a batch of songs play back at a consistent level.

Parameters:
  • audio – The song (.wav is preferred when you have it — YouTube re-encodes regardless, so give it the cleanest input).

  • image – Cover art. Used for the picture, and composed onto a 16:9 canvas.

  • visual – A registered strategy name ("still", "ken_burns", "cqt", "bars", "spectrum", "waves", "scope"), "auto", or any callable (see muvid.visualize.visuals).

  • saveas – Output path (default: <audio-stem>.mp4).

  • size – Canvas size; the default is 1080p.

  • fps – Frame rate.

  • title – Burn this title into the frame.

  • layout – How the cover sits on the canvas.

  • title_style – How the title is drawn.

  • normalize – Loudness-normalize the audio (two-pass EBU R128).

  • loudness – The loudness target; a YouTube-appropriate default is used when omitted.

  • gop_seconds (crf / preset / audio_bitrate /) – Encoder knobs.

  • options – Strategy-specific options, passed to the visual.

  • workdir – Where intermediates go (a temporary directory by default).

Returns:

A RenderResult.

Raises:

ValueErrorsize has an odd dimension — H.264 at yuv420p (the only pixel format every player decodes) cannot encode one.

muvid.visualize.report(checks: list[Check]) str[source]

Render checks as an aligned, readable block.

muvid.visualize.require_ffmpeg(*tools: str) None[source]

Raise a helpful FfmpegError if any of tools is not on PATH.

Parameters:

*tools – Binaries to require (defaults to ffmpeg and ffprobe).

Raises:

FfmpegError – With per-platform install instructions.

muvid.visualize.resolve_visual(visual: str | Callable[[VisualContext], VisualPlan | Path | str], ctx: VisualContext) VisualPlan[source]

Turn visual (a name, or any callable) into a VisualPlan.

"auto" picks the cheapest strategy that suits the inputs: a still cover when there is an image, an audio-reactive CQT when there is not.

A callable may return a VisualPlan, or the path of a silent video it rendered itself — the latter is the escape hatch for backends that do not express themselves as an ffmpeg filtergraph (librosa/matplotlib, projectM, a headless-browser capture…).

Raises:

ValueErrorvisual names a strategy that is not registered.

muvid.visualize.run_ffmpeg(args: list[str], *, overwrite: bool = True) CompletedProcess[source]

Run ffmpeg with args, raising a readable error on failure.

Parameters:
  • args – Arguments after the global flags (inputs, filters, output).

  • overwrite – Pass -y (overwrite the output without prompting).

Returns:

The completed process.

Raises:

FfmpegError – ffmpeg exited non-zero; the message carries the tail of stderr and the full command, which is what you actually need to debug a filtergraph.

muvid.visualize.thumbnail_image(image: str | Path, *, saveas: str | Path | None = None, size: tuple[int, int] = (1280, 720), layout: CoverLayout | None = None, title: str | None = None, title_style: TitleStyle | None = None, max_bytes: int = 2097152) Path[source]

Render image as a 16:9 JPEG thumbnail that YouTube will accept.

Same composition as the video canvas, so the thumbnail matches what the viewer sees when they press play. JPEG quality is stepped down until the file fits max_bytes (YouTube’s hard limit).

Parameters:
  • image – The cover art.

  • saveas – Output JPEG path (default: <image-stem>.thumb.jpg).

  • size – Thumbnail size (YouTube wants >= 1280x720, 16:9).

  • layout – Placement/treatment of the cover.

  • title – Burn this title into the thumbnail (omit for none).

  • title_style – How to draw that title.

  • max_bytes – Hard size ceiling.

Returns:

Path to the rendered JPEG.

muvid.visualize.verify_video(video: str | Path, *, audio: str | Path | None = None, thumbnail: str | Path | None = None, loudness: Loudness | None = None, check_loudness: bool = False, duration_tolerance: float = 0.5, expected_canvas: tuple[int, int] | None = None) list[Check][source]

Check video against YouTube’s expectations; return one result per check.

Parameters:
  • video – The rendered mp4.

  • audio – The source song — enables the duration-match check, which is the one that catches a mis-built filtergraph.

  • thumbnail – The thumbnail to check against YouTube’s limits.

  • loudness – The target the video was normalized to.

  • check_loudness – Actually measure the output’s loudness. This decodes the whole track, so it is off by default.

  • duration_tolerance – Allowed audio/video duration difference, in seconds.

  • expected_canvas – The (width, height) the render was ASKED for. When given, the aspect/resolution checks verify the output matches it — a deliberate portrait render must not fail a hard-coded 16:9 check. When None, the classic YouTube-landscape expectations apply.

Returns:

A list of Check. Falsy checks are the problems; report() renders them, and failures() filters them.