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.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 flatbackground_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.0touches 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.
measuredis theloudnormanalysis pass output (Noneuntilmeasure_loudness()has run). Carrying both letsfilter_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.
- 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:
- 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
Noneto 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.
- title
Title to burn in, if any.
- Type:
str | None
- title_style
How to draw that title.
- Type:
- 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
- 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_complexchains, 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
audioto rawPCM_SAMPLE_FORMATsamples 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_Sbounds 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” (seemuvid.visualize.reactive.flash_filter()).
- 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
sendcmdscript intoworkdir, 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 lacksFLASH_FILTERS, so a caller can append it unconditionally and still render.The
eqstarts as a no-op (brightness=0:saturation=1); the script drives it.eval=frameso 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
sendcmdscript into.label –
sendcmdlabel for this flash’seq.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
namefilter compiled in.
- muvid.visualize.measure_loudness(audio: str | Path, target: Loudness | None = None) Loudness[source]
Analyse
audio(loudnorm pass 1) and returntargetwith the result.Two-pass
loudnormis 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
Loudnesswithmeasuredpopulated.
- muvid.visualize.media_duration(media: str | Path) float[source]
Duration of
mediain 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
audioto 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 bydecayper 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’sformat+streamsJSON formedia.
- 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
audiointo a video, usingvisualfor the picture.The video is exactly as long as the audio, 16:9, H.264/yuv420p + AAC — what YouTube asks for. With
normalize=Truethe audio is brought to a fixed EBU R128 loudness with a two-passloudnorm, which is what makes a batch of songs play back at a consistent level.- Parameters:
audio – The song (
.wavis 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 (seemuvid.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:
ValueError –
sizehas 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
checksas an aligned, readable block.
- muvid.visualize.require_ffmpeg(*tools: str) None[source]
Raise a helpful
FfmpegErrorif any oftoolsis not on PATH.- Parameters:
*tools – Binaries to require (defaults to
ffmpegandffprobe).- 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 aVisualPlan."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:
ValueError –
visualnames a strategy that is not registered.
- muvid.visualize.run_ffmpeg(args: list[str], *, overwrite: bool = True) CompletedProcess[source]
Run
ffmpegwithargs, 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
imageas 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
videoagainst 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. WhenNone, the classic YouTube-landscape expectations apply.
- Returns:
A list of
Check. Falsy checks are the problems;report()renders them, andfailures()filters them.