> built 2026-10-14 12:29 UTC from f2479a9 (main) · muvid 0.0.94. Details: build_info.json

# index.html.md

<!-- generated by epythet -->

# muvid

Tools to make music videos — four ways: from a song and a cover, from a pile of
phone recordings of one gig, from an AI narrative pipeline, or as a lyric video
whose words appear in time with the singing. See the table below for which is
which; the rest of this section describes the AI pipeline.

The AI pipeline orchestrates the local ecosystem (`falaw`, `lookbook`, `lacing`,
`an`, `mixing`) into a song-to-video pipeline. The user is the director; an agent
(Claude in the terminal, or the local web UI) drives the stages.

> **Status (the AI pipeline):** v0+. The pipeline (init → transcribe → align → cast →
> environments → script → render → compose) works end to end. Render
> strategies: `lipsync`, `image_to_video`, `text_to_video`,
> `animation`, `still`. CLI, Claude skill (`.claude/skills/muvid/`),
> and a single-page local UI all dispatch to the same Python
> functions. The v0 audit follow-up ([`improvement_ideas.md`]())
> shipped pluggable aligners, cost rollups + `--budget`, structured
> falaw progress events streamed to `.muvid/fal_events.jsonl`,
> end-to-end smoke fixture, lacing as the SSOT for word timings (no
> redundant whisper passes inside `an`), and a `muvid.contracts`
> adapter layer to sibling-package shapes. See
> [`misc/docs/design.md`]() for the design
> rationale and
> [`misc/docs/alignment_references.md`]()
> for the lyric-alignment literature muvid builds on.

muvid is **four independent parts** — the fourth is a plugin surface that
currently ships three subgenres:

| part                              | what it does                                                                                       | needs                       |
|-----------------------------------|----------------------------------------------------------------------------------------------------|-----------------------------|
| [`muvid.visualize`]()             | a song + a cover → a 16:9 audio-reactive visualizer video, deterministic and ffmpeg-only           | `ffmpeg`                    |
| [the `music_video` genre]()       | N phone recordings of ONE song → aligned, scored, cut and assembled into a music video             | `ffmpeg`                    |
| the AI narrative pipeline (above) | a song → a cast, a script and generated shots                                                      | API keys, `muvid[ai]`       |
| [`muvid.lyricvid`]()              | a song → a typographic music video: the words appear in time with the singing                      | `ffmpeg`, `muvid[lyricvid]` |
| [`muvid.choreo`]()                | a song and nothing else → event-driven visual music: onsets become objects, sections become scenes | `ffmpeg`                    |
| [`muvid.montage`]()               | a song + a pool of photos/clips → a montage cut on the beat, with Ken Burns, crossfades and grids  | `ffmpeg`                    |

Three of the four are free, deterministic and key-free, and are registered
[`nw`](https://github.com/thorwhalen/nw) genres — so a host connector serves them
directly. The AI narrative pipeline is the generative one. The lyric video is free
by default; only its optional LLM creative director spends money.

`muvid.lyricvid` is also the first **subgenre plugin** — see
[Plugins]() for how to add another kind of video
without editing muvid.

## Install

```bash
pip install muvid                  # core: CLI + muvid.visualize + muvid.footage (needs ffmpeg)
pip install 'muvid[scoring]'       # footage scoring: quality + motion-to-beat + the weighted selector
pip install 'muvid[editor]'        # export a footage project as lacing annotations
pip install 'muvid[mcp]'           # serve the nw genres over MCP (fastmcp, py2mcp, nw)
pip install 'muvid[lyricvid]'      # lyric videos: the ASS renderer (pysubs2)
pip install 'muvid[lyricvid-web]'  # ...plus the browser renderer (playwright)
pip install 'muvid[ai]'            # the narrative pipeline (falaw, lacing, lookbook)
pip install 'muvid[ui]'            # FastAPI + uvicorn for the web UI
```

The narrative pipeline depends on local sibling packages (`falaw`, `lookbook`,
`lacing`); with editable installs, install them first and use `pip install -e ./muvid[ai]`. `muvid.visualize` needs only `mixing`.

System: `ffmpeg` and `ffprobe` on `PATH`. Env (pipeline only):
`ELEVENLABS_API_KEY` (transcription), `FAL_KEY` (fal.ai generation).

## `muvid.visualize` — audio + cover → video

Turn a song into a publishable music video, without the AI pipeline:

```python
from muvid.visualize import render_audio_video, list_visuals, verify_video, report

# Simplest: the cover on a 16:9 canvas, held for the song, loudness-normalized.
result = render_audio_video("song.wav", image="cover.png")

# Pick a visualizer (list_visuals() -> still, ken_burns, cqt, spectrum, waves,
# bars, scope), or pass "auto" / your own callable.
render_audio_video("song.wav", image="cover.png", visual="cqt", normalize=True)

# Check what you produced before shipping it.
print(report(verify_video(result.path, audio="song.wav")))
```

What it does, by default:

- **16:9, never pillarboxed** — square/portrait art is composed onto 1080p filled
  with a blurred, darkened copy of itself, sharp cover centred on top.
- **Loudness −14 LUFS** (two-pass EBU R128), so a set of songs plays level.
- **Video exactly as long as the song**, H.264 High / yuv420p / AAC 48 kHz,
  `+faststart`, no edit lists — what YouTube asks for.
- **A teal accent** across the reactive visualizers (one tunable tint) over a
  muted background, so an album reads as one release whatever each cover’s colours.
- **A matching thumbnail** derivable from the same composition
  (`thumbnail_image`, 1280×720, under YouTube’s 2 MiB cap).

Every knob is overridable (`visual`, `size`, `fps`, `normalize`,
`CoverLayout(...)`, `options={...}`); add your own look with
`register_visual`. Needs only **ffmpeg** — every built-in visual is ffmpeg-native
except Ken Burns (via `burns`, which comes with `mixing`).

Publishing these to YouTube (single song or a whole folder as an album) is the
[`yb`](https://github.com/thorwhalen/yb) package’s job — it renders through
`muvid.visualize` and uploads.

## The `music_video` genre — footage assembly

Several people filmed the same gig on their phones. Each recording caught the *same
song* through a different mic, from a different seat, starting at a different moment.
`muvid.footage` puts them all on the clean studio master’s timeline and cuts a music
video out of them — no AI, no keys, no cost, just `ffmpeg` and arithmetic.

```python
from muvid.footage import select_edl, validate_edl, derive_cuts
from muvid.footage.align import align_footage
from muvid.footage.edl import fill_gaps
from muvid.footage.assemble import assemble_music_video

song, dur = "master.wav", 205.0
clips = [("c1", "phone_a.mov"), ("c2", "phone_b.mp4"), ("c3", "tripod.mov")]

# 1. Where does each clip sit on the song? (audio cross-correlation, via mixing.audio)
aligns = align_footage(song, clips, song_duration=dur)

# 2. Who is on air over each span? A strategy turns alignments into an EDL,
#    fill_gaps makes it span the whole song, validate_edl is the one gate.
edl = validate_edl(
    fill_gaps(select_edl("best_confidence", aligns, dur), dur), aligns, dur
)

# 3. Cut it, over the clean master audio.
cuts = derive_cuts(edl, aligns, dict(clips))
assemble_music_video(cuts, song, "out.mp4", canvas=(1920, 1080))
```

**The five stages**

| stage    | module                                         | what comes out                                                               |
|----------|------------------------------------------------|------------------------------------------------------------------------------|
| align    | `muvid/footage/align.py`                       | per clip: `offset_s`, a confidence, its `coverage` of the song, `overlaps`   |
| score    | `muvid/footage/scoring/`                       | a tensor `S[clip, frame, metric]` on ONE shared song-time grid (`hop≈0.1 s`) |
| select   | `muvid/footage/strategy.py`, `select_score.py` | an EDL — which clip covers which span                                        |
| validate | `muvid/footage/edl.py`                         | the one gate: ordered, non-overlapping, gapless, inside each clip’s coverage |
| assemble | `muvid/footage/assemble.py`                    | one ffmpeg pass per cut → concat by stream copy → mux the master             |

**What the design commits to**

- **Nothing vanishes because it measured badly.** A clip that doesn’t overlap the song
  is still recorded, still listed, still addressable — it just carries `overlaps=False`.
- **A hole is an explicit gap entry**, not an absence. `EdlEntry(clip_id="")` (`null` over
  JSON) means “no footage here, fill it”, so the render is always exactly the song’s
  length and every span is accounted for by exactly one entry.
- **Bounded memory.** One ffmpeg invocation per cut, then a stream-copy concat — peak
  memory is O(1) in the cut count, not O(n). A 70-cut score-driven edit renders on the
  same box as a 3-cut one.
- **Mixed phones are the normal case.** Clips are scaled and padded onto a fixed canvas
  (`landscape` / `portrait` / `square`), rotation metadata is honoured, and mismatched
  frame rates are unified — so a portrait iPhone clip and a 29.97 fps camera cut together
  without drift.
- **The strategy is a config object, not a branch.** `best_confidence` (default),
  `longest_take` and `fewest_cuts` work from the alignments alone; `weighted` runs a
  beat-snapped Viterbi over the score tensor. Add your own with
  `register_selection_strategy`.

**Scoring** (`pip install 'muvid[scoring]'`) resolves every clip’s every metric onto that
one song-time grid, so the same numbers drive both the automatic cut and a human editor’s
lanes. The **core tier** — sharpness, exposure, shake, face framing, motion-to-beat, the
selector — is torch-free and MIT/BSD/Apache/ISC only, and is what a deployment installs.
The **lip-sync tier** (`muvid[scoring-lipsync]`: Demucs + SyncNet) is **opt-in and off by
default**: the htdemucs weights are CC-BY-NC (research-only, not commercial-clean), and
it peaks at 2–3 GB on CPU. Turning it on takes the extra, `MUVID_SCORING_ENABLE_LIPSYNC=1`,
*and* weights you point at yourself via `MUVID_SYNCNET_S3FD_WEIGHTS` /
`MUVID_SYNCNET_WEIGHTS` — muvid never downloads model weights at runtime. Without all
three it skips cleanly rather than scoring zero.

**As a hosted genre.** `muvid.genre_music_video` registers `music_video` as an `nw.Genre` (canvas presets as its Templates) with a project factory. Every operation on a footage project — set the song, add or remove a video, find where each fits (`align`) or place one by hand (`set_offset`), score, cut (`propose_edit`), keep and change named edits cut by cut (`save_edit`, `set_cut`, `split_cut`, `merge_cut`, `replace_edit`), trim an edit to part of the song (`set_span`), render — is a plain function in `muvid.footage.service`, listed once in its `FOOTAGE_OP_SPECS` and registered with nw as `nw.GenreOp`s, so a host serves them without importing muvid’s internals. `muvid.mcp.footage_tools` and `muvid.mcp.scoring_tools` expose the same operations as MCP tools (`set_song`, `add_footage`, `align_footage`, `propose_edit`, `assemble_music_video`, `footage_set_cut`, `footage_render`, …) over a stateful per-user workspace under `~/.local/share/muvid` (`MUVID_DATA_HOME` to relocate). A host that serves the project itself (the reelee studio) creates it where it wants with `projects_dir` — a `muvid.Project`, an `nw.Project` whose media is registered in the host’s artifact catalog — and `python -m muvid.importing MANIFEST PROJECTS_DIR` brings a finished production into one.

**As an editor document.** `pip install 'muvid[editor]'` adds
`muvid.footage.lacing_bridge`, which exports a project as
[`lacing`](https://github.com/thorwhalen/lacing) standoff annotations — three body
schemas, `clip-alignment/v1`, `clip-score-track/v1` and `music-video-edl/v1`, all in song
time on one axis — and reads an edited `DECISION` tier back into an EDL. Annotate → edit
→ export → render round-trips to the same cuts, transitions included.

## `muvid.lyricvid` — lyric videos

A song in, a typographic music video out: the words appear in time with the
singing. Free, deterministic, and no API key.

```bash
pip install 'muvid[lyricvid]'
python -m muvid.lyricvid render song.wav out.mp4 --lyrics lyrics.md
```

That is the whole minimum. Everything else is optional: `--lyrics` may be
muvid’s lyrics markdown, plain text, an `.srt`, an enhanced `.lrc`, an existing
muvid project — or nothing at all, in which case the words are transcribed.

The pipeline is four seams, each with a default that genuinely works:

```default
audio (+ lyrics)  ->  TimedText     measured word times, from muvid's own aligner
                  ->  TreatmentSpec the creative decision
                  ->  Scene         every position and time, computed in Python
                  ->  mp4           the ASS renderer, or the browser one
```

**The layout is chosen from a closed set of archetypes** — one word centred,
stacked lines, a karaoke wipe, a fixed concrete page whose words ignite in
reading order, words packed into a shape, text on a path, scatter:

```bash
python -m muvid.lyricvid vocabulary          # the archetypes, with descriptions
python -m muvid.lyricvid analyze song.wav    # words/second, sections, timing quality
python -m muvid.lyricvid propose song.wav    # 3 ranked treatments, free, no model
```

A **treatment** is a small JSON document with two layers: a `direction` (mood,
palette, typography, motion vocabulary, and a rationale) and `scenes` (an
archetype plus parameters). It never contains a coordinate or a timestamp —
those are computed from measurement, which is what keeps the words in sync. Edit
it by hand, validate it, re-render:

```bash
python -m muvid.lyricvid validate treatment.json
python -m muvid.lyricvid render song.wav out.mp4 --treatment treatment.json
```

An **LLM creative director** can propose the treatment instead (`--use-llm`).
It is opt-in, it is the only part of muvid that spends money, and it still emits
only a treatment — never rendering code, never coordinates.

Two renderers. `ass` (default) writes an
[ASS](https://en.wikipedia.org/wiki/SubStation_Alpha) subtitle file and burns it
in with ffmpeg: frame-exact, no browser, and the `.ass` file is itself an
editable deliverable you can open in Aegisub. `web` drives headless Chromium for
effects ASS cannot express, at 10-100x the render time.

## `muvid.choreo` — visual music

Audio in, nothing else. The Oskar Fischinger / Norman McLaren / “Star Guitar”
lineage: discrete musical **events** become **objects** with persistence on
screen, and sections become different arrangements — choreographed and
structural, unlike the continuous spectrum readout of `muvid.visualize`.

```bash
python -m muvid.choreo render song.wav out.mp4 --archetype star_guitar
```

Four archetypes: `fischinger` (shapes ignite on a grid per onset), `star_guitar`
(a scrolling landscape — poles on the kicks, buildings on the mids, and their
spacing *is* the rhythm), `mclaren` (scratches on black), `swarm`. Analysis is
numpy only — a three-band spectral-flux onset detector with hysteresis and a
beat-derived minimum inter-onset interval, a beat grid (from `mixing` when its
librosa is present, else a built-in estimator), sections from energy. Frames
are drawn in numpy and piped into ffmpeg. Deterministic: same song and seed,
same video. `events.json` and `scene.json` ride along as inspectable artifacts.

## `muvid.montage` — beat-cut montage

A song plus a pool of photos and/or short clips that have no timeline of their
own — the thing “photo beat sync” templates sell.

```bash
python -m muvid.montage render song.wav out.mp4 --photos a.jpg b.jpg c.jpg
python -m muvid.montage plan   song.wav --photos a.jpg b.jpg c.jpg   # the EDL, as JSON
```

Its core is a **planner**: a pool with no timeline → slots on the song’s beat
grid and section structure, with section-aware pacing (choruses cut twice as
fast, intros half as fast, never a hold over ten seconds) and an explicit
**reuse policy** so twelve photos can carry a three-minute song — never the same
image within *n* cuts, each revisit a different crop, the strongest images
reserved to lead the final chorus, a cover pinned to the first and last slot.
Four archetypes: `ballad_dissolve`, `beat_cut`, `grid` (2×2 tiles swapping on
the beat), `stop_motion`. Rendering is one ffmpeg graph. `plan.json` is written
*before* rendering and rides along as an artifact, so an edit is a re-render.

## Plugins — adding a kind of video

`muvid.subgenres` is how another kind of video gets added, by muvid or by anyone
else, without editing muvid. A subgenre is declared as pure data:

```python
from muvid.subgenres import Subgenre

RANSOM_NOTE = Subgenre(
    slug="ransom-note",
    title="Ransom note",
    description="Lyrics cut from magazines, one word per beat.",
    render="my_package.render:render",  # a STRING, imported lazily
    inputs={
        "type": "object",
        "required": ["audio"],
        "properties": {"audio": {"type": "string"}},
    },
)
```

and shipped by pointing an entry point at the manifest:

```toml
[project.entry-points."muvid.subgenres.v1"]
ransom-note = "my_package.subgenre:RANSOM_NOTE"
```

The renderer is a plain function, `(RenderRequest) -> RenderResult`, handed paths
and primitives — never a muvid project object, so muvid can change its own
schema without breaking you.

Two properties are worth the design they cost. **Listing imports nothing**: the
entry point resolves to a manifest, not a renderer, so a UI or an agent can
choose among a dozen installed subgenres in microseconds, and a plugin whose
heavy dependency is missing becomes a recorded error rather than a dead
catalogue. **It composes with `nw` rather than competing**: muvid bridges
subgenres up into the `nw` genre catalogue, so a plugin depends on `muvid` alone
and a host connector picks it up for free.

Verify yours against the contract:

```python
from muvid.subgenres.testing import check_subgenre_conformance

report = check_subgenre_conformance(RANSOM_NOTE, workdir=tmp_path)
assert report.ok, report.summary()
```

```bash
python -m muvid.lyricvid subgenres    # everything installed, as JSON
```

`choreo` and `montage` are themselves registered this way and nothing else —
nothing in muvid names them. They appear in the `nw` genre catalogue through
`muvid/genre_subgenres.py` and are renderable over MCP through the generic
`render_subgenre` tool, which fetches every file input into the caller’s
workspace and validates the request against the manifest’s own schema first.

## 30-second tour

Every verb takes the project root explicitly. Ask muvid where a project belongs rather
than inventing a path: an app-named folder under your home directory is data sitting where
only code belongs, and nothing backs it up or cleans it up.

```bash
root="$(muvid project-root park-bench)"   # → ~/.local/share/muvid/projects/park-bench
                                          # $MUVID_DATA_HOME relocates the root
```

```bash
# Bootstrap a project around a song.
muvid init "$root" --song ~/Downloads/park_bench.mp3 --title "Park Bench"

# Transcribe to a draft lyrics.md (you'll edit it).
muvid transcribe "$root"

# … you edit lyrics/lyrics.md to fix mishears and add [section] tags …

# Align lyrics.md against the transcript and write lyrics/alignment.annot.
muvid align "$root"

# Cast a character: card, then images, then lookbook curation.
muvid character "$root" maya --description "mid-30s, dark curly hair, wary eyes"
muvid character-generate "$root" maya --n 6
muvid character-curate    "$root" maya --k 8

# Establish an environment.
muvid environment "$root" park_bench --description "wooden park bench at dusk"
muvid environment-render "$root" park_bench

# Write/edit script/script.md (let an agent draft it from the lyrics + cast),
# then sync it back into project.json:
muvid script-apply "$root"

# Estimate cost before committing fal calls.
muvid estimate-cost "$root"

# Render every shot (optionally gated on a USD budget), then composite.
muvid render  "$root" --budget=2.50
muvid compose "$root"
# → "$root"/output/final.mp4

# Inspect progress.
muvid status        "$root"           # human-readable
muvid status --json "$root"           # structured shape

# Or open the local UI (FastAPI + single HTML page).
muvid serve "$root"
```

### Pluggable aligners

`muvid align --aligner=...` accepts:

- `scribe-greedy` (default) — Scribe transcript + greedy token-match.
- `user` — caller-supplied `line_index → (start, end)` timings.
- `whisperx-lite` — local faster-whisper, falls back to scribe-greedy
  if no `audio_path=` is given.
- `stars` — singing-grade joint inference (stub; `NotImplementedError`).

Plug your own with `muvid.align.register_aligner(name, fn, ...)`.

### Interactive character curation

When a recipe’s automatic top-k isn’t quite right, replay a JSON
of decisions:

```bash
# decisions.json:
# [{"keep": ["<image_id>"], "reject": [...], "stop": false}, ...]
muvid character-curate-interactive "$root" maya \
    --decisions decisions.json --k 8 --present 6
```

## How it fits the ecosystem

| Concern                                                | Owner       |
|--------------------------------------------------------|-------------|
| AI media (TTS, image, video, lipsync, voice clone)     | `falaw`     |
| Reference image curation (LoRA-style sets)             | `lookbook`  |
| Timeline / interval annotations (lyrics, sections)     | `lacing`    |
| Structured 2D animation (cutout characters)            | `an`        |
| Audio/video editing, alignment + ElevenLabs Scribe     | `mixing`    |
| Genre / Template registry, durable async jobs          | `nw`        |
| **Visualizer, footage assembly, pipeline, dispatcher** | **`muvid`** |

`muvid` is the orchestrator: a folder layout (`project.json` + `song/`,
`lyrics/`, `characters/`, `environments/`, `script/`, `shots/`,
`output/`), a content-addressed cache (re-render only what changed),
and a uniform dispatch layer with three surfaces (CLI, skill, UI)
all calling the same Python functions in `muvid.facade`.

## Render strategies

Each shot picks one. The dispatcher resolves shared inputs (audio
slice, lyric lines that fall in the shot interval, character / env
anchor images) once and hands them to the strategy:

| strategy         | use it for                             | calls                  |
|------------------|----------------------------------------|------------------------|
| `lipsync`        | character singing on screen            | `falaw.animate_face`   |
| `image_to_video` | cinematic shot, env anchor as i2v seed | `falaw.image_to_video` |
| `text_to_video`  | no anchor, pure prompt                 | `falaw.text_to_video`  |
| `animation`      | stylized 2D cutout                     | `an.orchestrate`       |
| `still`          | single image held for the duration     | `ffmpeg`               |

## The Claude skill

`.claude/skills/muvid/SKILL.md` walks Claude (or any agent that follows
Claude Code skills) through the eight stages. It will:

- run `muvid status` first to see where you are
- pick the next stage and offer to run it
- never re-transcribe after you’ve edited `lyrics.md`
- never `--force` a render without asking
- offer to draft `script/script.md` from your lyrics + cast

## Layout

```default
muvid/
  __init__.py         public surface (the facade)
  __main__.py         CLI (cw)
  schema.py           ProjectSpec, ShotSpec, SectionSpec, …
  project.py          MusicVideoProject (folder facade)
  lyrics.py           transcribe + parse/render lyrics.md
  align.py            pluggable aligners + lacing SqliteStore writer
  characters.py       cards + ref images + lookbook curation (incl. interactive)
  environments.py     cards + establishing-image generation
  script.py           script.md ↔ ShotSpec list
  cost.py             render-cost rollup over pending shots
  events.py           pipe falaw progress events → .muvid/fal_events.jsonl
  contracts.py        adapters: muvid SSOT ↔ falaw / an / lacing shapes
  renderers/
    __init__.py       dispatcher + RenderContext + caching
    lipsync.py        falaw.animate_face
    image_to_video.py falaw.image_to_video
    text_to_video.py  falaw.text_to_video
    still.py          ffmpeg single-image loop
    animation.py      handoff to `an.orchestrate` with lacing-driven lipsync
  compose.py          ffmpeg concat + overlay song audio
  facade.py           top-level verbs the CLI/skill/UI call
  downloads.py        claim()/resolve() — muvid owns resolution, the host owns transport
  visualize/          part 1: the ffmpeg-only audio visualizer (+ its visual registry)
  footage/            part 2: align, edl, strategy, select_score, assemble, workspace
    service.py        the footage operations (the SSOT every surface derives from)
    scoring/          the score tensor: grid, frames, quality, motionbeat, segment, lipsync
    lacing_bridge.py  project ⇄ lacing standoff annotations (the editor document)
  genre.py            registers the `music-visualizer` nw genre (and imports the next)
  genre_music_video.py  registers the `music_video` footage genre and its operations
  production.py       muvid.Project — a production a host places and serves
  catalog.py          registers hosted media in the host's artifact catalog
  importing/          python -m muvid.importing — finished productions into a host
  mcp/                the MCP tool surface: tools, footage_tools, scoring_tools
  ui/
    app.py            FastAPI app
    static/index.html single-page UI
.claude/CLAUDE.md               agent & contributor guide (start here)
.claude/skills/                 muvid, muvid-visualize, muvid-score-footage,
                                muvid-choose-footage-segments
misc/docs/design.md             part 3's design rationale
misc/docs/footage_scoring_design.md    part 2's LOCKED design decisions
misc/docs/footage_scoring_research.md  the citations behind them
misc/docs/improvement_ideas.md  v0 audit + post-audit follow-through
```

<p class="epythet-aggregates">This documentation as a single file: <a href="muvid.md">muvid.md</a> (Markdown, for agents).</p>


# _autosummary/muvid.align.html.md

# muvid.align

Lyric → audio alignment.

We have:

- a transcript (Scribe / faster-whisper) with word-level (text, start, end)
- a user-edited `LyricsDoc` with section labels + line text + optional
  manual line-start anchors

We want a `lacing` store with three tiers (sections, lines, words) so
the rest of the system can ask “which lines fall in shot X” without
re-implementing interval math.

Strategy (greedy token-match):

1. Tokenize each lyric line into normalized words.
2. Walk the transcript word stream once, assigning each transcript word
   to the next unmatched lyric word that matches (case- and
   punctuation-insensitive). Tolerate small mismatches (transcript
   word missing in lyrics, vice versa) with a small lookahead window.
3. From the matched words, derive line `[start, end]` as
   `(first_matched_word.start, last_matched_word.end)`. If a line has
   *no* matched words, fall back to the user’s manual anchor (if any),
   then to a linear interpolation between neighboring anchored lines.
4. Sections inherit `[start, end]` from the union of their lines;
   if the user provided explicit `start_s` / `end_s` on a section,
   those win.

The result is written as a `lacing.SqliteStore` so it round-trips and
can be edited by other tools.

### Module Attributes

| [`AlignerName`](_autosummary/muvid.align.html.md#muvid.align.AlignerName)              | Built-in aligner names.                                                                                                                                                                                                    |
|---------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`WHISPERX_LITE_MODEL_SIZE`](_autosummary/muvid.align.html.md#muvid.align.WHISPERX_LITE_MODEL_SIZE) | `tiny` is fast but, on sung/repetitive audio, produces a transcript so garbled that the greedy matcher walks past whole minutes before finding a plausible match (muvid#101: measured 53 s late on a repeated-word lyric). |

### Functions

| [`align_lyrics`](_autosummary/muvid.align.html.md#muvid.align.align_lyrics)(lyrics, transcript, \*[, ...])        | Align a `LyricsDoc` to a transcript.                       |
|-----------------------------------------------------------------------------------------------------|------------------------------------------------------------|
| [`align_scribe_greedy`](_autosummary/muvid.align.html.md#muvid.align.align_scribe_greedy)(lyrics, transcript, \*[, ...]) | Greedy token-match against a word-timestamped transcript.  |
| [`align_stars`](_autosummary/muvid.align.html.md#muvid.align.align_stars)(lyrics, transcript, \*[, duration_s])  | Singing-specific alignment (STARS / similar).              |
| [`align_user_provided`](_autosummary/muvid.align.html.md#muvid.align.align_user_provided)(lyrics, transcript, \*[, ...]) | Use line-level timings the caller has already determined.  |
| [`align_whisperx_lite`](_autosummary/muvid.align.html.md#muvid.align.align_whisperx_lite)(lyrics, transcript, \*[, ...]) | Local-only aligner that re-uses `faster-whisper` (no API). |
| `aligner_info`(name)                                                                                |                                                            |
| [`list_aligners`](_autosummary/muvid.align.html.md#muvid.align.list_aligners)()                                    | Return all registered aligner names, sorted.               |
| [`register_aligner`](_autosummary/muvid.align.html.md#muvid.align.register_aligner)(name, fn, \*, description[, ...]) | Register an aligner under `name`.                          |
| [`write_alignment_store`](_autosummary/muvid.align.html.md#muvid.align.write_alignment_store)(alignment, \*, path[, ...])  | Write alignment to a `lacing.SqliteStore` file (.annot).   |

### Classes

| [`AlignerSpec`](_autosummary/muvid.align.html.md#muvid.align.AlignerSpec)(\*, name, description, fn[, requires])   | One row in the aligner registry.                           |
|-------------------------------------------------------------------------------------------------------|------------------------------------------------------------|
| [`AlignmentResult`](_autosummary/muvid.align.html.md#muvid.align.AlignmentResult)(\*, sections)                        |                                                            |
| [`LineAlignment`](_autosummary/muvid.align.html.md#muvid.align.LineAlignment)(\*, line_index, section_label, ...)    |                                                            |
| [`SectionAlignment`](_autosummary/muvid.align.html.md#muvid.align.SectionAlignment)(\*, label, title, start_s, ...)     |                                                            |
| [`WordAlignment`](_autosummary/muvid.align.html.md#muvid.align.WordAlignment)(\*, line_index, token_index, ...)      | One alignment between a lyric token and a transcript word. |

### muvid.align.AlignerName

Built-in aligner names. `align_lyrics(aligner=...)` accepts these
plus any name added later via [`register_aligner()`](_autosummary/muvid.align.html.md#muvid.align.register_aligner).

### *class* muvid.align.AlignerSpec(, name, description, fn, requires=())

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One row in the aligner registry.

### *class* muvid.align.AlignmentResult(, sections)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

#### lines_in(start_s, end_s)

Lines that fall (at least partially) inside `[start_s, end_s]`.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`LineAlignment`](_autosummary/muvid.align.html.md#muvid.align.LineAlignment)]

### *class* muvid.align.LineAlignment(, line_index, section_label, text, start_s, end_s, word_alignments)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

### *class* muvid.align.SectionAlignment(, label, title, start_s, end_s, lines)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

### muvid.align.WHISPERX_LITE_MODEL_SIZE *= 'small'*

`tiny` is fast but, on sung/repetitive audio, produces a transcript so
garbled that the greedy matcher walks past whole minutes before finding a
plausible match (muvid#101: measured 53 s late on a repeated-word lyric).
The lyric-video path is happy to wait, so default to a materially more
accurate size; override per call or via this env var.

### *class* muvid.align.WordAlignment(, line_index, token_index, text, start_s, end_s, confidence=1.0)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One alignment between a lyric token and a transcript word.

### muvid.align.align_lyrics(lyrics, transcript, , duration_s=0.0, aligner='scribe-greedy', \*\*aligner_kwargs)

Align a `LyricsDoc` to a transcript.

* **Parameters:**
  * **lyrics** ([`LyricsDoc`](_autosummary/muvid.lyrics.html.md#muvid.lyrics.LyricsDoc)) – User-edited lyrics document (the ground-truth text).
  * **transcript** ([`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)) – Aligner-specific input. For `"scribe-greedy"`
    this is a Scribe / faster-whisper response with
    `words: [...]`. For `"user"` this can be empty if you
    pass `user_line_timings=...`. For `"whisperx-lite"` the
    transcript is ignored — the aligner runs on the audio
    directly (path passed via `audio_path=`).
  * **duration_s** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – Used to extrapolate end times for lines with no
    matched words and no later anchor.
  * **aligner** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – Name of a registered aligner. See [`list_aligners()`](_autosummary/muvid.align.html.md#muvid.align.list_aligners).
  * **\*\*aligner_kwargs** – Forwarded to the aligner.
* **Return type:**
  [`AlignmentResult`](_autosummary/muvid.align.html.md#muvid.align.AlignmentResult)
* **Returns:**
  [`AlignmentResult`](_autosummary/muvid.align.html.md#muvid.align.AlignmentResult).

### muvid.align.align_scribe_greedy(lyrics, transcript, , duration_s=0.0, lookahead=6)

Greedy token-match against a word-timestamped transcript.

Cheap, network-only (assumes the transcript came from Scribe or
similar). Tolerates 1-character mishears between sung and written
text. `duration_s` is used only when extrapolating end times.

* **Return type:**
  [`AlignmentResult`](_autosummary/muvid.align.html.md#muvid.align.AlignmentResult)

### muvid.align.align_stars(lyrics, transcript, , duration_s=0.0, \*\*kwargs)

Singing-specific alignment (STARS / similar). Not yet implemented.

See `misc/docs/alignment_references.md` for the literature
motivating this aligner. The slot exists so callers can already
write `aligner="stars"` and get a clear NotImplementedError.

* **Return type:**
  [`AlignmentResult`](_autosummary/muvid.align.html.md#muvid.align.AlignmentResult)

### muvid.align.align_user_provided(lyrics, transcript, , duration_s=0.0, user_line_timings=None)

Use line-level timings the caller has already determined.

Useful when the user has hand-anchored every line, or when an
external aligner has produced `line_index → (start, end)`
timings and you don’t want any token-matching.

`user_line_timings` is a list of `{"line_index": int,
"start_s": float, "end_s": float}`. If omitted, this aligner
falls back to the manual anchors already on `lyrics` (the
`// 12.5` end-of-line markers in `lyrics.md`).

* **Return type:**
  [`AlignmentResult`](_autosummary/muvid.align.html.md#muvid.align.AlignmentResult)

### muvid.align.align_whisperx_lite(lyrics, transcript, , duration_s=0.0, audio_path=None, model_size='small', lookahead=6)

Local-only aligner that re-uses `faster-whisper` (no API).

The `transcript` argument is ignored when `audio_path` is given:
we re-transcribe locally with faster-whisper and then run the same
greedy match used by `scribe-greedy`. When `audio_path` is not
given, we fall through and just use the supplied `transcript`.

Trade-offs vs `scribe-greedy`: free + offline; slower; less
accurate on singing; needs torch + faster-whisper installed.

`model_size` defaults to [`WHISPERX_LITE_MODEL_SIZE`](_autosummary/muvid.align.html.md#muvid.align.WHISPERX_LITE_MODEL_SIZE) (`"small"`,
overridable via `MUVID_WHISPERX_LITE_MODEL_SIZE`) rather than
faster-whisper’s own `"tiny"` default — see muvid#101.

* **Return type:**
  [`AlignmentResult`](_autosummary/muvid.align.html.md#muvid.align.AlignmentResult)

### muvid.align.list_aligners()

Return all registered aligner names, sorted.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### muvid.align.register_aligner(name, fn, , description, requires=())

Register an aligner under `name`.

The function should accept `(lyrics, transcript, *, duration_s,
\*\*kw) -> AlignmentResult`. Extra keyword arguments forwarded by
[`align_lyrics()`](_autosummary/muvid.align.html.md#muvid.align.align_lyrics) are passed through.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

### muvid.align.write_alignment_store(alignment, , path, asset_id='song:audio', rate=1000)

Write alignment to a `lacing.SqliteStore` file (.annot).

Uses `lacing.tracks.subtitle.SubtitleBuilder` for the
standard `(sections, lines, words)` tier set.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)


# _autosummary/muvid.characters.html.md

# muvid.characters

Character cards + reference image curation via lookbook.

A character lives at `characters/<name>/` inside a project. The card
(`card.json`) holds the human-meaningful description, the reference
image URL/path used as the lipsync anchor, and the optional voice spec.

Reference images go through three states:

- `refs/` — raw image dump (anything the user collected, plus
  optional `generate_references` outputs).
- `selected/` — lookbook’s curated subset, ready for LoRA training
  or just for picking the canonical face.
- `card.reference_image_url` — a single chosen anchor (the best
  selected image) used by every downstream render.

### Functions

| [`add_character`](_autosummary/muvid.characters.html.md#muvid.characters.add_character)(project, name, \*[, ...])           | Create or update a character card.                                                                                                                                                                                    |
|----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`add_reference_images`](_autosummary/muvid.characters.html.md#muvid.characters.add_reference_images)(project, name, images, \*)   | Drop user-provided images into `characters/<name>/refs/`.                                                                                                                                                             |
| [`curate_references`](_autosummary/muvid.characters.html.md#muvid.characters.curate_references)(project, name, \*[, k, recipe]) | Run lookbook on `refs/` and copy the selected images to `selected/`.                                                                                                                                                  |
| [`curate_references_interactive`](_autosummary/muvid.characters.html.md#muvid.characters.curate_references_interactive)(project, name, ...) | Same as [`curate_references()`](_autosummary/muvid.characters.html.md#muvid.characters.curate_references) but routes through `lookbook.curate_interactive()` so the user (or a scripted decision callable) drives keep/reject decisions per round. |
| [`generate_reference_images`](_autosummary/muvid.characters.html.md#muvid.characters.generate_reference_images)(project, name, \*)      | Generate raw reference images via `falaw.generate_image`.                                                                                                                                                             |
| [`get_character_anchor_image`](_autosummary/muvid.characters.html.md#muvid.characters.get_character_anchor_image)(project, name)         | Return the canonical anchor image path for a character.                                                                                                                                                               |

### muvid.characters.add_character(project, name, , description='', voice_id='', reference_audio_url='', voice_style='')

Create or update a character card. Idempotent.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### muvid.characters.add_reference_images(project, name, images, , copy=True)

Drop user-provided images into `characters/<name>/refs/`.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)]

### muvid.characters.curate_references(project, name, , k=8, recipe='person_mock')

Run lookbook on `refs/` and copy the selected images to `selected/`.

Default recipe is `person_mock` so this works without the heavy ML
dependencies; pass `recipe="person"` once those are installed.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)]

### muvid.characters.curate_references_interactive(project, name, , on_decision, k=8, recipe='person_mock', present=6, max_rounds=20)

Same as [`curate_references()`](_autosummary/muvid.characters.html.md#muvid.characters.curate_references) but routes through
`lookbook.curate_interactive()` so the user (or a scripted
decision callable) drives keep/reject decisions per round.

`on_decision` is forwarded directly; see lookbook for the API.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)]

### muvid.characters.generate_reference_images(project, name, , n=6, style_variants=(), quality='balanced')

Generate raw reference images via `falaw.generate_image`.

The character’s `description` is used as the prompt; each variant
(e.g. “front portrait”, “three-quarter”, “wide shot”) is appended.
Output goes to `characters/<name>/refs/`.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)]

### muvid.characters.get_character_anchor_image(project, name)

Return the canonical anchor image path for a character.

Resolves in order: `card.reference_image_path` (curated),
first file in `selected/`, first file in `refs/`.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)


# _autosummary/muvid.choreo.analysis.html.md

# muvid.choreo.analysis

Turn a song into an EVENT LIST — the musical facts every archetype draws from.

The visualizer ([`muvid.visualize`](_autosummary/muvid.visualize.html.md#module-muvid.visualize)) reads the spectrum continuously; this
subgenre needs something discrete instead: \*when did something happen, in which
register, and how hard\*. So the analysis produces

* **events** `[{t, band, strength}]` — onsets in three bands (low/mid/high),
  from a half-wave-rectified spectral flux with an adaptive threshold,
  **hysteresis** and a \*\*minimum inter-onset interval derived from the beat
  grid\*\*, so a noisy attack triggers once rather than flickering;
* a **beat grid** (tempo + beat times) — from `mixing.audio.beat_grid` when
  librosa is installed (the `scoring` extra), else a built-in autocorrelation
  of the onset envelope, and the result says which;
* coarse **sections** from energy — contiguous bar-blocks of similar loudness,
  labelled `low` / `mid` / `high` by tier so a treatment can say “the loud
  parts get the Fischinger grid”.

Everything is a function of the samples and the parameters: no randomness, no
network, no model. The output is JSON-able and is written next to the video as
`events.json` so a render can be inspected — or re-choreographed — without
re-analysing the song.

numpy is imported inside functions only: this module sits on the listing path
of [`muvid.choreo.tools`](_autosummary/muvid.choreo.tools.html.md#module-muvid.choreo.tools), and the subprocess import-safety tests keep the
manifest side of the package free of it.

### Module Attributes

| [`BANDS`](_autosummary/muvid.choreo.analysis.html.md#muvid.choreo.analysis.BANDS)   | The three registers, as (low_hz, high_hz].   |
|----------------------------------------------------------|----------------------------------------------|

### Functions

| [`analyze`](_autosummary/muvid.choreo.analysis.html.md#muvid.choreo.analysis.analyze)(audio, \*[, sample_rate, ...])             | Analyse a song (a path, or an already-decoded mono array at `sample_rate`).   |
|-----------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------|
| [`beat_grid_numpy`](_autosummary/muvid.choreo.analysis.html.md#muvid.choreo.analysis.beat_grid_numpy)(envelope, \*, hop_s, duration)     | Tempo and beat times from the autocorrelation of an onset envelope.           |
| [`find_sections`](_autosummary/muvid.choreo.analysis.html.md#muvid.choreo.analysis.find_sections)(y, \*, sample_rate, tempo, duration) | Coarse sections from per-bar RMS energy, labelled by loudness tier.           |
| [`onset_envelope`](_autosummary/muvid.choreo.analysis.html.md#muvid.choreo.analysis.onset_envelope)(flux, \*[, weights])                | One envelope for the beat tracker: each band normalised, then weighted.       |
| [`pick_onsets`](_autosummary/muvid.choreo.analysis.html.md#muvid.choreo.analysis.pick_onsets)(flux, \*, hop_s, min_ioi_s[, ...])     | Onset `(time, strength)` pairs from one band's flux.                          |

### Classes

| [`Analysis`](_autosummary/muvid.choreo.analysis.html.md#muvid.choreo.analysis.Analysis)(\*, duration, tempo, events, sections)   | Everything an archetype needs, and the JSON artifact a render leaves behind.   |
|----------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------|
| [`Event`](_autosummary/muvid.choreo.analysis.html.md#muvid.choreo.analysis.Event)(\*, t, band, strength)                      | One onset: when, in which band, how hard (0..1, band-relative).                |
| [`Section`](_autosummary/muvid.choreo.analysis.html.md#muvid.choreo.analysis.Section)(\*, index, label, start, end, energy_db)  | A contiguous stretch of similar loudness.                                      |
| [`Tempo`](_autosummary/muvid.choreo.analysis.html.md#muvid.choreo.analysis.Tempo)(\*, bpm, beats, source)                     | The beat grid, and where it came from (`"mixing"` or `"numpy"`).               |

### *class* muvid.choreo.analysis.Analysis(\*, duration, tempo, events, sections, bands=<factory>, meta=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Everything an archetype needs, and the JSON artifact a render leaves behind.

```pycon
>>> a = Analysis(duration=2.0, tempo=Tempo(bpm=120.0, beats=(0.0, 0.5), source='numpy'),
...              events=(Event(t=0.0, band='low', strength=1.0),),
...              sections=(Section(index=0, label='mid', start=0.0, end=2.0, energy_db=-12.0),))
>>> Analysis.from_dict(a.to_dict()) == a
True
```

#### events_in(start, end, , band=None)

Events with `start <= t < end` (optionally one band).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Event`](_autosummary/muvid.choreo.analysis.html.md#muvid.choreo.analysis.Event)]

### muvid.choreo.analysis.BANDS *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[float](https://docs.python.org/3/builtins/functions.html#float), [float](https://docs.python.org/3/builtins/functions.html#float)]]* *= {'high': (2000.0, 11025.0), 'low': (20.0, 200.0), 'mid': (200.0, 2000.0)}*

The three registers, as (low_hz, high_hz]. `high` runs to Nyquist.

### *class* muvid.choreo.analysis.Event(, t, band, strength)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One onset: when, in which band, how hard (0..1, band-relative).

### *class* muvid.choreo.analysis.Section(, index, label, start, end, energy_db)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A contiguous stretch of similar loudness. `label` is its tier.

### *class* muvid.choreo.analysis.Tempo(, bpm, beats, source)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The beat grid, and where it came from (`"mixing"` or `"numpy"`).

#### *property* period *: [float](https://docs.python.org/3/builtins/functions.html#float)*

Seconds per beat.

### muvid.choreo.analysis.analyze(audio, , sample_rate=22050, beat_source='auto', bands={'high': (2000.0, 11025.0), 'low': (20.0, 200.0), 'mid': (200.0, 2000.0)}, min_ioi_beats={'high': 0.25, 'low': 0.5, 'mid': 0.25})

Analyse a song (a path, or an already-decoded mono array at `sample_rate`).

An array input never consults `mixing` (it needs a file), so its beat
grid is always the numpy one; this is the path unit tests use.

* **Return type:**
  [`Analysis`](_autosummary/muvid.choreo.analysis.html.md#muvid.choreo.analysis.Analysis)

### muvid.choreo.analysis.beat_grid_numpy(envelope, , hop_s, duration, bpm_min=60.0, bpm_max=200.0, prior_bpm=120.0)

Tempo and beat times from the autocorrelation of an onset envelope.

The lag with the strongest (prior-weighted) autocorrelation in
`[60/bpm_max, 60/bpm_min]` is the beat period; the phase is the offset
whose comb picks up the most envelope. A constant grid — no tracking of
tempo drift — which is the honest floor for a dependency-free estimate,
and enough for the inter-onset interval and the section bars it serves.

* **Return type:**
  [`Tempo`](_autosummary/muvid.choreo.analysis.html.md#muvid.choreo.analysis.Tempo)

```pycon
>>> import numpy as np
>>> env = np.zeros(2000, np.float32); env[::50] = 1.0     # a click every 0.5 s
>>> t = beat_grid_numpy(env, hop_s=0.01, duration=20.0)
>>> round(t.bpm), t.source, round(t.beats[1] - t.beats[0], 2)
(120, 'numpy', 0.5)
```

### muvid.choreo.analysis.find_sections(y, , sample_rate, tempo, duration, beats_per_bar=4, min_bars=4, step_db=3.0)

Coarse sections from per-bar RMS energy, labelled by loudness tier.

Greedy: a section absorbs the next bar unless the level of the *following*
`min_bars` bars sits more than `step_db` from the section’s mean, and
the section is already `min_bars` long. Sections tile `[0, duration]`
exactly and there is always at least one.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`Section`](_autosummary/muvid.choreo.analysis.html.md#muvid.choreo.analysis.Section), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

```pycon
>>> import numpy as np
>>> sr = 8000; y = np.concatenate([np.full(sr * 8, 0.05), np.full(sr * 8, 0.5)])
>>> secs = find_sections(y, sample_rate=sr, tempo=Tempo(bpm=120, beats=(), source='numpy'), duration=16.0)
>>> [(s.label, s.start, s.end) for s in secs]
[('low', 0.0, 8.0), ('high', 8.0, 16.0)]
```

### muvid.choreo.analysis.onset_envelope(flux, , weights={'high': 0.3, 'low': 1.0, 'mid': 0.6})

One envelope for the beat tracker: each band normalised, then weighted.

Per-band normalisation (by the band’s own 95th percentile) stops the
broadband high band — hundreds of bins against the low band’s handful —
from owning the sum; the weights then let the bass lead.

### muvid.choreo.analysis.pick_onsets(flux, , hop_s, min_ioi_s, window_s=0.5, delta=0.35, hysteresis=0.5)

Onset `(time, strength)` pairs from one band’s flux.

A local maximum triggers when it exceeds `median(window) + delta·scale`
and the flux has dropped back below the hysteresis level since the last
trigger (so one attack is one event). Inside `min_ioi_s` of the previous
trigger a *stronger* peak replaces it and a weaker one is dropped — a weak
early trigger must not block the real hit a few frames later. Strength is
the peak’s height over the 95th percentile of all peak heights, clipped
to 1, so the loudest hits read as 1 and a click reads as small.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)]]

```pycon
>>> import numpy as np
>>> f = np.zeros(200, np.float32); f[[20, 24, 100]] = [1.0, 0.9, 0.5]
>>> [round(t, 3) for t, _ in pick_onsets(f, hop_s=0.01, min_ioi_s=0.1)]
[0.2, 1.0]
>>> f[[20, 24]] = [0.3, 1.0]     # now the STRONGER hit is the later one
>>> [round(t, 3) for t, _ in pick_onsets(f, hop_s=0.01, min_ioi_s=0.1)]
[0.24, 1.0]
```


# _autosummary/muvid.choreo.html.md

# muvid.choreo

Choreo — event-driven visual music, muvid’s second subgenre plugin.

Audio in, nothing else. Onsets in three frequency bands become objects that
appear ON the event and then live — Fischinger’s igniting shapes, Gondry’s
*Star Guitar* landscape whose spacing is the rhythm, McLaren’s scratches on
black, or a swarm — and the song’s sections change the arrangement.

> from muvid.choreo import tools
> tools.render_choreo(‘song.wav’, ‘out.mp4’, archetype=’star_guitar’)

or from the command line:

```default
python -m muvid.choreo render song.wav out.mp4 --archetype star_guitar
```

The pipeline is four seams, each with a default that genuinely works:

`analysis`
: the event list, beat grid and sections — numpy only; `mixing`’s
  librosa beat tracker when installed, a built-in estimate otherwise.

`spec`
: the treatment: direction {palette, background, density} + scenes[]
  {applies_to, archetype, params}. Closed vocabularies; repair, not reject.

`scene`
: every object’s birth, death, shape, place and motion — computed in Python,
  deterministic given a seed.

`render`
: numpy frames piped into ffmpeg; a YouTube-spec mp4 with the song muxed.

This module is a PEP 562 lazy facade, like `muvid/__init__.py`: importing it
pulls nothing heavy, and each attribute is resolved on first use.

### *class* muvid.choreo.Analysis(\*, duration, tempo, events, sections, bands=<factory>, meta=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Everything an archetype needs, and the JSON artifact a render leaves behind.

```pycon
>>> a = Analysis(duration=2.0, tempo=Tempo(bpm=120.0, beats=(0.0, 0.5), source='numpy'),
...              events=(Event(t=0.0, band='low', strength=1.0),),
...              sections=(Section(index=0, label='mid', start=0.0, end=2.0, energy_db=-12.0),))
>>> Analysis.from_dict(a.to_dict()) == a
True
```

#### events_in(start, end, , band=None)

Events with `start <= t < end` (optionally one band).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Event`](_autosummary/muvid.choreo.analysis.html.md#muvid.choreo.analysis.Event)]

### *class* muvid.choreo.Canvas(, width=1920, height=1080, fps=30)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Output geometry.

### *class* muvid.choreo.ChoreoScene(\*, canvas, duration, backdrops, objects, meta=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Everything the renderer needs, and nothing it has to interpret.

### *class* muvid.choreo.TreatmentSpec(\*, spec_version='1.0', title='', direction=<factory>, scenes=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A complete, renderable treatment.

```pycon
>>> s = TreatmentSpec()
>>> validate(s)
[]
>>> TreatmentSpec.from_dict(s.to_dict()) == s
True
```

#### *classmethod* from_dict(d)

Build from a plain mapping — TOTAL over what a model or caller sends.

* **Return type:**
  [`TreatmentSpec`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.TreatmentSpec)

```pycon
>>> s = TreatmentSpec.from_dict({'scenes': [None, {'applies_to': 'high',
...     'archetype': 'swarm', 'params': 'nope'}],
...     'direction': {'palette': {'foo': 1, 'bg': '#000000'}, 'density': None}})
>>> s.scenes[1].applies_to, s.scenes[1].params, s.direction.palette.bg
(('high',), {}, '#000000')
```

### muvid.choreo.analyze(audio, , sample_rate=22050, beat_source='auto', bands={'high': (2000.0, 11025.0), 'low': (20.0, 200.0), 'mid': (200.0, 2000.0)}, min_ioi_beats={'high': 0.25, 'low': 0.5, 'mid': 0.25})

Analyse a song (a path, or an already-decoded mono array at `sample_rate`).

An array input never consults `mixing` (it needs a file), so its beat
grid is always the numpy one; this is the path unit tests use.

* **Return type:**
  [`Analysis`](_autosummary/muvid.choreo.analysis.html.md#muvid.choreo.analysis.Analysis)

### muvid.choreo.compile_scene(treatment, analysis, , canvas=None, seed=0)

Every object and backdrop for the whole song. Deterministic given `seed`.

* **Return type:**
  [`ChoreoScene`](_autosummary/muvid.choreo.scene.html.md#muvid.choreo.scene.ChoreoScene)

```pycon
>>> from muvid.choreo.analysis import Analysis, Event, Section, Tempo
>>> a = Analysis(duration=4.0, tempo=Tempo(bpm=120, beats=(0, .5, 1, 1.5), source='numpy'),
...              events=(Event(t=1.0, band='low', strength=0.9),
...                      Event(t=2.0, band='high', strength=0.5)),
...              sections=(Section(index=0, label='mid', start=0.0, end=4.0, energy_db=-10),))
>>> s = compile_scene(spec_mod.default_treatment('fischinger'), a, seed=1)
>>> [(o.t_born, o.kind, o.band) for o in s.objects]
[(1.0, 'circle', 'low'), (2.0, 'triangle', 'high')]
>>> compile_scene(spec_mod.default_treatment('fischinger'), a, seed=1) == s
True
```

### muvid.choreo.render_choreo(audio, output, , cover=None, treatment=None, archetype=None, seed=0, beat_source='auto', strict=False, width=1920, height=1080, fps=30, workdir=None)

Render a choreo video. The one verb that produces a file.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### Modules

| [`analysis`](_autosummary/muvid.choreo.analysis.html.md#module-muvid.choreo.analysis)   | Turn a song into an EVENT LIST — the musical facts every archetype draws from.                                                   |
|------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------|
| [`manifest`](_autosummary/muvid.choreo.manifest.html.md#module-muvid.choreo.manifest)   | The choreo subgenre's manifest — visual music, declared without importing it.                                                    |
| [`pipeline`](_autosummary/muvid.choreo.pipeline.html.md#module-muvid.choreo.pipeline)   | The choreo pipeline — the one path from a song to a finished video.                                                              |
| [`render`](_autosummary/muvid.choreo.render.html.md#module-muvid.choreo.render)       | Draw a [`ChoreoScene`](_autosummary/muvid.choreo.scene.html.md#muvid.choreo.scene.ChoreoScene) frame by frame and encode it. |
| [`scene`](_autosummary/muvid.choreo.scene.html.md#module-muvid.choreo.scene)         | The scene compiler: events + sections -> a list of drawable OBJECTS.                                                             |
| [`spec`](_autosummary/muvid.choreo.spec.html.md#module-muvid.choreo.spec)           | The choreo treatment spec — what a director decides, as data; stdlib-only.                                                       |
| [`tools`](_autosummary/muvid.choreo.tools.html.md#module-muvid.choreo.tools)         | The SSOT verbs for the choreo subgenre.                                                                                          |


# _autosummary/muvid.choreo.manifest.html.md

# muvid.choreo.manifest

The choreo subgenre’s manifest — visual music, declared without importing it.

Deliberately stdlib-only and free of any import from the rest of
[`muvid.choreo`](_autosummary/muvid.choreo.html.md#module-muvid.choreo): this module is what a catalogue, a UI form, an MCP tool
definition or an LLM choosing among installed subgenres reads, and none of them
should pay for numpy or an ffmpeg probe to do it. The renderer is named as a
string and imported only when a render actually runs — the same shape as
[`muvid.lyricvid.manifest`](_autosummary/muvid.lyricvid.manifest.html.md#module-muvid.lyricvid.manifest), which is the reference plugin.

What `choreo` is: event-driven visual music in the Fischinger / McLaren /
Gondry lineage. Audio in, nothing else. Discrete musical **events** (onsets, per
frequency band) become **objects** with persistence on screen, and **sections**
become different scene arrangements. It is choreographed and structural, not a
spectrum readout — objects appear ON events and then live — which is what makes
it a different thing from [`muvid.visualize`](_autosummary/muvid.visualize.html.md#module-muvid.visualize).

### Module Attributes

| [`ARCHETYPE_NAMES`](_autosummary/muvid.choreo.manifest.html.md#muvid.choreo.manifest.ARCHETYPE_NAMES)   | The archetype names, restated here (rather than imported from `spec`) so the manifest module stays free of every other choreo module.   |
|--------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------|
| [`BEAT_SOURCES`](_autosummary/muvid.choreo.manifest.html.md#muvid.choreo.manifest.BEAT_SOURCES)      | The beat-grid sources `analysis` knows.                                                                                                 |

### muvid.choreo.manifest.ARCHETYPE_NAMES *= ('fischinger', 'star_guitar', 'mclaren', 'swarm')*

The archetype names, restated here (rather than imported from `spec`) so
the manifest module stays free of every other choreo module. A test pins
this tuple against `spec.ARCHETYPES` and `scene.ARCHETYPE_FNS` so the
three cannot drift.

### muvid.choreo.manifest.BEAT_SOURCES *= ('auto', 'mixing', 'numpy')*

The beat-grid sources `analysis` knows. `auto` tries `mixing` (librosa,
in the `scoring` extra) and falls back to the built-in numpy estimate only
when librosa is absent — a *missing* dependency, never a failing one.


# _autosummary/muvid.choreo.pipeline.html.md

# muvid.choreo.pipeline

The choreo pipeline — the one path from a song to a finished video.

> audio (+ optional cover)
> : -> Analysis        events, beat grid, sections   (muvid.choreo.analysis)
>   -> TreatmentSpec   the creative decision          (muvid.choreo.spec)
>   -> ChoreoScene     every object computed          (muvid.choreo.scene)
>   -> mp4             numpy frames -> ffmpeg         (muvid.choreo.render)

Each arrow is a seam with a working default, so the whole thing runs on a bare
`song.wav` with no cover, no AI, no network. [`render()`](_autosummary/muvid.choreo.pipeline.html.md#muvid.choreo.pipeline.render) satisfies
[`muvid.subgenres.Renderer`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.Renderer); it is what the manifest names.

Resource bounds are **refusals, not clamps** — the same shape and defaults as
`muvid.lyricvid.pipeline.check_render_bounds()` under `MUVID_CHOREO_*`
names — because a clamped request silently produces a different video than
the one asked for. The bounds are checked before the analysis runs, so an
oversized request fails in milliseconds.

The cover, when given, is a **palette source only**: decoded once through
ffmpeg to a 24x24 thumbnail and reduced to six colours by numpy. No Pillow.

### Module Attributes

| [`MAX_PIXELS`](_autosummary/muvid.choreo.pipeline.html.md#muvid.choreo.pipeline.MAX_PIXELS)      | Resource bounds, env-configurable.     |
|------------------------------------------------------------------|----------------------------------------|
| [`MAX_INPUT_FILES`](_autosummary/muvid.choreo.pipeline.html.md#muvid.choreo.pipeline.MAX_INPUT_FILES) | How many files any one input may name. |

### Functions

| [`check_input_counts`](_autosummary/muvid.choreo.pipeline.html.md#muvid.choreo.pipeline.check_input_counts)(inputs)              | Refuse any input naming more than [`MAX_INPUT_FILES`](_autosummary/muvid.choreo.pipeline.html.md#muvid.choreo.pipeline.MAX_INPUT_FILES) files.   |
|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------|
| [`check_render_bounds`](_autosummary/muvid.choreo.pipeline.html.md#muvid.choreo.pipeline.check_render_bounds)(canvas, duration_s) | Refuse a render that would exceed the resource bounds.                                                      |
| [`palette_from_cover`](_autosummary/muvid.choreo.pipeline.html.md#muvid.choreo.pipeline.palette_from_cover)(cover, \*, workdir)  | Six colours from an image: dark bg pair, light fg, three spread accents.                                    |
| [`render`](_autosummary/muvid.choreo.pipeline.html.md#muvid.choreo.pipeline.render)(request)                         | Render one choreo video.                                                                                    |

### muvid.choreo.pipeline.MAX_INPUT_FILES *= 64*

How many files any one input may name. The inputs today are single paths;
the bound exists so a list-valued input added later is bounded from day one.

### muvid.choreo.pipeline.MAX_PIXELS *= 8294400*

Resource bounds, env-configurable. Same defaults as the lyric video’s.

### muvid.choreo.pipeline.check_input_counts(inputs)

Refuse any input naming more than [`MAX_INPUT_FILES`](_autosummary/muvid.choreo.pipeline.html.md#muvid.choreo.pipeline.MAX_INPUT_FILES) files.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

```pycon
>>> check_input_counts({'audio': 'a.wav'})
>>> check_input_counts({'frames': ['x'] * 65})
Traceback (most recent call last):
...
ValueError: inputs['frames'] names 65 files; the bound is 64 (MUVID_CHOREO_MAX_INPUT_FILES)
```

### muvid.choreo.pipeline.check_render_bounds(canvas, duration_s)

Refuse a render that would exceed the resource bounds. Refuse, not clamp.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

```pycon
>>> check_render_bounds(Canvas(width=1920, height=1080, fps=30), 200.0)
>>> check_render_bounds(Canvas(width=30000, height=30000, fps=30), 10.0)
Traceback (most recent call last):
...
ValueError: canvas 30000x30000 is 900000000 px/frame; the bound is 8294400 (MUVID_CHOREO_MAX_PIXELS)
```

### muvid.choreo.pipeline.palette_from_cover(cover, , workdir)

Six colours from an image: dark bg pair, light fg, three spread accents.

The image is decoded by ffmpeg to a `_PALETTE_THUMB` square (one
process, any format ffmpeg reads), then: `bg` is the mean of the darkest
quarter (darkened further so objects always read), `bg2` the next quarter,
`fg` the lightest eighth pushed toward white, `low` the most saturated
pixel, and `mid`/`high` that hue a third of a turn on — so the three
bands are always distinguishable whatever the cover.

* **Return type:**
  [`Palette`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.Palette)

### muvid.choreo.pipeline.render(request)

Render one choreo video. Satisfies [`muvid.subgenres.Renderer`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.Renderer).

`request.inputs` takes `audio` (required) and `cover`. `request.params`
takes `treatment` or `archetype`, `seed`, `beat_source`, `strict`,
`width`, `height`, `fps`.

* **Return type:**
  [`RenderResult`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.RenderResult)


# _autosummary/muvid.choreo.render.html.md

# muvid.choreo.render

Draw a [`ChoreoScene`](_autosummary/muvid.choreo.scene.html.md#muvid.choreo.scene.ChoreoScene) frame by frame and encode it.

numpy paints each frame — filled circles, rects, triangles, lines, diamonds
and rings, from coordinate grids and coverage masks with a one-pixel
anti-aliased edge — and the raw `rgb24` frames are piped straight into
ffmpeg’s stdin, which muxes the song and encodes the YouTube-spec H.264/AAC
mp4 [`muvid.visualize.video`](_autosummary/muvid.visualize.video.html.md#module-muvid.visualize.video) produces. Nothing touches the disk between
the scene and the mp4: a 3-minute 720p render is ~15 GB of frames, and that
is why this is a pipe and not a frame directory.

Two things keep it fast enough on a CPU:

* **only the dirty pixels are touched** — every primitive computes its
  bounding box and paints inside it; a frame’s cost is the sum of the objects
  alive in it, not the canvas size;
* **the frame buffer is the backdrop copied**, so a frame with nothing alive
  costs one `memcpy`.

This is the ONE place choreo spawns ffmpeg itself rather than through
[`muvid.visualize.ffmpeg.run_ffmpeg()`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.run_ffmpeg), because that helper has no stdin
seam. It uses the same binary check, the same timeout env var and the same
encode/container arguments, so the mp4 it writes is the one `verify_video`
expects; when a streaming primitive lands in `muvid.visualize.ffmpeg` this
should collapse onto it.

### Functions

| [`render_scene`](_autosummary/muvid.choreo.render.html.md#muvid.choreo.render.render_scene)(scene, \*, audio, output, workdir)   | Paint every frame of `scene`, pipe them into ffmpeg, mux `audio`.   |
|----------------------------------------------------------------------------------------------------|---------------------------------------------------------------------|
| [`iter_frames`](_autosummary/muvid.choreo.render.html.md#muvid.choreo.render.iter_frames)(scene)                                | Every frame of `scene`, in order.                                   |

### Classes

| [`RenderedVideo`](_autosummary/muvid.choreo.render.html.md#muvid.choreo.render.RenderedVideo)(\*, output, duration_s, ...)   | What [`render_scene()`](_autosummary/muvid.choreo.render.html.md#muvid.choreo.render.render_scene) produced, and how long it took.   |
|-----------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------|
| [`Painter`](_autosummary/muvid.choreo.render.html.md#muvid.choreo.render.Painter)(scene)                               | Paints frames of one scene.                                                                            |

### *class* muvid.choreo.render.Painter(scene)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Paints frames of one scene. Holds the grids and backdrops between frames.

#### alive(t)

Objects alive at `t`. Frames must be asked for in increasing `t`.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Obj`](_autosummary/muvid.choreo.scene.html.md#muvid.choreo.scene.Obj)]

#### frame(k)

Frame `k` (at `k / fps` seconds) as a fresh `uint8` HxWx3 array.

* **Return type:**
  `ndarray`

### *class* muvid.choreo.render.RenderedVideo(, output, duration_s, n_frames, render_s)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

What [`render_scene()`](_autosummary/muvid.choreo.render.html.md#muvid.choreo.render.render_scene) produced, and how long it took.

### muvid.choreo.render.iter_frames(scene)

Every frame of `scene`, in order.

* **Return type:**
  [`Iterator`](https://docs.python.org/3/library/typing.html#typing.Iterator)[`ndarray`]

### muvid.choreo.render.render_scene(scene, , audio, output, workdir, crf=18, preset='medium')

Paint every frame of `scene`, pipe them into ffmpeg, mux `audio`.

* **Raises:**
  * [**FfmpegError**](_autosummary/muvid.visualize.html.md#muvid.visualize.FfmpegError) – ffmpeg exited non-zero, died mid-stream, or overran
        `$MUVID_FFMPEG_TIMEOUT_S`; the message carries the log’s tail.
  * [**ValueError**](https://docs.python.org/3/builtins/exceptions.html#ValueError) – the canvas has an odd dimension (yuv420p cannot encode one).
* **Return type:**
  [`RenderedVideo`](_autosummary/muvid.choreo.render.html.md#muvid.choreo.render.RenderedVideo)


# _autosummary/muvid.choreo.scene.html.md

# muvid.choreo.scene

The scene compiler: events + sections -> a list of drawable OBJECTS.

An [`Obj`](_autosummary/muvid.choreo.scene.html.md#muvid.choreo.scene.Obj) is one thing on screen with a life: born at `t_born`, dead at
`t_die`, a shape `kind`, a position, a size, a colour and a `motion` that
says how it moves in between. Every number is computed here, in Python, from
the event list and the treatment; the renderer only draws.

Each **archetype** is a function `(events, section, ...) -> [Obj]` in a
registry (`ARCHETYPE_FNS`), the same seam [`muvid.lyricvid.scene`](_autosummary/muvid.lyricvid.scene.html.md#module-muvid.lyricvid.scene) uses:
adding a look is adding a function and a vocabulary entry. The four shipped
ones are the closed set in [`muvid.choreo.spec.ARCHETYPES`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.ARCHETYPES).

**Determinism is a design rule, not a hope.** The only “randomness” is
[`unit_hash()`](_autosummary/muvid.choreo.scene.html.md#muvid.choreo.scene.unit_hash), a closed-form integer hash of `(seed, index, ...)` — so
the same audio and seed produce the same objects on any machine, a re-render
after a palette change moves nothing, and a test can pin a coordinate.

Positions are normalised (`x` in fractions of width, `y` of height, origin
top-left) and sizes are fractions of canvas **height**, so one scene renders at
any resolution. Velocities are in the same fractions per second.

### Module Attributes

| [`KINDS`](_autosummary/muvid.choreo.scene.html.md#muvid.choreo.scene.KINDS)   | Shape classes the renderer knows how to draw.   |
|----------------------------------------------------------|-------------------------------------------------|
| [`MOTIONS`](_autosummary/muvid.choreo.scene.html.md#muvid.choreo.scene.MOTIONS) | How an object moves between birth and death.    |

### Functions

| [`compile_scene`](_autosummary/muvid.choreo.scene.html.md#muvid.choreo.scene.compile_scene)(treatment, analysis, \*[, ...])   | Every object and backdrop for the whole song.                         |
|--------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------|
| [`register_archetype`](_autosummary/muvid.choreo.scene.html.md#muvid.choreo.scene.register_archetype)(name)                        | Register an archetype under `name`.                                   |
| [`unit_hash`](_autosummary/muvid.choreo.scene.html.md#muvid.choreo.scene.unit_hash)(seed, \*keys)                         | A float in `[0, 1)` that is a pure function of its integer arguments. |

### Classes

| [`Backdrop`](_autosummary/muvid.choreo.scene.html.md#muvid.choreo.scene.Backdrop)(\*, start, end, kind, top, bottom)       | What is behind the objects during `[start, end)`.               |
|----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------|
| [`Canvas`](_autosummary/muvid.choreo.scene.html.md#muvid.choreo.scene.Canvas)(\*[, width, height, fps])                  | Output geometry.                                                |
| [`ChoreoScene`](_autosummary/muvid.choreo.scene.html.md#muvid.choreo.scene.ChoreoScene)(\*, canvas, duration, backdrops, ...) | Everything the renderer needs, and nothing it has to interpret. |
| [`Obj`](_autosummary/muvid.choreo.scene.html.md#muvid.choreo.scene.Obj)(\*, t_born, t_die, kind, x, y, size, colour)  | One drawable thing with a life.                                 |

### *class* muvid.choreo.scene.Backdrop(, start, end, kind, top, bottom)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

What is behind the objects during `[start, end)`.

### *class* muvid.choreo.scene.Canvas(, width=1920, height=1080, fps=30)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Output geometry.

### *class* muvid.choreo.scene.ChoreoScene(\*, canvas, duration, backdrops, objects, meta=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Everything the renderer needs, and nothing it has to interpret.

### muvid.choreo.scene.KINDS *= ('circle', 'rect', 'triangle', 'line', 'diamond', 'ring')*

Shape classes the renderer knows how to draw.

### muvid.choreo.scene.MOTIONS *= ('hold', 'scroll', 'pulse', 'drift', 'flicker')*

How an object moves between birth and death.

### *class* muvid.choreo.scene.Obj(, t_born, t_die, kind, x, y, size, colour, motion='hold', band='', strength=1.0, aspect=1.0, angle=0.0, vx=0.0, vy=0.0, attack_s=0.0, release_s=0.0, layer=0, seed=0)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One drawable thing with a life.

* **Parameters:**
  * **y** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – centre, normalised (x of width, y of height), at birth.
  * **size** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – the shape’s main dimension as a fraction of canvas height —
    diameter for `circle`/`ring`/`diamond`, height for `rect`/
    `triangle`, length for `line`.
  * **aspect** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – width/height for `rect`/`triangle`; length/thickness
    for `line`.
  * **angle** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – degrees, for `line`.
  * **vy** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – velocity (fractions per second) for `scroll`/`drift`.
  * **release_s** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – alpha ramps at the start and end of life.
  * **seed** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – per-object key for `flicker`’s per-frame jitter.

### muvid.choreo.scene.compile_scene(treatment, analysis, , canvas=None, seed=0)

Every object and backdrop for the whole song. Deterministic given `seed`.

* **Return type:**
  [`ChoreoScene`](_autosummary/muvid.choreo.scene.html.md#muvid.choreo.scene.ChoreoScene)

```pycon
>>> from muvid.choreo.analysis import Analysis, Event, Section, Tempo
>>> a = Analysis(duration=4.0, tempo=Tempo(bpm=120, beats=(0, .5, 1, 1.5), source='numpy'),
...              events=(Event(t=1.0, band='low', strength=0.9),
...                      Event(t=2.0, band='high', strength=0.5)),
...              sections=(Section(index=0, label='mid', start=0.0, end=4.0, energy_db=-10),))
>>> s = compile_scene(spec_mod.default_treatment('fischinger'), a, seed=1)
>>> [(o.t_born, o.kind, o.band) for o in s.objects]
[(1.0, 'circle', 'low'), (2.0, 'triangle', 'high')]
>>> compile_scene(spec_mod.default_treatment('fischinger'), a, seed=1) == s
True
```

### muvid.choreo.scene.register_archetype(name)

Register an archetype under `name`.

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis), [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Obj`](_autosummary/muvid.choreo.scene.html.md#muvid.choreo.scene.Obj)]]], [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis), [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Obj`](_autosummary/muvid.choreo.scene.html.md#muvid.choreo.scene.Obj)]]]

```pycon
>>> @register_archetype('doctest-demo')
... def _demo(events, **kw): return []
>>> 'doctest-demo' in ARCHETYPE_FNS
True
>>> del ARCHETYPE_FNS['doctest-demo']
```

### muvid.choreo.scene.unit_hash(seed, \*keys)

A float in `[0, 1)` that is a pure function of its integer arguments.

splitmix64-style mixing; no state, no platform dependence, so it is the
only “random” this package allows itself.

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

```pycon
>>> unit_hash(0, 1) == unit_hash(0, 1), 0.0 <= unit_hash(3, 4, 5) < 1.0
(True, True)
>>> unit_hash(0, 1) != unit_hash(1, 1)
True
```


# _autosummary/muvid.choreo.spec.html.md

# muvid.choreo.spec

The choreo treatment spec — what a director decides, as data; stdlib-only.

The same two-layer split as [`muvid.lyricvid.spec`](_autosummary/muvid.lyricvid.spec.html.md#module-muvid.lyricvid.spec), for the same reason:

`direction`
: WHAT and WHY — palette, background, density, mood, a rationale. The half a
  model (or a person in a hurry) is genuinely good at.

`scenes`
: HOW — an **archetype** from a closed set, which sections it applies to,
  and a small bag of archetype parameters. Never geometry, never timings:
  every coordinate and every time is computed in Python from the event list.

Closed vocabularies everywhere (archetypes, backgrounds, densities), and a
`repair` that projects a nearly-right spec onto the valid space rather than
handing it back — a mechanical substitution renders something good now.

### Module Attributes

| [`ARCHETYPES`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.ARCHETYPES)       | How events become objects.                                                     |
|-------------------------------------------------------------------|--------------------------------------------------------------------------------|
| [`BACKGROUNDS`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.BACKGROUNDS)      | What is behind the objects.                                                    |
| [`DENSITIES`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.DENSITIES)        | How many events become objects, and how many objects an event makes.           |
| [`DENSITY_GATE`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.DENSITY_GATE)     | an event below it makes no object.                                             |
| [`DENSITY_FACTOR`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.DENSITY_FACTOR)   | Count multiplier per density, for the archetypes that spawn several per event. |
| [`ARCHETYPE_PARAMS`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.ARCHETYPE_PARAMS) | Archetype parameters, documented once for the schema and the prompt.           |

### Functions

| [`coerce`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.coerce)(obj)                    | Whatever a caller produced -> a renderable spec plus the repair notes.                |
|---------------------------------------------------------------------------------|---------------------------------------------------------------------------------------|
| [`default_treatment`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.default_treatment)([archetype]) | A one-scene treatment for `archetype`; the CLI's `--archetype` shortcut.              |
| [`json_schema`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.json_schema)()                  | The JSON Schema for a [`TreatmentSpec`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.TreatmentSpec). |
| [`repair`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.repair)(spec)                   | Project `spec` onto the valid space; return `(spec, notes)`.                          |
| [`validate`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.validate)(spec)                 | Human-readable problems; empty means renderable.                                      |
| [`vocabulary`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.vocabulary)()                   | Every closed vocabulary, for prompts and UI.                                          |

### Classes

| [`Direction`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.Direction)(\*[, mood, palette, background, ...])   | The song-level creative decision.          |
|----------------------------------------------------------------------------------------------------|--------------------------------------------|
| [`Palette`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.Palette)(\*[, bg, bg2, fg, low, mid, high])        | Colours as `#rrggbb`.                      |
| [`Scene`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.Scene)(\*[, applies_to, archetype, params])        | One archetype applied to part of the song. |
| [`TreatmentSpec`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.TreatmentSpec)(\*[, spec_version, title, ...])     | A complete, renderable treatment.          |

### muvid.choreo.spec.ARCHETYPES *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [str](https://docs.python.org/3/builtins/stdtypes.html#str)]* *= {'fischinger': 'Geometric shapes ignite per onset on a grid — band picks the shape class (low: discs, mid: squares, high: triangles), strength the size, and each section a different arrangement. The Study No. 7 look.', 'mclaren': 'White scratches and marks on black, one per onset, jittered, gone within a fraction of a beat. Hand-scratched film.', 'star_guitar': 'A side-scrolling landscape: bass onsets are poles, mids are buildings, highs are wires. Everything scrolls left at one constant speed, so the spacing of the objects IS the rhythm. Sections change the sky.', 'swarm': 'Particles whose count and speed follow band energy, thrown from a band-specific origin and persisting as they slow. Continuous, organic.'}*

How events become objects. Each is a function in [`muvid.choreo.scene`](_autosummary/muvid.choreo.scene.html.md#module-muvid.choreo.scene).

### muvid.choreo.spec.ARCHETYPE_PARAMS *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[float](https://docs.python.org/3/builtins/functions.html#float), [float](https://docs.python.org/3/builtins/functions.html#float), [float](https://docs.python.org/3/builtins/functions.html#float), [str](https://docs.python.org/3/builtins/stdtypes.html#str)]]]* *= {'fischinger': {'columns': (6, 2, 24, 'grid columns'), 'hold_beats': (1.0, 0.1, 8.0, 'how long a shape lives, in beats'), 'rows': (3, 1, 12, 'grid rows')}, 'mclaren': {'life_beats': (0.25, 0.05, 2.0, 'how long a scratch lives, in beats')}, 'star_guitar': {'horizon': (0.72, 0.3, 0.95, 'ground line, fraction of height from the top'), 'speed': (0.35, 0.05, 2.0, 'scroll speed, canvas widths per second')}, 'swarm': {'life_s': (1.5, 0.2, 6.0, 'particle lifetime, seconds'), 'particles': (6, 1, 40, 'particles per event at normal density')}}*

Archetype parameters, documented once for the schema and the prompt. Each is
read by its archetype with the default given here and clamped to the range.

### muvid.choreo.spec.BACKGROUNDS *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [str](https://docs.python.org/3/builtins/stdtypes.html#str)]* *= {'gradient': 'Vertical gradient from palette.bg (top) to palette.bg2 (bottom).', 'solid': 'One flat colour (palette.bg).', 'vignette': 'palette.bg, darkened toward the corners.'}*

What is behind the objects.

### muvid.choreo.spec.DENSITIES *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [str](https://docs.python.org/3/builtins/stdtypes.html#str)]* *= {'dense': 'Every onset marks, and each makes more.', 'normal': 'Most onsets mark.', 'sparse': 'Only strong onsets mark; few objects at a time.'}*

How many events become objects, and how many objects an event makes.

### muvid.choreo.spec.DENSITY_FACTOR *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [float](https://docs.python.org/3/builtins/functions.html#float)]* *= {'dense': 2.0, 'normal': 1.0, 'sparse': 0.5}*

Count multiplier per density, for the archetypes that spawn several per event.

### muvid.choreo.spec.DENSITY_GATE *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [float](https://docs.python.org/3/builtins/functions.html#float)]* *= {'dense': 0.0, 'normal': 0.15, 'sparse': 0.45}*

an event below it makes no object.

* **Type:**
  Strength gate per density

### *class* muvid.choreo.spec.Direction(\*, mood='', palette=<factory>, background='solid', density='normal', rationale='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The song-level creative decision.

### *class* muvid.choreo.spec.Palette(, bg='#0b0b12', bg2='#1b1b2e', fg='#f4f1e8', low='#e4572e', mid='#f3c623', high='#4cc9f0')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Colours as `#rrggbb`. `low`/`mid`/`high` are the band colours.

### *class* muvid.choreo.spec.Scene(\*, applies_to=('\*', ), archetype='fischinger', params=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One archetype applied to part of the song.

`applies_to` names section tiers (`low`/`mid`/`high`), section
indices as strings (`"2"`), or `"*"` for everything. A named scene wins
its sections over a `"*"` one; sections nobody names fall back to the
first `"*"` scene, and to the first scene if there is none.

### *class* muvid.choreo.spec.TreatmentSpec(\*, spec_version='1.0', title='', direction=<factory>, scenes=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A complete, renderable treatment.

```pycon
>>> s = TreatmentSpec()
>>> validate(s)
[]
>>> TreatmentSpec.from_dict(s.to_dict()) == s
True
```

#### *classmethod* from_dict(d)

Build from a plain mapping — TOTAL over what a model or caller sends.

* **Return type:**
  [`TreatmentSpec`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.TreatmentSpec)

```pycon
>>> s = TreatmentSpec.from_dict({'scenes': [None, {'applies_to': 'high',
...     'archetype': 'swarm', 'params': 'nope'}],
...     'direction': {'palette': {'foo': 1, 'bg': '#000000'}, 'density': None}})
>>> s.scenes[1].applies_to, s.scenes[1].params, s.direction.palette.bg
(('high',), {}, '#000000')
```

### muvid.choreo.spec.coerce(obj)

Whatever a caller produced -> a renderable spec plus the repair notes.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`TreatmentSpec`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.TreatmentSpec), [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]

### muvid.choreo.spec.default_treatment(archetype='fischinger', \*\*direction)

A one-scene treatment for `archetype`; the CLI’s `--archetype` shortcut.

* **Return type:**
  [`TreatmentSpec`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.TreatmentSpec)

```pycon
>>> default_treatment('mclaren').scenes[0].archetype
'mclaren'
```

### muvid.choreo.spec.json_schema()

The JSON Schema for a [`TreatmentSpec`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.TreatmentSpec).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> json_schema()['properties']['scenes']['items']['properties']['archetype']['enum']
['fischinger', 'star_guitar', 'mclaren', 'swarm']
```

### muvid.choreo.spec.repair(spec)

Project `spec` onto the valid space; return `(spec, notes)`.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`TreatmentSpec`](_autosummary/muvid.choreo.spec.html.md#muvid.choreo.spec.TreatmentSpec), [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]

```pycon
>>> fixed, notes = repair(TreatmentSpec(scenes=(Scene(archetype='swirl',
...     params={'columns': 99, 'bogus': 1}),)))
>>> fixed.scenes[0].archetype, dict(fixed.scenes[0].params)
('fischinger', {'columns': 24})
>>> notes
["scenes[0].archetype 'swirl' -> 'fischinger'", 'scenes[0].params.columns 99 -> 24', "scenes[0].params dropped unknown ['bogus']"]
```

### muvid.choreo.spec.validate(spec)

Human-readable problems; empty means renderable.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

```pycon
>>> validate(TreatmentSpec(scenes=(Scene(archetype='nope'),)))
["scenes[0].archetype 'nope' is not one of the known archetypes"]
```

### muvid.choreo.spec.vocabulary()

Every closed vocabulary, for prompts and UI. One copy, several readers.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]


# _autosummary/muvid.choreo.tools.html.md

# muvid.choreo.tools

The SSOT verbs for the choreo subgenre.

Plain functions, JSON-able arguments in, JSON-able `dict` out, and
deliberately agnostic about CLI/MCP/HTTP/agent — the same shape as
[`muvid.lyricvid.tools`](_autosummary/muvid.lyricvid.tools.html.md#module-muvid.lyricvid.tools). The CLI (`python -m muvid.choreo`) and any
MCP or HTTP surface call *these*, so there is one implementation and one
place a behaviour changes.

Nothing here imports numpy or a renderer at module scope, so
`import muvid.choreo.tools` stays cheap and safe.

### Functions

| [`catalog`](_autosummary/muvid.choreo.tools.html.md#muvid.choreo.tools.catalog)()                                          | Every installed subgenre, without importing any renderer.         |
|-----------------------------------------------------------------------------------------------------|-------------------------------------------------------------------|
| [`manifest`](_autosummary/muvid.choreo.tools.html.md#muvid.choreo.tools.manifest)()                                         | This subgenre's manifest, as a catalogue would show it.           |
| [`vocabulary`](_autosummary/muvid.choreo.tools.html.md#muvid.choreo.tools.vocabulary)()                                       | The closed vocabularies a treatment may draw on.                  |
| [`treatment_schema`](_autosummary/muvid.choreo.tools.html.md#muvid.choreo.tools.treatment_schema)()                                 | JSON Schema for a treatment spec.                                 |
| [`analyze_song`](_autosummary/muvid.choreo.tools.html.md#muvid.choreo.tools.analyze_song)(audio, \*[, beat_source, max_events]) | Hear a song the way the archetypes will: events, tempo, sections. |
| [`validate_treatment`](_autosummary/muvid.choreo.tools.html.md#muvid.choreo.tools.validate_treatment)(treatment)                      | Validate a treatment and return the repaired version alongside.   |
| [`render_choreo`](_autosummary/muvid.choreo.tools.html.md#muvid.choreo.tools.render_choreo)(audio, output, \*[, cover, ...])     | Render a choreo video.                                            |

### muvid.choreo.tools.analyze_song(audio, , beat_source='auto', max_events=200)

Hear a song the way the archetypes will: events, tempo, sections.

The full event list is what `events.json` carries after a render; this
verb truncates it to `max_events` so a terminal or an agent gets the
shape without the flood.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### muvid.choreo.tools.catalog()

Every installed subgenre, without importing any renderer.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### muvid.choreo.tools.manifest()

This subgenre’s manifest, as a catalogue would show it.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> manifest()['slug']
'choreo'
```

### muvid.choreo.tools.render_choreo(audio, output, , cover=None, treatment=None, archetype=None, seed=0, beat_source='auto', strict=False, width=1920, height=1080, fps=30, workdir=None)

Render a choreo video. The one verb that produces a file.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### muvid.choreo.tools.treatment_schema()

JSON Schema for a treatment spec.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> treatment_schema()['type']
'object'
```

### muvid.choreo.tools.validate_treatment(treatment)

Validate a treatment and return the repaired version alongside.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> r = validate_treatment({'scenes': [{'archetype': 'swirl'}]})
>>> r['valid'], r['repairs']
(False, ["scenes[0].archetype 'swirl' -> 'fischinger'"])
```

### muvid.choreo.tools.vocabulary()

The closed vocabularies a treatment may draw on.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> sorted(vocabulary())
['archetype_params', 'archetypes', 'backgrounds', 'densities']
```


# _autosummary/muvid.compose.html.md

# muvid.compose

Compose all rendered shots into the final music video.

Concatenates per-shot videos in timeline order, then overlays the
master song audio so any per-shot audio (lipsync output) gets mixed
under the original mix. Pure ffmpeg — no fancy crossfades in v0.

### Functions

| [`compose`](_autosummary/muvid.compose.html.md#muvid.compose.compose)(project, \*[, out_name, use_song_audio])   | Concatenate `shots/*/output.mp4` and (optionally) overlay song audio.   |
|-----------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|

### muvid.compose.compose(project, , out_name='final.mp4', use_song_audio=True)

Concatenate `shots/*/output.mp4` and (optionally) overlay song audio.

`use_song_audio=True` (default) replaces the audio track with the
original song’s audio over `[shots[0].start_s, shots[-1].end_s]`.
Set to False to keep each shot’s own audio (useful when most shots
are lipsync renders that already carry their slice).

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)


# _autosummary/muvid.contracts.html.md

# muvid.contracts

Adapters between muvid’s SSOT and sibling-package shapes.

muvid’s source of truth is `project.json` + a folder of cards. The
sibling packages (`falaw`, `an`, `lacing`) have their own typed
shapes for related concepts — `falaw.Character` carries inline URLs,
`an.audio.WordTiming` is a tuple, etc. Each package having its own
abstraction is intentional (different concerns, different lifecycles),
but **translation** between them belongs in one place — here — so the
seams are inspectable and a single change in any sibling’s type only
ripples through one module.

Three families of adapters:

- `character_to_falaw(project, name)` / `environment_to_falaw(...)`:
  build live `falaw.Character` / `falaw.Environment` instances from
  muvid’s persistent card.json + curated reference images.
- `word_timings_for_window(project, start_s, end_s)`: pull
  `(text, start, end)` tuples from the project’s lacing alignment
  store, in the shape `an.audio.WordTimingProvider` and
  `muvid.cost` callers expect. Times are absolute (in song-seconds),
  not slice-relative — see [`shifted_word_timings()`](_autosummary/muvid.contracts.html.md#muvid.contracts.shifted_word_timings) if you need
  shot-slice-relative output.
- `shifted_word_timings(timings, *, offset_s)` / `progress_event_to_dict(event)`:
  pure-data transformations that don’t need a project handle.

These are deliberately thin: each function delegates to the canonical
home (`falaw.scene.Character`, `lacing.tracks.subtitle.SubtitleTrack`,
etc.) and just glues to muvid’s persistent state. None of them
introduces new types — they translate, they don’t redefine.

### Functions

| [`character_to_falaw`](_autosummary/muvid.contracts.html.md#muvid.contracts.character_to_falaw)(project, name)              | Build a `falaw.Character` from this project's character card.     |
|-------------------------------------------------------------------------------------------------|-------------------------------------------------------------------|
| [`environment_to_falaw`](_autosummary/muvid.contracts.html.md#muvid.contracts.environment_to_falaw)(project, name)            | Build a `falaw.Environment` from this project's environment card. |
| [`progress_event_to_dict`](_autosummary/muvid.contracts.html.md#muvid.contracts.progress_event_to_dict)(event)                  | Serialize a `falaw.ProgressEvent` into a JSON-safe dict.          |
| [`shifted_word_timings`](_autosummary/muvid.contracts.html.md#muvid.contracts.shifted_word_timings)(timings, \*, offset_s)    | Shift each timing by `-offset_s` (clamping starts at 0).          |
| [`word_timings_for_window`](_autosummary/muvid.contracts.html.md#muvid.contracts.word_timings_for_window)(project, start_s, ...) | Read `(text, start, end)` tuples from the alignment store.        |

### muvid.contracts.character_to_falaw(project, name)

Build a `falaw.Character` from this project’s character card.

Resolves `reference_image_url` from the curated anchor (the first
`selected/` image, falling back to the first `refs/` image) so
downstream falaw renders have a usable URL or local path. The
voice spec (if any) is mirrored 1:1.

### muvid.contracts.environment_to_falaw(project, name)

Build a `falaw.Environment` from this project’s environment card.

### muvid.contracts.progress_event_to_dict(event)

Serialize a `falaw.ProgressEvent` into a JSON-safe dict.

The shape matches the records [`muvid.events.log_fal_events_to()`](_autosummary/muvid.events.html.md#muvid.events.log_fal_events_to)
writes — pulled out as a public helper so other consumers (a UI
SSE stream, a remote telemetry sink) don’t have to rebuild it.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### muvid.contracts.shifted_word_timings(timings, , offset_s)

Shift each timing by `-offset_s` (clamping starts at 0).

Use when handing absolute song-time timings to a tool that wants
them relative to a shot’s audio slice (where t=0 is the slice’s
start). Mirrors `audio[shot.start_s:shot.end_s]` cropping.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)]]

### muvid.contracts.word_timings_for_window(project, start_s, end_s, , asset_id=None)

Read `(text, start, end)` tuples from the alignment store.

Times are **absolute song-seconds**. Returns an empty list when
the alignment store doesn’t exist yet, or when `lacing` /
`lacing.tracks` aren’t installed (so callers can degrade
gracefully).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)]]


# _autosummary/muvid.cost.html.md

# muvid.cost

Cost estimation for a muvid project.

Walks the project’s ShotSpecs and returns the same CostRollup shape
`falaw.estimate_scene_cost` returns, but priced against muvid’s
render strategies (lipsync / image_to_video / text_to_video / animation
/ still) instead of falaw’s Scene/Beat IR. The pricing pulls
`falaw.pick_model` per category + `falaw.estimate_call_cost` per
ModelRecord, so any improvements to `falaw.cost` flow through.

Used by:

- `muvid.facade.estimate_render_cost(root, *, quality, force)()`
- `muvid status` shows the rollup as a summary line.
- `muvid render --budget=$X` aborts before any fal call when the
  estimate exceeds X.

### Functions

| [`estimate_render_cost`](_autosummary/muvid.cost.html.md#muvid.cost.estimate_render_cost)(project, \*[, quality, ...])   | Estimate USD cost of running `muvid render` for the whole project.   |
|------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------|

### Classes

| [`CostLine`](_autosummary/muvid.cost.html.md#muvid.cost.CostLine)   |    |
|-------------------------------------------------------------|----|
| [`CostRollup`](_autosummary/muvid.cost.html.md#muvid.cost.CostRollup) |    |

### muvid.cost.CostLine

alias of `_RolledLine`

### muvid.cost.CostRollup

alias of `_Rollup`

### muvid.cost.estimate_render_cost(project, , quality='balanced', force=False)

Estimate USD cost of running `muvid render` for the whole project.

Returns a structured rollup. Per-shot pricing depends on
`shot.render_strategy`: `image_to_video` and `text_to_video`
cost 1 image-gen + 1 video-gen × duration; `lipsync` is 1
avatar × duration; `still` is 1 image-gen; `animation` is
free of fal calls (rendered locally via `an`).

`force` must mirror the flag the render will run with: under
`--force` nothing is cached, so everything is pending and priced.
Which shots are “already rendered” is decided by the RENDERER’S own
predicate (`muvid.renderers.shot_is_rendered`), not re-derived here —
the re-derivation (`output.mp4` exists) priced an edited shot and a
forced re-render at $0.00 and then billed them (muvid#52).

* **Return type:**
  `_Rollup`


# _autosummary/muvid.data.html.md

# muvid.data


# _autosummary/muvid.environments.html.md

# muvid.environments

Environment cards + canonical establishing image generation.

### Functions

| [`add_environment`](_autosummary/muvid.environments.html.md#muvid.environments.add_environment)(project, name, \*[, ...])        | Create or update an environment card.                                |
|---------------------------------------------------------------------------------------------------|----------------------------------------------------------------------|
| [`get_environment_anchor_image`](_autosummary/muvid.environments.html.md#muvid.environments.get_environment_anchor_image)(project, name)      | Return the canonical environment image, or None if not yet rendered. |
| [`render_environment`](_autosummary/muvid.environments.html.md#muvid.environments.render_environment)(project, name, \*[, quality]) | Generate the canonical establishing image for the environment.       |

### muvid.environments.add_environment(project, name, , description='', time_of_day='', lighting='')

Create or update an environment card.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### muvid.environments.get_environment_anchor_image(project, name)

Return the canonical environment image, or None if not yet rendered.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

### muvid.environments.render_environment(project, name, , quality='high')

Generate the canonical establishing image for the environment.

Saves to `environments/<name>/establishing.png` and stores the
relative path on the card as `reference_image_path`.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)


# _autosummary/muvid.events.html.md

# muvid.events

Surface fal progress events into the muvid project.

Whenever muvid invokes fal (during `render`, `render_environment`,
`transcribe` for transcripts that arrive via fal, etc.), the
`falaw.events` stream is written to `.muvid/fal_events.jsonl` so:

- `muvid status` can show “currently running …” and recent
  per-shot timings;
- the local UI can stream the same events over SSE without setting
  up its own subscriber;
- post-mortem of long renders is just `cat .muvid/fal_events.jsonl`.

The integration is opt-in via [`log_fal_events_to()`](_autosummary/muvid.events.html.md#muvid.events.log_fal_events_to), which yields a
context manager that subscribes / unsubscribes around the `with`
block. `muvid.facade.render` wraps every render call in one.

### Functions

| [`log_fal_events_to`](_autosummary/muvid.events.html.md#muvid.events.log_fal_events_to)(path)                   | Subscribe a JSONL writer to falaw's event bus for the duration.   |
|--------------------------------------------------------------------------------------------|-------------------------------------------------------------------|
| [`read_recent_fal_events`](_autosummary/muvid.events.html.md#muvid.events.read_recent_fal_events)(path, \*[, limit]) | Read the tail of the JSONL log.                                   |

### muvid.events.log_fal_events_to(path)

Subscribe a JSONL writer to falaw’s event bus for the duration.

No-op (silent) if `falaw` isn’t installed.

* **Return type:**
  [`Iterator`](https://docs.python.org/3/library/typing.html#typing.Iterator)[[`None`](https://docs.python.org/3/builtins/constants.html#None)]

### muvid.events.read_recent_fal_events(path, , limit=50)

Read the tail of the JSONL log. Returns empty list if absent.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]


# _autosummary/muvid.facade.html.md

# muvid.facade

Top-level facade — the verbs the CLI / skill / UI all call.

These are the same functions, just packaged so each one takes a
project root path (string) instead of a `MusicVideoProject`. They
are deliberately thin: each one resolves the project, then delegates
to the underlying module.

### Functions

| `add_character`(root, name, \*[, description, ...])                                                |                                                                                                                                        |
|----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------|
| `add_character_images`(root, name, paths)                                                          |                                                                                                                                        |
| `add_environment`(root, name, \*[, ...])                                                           |                                                                                                                                        |
| [`align_lyrics`](_autosummary/muvid.facade.html.md#muvid.facade.align_lyrics)(root, \*[, aligner])                 | Build `lyrics/alignment.annot` from transcript + lyrics.md.                                                                            |
| `compose`(root, \*[, out_name, use_song_audio])                                                    |                                                                                                                                        |
| `curate_character`(root, name, \*[, k, recipe])                                                    |                                                                                                                                        |
| [`curate_character_interactive`](_autosummary/muvid.facade.html.md#muvid.facade.curate_character_interactive)(root, name, \*, ...) | Interactive curate driven by a pre-recorded decisions JSON.                                                                            |
| [`default_project_root`](_autosummary/muvid.facade.html.md#muvid.facade.default_project_root)(name)                        | Where a project called `name` belongs by default, as an absolute path.                                                                 |
| [`estimate_render_cost`](_autosummary/muvid.facade.html.md#muvid.facade.estimate_render_cost)(root, \*[, quality, force])  | Return a [`muvid.cost.CostRollup`](_autosummary/muvid.cost.html.md#muvid.cost.CostRollup) for the project's pending shots. |
| [`format_status`](_autosummary/muvid.facade.html.md#muvid.facade.format_status)(status_dict)                        | Human-readable rendering of [`status()`](_autosummary/muvid.facade.html.md#muvid.facade.status)'s output.                                        |
| `generate_character_images`(root, name, \*[, ...])                                                 |                                                                                                                                        |
| [`init_project`](_autosummary/muvid.facade.html.md#muvid.facade.init_project)(root, \*[, title, song])             | Create a new music video project.                                                                                                      |
| `parse_script`(root)                                                                               |                                                                                                                                        |
| [`render`](_autosummary/muvid.facade.html.md#muvid.facade.render)(root, \*[, quality, force, budget, ...])   | Render every shot.                                                                                                                     |
| `render_environment`(root, name, \*[, quality])                                                    |                                                                                                                                        |
| `render_shot`(root, shot_id, \*[, quality, force])                                                 |                                                                                                                                        |
| [`status`](_autosummary/muvid.facade.html.md#muvid.facade.status)(root)                                      | Return a summary dict of the project's current state.                                                                                  |
| [`transcribe_song`](_autosummary/muvid.facade.html.md#muvid.facade.transcribe_song)(root, \*[, api_key])              | Run ElevenLabs Scribe on the project's song.                                                                                           |
| `write_script`(root)                                                                               |                                                                                                                                        |

### muvid.facade.align_lyrics(root, , aligner='scribe-greedy', \*\*aligner_kwargs)

Build `lyrics/alignment.annot` from transcript + lyrics.md.

`aligner` selects the alignment strategy (see
[`muvid.align.list_aligners()`](_autosummary/muvid.align.html.md#muvid.align.list_aligners)); extra kwargs are forwarded to
the chosen aligner. Defaults to `scribe-greedy`.

Returns the path to the alignment store.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.facade.curate_character_interactive(root, name, , decisions, k=8, recipe='person_mock', present=6, max_rounds=20)

Interactive curate driven by a pre-recorded decisions JSON.

`decisions` is either a path to a JSON file or an in-memory list,
each element shaped like `{"keep": [<image_id>], "reject": [...],
"stop": false}`. The decisions are applied in order, one per
round. Useful for skill-driven flows: the agent shows the user the
candidates, collects their answers, writes a JSON, and re-runs.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### muvid.facade.default_project_root(name)

Where a project called `name` belongs by default, as an absolute path.

`root` is a required positional on every other verb here and on
[`muvid.project.MusicVideoProject`](_autosummary/muvid.project.html.md#muvid.project.MusicVideoProject), deliberately — a pipeline that guessed
which project it was operating on would be worse than one that asks. But required
*everywhere* left **no** code answering where a NEW project should go, so the only
written-down answer lived in prose: the `muvid` skill and the `README` both said
`~/muvid/<song-stem>`, which put a real project’s 36 MB in an app-named directory
under `$HOME` that nothing owns. This is that answer in code, where it can be
tested and where callers — the skill, the README, the CLI, a script — can *ask*
instead of restating it. It creates nothing.

The CLI’s `serve` is the one exception to “required”: it defaults `root` to the
cwd. See `muvid/paths.py` — the default is right, and it was the UI’s missing
is-this-a-project precondition that made it write.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.facade.estimate_render_cost(root, , quality='balanced', force=False)

Return a [`muvid.cost.CostRollup`](_autosummary/muvid.cost.html.md#muvid.cost.CostRollup) for the project’s pending shots.

“Pending” is the renderer’s own definition (hash + `force`), so pass the
same `force` you will render with to price what that run will actually do.

### muvid.facade.format_status(status_dict)

Human-readable rendering of [`status()`](_autosummary/muvid.facade.html.md#muvid.facade.status)’s output.

Single-screen-ish: title, song, stage checkmarks, render progress
bar, alignment quality summary. No colour (we don’t pull in a TTY
library).

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.facade.init_project(root, , title='', song=None)

Create a new music video project. Returns the absolute root path.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.facade.render(root, , quality='balanced', force=False, budget=None, allow_unpriced=False)

Render every shot. Returns the produced mp4 paths.

`budget` (USD): when set, refuses to start if
[`estimate_render_cost()`](_autosummary/muvid.facade.html.md#muvid.facade.estimate_render_cost) exceeds it — \*\*or if any part of the project
could not be priced at all\*\*. Pass `None` (CLI: `--budget=-1`) to skip
the gate entirely.

`allow_unpriced`: proceed despite unpriceable work, after reading what it
is. The gate is otherwise TWO conditions, and the second is the one muvid#47
was filed about: a price this code could not determine contributes nothing to
`total_amount`, so comparing the number alone let an unpriceable shot clear
**any** budget. Unknown is not zero.

The escape exists because a threshold and an approval are different things. A
hard refusal with no way past it would make `--budget` unusable for a
project containing one exotic model — so the default refuses, and a caller
who has read the names can accept them. What it must never become is a
silent default.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### muvid.facade.status(root)

Return a summary dict of the project’s current state.

Useful for the skill / UI to show the user where they are in the
pipeline. No side effects.

Returns a structured shape with stage progression, per-shot render
status, and (when an alignment store exists) a word-confidence
histogram. Stable enough to be programmatic; pass through
[`format_status()`](_autosummary/muvid.facade.html.md#muvid.facade.format_status) for human-readable text.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.facade.transcribe_song(root, , api_key=None)

Run ElevenLabs Scribe on the project’s song.

Writes the raw response to `lyrics/transcript.json` and a draft
`lyrics/lyrics.md` with auto-detected line breaks. The user is
expected to edit `lyrics.md` to fix mishears and add real section
tags. Returns the path to the lyrics markdown.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)


# _autosummary/muvid.footage.align.html.md

# muvid.footage.align

Align a set of footage clips to the song — a thin wrapper over `mixing.audio`.

Each clip is a video file; its audio track (the different-device capture of the song) is
what we align. `mixing.audio.align_clips_to_reference` loads each clip’s audio via
pydub/ffmpeg (so a video path works directly — no separate extraction, no moviepy), and
returns offsets + a scale-invariant confidence + coverage clamped to the song timeline.

muvid does not estimate offsets itself — the estimator lives in `mixing`, which is
where the multi-device primitive and its measurements belong — but muvid is the one that
turns a measurement into a cut, so muvid is where “we are not sure” stops being a number
and becomes a refusal (muvid#59). The **verdict** that makes that possible is
[`vouches_for()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.vouches_for), re-exported here beside the aligner that applies
it; it is defined in [`muvid.footage.edl`](_autosummary/muvid.footage.edl.html.md#module-muvid.footage.edl), beside the record it judges and the gate
([`validate_edl()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.validate_edl)) that enforces it.

### Module Attributes

| [`ALIGN_SAMPLE_RATE`](_autosummary/muvid.footage.align.html.md#muvid.footage.align.ALIGN_SAMPLE_RATE)   | Analysis sample rate for alignment (mono).                                                                                                                                                                                                     |
|----------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`WINDOW_PARAMETERS`](_autosummary/muvid.footage.align.html.md#muvid.footage.align.WINDOW_PARAMETERS)   | Estimator parameters [`align_footage()`](_autosummary/muvid.footage.align.html.md#muvid.footage.align.align_footage) refuses to forward, because changing them changes what [`MIN_SUPPORT`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MIN_SUPPORT) MEANS. |

### Functions

| [`align_footage`](_autosummary/muvid.footage.align.html.md#muvid.footage.align.align_footage)(song_path, clips, \*[, ...])   | Align `clips` (`[(clip_id, clip_path), ...]`) to the song at `song_path`.      |
|-----------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------|
| [`vouches_for`](_autosummary/muvid.footage.align.html.md#muvid.footage.align.vouches_for)(\*, confidence, support[, ...])  | Does the aligner vouch for this offset? The ONE place that verdict is reached. |

### muvid.footage.align.ALIGN_SAMPLE_RATE *= 16000*

Analysis sample rate for alignment (mono). 16 kHz is plenty for offset precision.

### muvid.footage.align.WINDOW_PARAMETERS *= ('window_s', 'hop_s')*

Estimator parameters [`align_footage()`](_autosummary/muvid.footage.align.html.md#muvid.footage.align.align_footage) refuses to forward, because changing them
changes what [`MIN_SUPPORT`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MIN_SUPPORT) MEANS. Per mixing#43 (mixing PR
#51), an explicit `window_s` wider than a clip can hold a second, independent look
at is refused by `mixing` itself with a typed `WindowTooWideForClip`, so that
direction no longer reaches muvid as a silent `support=None`. (Where exactly that
bound falls is `mixing`’s call to make and to change; muvid does not restate it
here.) Narrowing the window is not caught by that refusal and still
deflates every support — measured across eleven clip lengths, correct alignments read
0.38-0.70 at a 20 s window and 0.00-1.00 at a 4 s one — so correct alignments fall
under the threshold instead. Refusing here regardless of what `mixing` does about
the wide-window case keeps the two packages from disagreeing about it later, and keeps
the calibration decision (moving `MUVID_FOOTAGE_MIN_SUPPORT` if the window moves)
something a person makes out loud rather than a side effect of a keyword — the
refusal at `mixing`’s own entry point is a different failure mode for a different
caller, not a substitute for muvid’s own gate.

(Since `mixing>=0.0.48`, passing `window_s` alone also pairs it with
`hop = window/2`, so the two are one knob at the estimator too. Another reason not
to accept half of it through a keyword.)

Refused rather than merely documented for the reason the rest of this package refuses
things: an argument that quietly turns a safety check off is worse than one that is
not accepted at all. Tuning the window is legitimate — it just has to move
`MUVID_FOOTAGE_MIN_SUPPORT` with it, which is a decision someone has to make out
loud rather than a side effect of a keyword.

### muvid.footage.align.align_footage(song_path, clips, , song_duration=None, sample_rate=16000, \*\*estimator_kwargs)

Align `clips` (`[(clip_id, clip_path), ...]`) to the song at `song_path`.

Returns one [`FootageAlignment`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.FootageAlignment) per clip, keyed by `clip_id`
— including clips that do not overlap the song, which carry `overlaps=False` rather
than being omitted, and clips the aligner could not vouch for, which carry
`reliable=False` rather than being omitted. Nothing may vanish from the record just
because it matched badly; what a bad match loses is the right to be cut to silently
([`validate_edl()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.validate_edl) is where that is enforced).

\*\*A verdict already on disk is never re-judged, and re-aligning is how you re-judge
it.\*\* A `reliable=True` written by muvid 0.0.52 or earlier was reached against the
UNGRADED support scale, where the muvid#59 shoot’s own correct offsets scored 0.42
and 0.46 — numbers the current threshold refuses. Those records keep their verdicts
deliberately (`from_dict()` derives only an
ABSENT verdict), because a stored verdict is a measurement someone’s project already
depends on, not a value to reinterpret under a scale it never saw. Calling this
function again is the supported way to get a verdict on today’s terms.
Heavy deps (mixing.audio → numpy/scipy/pydub) are imported lazily here so importing the
genre stays light.

* **Parameters:**
  * **song_path** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – The clean master every clip is aligned to.
  * **clips** ([`Sequence`](https://docs.python.org/3/library/typing.html#typing.Sequence)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)]) – `(clip_id, clip_path)` pairs; a video path works directly.
  * **song_duration** ([`float`](https://docs.python.org/3/builtins/functions.html#float) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – The song timeline length; probed from `song_path` when omitted.
  * **sample_rate** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – Analysis sample rate (mono).
  * **\*\*estimator_kwargs** – 

    Passed straight to `mixing.audio.align_clips_to_reference`
    — the seam for the windowed-consensus estimator’s parameters (muvid#59).
    Forwarding rather than enumerating keeps muvid out of the business of
    tracking `mixing`’s estimator vocabulary, and an argument the installed
    `mixing` does not accept raises [`TypeError`](https://docs.python.org/3/builtins/exceptions.html#TypeError) from `mixing` naming
    it. That failure is the point: a window parameter silently ignored is a
    caller believing they tuned an estimator that never saw the value.

    **The window parameters are the exception and are REFUSED** — see
    [`WINDOW_PARAMETERS`](_autosummary/muvid.footage.align.html.md#muvid.footage.align.WINDOW_PARAMETERS). They do not tune the estimator so much as re-scale
    the gate that reads it, and muvid refuses them at this entry point regardless
    of whether `mixing` itself would also refuse or silently answer.
* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`FootageAlignment`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.FootageAlignment)]

### muvid.footage.align.vouches_for(, confidence, support, margin=None, window_s=None)

Does the aligner vouch for this offset? The ONE place that verdict is reached.

Lives here, beside the record it judges and the gate that enforces it, rather than
with the aligner: `FootageAlignment.from_dict()` has to reach it to derive a
verdict for a record written before the field existed, and a module that owns a
dataclass should not need its own consumer to interpret one.

`support` (how much of the clip agrees) decides when the aligner measured it,
because it is the only one of the two numbers that can tell a clear winner from a
coin flip — see `FootageAlignment.support`.

\*\*When support is absent, this falls back to `confidence`, and that fallback is
known to be inadequate.\*\* It is the same instrument muvid#59 was filed about: on
that shoot it would have refused two of the three wrong offsets and passed the
third, which was 83 s out. So this is not “unknown forces approval” — an unmeasured
support is judged by the weaker test rather than refused outright.

The estimator fits its window to the clip and grades each window’s evidence, so a
vote is held and is READABLE for almost everything — `support is None` now only
for a clip too short to hold two windows at the 3 s floor (measured: 4 s yes, 6 s
no). The support is used **at whatever window it was measured at**, which is a
deliberate reversal: an earlier revision of this function refused to read a support
from a fitted window, on the reasoning that an argmax headcount over few short
windows is a weaker statistic than the same fraction at 20 s. That was true of the
ungraded tally and is now actively wrong — see the note above
`MIN_SUPPORT`. Measured on six pure-noise clips at a fitted 6.67 s window,
the window guard vouches for FOUR of them by routing to the coefficient, while
reading the graded support refuses all six.

\*\*Where it still falls back to the coefficient, it is guessing, and the
measurements say so plainly.\*\* On heavily degraded cross-device clips of the
muvid#59 master, the worst CORRECT alignment scored 0.129 while the worst pure-noise
clip scored 0.139 — the distributions overlap, so no threshold admits the correct
ones and refuses the noise. Worse, the wrong offsets scored 0.183-0.252, *higher*
than the correct ones. The real material agrees: of the three 10 s excerpts, the
WRONG one carried the highest coefficient of the three (0.834 against 0.566 and
0.621).

So `MIN_CONFIDENCE` was deliberately NOT re-tuned. There is no value to tune it
to: raising it past the noise floor refuses correct footage, and a number chosen to
make a noise-floor test pass would hide that behind a green run. That fallback is now
reached only by a clip too short to vote at all, which is the narrowest it has been.

**Two numbers, asking different questions, and BOTH are required.**
`MIN_SUPPORT` asks whether enough of the clip’s evidence reached this offset;
`MIN_MARGIN` asks whether that evidence prefers this offset over every other
one it considered. Neither subsumes the other, and the measurement that proves it is
in `MIN_SUPPORT`’s note: margin alone scores 24/24 on real material where the
support threshold scores 18/24, and then vouches for a bar-multiple REPEAT that the
support threshold refuses. Aliases and noise fail differently; margin catches noise
and only some aliases.

So this is a strict tightening of what muvid 0.0.53 shipped — it can only refuse
more, never less.

**What muvid#91 becomes.** Since the estimator fits its window down to a 3 s floor,
the regime with no vote at all is now exactly clips shorter than
`window_floor + hop` — measured, **4.5 s**: 4.4 s gives `support is None` and
4.5 s is the first with a number. Those still fall back to the coefficient, which
produces an inversion worth naming: a 4.4 s clip is VOUCHED while a 4.5 s one is
refused, because falling off the bottom of the vote hands the decision to the weaker
instrument. That is not defensible on its own terms, and it is left alone HERE only
because changing it means separating a fresh verdict from a DERIVED one — a record
predating these fields also has `support is None`, and refusing those would
re-break what muvid#87 fixed. muvid#91 is now that specific decision, over a band too
short to be usable music-video footage, rather than the open question about a third
of a shoot that it started as.

* **Parameters:**
  * **confidence** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – The estimator’s correlation coefficient at the chosen lag.
  * **support** ([`float`](https://docs.python.org/3/builtins/functions.html#float) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Graded fraction of window evidence reaching the offset, or `None`
    when no vote could be held.
  * **margin** ([`float`](https://docs.python.org/3/builtins/functions.html#float) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – That tally minus the tally at the best offset outside the tolerance.
    `None` on exactly the same quorum as `support`. Required to vouch when a
    vote WAS held: an aligner reporting support without it has not answered the
    separating question, and unknown does not vouch. The `mixing>=0.0.51` floor
    guarantees both, and `tests/test_ci_extras_canary.py` asserts the
    capability so a mis-resolved floor fails loudly rather than quietly refusing
    every clip.
  * **window_s** ([`float`](https://docs.python.org/3/builtins/functions.html#float) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – The window support was measured at. Recorded and reported as a
    diagnostic; it does **not** enter the verdict, for the reason above.
* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)
* **Returns:**
  True when the offset may be cut to without the caller opting in.


# _autosummary/muvid.footage.assemble.html.md

# muvid.footage.assemble

Assemble validated cuts into a music video, in BOUNDED memory.

The previous shape — one ffmpeg `-filter_complex` pass with one input per cut — held a
decoder context and inter-filter queues for EVERY cut at once, so peak RSS grew roughly
linearly with cut count: a real 30-cut weighted edit needed >2.3 GB and was OOM-killed on
the 3.7 GB production box, while 15 cuts only just fit (muvid#21/#24). Score-driven edits
routinely produce 30-70 cuts, so that shape caps exactly the edits the scoring layer
exists to make.

Now three bounded stages, memory O(1) in cut count:

1. **One intermediate per PART** — a single-input ffmpeg run per cut (input-side `-ss`,
   so a late cut no longer decodes the whole head of its clip), scaled+padded onto the
   fixed canvas at a constant `fps`. A gap entry (`clip_path == ""` — a span of the
   song with no footage) renders as black from a `color` source, which is what makes
   partial-coverage edits renderable at all. Each intermediate is cut to an EXACT frame
   count derived from the shared song-time grid (`round(end*fps) - round(start*fps)`);
   sub-frame cuts (0 grid frames) are dropped, an exhausted source clones its last frame
   up to the count (`tpad`), and a cut whose source yields no frames at all falls back
   to black — so total frames equals `round(song_duration*fps)` by construction,
   whatever the cut count, source rates, or a clip whose audio outlives its video.

   A boundary carrying a [`Transition`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.Transition) contributes a fourth
   kind of part: a **two-input** `xfade`, sitting between the two solos it blends,
   each of which is correspondingly shortened (see `_part_plan()`). TWO is the
   number that matters — the bounded-memory guarantee is O(1) in CUT count, and a
   constant number of decoders per invocation keeps it. Reaching for one filtergraph
   over all parts is exactly how the OOM below comes back. The counts still telescope:
   the `pre`/`post` terms cancel against the transition length, so the total is
   `round(song_duration*fps)` with or without transitions.
2. **Concat by stream copy** (concat demuxer) — no re-encode, no filtergraph.
3. **Mux the clean song** in the same final pass. When the master already IS the delivery
   contract (aac, 48 kHz stereo), its packets are stream-copied bit-identically; anything
   else is encoded to the contract (aac 192k, 48 kHz stereo — verify_video’s audio check
   must be a property of the renderer, not of which song the user brought: muvid#24 B3).

ffmpeg auto-applies each clip’s rotation metadata on decode (default `-autorotate 1`),
so a display-matrix portrait clip lands upright and pillarboxed, never stretched.

A cut may also carry a **look** — a compiled `looks` filter fragment
([`muvid.footage.look`](_autosummary/muvid.footage.look.html.md#module-muvid.footage.look)) spliced into the per-part chain by `_part_filter()`.
It is the federation seam and it is deliberately the cheapest possible one: a `-vf`
fragment adds no `-i`, so it cannot move the invariant above. That is enforced rather
than trusted, and by an ALLOWLIST rather than by refusals —
`_validate_look()` accepts only the filters
[`LOOK_FILTERS`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.LOOK_FILTERS) names, and refuses a fragment that names a
container input, that is more than one linear chain, or that is not lexically closed.
The allowlist is what closes `movie=`/`amovie=` (a second container opened from
*inside* the fragment, which is the invariant leaving by the back door) and, because
`assemble_music_video` is a live per-caller MCP tool whose `edl` argument is
free-form, the filters that write the host’s disk (`metadata=…:file=`,
`deshake=filename=`, `sendcmd`, `signature`). The allowlist is a
vocabulary and bounds no PARAMETER, so the frame size a look asks for is bounded
separately against the delivery canvas (muvid#75) — `scale=8000:8000` peaks at
328 MB from a 64x48 source against 19 MB for a look that stays at canvas size,
and `pad`/`zoompan` reach the same magnitude. \*\*Nor is a size bound a bound on
the OPTIONS that set one\*\*: `pad`’s `aspect` and `scale`’s
`force_original_aspect_ratio` both move the frame while declaring no dimension
a bound can read, and on the production canvas both are larger than the case the
size bound refuses (590 MB and 941 MB against 403 MB). So the four filters that
can change the output geometry are allowlisted per OPTION and per positional
slot — see `_LOOK_GEOMETRY_FILTERS`.

A cut whose look is **time-varying** — a punch-in, a pan, anything reading the
filter clock — additionally makes `_part_plan()` **warn** when the cut borders
a transition: the blend is a separate invocation whose inputs are
input-side-seeked, so the clock restarts and the move plays again from the start
(muvid#73). The EDL says which kind a look is
([`look_time_varying`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry.look_time_varying)) because the fragment is a
bare string muvid did not author; rebasing it would mean rewriting an arbitrary
ffmpeg expression, which is what `looks`’ rule 27 refuses.

\*\*Both of `_part_plan()`’s findings go to two places\*\*, and the second half
was missing until now: a [`AssemblyWarning`](_autosummary/muvid.footage.assemble.html.md#muvid.footage.assemble.AssemblyWarning) on stderr, *and* the `on_note`
sink the caller’s reply is built from. `assemble_music_video` is a live
per-caller MCP tool and its caller has no stderr, so a warning that reached only
stderr was a hitch the caller was billed for and never told about — the silent
no-op this module refuses everywhere else. See `_emit()`.

Deliberately NOT moviepy (its `write_videofile` runs in-process and would escape the
`$MUVID_FFMPEG_TIMEOUT_S` worker guard). Every stage runs through
[`muvid.visualize.ffmpeg.run_ffmpeg()`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.run_ffmpeg); note the guard is **per invocation** now, so a
render’s wall-clock bound is `(parts + 1) * MUVID_FFMPEG_TIMEOUT_S` — the env var bounds
a hang, not the total render. Parts equal cuts for an untransitioned edit and approach
`2 * cuts` when every boundary is transitioned.

### Module Attributes

| [`DEFAULT_FPS`](_autosummary/muvid.footage.assemble.html.md#muvid.footage.assemble.DEFAULT_FPS)    | Default output frame rate for the assembled video.   |
|-----------------------------------------------------------------|------------------------------------------------------|
| [`DEFAULT_CANVAS`](_autosummary/muvid.footage.assemble.html.md#muvid.footage.assemble.DEFAULT_CANVAS) | 9 1080p) when the caller/genre gives none.           |

### Functions

| [`assemble_music_video`](_autosummary/muvid.footage.assemble.html.md#muvid.footage.assemble.assemble_music_video)(cuts, song_path, ...[, ...])   | Render `cuts` (a validated, contiguous, gap-filled EDL) into `out_path`.   |
|------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------|

### Exceptions

| [`AssemblyWarning`](_autosummary/muvid.footage.assemble.html.md#muvid.footage.assemble.AssemblyWarning)   | A render-plan finding the caller should see — not an error, not silence.   |
|--------------------------------------------------------------------|----------------------------------------------------------------------------|

### *exception* muvid.footage.assemble.AssemblyWarning

Bases: [`RuntimeWarning`](https://docs.python.org/3/builtins/exceptions.html#RuntimeWarning)

A render-plan finding the caller should see — not an error, not silence.

A `RuntimeWarning` subclass, so every existing `pytest.warns(RuntimeWarning)`
and every stderr reader keeps working. It exists so the *reply* half can pick
these out: `warnings.warn` reaches a developer’s stderr and nothing else, and
[`assemble_music_video()`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.assemble_music_video) is a live per-caller MCP
tool whose caller has no stderr. A warning the caller cannot see is the silent
no-op this module refuses everywhere else — so [`assemble_music_video()`](_autosummary/muvid.footage.assemble.html.md#muvid.footage.assemble.assemble_music_video)
takes an `on_note` sink and the tool returns what it collects.

A distinct class rather than a stderr filter or a `catch_warnings` block:
`catch_warnings` mutates process-global state and the connector serves
concurrent callers, so one render could swallow or steal another’s warnings.

### muvid.footage.assemble.DEFAULT_CANVAS *= (1920, 1080)*

9 1080p) when the caller/genre gives none.

* **Type:**
  Default canvas (16

### muvid.footage.assemble.DEFAULT_FPS *= 30*

Default output frame rate for the assembled video.

### muvid.footage.assemble.assemble_music_video(cuts, song_path, out_path, , canvas=(1920, 1080), fps=30, crf=20, preset='veryfast', on_note=None, fade_out_s=0.0, should_cancel=None)

Render `cuts` (a validated, contiguous, gap-filled EDL) into `out_path`.

Bounded stages: one single-input ffmpeg run per cut (gaps render black), a stream-copy
concat, and a final mux of the clean song for `[cuts[0].song_start,
cuts[-1].song_end]` — which, for EDLs produced by `fill_gaps`, is the whole song.
Returns `out_path`.

* **Parameters:**
  * **on_note** – optional `str -> None` sink for the render-plan findings
    `_part_plan()` raises (a transition that rounds to zero frames; a
    time-varying look on a blended boundary — muvid#73). They are ALWAYS
    raised as [`AssemblyWarning`](_autosummary/muvid.footage.assemble.html.md#muvid.footage.assemble.AssemblyWarning) as well; this is the additional
    path, and the only one a remote caller can see. `assemble_music_video`
    is a live per-caller MCP tool, so a finding that reaches only the
    server’s stderr is a hitch the caller is billed for and never told
    about. A callback rather than a changed return type, because the
    return type is a public contract and because `catch_warnings`
    mutates process-global state that concurrent renders would share.
  * **fade_out_s** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – fade the song out over this many seconds at the END of the
    render. For an edit that stops before the song does (a trimmed
    `span`), so the music does not stop dead mid-bar; `0` (the default)
    keeps the master untouched — and, for an aac/48k/2ch master, stream-copied
    bit for bit. A fade re-encodes the audio (a filter cannot run on a copy).
  * **should_cancel** – a zero-argument callable polled before every part and before the
    mux; when it returns True the render stops there (its parts are removed)
    and [`FootageCancelled`](_autosummary/muvid.footage.errors.html.md#muvid.footage.errors.FootageCancelled) is raised — so a
    cancelled render ends within one cut’s encode rather than minutes later.
* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)


# _autosummary/muvid.footage.beat_fit.html.md

# muvid.footage.beat_fit

Fitting a cut’s timing to the beat — the pure numerics behind `service.fit_to_beat`.

A cut shows its clip through an affine map of song time (`edl.clip_time_at`):
`clip_in + rate * (t - song_start)` with `clip_in = song_start - offset + slip`. So
a movement accent recorded at clip time `tau` lands at song time
`song_start + (tau - clip_in) / rate`. Fitting a cut means choosing `(slip, rate)`
so the clip’s accents (`beats.MOTION_STOPS`) land on the song’s beat grid.

**The score** of a candidate is the accents’ weighted mean alignment with the grid,
`sum(w * cos(2 pi (t - phase) / period)) / sum(w)`: +1 when every accent is on a beat,
-1 when every one is half a beat off, 0 when they ignore the beat. Weights are the
accent strengths above their median, so the steady floor of a moving crowd does not
vote.

**The confidence** of the best fit is a z-score against a null that keeps what the
signal is made of and destroys only its relation to the beat: the samples are cut into
blocks of about two beats and each block is rotated by its own random amount, and the
SAME search is run on every such null signal. The search’s own freedom (it will always
find some slip that looks good) is therefore in the null too. A whole-signal rotation
would not do: it only turns every phase by the same angle, which the slip search undoes.

**The window** is wider than a short cut: a 1.5 s cut holds three beats, too few to judge
by. The fit is judged over at least `window_s` of the clip around the cut, read
through the same map — the same dancer, a few seconds either side.

Pure numpy; nothing here reads a file.

### Module Attributes

| [`FIT_WINDOW_S`](_autosummary/muvid.footage.beat_fit.html.md#muvid.footage.beat_fit.FIT_WINDOW_S)   | Seconds of clip the fit is judged over, at least (centred on the cut).   |
|-----------------------------------------------------------------|--------------------------------------------------------------------------|
| [`FIT_MIN_BEATS`](_autosummary/muvid.footage.beat_fit.html.md#muvid.footage.beat_fit.FIT_MIN_BEATS)  | A cut shorter than this many beats is not fitted.                        |

### Functions

| [`fit_cut`](_autosummary/muvid.footage.beat_fit.html.md#muvid.footage.beat_fit.fit_cut)(tau, values, \*, song_start, ...[, ...])   | The best timing for a cut over song time `[song_start, song_end]` of a clip at `offset`, from the clip's accent signal `values` sampled at clip times `tau`.   |
|-----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|

### Classes

| [`BeatGrid`](_autosummary/muvid.footage.beat_fit.html.md#muvid.footage.beat_fit.BeatGrid)(period, phase)                         | A steady beat: beat `k` falls at `phase + k * period` seconds of the song.                                                 |
|--------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------|
| [`Fit`](_autosummary/muvid.footage.beat_fit.html.md#muvid.footage.beat_fit.Fit)(slip_s, rate, score, current_score, z, ...) | The best `(slip, rate)` for one cut, its score, the current setting's score, and the z of the best score against the null. |

### *class* muvid.footage.beat_fit.BeatGrid(period, phase)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A steady beat: beat `k` falls at `phase + k * period` seconds of the song.

#### *classmethod* fitted(beats, , min_beats=8)

The least-squares grid through tracked beat instants (each beat numbered by
`beats._beat_numbers`, so a skipped or spurious beat does not bend it), or
`None` when the beats are too few or not steady (`beats.fitted_tempo`).

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`BeatGrid`](_autosummary/muvid.footage.beat_fit.html.md#muvid.footage.beat_fit.BeatGrid)]

### muvid.footage.beat_fit.FIT_MIN_BEATS *= 3.0*

A cut shorter than this many beats is not fitted.

### muvid.footage.beat_fit.FIT_WINDOW_S *= 8.0*

Seconds of clip the fit is judged over, at least (centred on the cut).

### *class* muvid.footage.beat_fit.Fit(slip_s, rate, score, current_score, z, n_accents)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The best `(slip, rate)` for one cut, its score, the current setting’s score,
and the z of the best score against the null.

### muvid.footage.beat_fit.fit_cut(tau, values, , song_start, song_end, offset, current_slip=0.0, current_rate=1.0, grid, feasible=None, window_s=8.0, slips=None, rates=None, null_draws=200, seed=0)

The best timing for a cut over song time `[song_start, song_end]` of a clip at
`offset`, from the clip’s accent signal `values` sampled at clip times `tau`.

`feasible(slip, rate) -> bool` rules out settings the clip cannot hold. `None`
when no accent lands in the window under any setting.

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`Fit`](_autosummary/muvid.footage.beat_fit.html.md#muvid.footage.beat_fit.Fit)]


# _autosummary/muvid.footage.beats.html.md

# muvid.footage.beats

Beat signals — continuous envelopes of where the beat is, in the song and in each video.

`beat_grid` answers *when* the song’s beats fall (instants). An editor lining a dancer’s
hits up with the music wants more than instants: a **continuous** signal it can look at,
threshold anywhere between its minimum and maximum, and bend towards binary. That is what
this module measures, per piece of media and in that media’s OWN time (a clip reaches song
time through its offset, exactly as filmstrips do).

Sound, for the song and for every clip with a soundtrack:

- `audio_onset` — the onset-strength envelope `mixing.audio.beat_grid` estimates beats
  from (librosa’s spectral flux on a log-mel spectrogram — the standard envelope, adequate
  for percussive pop; SuperFlux’s vibrato suppression matters for voice and strings). The
  estimator’s beat instants come along, with a tempo FITTED to them (see `fitted_tempo`).
- `novelty` (the song only) — how much the music changes character around each moment:
  Foote’s checkerboard novelty over a self-similarity matrix of timbre and harmony. Its
  peaks are section boundaries (verse, chorus, drop) — the other place an editor cuts.

Picture, for every clip:

- `motion` — subject-motion energy: the mean camera-compensated optical-flow magnitude,
  in frame-heights per second.
- `motion_stops` — movement accents: how much motion, direction by direction, STOPS
  between samples — the half-wave-rectified decrease of a magnitude-weighted flow
  direction histogram (the histogram of oriented optical flow, HOOF, of Chaudhry et al.
  2009). Measuring motion that stops follows Davis & Agrawala (SIGGRAPH 2018, §4.2),
  using the decrease their prose and released code describe. It is a deceleration
  measure, which is where the conducting literature puts the beat: ensembles synchronise
  with the **maximal deceleration** of the conductor’s hand (Luck & Toiviainen 2006), and
  with absolute acceleration along the trajectory (Luck & Sloboda 2009) — the *ictus*,
  not the moment a movement starts (Takehana et al. 2019: movement initiation never
  coincided with beats).
- `motion_stops_local` — the same stopping without directions: the decrease of
  camera-compensated SPEED in each cell of an 8 x 6 grid, summed over cells. Per-place
  rather than per-direction, so several dancers braking in different places add up
  instead of cancelling.

**What the evidence on real footage says** (three phone videos of a crowd dancing to one
song, 2 minutes each; beat locking measured as the phase concentration of each signal on
the song’s beat, against a null that shifts each 4 s block independently — a whole-signal
circular shift cannot detect locking at all, since it only rotates the phase):

- each clip’s own soundtrack locks strongly (z = 7 to 15) — the positive control, and the
  confirmation that the clips’ offsets are right;
- `motion_stops` and `motion_stops_local` lock on one clip (z = 2.8 and 2.5, at the same
  -25 ms lag as that clip’s soundtrack) and on neither of the others; whole-frame speed,
  pose-based limb deceleration (a person found in only 38-75 % of frames of a crowd) and
  AIST++-style velocity minima (half a beat off) did no better;
- a 1.25 Hz high-pass (Davis & Agrawala’s post-filter) helped no clip consistently.

So these envelopes SHOW where movement lands; on a crowd they are weak evidence of the
beat. `service.fit_to_beat` uses them to propose per-cut timing, and applies a fit only
where it beats the same null — so on footage like this it will usually leave cuts alone.

Every signal is **unnormalised** and carries its own grid (`t0`, `hop_s`) and its
`min`, `max` and `p99` (a robust top a display can scale by), because thresholding is a
view the caller chooses, not something the data should have decided.

Cached per media content hash and parameters at `footage/beats/<hash>-<kind>-<key>.json`,
beside `peaks/` — derived from media the project holds, expensive to make, cheap to keep.

### Module Attributes

| [`SIGNAL_LABELS`](_autosummary/muvid.footage.beats.html.md#muvid.footage.beats.SIGNAL_LABELS)   | What each signal is, in the words a screen can use.   |
|------------------------------------------------------------------|-------------------------------------------------------|

### Functions

| [`activity_signal`](_autosummary/muvid.footage.beats.html.md#muvid.footage.beats.activity_signal)(path, \*[, sample_fps, ...])       | `{signals: {activity, activity_hits, sharpness}}` — how much the WHOLE picture changes, and how sharp it is while it does.                                                                                                                                                   |
|-----------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`audio_signals`](_autosummary/muvid.footage.beats.html.md#muvid.footage.beats.audio_signals)(path)                                | `{signals: {audio_onset}, beats, tempo_bpm}` for a media file's soundtrack, from `mixing.audio.beat_grid`.                                                                                                                                                                   |
| [`visual_signals`](_autosummary/muvid.footage.beats.html.md#muvid.footage.beats.visual_signals)(path, \*[, sample_fps, ...])        | `{signals: {motion, motion_stops, motion_stops_local}}` for a video, in one decode pass.                                                                                                                                                                                     |
| [`has_audio`](_autosummary/muvid.footage.beats.html.md#muvid.footage.beats.has_audio)(path)                                    | Whether a media file carries an audio stream (an unprobeable file: no).                                                                                                                                                                                                      |
| [`fitted_tempo`](_autosummary/muvid.footage.beats.html.md#muvid.footage.beats.fitted_tempo)(beats, \*[, min_beats])               | The tempo (BPM) of a steady beat train, fitted to ALL its beats — or `None` when the beats are too few or not steady enough to have one tempo.                                                                                                                               |
| [`novelty_signal`](_autosummary/muvid.footage.beats.html.md#muvid.footage.beats.novelty_signal)(path)                               | `{signals: {novelty}}` — Foote's checkerboard novelty of a song: a Gaussian- tapered checkerboard kernel slid along the diagonal of the cosine self-similarity of per-frame timbre (20 MFCCs) and harmony (12 chroma), each standardised.                                    |
| [`checkerboard_novelty`](_autosummary/muvid.footage.beats.html.md#muvid.footage.beats.checkerboard_novelty)(features, \*[, half])         | Novelty along a feature sequence `[dims, frames]`: the correlation of a Gaussian-tapered checkerboard kernel (`half` frames each side) with the cosine self-similarity matrix around each frame.                                                                             |
| [`direction_histogram`](_autosummary/muvid.footage.beats.html.md#muvid.footage.beats.direction_histogram)(fx, fy, \*[, bins])            | Flow magnitude summed per direction bin, divided by the pixel count: how much of the picture moves which way.                                                                                                                                                                |
| [`stop_strength`](_autosummary/muvid.footage.beats.html.md#muvid.footage.beats.stop_strength)(hists)                               | Per sample, the motion that stopped since the previous one, summed over the columns of `hists` (`sum(max(0, h[t-1] - h[t]))`) — directions of a flow direction histogram (`motion_stops`) or cells of a grid (`motion_stops_local`).                                         |
| [`region_speeds`](_autosummary/muvid.footage.beats.html.md#muvid.footage.beats.region_speeds)(fx, fy, \*[, grid])                  | Mean flow speed in each cell of a `cols x rows` grid, row-major.                                                                                                                                                                                                             |
| [`signal_record`](_autosummary/muvid.footage.beats.html.md#muvid.footage.beats.signal_record)(values, \*, t0, hop_s, name, domain) | One signal on a regular grid: sample `i` is at `t0 + i * hop_s` seconds of the media's own time.                                                                                                                                                                             |
| [`decimated`](_autosummary/muvid.footage.beats.html.md#muvid.footage.beats.decimated)(record, max_points)                      | A signal with at most `max_points` samples: each kept sample is the MAX of the `k` it stands for (a beat is a peak; averaging would erase it), on a grid whose hop grows by `k` and whose `t0` moves to the centre of the first block — so a pooled peak stays where it was. |
| [`cached_signals`](_autosummary/muvid.footage.beats.html.md#muvid.footage.beats.cached_signals)(root, media_hash, kind, compute)    | The record for `(media, kind)`: a file read when it was made before, else `compute()` written atomically under `<root>/beats/`.                                                                                                                                              |
| [`cache_key`](_autosummary/muvid.footage.beats.html.md#muvid.footage.beats.cache_key)(kind)                                    | Every parameter that changes the bytes, so a new setting is a new file: the record format, the audio estimator's versions (`mixing` and `librosa` — a record must never disagree with a fresh `beat_grid`), and every constant of the visual pass.                           |
| [`has_signal`](_autosummary/muvid.footage.beats.html.md#muvid.footage.beats.has_signal)(record)                                 | Whether a measured record carries at least one sample of anything.                                                                                                                                                                                                           |
| [`binned_visual_signals`](_autosummary/muvid.footage.beats.html.md#muvid.footage.beats.binned_visual_signals)(mids, motion, hists, ...)    | Per-pair rates (at pair midpoints `mids`) averaged into `hop`-second bins.                                                                                                                                                                                                   |

### muvid.footage.beats.SIGNAL_LABELS *= {'activity': 'Picture change', 'activity_hits': 'Picture change hits', 'audio_onset': 'Sound hits', 'motion': 'Movement', 'motion_stops': 'Moves that stop or turn', 'motion_stops_local': 'Moves that stop, place by place', 'novelty': 'Section changes', 'sharpness': 'Sharpness'}*

What each signal is, in the words a screen can use.

### muvid.footage.beats.activity_signal(path, , sample_fps=12.0, width=160, max_seconds=900.0)

`{signals: {activity, activity_hits, sharpness}}` — how much the WHOLE picture
changes, and how sharp it is while it does.

The mean absolute difference between consecutive small grey frames, camera move
INCLUDED. That is the deliberate difference from [`visual_signals()`](_autosummary/muvid.footage.beats.html.md#muvid.footage.beats.visual_signals), whose
`motion` takes the camera’s own move out so a pan does not read as a dancer
moving: in holiday footage and b-roll the camera move IS the event (a pan arriving
on the abbey, a whip to the dog), and a montage cut wants to land on it.

`activity_hits` is its positive rate of change, the onsets of visual change —
the visual counterpart of an audio onset envelope, what a montage aligns to the
song’s beats (Davis & Agrawala’s “visual beats”, in its cheapest form). Values are
per frame-sample, in [0, 1] units of grey level; one decode pass, no flow.
`sharpness` is the variance of the frame’s Laplacian (the standard blur measure),
so a montage can prefer the stretch that is lively AND in focus over a whip-pan
blur, which changes a lot and shows nothing.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.beats.audio_signals(path)

`{signals: {audio_onset}, beats, tempo_bpm}` for a media file’s soundtrack, from
`mixing.audio.beat_grid`. Raises `ImportError` without librosa (the caller names
the install).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.beats.binned_visual_signals(mids, motion, hists, hop, , cells=None)

Per-pair rates (at pair midpoints `mids`) averaged into `hop`-second bins.

`motion` is each bin’s mean, reported at the bin’s CENTRE (`t0 = hop / 2`).
`motion_stops` is the stop strength between consecutive bin-mean direction
histograms, so it belongs to the BOUNDARY between two bins and is reported there
(`t0 = 0`: sample `i` at `i * hop`, sample 0 unmeasured) — half a hop earlier
than a centre would put it, which matters once it drives a time-warp. Pairs before
the clip’s first frame (a negative container timestamp) are dropped. Pure numpy —
the part of the visual pass a test can reach.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.beats.cache_key(kind)

Every parameter that changes the bytes, so a new setting is a new file: the
record format, the audio estimator’s versions (`mixing` and `librosa` — a
record must never disagree with a fresh `beat_grid`), and every constant of the
visual pass.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.footage.beats.cached_signals(root, media_hash, kind, compute)

The record for `(media, kind)`: a file read when it was made before, else
`compute()` written atomically under `<root>/beats/`.

One computation per record at a time: a second request for the same record waits
on a lock and then reads what the first wrote, instead of starting a second
minutes-long pass (an editor opening every video’s channel at once, twice, would
otherwise run each pass twice). A record with nothing in it is NOT kept — a
truncated file or an unreadable stream must be measured again next time, not be
remembered as silence — and `compute` should refuse rather than return one.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.beats.checkerboard_novelty(features, , half=None)

Novelty along a feature sequence `[dims, frames]`: the correlation of a
Gaussian-tapered checkerboard kernel (`half` frames each side) with the cosine
self-similarity matrix around each frame. Frames without a full kernel are NaN.

* **Return type:**
  `ndarray`

### muvid.footage.beats.decimated(record, max_points)

A signal with at most `max_points` samples: each kept sample is the MAX of the
`k` it stands for (a beat is a peak; averaging would erase it), on a grid whose
hop grows by `k` and whose `t0` moves to the centre of the first block — so a
pooled peak stays where it was. `min` / `max` are the full-resolution ones;
`p99` is the POOLED one (max-pooling raises the typical value, and a display
scaled by the full-resolution p99 would saturate).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.beats.direction_histogram(fx, fy, , bins=8)

Flow magnitude summed per direction bin, divided by the pixel count: how much of
the picture moves which way. Flow under the noise floor votes for no direction.

* **Return type:**
  `ndarray`

### muvid.footage.beats.fitted_tempo(beats, , min_beats=8)

The tempo (BPM) of a steady beat train, fitted to ALL its beats — or `None`
when the beats are too few or not steady enough to have one tempo.

A beat tracker’s own tempo can be biased: librosa’s is the median inter-beat
interval, and on a song whose tracked beats run slightly fast with an occasional
skip it reported 129.2 BPM where the beats themselves fit 126.9 (a 1.8 % error
that puts a straight grid a full beat off within a minute).

So each beat is given its beat NUMBER by walking the intervals: an interval of
about one period is one step, a skipped beat two, and a spurious extra beat
(half a period) no step at all — it shares its neighbour’s number, so it neither
adds nor removes a beat. Time is fitted against those numbers by least squares,
twice, re-numbering with the refined period. A train whose residual exceeds
`_TEMPO_FIT_MAX_RMS` of a period (a tempo change, a rubato, heavy tracking
errors) has no single tempo: `None`, and the caller keeps the estimator’s.

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

### muvid.footage.beats.has_audio(path)

Whether a media file carries an audio stream (an unprobeable file: no).

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

### muvid.footage.beats.has_signal(record)

Whether a measured record carries at least one sample of anything.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

### muvid.footage.beats.novelty_signal(path)

`{signals: {novelty}}` — Foote’s checkerboard novelty of a song: a Gaussian-
tapered checkerboard kernel slid along the diagonal of the cosine self-similarity
of per-frame timbre (20 MFCCs) and harmony (12 chroma), each standardised. Peaks
are where the music changes character — section boundaries.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.beats.region_speeds(fx, fy, , grid=(8, 6))

Mean flow speed in each cell of a `cols x rows` grid, row-major. Edge pixels
that do not fill a whole cell are left out.

* **Return type:**
  `ndarray`

### muvid.footage.beats.signal_record(values, , t0, hop_s, name, domain)

One signal on a regular grid: sample `i` is at `t0 + i * hop_s` seconds of the
media’s own time. Non-finite samples become `None` (not measured, never zero).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.beats.stop_strength(hists)

Per sample, the motion that stopped since the previous one, summed over the
columns of `hists` (`sum(max(0, h[t-1] - h[t]))`) — directions of a flow direction
histogram (`motion_stops`) or cells of a grid (`motion_stops_local`). `hists` is `[k, n]`
with NaN rows where nothing was measured; the first sample, and any sample next to
a NaN row, is NaN.

* **Return type:**
  `ndarray`

### muvid.footage.beats.visual_signals(path, , sample_fps=15.0, max_seconds=900.0, downscale=4, bins=8, should_cancel=None)

`{signals: {motion, motion_stops, motion_stops_local}}` for a video, in one decode pass.

Flow is measured between CONSECUTIVE frames (at most `_MAX_PAIR_RATE` pairs a
second) and each pair’s rate is averaged into bins of `1 / sample_fps` s. That
average is the low-pass a decimation needs: measuring on every other frame instead
aliases whatever moves faster than half the sampling rate into a fake slow rhythm
(on 30 fps phone footage, a steady 3 Hz pulse that is not in the picture).

Pairs are timed by the frames’ TIMESTAMPS, not by index over the container’s frame
rate — a phone file can declare 120 fps and carry 24. Both signals are per SECOND
and per frame HEIGHT, so the frame rate does not change their scale and the
resolution changes it little. Bin `i` covers `[i, i + 1) / sample_fps` s; a bin
no pair fell in is `None`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)


# _autosummary/muvid.footage.edl.html.md

# muvid.footage.edl

EDL data types + the `validate_edl` single-source-of-truth gate.

An **EDL** (edit decision list) says which clip covers which span of the SONG timeline:
an ordered list of [`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry) `{song_start, song_end, clip_id}`, where an empty/
null `clip_id` is an explicit **gap** (no footage — rendered as fill). A strategy or a
caller produces one; [`fill_gaps()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.fill_gaps) pads it to the full song (head/tail/interior holes
become gap entries); [`validate_edl()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.validate_edl) is the ONE gate every path (explicit and
auto/strategy) passes before any cutting, and [`derive_cuts()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.derive_cuts) centralizes the sign
convention (`clip_in = song_start - offset_s`) so no third-party strategy can desync the
result. Times are seconds (float).

The gate is a TRUST gate as well as a structural one: an alignment the aligner could
not vouch for (`FootageAlignment.reliable` False) is refused here with
[`UnreliableAlignmentError`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.UnreliableAlignmentError) rather than quietly cut to, because a wrong offset
does not fail — it renders a video out of sync with the song (muvid#59).

That refusal is right when the CALLER named the clip, and used to be blunt on the auto
path, where one badly-aligned clip out of five failed an edit the other four could have
covered (muvid#88). The auto path now recovers in two steps that leave the single gate
alone: a strategy treats `reliable` as a **preference** (it ranks a vouched clip above
an unvouched one for the same span, and falls back to unvouched only where nothing else
covers it), and [`exclude_unvouched()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.exclude_unvouched) sets those forced spans aside as reported
[`ExcludedSpan`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.ExcludedSpan) records, gap-filled like any other hole. What reaches the gate is
therefore a smaller edit; the gate still refuses, with the same error, when the recovery
would leave no footage at all.

An entry may carry an optional [`Transition`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.Transition) — a blend IN from its predecessor
instead of a hard cut (muvid#34). It is an annotation on a boundary that already exists
implicitly, so spans stay one-per-song-span and nothing about reading an EDL changes.

### Module Attributes

| [`MAX_EDL_ENTRIES`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MAX_EDL_ENTRIES)     | Cap on EDL entries (env-tunable), on BOTH the auto and the explicit-`edl` paths.                                                                                                                                                                                |
|----------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`MAX_LOOK_SCALE`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MAX_LOOK_SCALE)      | How many times the delivery canvas a `look` may ask for, PER DIMENSION (env-tunable).                                                                                                                                                                           |
| [`MAX_LOOK_LINKS`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MAX_LOOK_LINKS)      | How many filter links one look may carry, and how many of those may set a size.                                                                                                                                                                                 |
| [`DFLT_LOOK_CANVAS`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.DFLT_LOOK_CANVAS)    | The canvas [`validate_edl()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.validate_edl) bounds a look against when its caller names none: the element-wise maximum of `workspace.CANVASES`, so the default is the LOOSEST bound that is still a bound — never an absent one. |
| [`TRANSITION_SPLIT`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.TRANSITION_SPLIT)    | the fraction taken from BEFORE the boundary.                                                                                                                                                                                                                    |
| [`MIN_TRANSITION_S`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MIN_TRANSITION_S)    | Shortest transition that is not a lie.                                                                                                                                                                                                                          |
| [`TRANSITION_CURVES`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.TRANSITION_CURVES)   | The transition curves muvid offers.                                                                                                                                                                                                                             |
| [`MIN_CONFIDENCE`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MIN_CONFIDENCE)      | Below this, a whole-clip correlation coefficient does not vouch for its offset (env `MUVID_FOOTAGE_MIN_CONFIDENCE`).                                                                                                                                            |
| [`MIN_SUPPORT`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MIN_SUPPORT)         | Support must EXCEED this for an offset to be vouched for (env `MUVID_FOOTAGE_MIN_SUPPORT`).                                                                                                                                                                     |
| [`MIN_MARGIN`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MIN_MARGIN)          | Margin must EXCEED this for an offset to be vouched for (env `MUVID_FOOTAGE_MIN_MARGIN`).                                                                                                                                                                       |
| [`MEASURED`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MEASURED)            | the aligner found the offset, or a person set it.                                                                                                                                                                                                               |
| [`UNPLACED`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.UNPLACED)            | Two more `source` values, for records that are NEVER persisted.                                                                                                                                                                                                 |
| [`STILL_DURATION_S`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.STILL_DURATION_S)    | it can be held for any span of song, so its duration is a bound rather than a measurement — longer than any song muvid takes.                                                                                                                                   |
| [`SLIP_MAX_S`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.SLIP_MAX_S)          | about one beat at 128 BPM.                                                                                                                                                                                                                                      |
| [`RATE_MAX_DEV`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.RATE_MAX_DEV)        | 8 %, a nudge that keeps a dancer looking like themselves.                                                                                                                                                                                                       |
| [`NO_VOUCHED_COVERAGE`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.NO_VOUCHED_COVERAGE) | nothing the aligner vouches for covers it at all.                                                                                                                                                                                                               |
| [`UNVOUCHED_SELECTION`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.UNVOUCHED_SELECTION) | A vouched clip DOES cover the span and the strategy cut to an unvouched one anyway — so the loss is the SELECTOR's, not the footage's.                                                                                                                          |
| [`EXCLUSION_REASONS`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EXCLUSION_REASONS)   | Every reason [`exclude_unvouched()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.exclude_unvouched) can give, for a caller matching on the value.                                                                                                                                 |
| [`LOOK_FILTERS`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.LOOK_FILTERS)        | The filters a `look` may name.                                                                                                                                                                                                                                  |

### Functions

| [`clip_in_of`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.clip_in_of)(e, a)                                 | Where cut `e` starts in its clip `a`'s own time — THE sign convention: `song_start - offset + slip` — or, for a free cut, `source_in + slip`, the alignment playing no part.   |
|---------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`clip_time_at`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.clip_time_at)(e, a, t)                            | The moment of clip `a` cut `e` shows at song time `t` — the cut's affine map, `clip_in_of(e) + rate * (t - song_start)`.                                                       |
| [`derive_cuts`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.derive_cuts)(edl, alignments, clip_paths)         | Turn a *validated* EDL into render-ready cuts — the ONE place `clip_in` is derived.                                                                                            |
| [`exclude_unvouched`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.exclude_unvouched)(edl, alignments)               | Set aside the spans of an AUTO edit whose only footage is unvouched (muvid#88).                                                                                                |
| [`fill_gaps`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.fill_gaps)(entries, song_duration, \*[, start])   | Make an edit span the WHOLE song by inserting explicit gap entries.                                                                                                            |
| [`placed_as`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.placed_as)(e, a)                                  | The placement cut `e` reads its clip `a` through — THE free-cut rule.                                                                                                          |
| [`unplaced`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.unplaced)(clip_id, duration_s)                    | The record of a clip with no place on the song: usable by free cuts only.                                                                                                      |
| [`validate_edl`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.validate_edl)(edl, alignments, song_duration, \*) | Validate an EDL (from a strategy OR a caller) — the ONE gate before any cutting.                                                                                               |
| [`vouches_for`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.vouches_for)(\*, confidence, support[, ...])      | Does the aligner vouch for this offset? The ONE place that verdict is reached.                                                                                                 |
| [`with_start`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.with_start)(e, start, \*[, bounded])              | `e` starting at song time `start` and showing, at every moment it still covers, exactly the footage it showed before — its map from song time to clip time kept.               |

### Classes

| [`AssemblyCut`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.AssemblyCut)(song_start, song_end, clip_id, ...)   | A validated cut ready to render: the EDL span + the derived in-point + clip path.       |
|----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------|
| [`CropWindow`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.CropWindow)(x, y, w, h)                            | A rectangle to take from the source frame, as fractions of its width/height.            |
| [`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry)(song_start, song_end, clip_id[, ...])    | One cut: show `clip_id` over the song span `[song_start, song_end]`.                    |
| [`ExcludedSpan`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.ExcludedSpan)(clip_id, song_start, song_end)       | One span an AUTO-selected edit gave up rather than cut to unvouched footage (muvid#88). |
| [`FootageAlignment`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.FootageAlignment)(clip_id, offset_s, ...[, ...])   | Where one uploaded clip sits on the song timeline (muvid's per-clip record).            |
| [`Transition`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.Transition)(duration_s[, curve])                   | How this entry blends IN from its predecessor.                                          |

### Exceptions

| [`UnreliableAlignmentError`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.UnreliableAlignmentError)(unvouched)   | An edit cuts to a clip whose OFFSET the aligner could not vouch for (muvid#59).   |
|----------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------|

### *class* muvid.footage.edl.AssemblyCut(song_start, song_end, clip_id, clip_in, clip_path, transition=None, crop=None, crop_end=None, look=None, look_time_varying=False, rate=1.0)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A validated cut ready to render: the EDL span + the derived in-point + clip path.

#### crop *: [CropWindow](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.CropWindow) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

Carried through from the EDL entry, unchanged — the assembler compiles these
to a `crop` filter, because normalised fractions only become pixels once
you know the source dimensions, which only ffmpeg knows.

#### look *: [str](https://docs.python.org/3/builtins/stdtypes.html#str) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

Carried through from the EDL entry, unchanged and already validated — the
`looks` seam. The assembler splices it into the ONE filter template both
of its render sites share, so a look lands identically on a solo cut and on
each side of a blended boundary. See [`EdlEntry.look`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry.look).

#### look_time_varying *: [bool](https://docs.python.org/3/builtins/functions.html#bool)* *= False*

only it
knows which boundaries become a separate two-input invocation, which is
where a moving look’s ramp restarts (muvid#73). See
[`EdlEntry.look_time_varying`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry.look_time_varying) for the measurement.

* **Type:**
  Carried through unchanged, and the assembler is its ONE consumer

#### rate *: [float](https://docs.python.org/3/builtins/functions.html#float)* *= 1.0*

the assembler reads `duration * rate`
seconds of source and retimes it (`setpts`, before `fps`) onto the span.

* **Type:**
  Carried through from the EDL entry

#### *property* source_duration *: [float](https://docs.python.org/3/builtins/functions.html#float)*

Seconds of the clip this cut consumes — its span at its rate.

#### transition *: [Transition](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.Transition) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

Carried through from the EDL entry, unchanged. `derive_cuts` gains no
transition arithmetic: the extra source material a blend needs is measured
in FRAMES at the render fps, which only the assembler knows.

### *class* muvid.footage.edl.CropWindow(x, y, w, h)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A rectangle to take from the source frame, as fractions of its width/height.

Normalised rather than pixels so one window is valid for every clip in a
multi-device edit regardless of its resolution, and so an EDL survives a source
being re-encoded at a different size. `(0, 0, 1, 1)` is the whole frame.

The convention is `burns.Rect`’s, deliberately — top-left origin, window
fraction — so a crop authored here and a Ken Burns path computed there
interoperate with no rename table.

This is the spatial half the EDL lacked: without it every source is letterboxed
onto the canvas, so a portrait clip in a landscape edit is ~68% black bars and a
caller has no way to say which two-thirds of the frame to keep. That choice is
editorial — on a real 478x850 clip of dancers a whole body does not fit in a
full-width 16:9 window at all (315-380px of subject into 269px), so “heads or
feet” is a decision per cut, not a default.

### muvid.footage.edl.DFLT_LOOK_CANVAS *= (1920, 1920)*

The canvas [`validate_edl()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.validate_edl) bounds a look against when its caller names
none: the element-wise maximum of `workspace.CANVASES`, so the default is
the LOOSEST bound that is still a bound — never an absent one.

A `None` default would have been the smaller diff and the wrong shape: it
makes “nobody threaded the canvas through” indistinguishable from “this look
is fine”, which is the silent no-op this module refuses everywhere else. The
default only covers a direct caller of [`validate_edl()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.validate_edl).

\*\*Every muvid path that can carry a caller’s look passes the real canvas, and
that is asserted by an AST scan of the call sites\*\* rather than by behaviour.
The distinction is load-bearing and an earlier version of this comment glossed
it: three of the five sites validate machine-generated entries from
`select_edl`, which has no `look`, so deleting `canvas=` from any of
them left the whole suite green — the claim was true of one site and prose
about the rest. The scan holds all five (it found the fifth, in
`select_score`), and the one site that deliberately passes no canvas is
recorded with its reason plus a test of the premise that reason rests on. See
`tests/test_edl_look_size_bound.py`. Pinned against
`workspace.CANVASES` by a test rather than imported from it, because
`muvid.footage.edl` is on the import-safe path and `workspace` is not on
it for free.

### muvid.footage.edl.EXCLUSION_REASONS *= ('no_vouched_coverage', 'unvouched_selection')*

Every reason [`exclude_unvouched()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.exclude_unvouched) can give, for a caller matching on the value.

### *class* muvid.footage.edl.EdlEntry(song_start, song_end, clip_id, transition=None, crop=None, crop_end=None, look=None, look_time_varying=False, look_spec=None, slip_s=0.0, rate=1.0, source_in=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One cut: show `clip_id` over the song span `[song_start, song_end]`.

`clip_id == ""` is a **gap entry** — no footage covers this span, and the renderer
fills it (black in v1). Gaps are explicit entries rather than absences so that an EDL
is always contiguous over its span, every span of the song is accounted for by
exactly one entry, and “no clip here” survives a JSON round trip (`clip_id: null`).

#### crop *: [CropWindow](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.CropWindow) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

Take only this rectangle of the source frame. `None` keeps the whole frame
letterboxed onto the canvas, which is what every EDL written before this
field existed means — additive in both directions, like `transition`.

#### crop_end *: [CropWindow](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.CropWindow) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

With `crop`, makes the window MOVE linearly from `crop` to `crop_end`
across the cut — a pan. Same size as `crop` (see [`validate_edl()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.validate_edl)): a
window that changes size mid-cut resizes the filter’s output every frame,
which is a different and much less robust thing than a pan. A push-in is
expressed as a *different* fixed window on the *next* cut, or — since the
`looks` seam below — as a `look` carrying a `zoompan` ramp, which is
the one filter that CAN resize its window mid-cut (muvid#66).

#### *property* is_free *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

A free cut ([`source_in`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry.source_in) set) — cut to the music, not synced to it.

#### look *: [str](https://docs.python.org/3/builtins/stdtypes.html#str) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

\*\*The `looks` seam.\*\* A compiled ffmpeg filter-chain fragment applied to
this cut’s picture once it has been normalised onto the canvas. `None`
(the default) emits nothing at all, so an EDL written before this field
existed renders byte-identically — additive in both directions, like
`transition` and `crop`.

muvid does not author this string: [`muvid.footage.look`](_autosummary/muvid.footage.look.html.md#module-muvid.footage.look) compiles it
from a `looks.Look` or from a punch-in request. That split is the
whole point of the seam — `looks` decides what a pixel becomes, muvid
keeps `-c:v` and the process shape.

**A caller may still hand one over, so this field is a trust boundary.**
`assemble_music_video` is a live per-caller MCP tool taking free-form
`edl` dicts, and this string becomes ffmpeg the renderer runs. So
`_validate_look()` gates it against the ALLOWLIST
[`LOOK_FILTERS`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.LOOK_FILTERS) — not against a list of refusals — and also refuses a
fragment that names a container input, that is more than ONE linear chain,
that is not lexically closed, that sets an option muvid has not classified
on one of the four filters that can change the output geometry, or that
asks for a frame more than [`MAX_LOOK_SCALE`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MAX_LOOK_SCALE) times the delivery
canvas. The first two of those would break the bounded-memory invariant
the assembler rests on; the allowlist is what keeps a look from writing
this machine’s disk; and the last two are what keep an allowlisted filter
from spending 900 MB of it (muvid#75).

#### look_spec *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

WHICH named look produced [`look`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry.look) — `{"name": "slow_push", "zoom": 1.08}`
([`muvid.footage.named_looks`](_autosummary/muvid.footage.named_looks.html.md#module-muvid.footage.named_looks)) — so a screen can show and re-edit the choice
rather than a filter string. Descriptive only: `look` is what renders, and the
two are set together by `service.set_cut`. `None` (the default, and always
for a hand-written filter) emits nothing — additive in both directions. Excluded
from the hash because a dict is not hashable; equality still compares it.

#### look_time_varying *: [bool](https://docs.python.org/3/builtins/functions.html#bool)* *= False*

Whether [`look`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry.look) READS THE FILTER CLOCK — a punch-in, a pan, anything
whose expressions mention `t` / `in_time` / `n`. `False` (the
default) means a grade, a LUT, a posterise: a look that draws every frame
the same way and is therefore unaffected by where the clock starts.

**It exists because the string throws that answer away** (muvid#73). A
transitioned boundary renders as a separate two-input invocation whose
inputs are input-side-seeked to the blend window, and input-side `-ss`
rebases the filter timeline to 0 — so a moving look **restarts its ramp**
for the length of the blend. Measured on a 3.0 s cut at 25 fps with a
0.4 s fade and `punch_in(zoom=1.12)`: the solo part’s last frame is
drawn at zoom 1.109 (mean 

```
|diff|
```

 28.1/255 against the same frame rendered
with no look), the blend part’s first frame at zoom 1.000 (0.7/255 —
indistinguishable from no punch at all). muvid cannot rebase the fragment
without rewriting an arbitrary ffmpeg expression, which is exactly what
`looks` refuses to do for itself (its rule 27), so the assembler WARNS
rather than silently rendering the hitch
(`muvid.footage.assemble._part_plan()`) — the same “never a silent
no-op” posture as the zero-frame-transition warning beside it.

\*\*A caller-supplied look defaults to `False`, so an UNDECLARED moving
look stays silent.\*\* That is the known limit of the chosen shape, not an
oversight: the alternative is muvid deciding by reading the fragment,
which is the same rewriting-an-arbitrary-expression problem one step
earlier. muvid’s own compilers declare it for you —
[`muvid.footage.look.punch_in()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.punch_in) and
[`motion()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.motion) return a fragment that says `True`,
[`stylize()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.stylize) one that answers from the compiled
plan, and [`punch_in_cuts()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.punch_in_cuts) sets this field FROM
the fragment rather than hardcoding it.

#### rate *: [float](https://docs.python.org/3/builtins/functions.html#float)* *= 1.0*

how fast this cut plays its video — `1.03` shows 3 % more footage
over the same span of song, so the dancers move 3 % faster; the song is never
touched. With [`slip_s`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry.slip_s) it makes the cut’s footage time an AFFINE map of
song time, `clip_in_of(e) + rate * (t - song_start)` ([`clip_time_at()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.clip_time_at));
short cuts, each with its own, make a piecewise-linear time-warp. Bounded to
`1 +- RATE_MAX_DEV`; `1.0` (the default) emits nothing.

* **Type:**
  **Speed**

#### slip_s *: [float](https://docs.python.org/3/builtins/functions.html#float)* *= 0.0*

shift WHICH moment of the video this cut shows, by `slip_s`
seconds, without moving the cut on the song. The clip’s alignment offset is
the coarse sync (measured from its own soundtrack); a slip is the local
correction on top of it, per cut — a dancer who is a little late on this
stretch, shown a little earlier (`slip_s > 0` reads LATER footage). A jump
in footage time at a cut boundary is invisible, which is why this is a cut
property and needs no speed change. Bounded by [`SLIP_MAX_S`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.SLIP_MAX_S); `0.0`
(the default) emits nothing — additive in both directions, like the rest.

* **Type:**
  **Slip**

#### source_in *: [float](https://docs.python.org/3/builtins/functions.html#float) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

**Free placement** — the cut shows its clip from `source_in` seconds of the
clip’s OWN time, whatever the clip’s alignment says. `None` (the default) is an
ANCHORED cut: footage time follows from where the clip sits on the song
(`song_start - offset + slip`), which is what a recording of the song needs.
A free cut is what footage that does NOT contain the song needs — holiday clips,
b-roll, stills — cut to the music rather than synced to it: its map is
`source_in + slip + rate * (t - song_start)` ([`clip_in_of()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.clip_in_of)), and since it
claims no sync there is nothing for the aligner to vouch for (see
[`placed_as()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.placed_as)). Additive in both directions, like the rest: an EDL without it
means what it always meant.

#### transition *: [Transition](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.Transition) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

Blend in from the predecessor rather than hard-cutting. `None` (the
default) is a hard cut, so an EDL written before this field existed is a
valid EDL now, and one written with it, read by older code, renders hard
cuts — degraded, never wrong, in both directions.

### *class* muvid.footage.edl.ExcludedSpan(clip_id, song_start, song_end, reason='no_vouched_coverage', confidence=0.0, support=None, margin=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One span an AUTO-selected edit gave up rather than cut to unvouched footage (muvid#88).

A typed record, not a log line: it is returned to the caller (and over the MCP wire),
because “your video is shorter than the song” is only actionable next to *which* clip
was set aside, *where*, and *on what numbers*. The numbers are the same three
[`vouches_for()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.vouches_for) reached its verdict on, so the report and the gate can never
disagree about why.

#### to_dict()

JSON-ready. `support`/`margin` stay `None` — “not measured” is not zero.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### *class* muvid.footage.edl.FootageAlignment(clip_id, offset_s, confidence, duration_s, coverage, overlaps=True, support=None, reliable=True, window_s=None, hop_s=None, margin=None, source='measured')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Where one uploaded clip sits on the song timeline (muvid’s per-clip record).

Mirrors `mixing.audio.ClipAlignment` but keyed by the caller-facing `clip_id` and
JSON-round-trippable (persisted in the project manifest).

#### margin *: [float](https://docs.python.org/3/builtins/functions.html#float) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

How far `support`’s tally for this offset sits ABOVE the tally for the best
offset outside the tolerance — `None` on the same quorum as `support`.

`support` says how much evidence reaches this offset; this says whether
anything disputes it, and they are different questions. A NEGATIVE value means
the clip’s own evidence prefers somewhere else, which is a refusal rather than a
weak endorsement. See [`MIN_MARGIN`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MIN_MARGIN) — this is the number muvid#59 was
actually missing, since its near-ties (0.993/0.989/0.987) are invisible to any
fraction that does not look at the runner-up.

#### overlaps *: [bool](https://docs.python.org/3/builtins/functions.html#bool)* *= True*

Whether the clip intersects the song timeline at all. A clip that does NOT is
still recorded — a source must never leave the addressable set as a side effect
of being measured. Selection filters on this; reporting shows it with a reason.

#### reliable *: [bool](https://docs.python.org/3/builtins/functions.html#bool)* *= True*

Whether the aligner VOUCHES for `offset_s`. False means “measured, recorded,
and not to be cut to without the caller saying so” — see
[`UnreliableAlignmentError`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.UnreliableAlignmentError), which [`validate_edl()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.validate_edl) raises.

Defaults True for a record built in code; a record read from disk that predates
the field gets its verdict DERIVED instead (see `from_dict()`), never
assumed.

#### source *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)* *= 'measured'*

[`MEASURED`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MEASURED) (the aligner found it by audio) or
`DECLARED` (a person set it — `muvid.footage.service.set_offset`). A
declared record carries `reliable=True` because a person vouched for it, and
`confidence=1.0` / `support=None` because no measurement was made; read
`source` before reading either as evidence. Records on disk that predate the
field were all written by the aligner, so it defaults to measured.

* **Type:**
  How the offset is KNOWN

#### support *: [float](https://docs.python.org/3/builtins/functions.html#float) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

Fraction of the clip’s independent analysis windows that agree on `offset_s`.
`None` means no vote was held — since `mixing>=0.0.48` fits the window to the
clip, that is now only a clip shorter than `window_floor + hop` = 4.5 s
(measured: 4.4 s unvoted, 4.5 s the first with a number). It is a different fact from “the windows disagreed”,
which is why it is not 0.0. `window_s` records the grid it was measured on and
is reported as a diagnostic; it does NOT qualify the number — see the note above
[`MIN_SUPPORT`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MIN_SUPPORT) for why a window-based qualifier was removed rather than
re-tuned.

\*\*This, not `confidence`, is the honest number\*\* (muvid#59). A correlation
coefficient says how well the winning lag scored; it cannot say whether the
runner-up scored the same, which is the entire failure mode on repetitive
music. A support fraction says how much of the clip actually agrees — on the
shoot that produced this issue the three correct offsets carried 10/24, 17/37
and 45/61, while the confidently-wrong ones the old estimator returned had no
agreement to speak of at all.

#### window_s *: [float](https://docs.python.org/3/builtins/functions.html#float) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

The analysis grid `support` was measured on — `None` in exactly the cases
`support` is. Recorded because since `mixing>=0.0.48` the estimator fits the
window to the clip, so the SCALE of `support` is a per-clip quantity: without
these, comparing two clips’ support means comparing numbers that answer different
questions. [`vouches_for()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.vouches_for) reads `window_s`; `hop_s` is carried because
the pair is what makes a support fraction reproducible (which windows were
eligible to agree depends on the hop), and a record that cannot reproduce its own
measurement is a record you have to take on faith.

### muvid.footage.edl.LOOK_FILTERS *= frozenset({'bilateral', 'boxblur', 'colorchannelmixer', 'colorlevels', 'crop', 'eq', 'gblur', 'hue', 'lut3d', 'lutrgb', 'lutyuv', 'null', 'pad', 'scale', 'setpts', 'unsharp', 'zoompan'})*

The filters a `look` may name. \*\*An allowlist, because a look is executable
ffmpeg arriving from a remote caller.\*\*

`assemble_music_video` is a live MCP tool on the per-caller reelee AV
connector and its `edl` argument is free-form dicts, so this string is
attacker-supplied input to a process that can write the host’s filesystem.
Measured, on this branch before the allowlist existed: a look of
`metadata=mode=print:file=<any path the renderer can write>` passed the gate,
rendered normally, returned a success payload, and truncated the named file to
zero bytes. `deshake=filename=` is a second, structurally different write
primitive; `movie=`/`amovie=` open an unaccounted container; `sendcmd`,
`signature`, `ssim` and `psnr` each name a file of their own.

A blocklist cannot close that — there are ~481 filters and the dangerous ones
have nothing lexical in common. So the rule is the one this module already uses
for [`TRANSITION_CURVES`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.TRANSITION_CURVES) and that `an`’s camera table uses for moves:
\*\*a curated vocabulary we own, refused at the gate rather than discovered as an
ffmpeg side effect three stages later.\*\*

Two groups, and the split is the maintenance rule:

- **Compiled** — every filter the two compilers on this seam can emit:
  [`muvid.footage.look`](_autosummary/muvid.footage.look.html.md#module-muvid.footage.look) (`zoompan`/`crop`/`scale`/`setpts`, via
  > `looks.compile_motion`) and `looks`’ registered ffmpeg implementations
  > (their declared `ImplRef.requires_filters`). This set is \*pinned against
  > `looks` by a test\*, deliberately rather than derived from it at import
  > time: deriving would let a new `looks` effect widen muvid’s remote-input
  > surface silently, where the test makes it a decision someone records here.
- **Hand-authored** — `hue`, the one filter this repo’s own docstrings reach
  for and nothing compiles. It is LGPL, takes no path, and is what a person
  writes when they want “desaturate that shot”.

**What earns a place here**, and it is checked by
`tests/test_edl_look.py::test_no_allowlisted_filter_can_name_a_file`: the
filter must declare no filesystem-path option at all. `lut3d`’s `file` is
the single recorded exception (`_LOOK_FILE_OPTIONS`) — it *loads* a
`.cube`, which is how `looks`’ flagship grade reaches its LUT, and it reads
rather than writes. Nothing else may name a path, so adding a filter here is a
two-place edit and the second place is a measurement of the real binary.

**The frame size an allowlisted filter may ask for is bounded separately**
(muvid#75) — see `_LOOK_GEOMETRY_FILTERS` and [`MAX_LOOK_SCALE`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MAX_LOOK_SCALE).
The allowlist is a vocabulary; it says nothing about the PARAMETERS a member
is given, and one of those parameters is memory. Nor is a size bound a bound
on the OPTIONS that set a size: `pad`’s `aspect` and `scale`’s
`force_original_aspect_ratio` both move the frame while declaring no
dimension a bound can read, so the four filters that can change the output
geometry are allowlisted per OPTION as well as by name.

**What this still does NOT bound**, stated because a partial claim is worse
than none: `lut3d=file=` will *attempt* to open any path the renderer can
read. It cannot write, and a non-`.cube` file fails to parse. That is a
failure inside the caller’s OWN render; writing to someone else’s disk is the
class this closes.

### muvid.footage.edl.MAX_EDL_ENTRIES *= 500*

Cap on EDL entries (env-tunable), on BOTH the auto and the explicit-`edl` paths. The
assembler runs one bounded ffmpeg per PART (memory O(1) in cut count), so this caps
total WORK — N encoder invocations on a shared box — not a single command’s inputs.
It counts footage CUTS, and parts are no longer one-per-cut: a transitioned boundary
adds a part, so a fully-transitioned edit approaches `2 * cuts` invocations. The cap
stays on cuts deliberately — it is the number the caller wrote and can act on — but
read it as bounding work only to within that factor.

### muvid.footage.edl.MAX_LOOK_LINKS *= 16*

How many filter links one look may carry, and how many of those may set a
size. **Bounding the frame does not bound the memory** — that is the half
[`MAX_LOOK_SCALE`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MAX_LOOK_SCALE) misses, and it misses it by a factor of ten.

Measured, ffmpeg 9.0.1, three frames, `/usr/bin/time -l` peak RSS, every
link sitting exactly AT the size cap (7680x4320 for a 1920x1080 canvas), so
every fragment below is one `_validate_look_size()` accepts:

| look                  | peak RSS   |
|-----------------------|------------|
| `scale=7680:4320` x1  | 171 MB     |
| `scale=7680:4320` x8  | 505 MB     |
| `scale=7680:4320` x32 | 1648 MB    |
| `scale=7680:4320` x64 | 3171 MB    |

~47 MB per additional link, unbounded, on a box that has been OOM-killed at
30 cuts (muvid#21/#24). The size cap refuses `scale=8000:8000` at 241 MB
while accepting thirty-two links of 7680x4320 at 1648 MB — which is the
shape of a bound that measures the wrong quantity.

TWO caps rather than one, because they bound different things and one number
cannot do both: the total bounds the graph, and the resize count bounds how
many LARGE frames can exist in it. Together the measured worst case is
**292 MB** (2 links at the size cap plus 14 cheap ones) against 3171 MB
today. A single total-link cap loose enough for a rich grade would leave the
multiplicative worst case an order of magnitude higher.

The values are generous by construction: muvid’s own compilers emit **1**
link for `punch_in` and **2** for a two-effect `stylize`, and at most one
of those ever sets a size.

### muvid.footage.edl.MAX_LOOK_SCALE *= 4*

How many times the delivery canvas a `look` may ask for, PER DIMENSION
(env-tunable). So the area bound is `MAX_LOOK_SCALE ** 2` — 16x the canvas.

**Four, chosen against measurements rather than taste.** Three of them:

- `2` is the standard supersample and would be the tempting answer, but it
  REFUSES a look muvid itself compiles: `stylize(fill, target="1080x1080")`
  on a 640x360 canvas emits `scale=1920:1080,crop=1080:1080:420:0`, which is
  exactly 3x linear. A bound that refuses the seam it protects is not a bound,
  it is an outage.
- `3` accepts that one *exactly on the boundary*, which is one rounding away
  from the same outage.
- `4` caps the worst case at 4x muvid’s largest canvas — 7680x4320 — which
  measures **184.8 MB** peak RSS, against **327.6 MB** for the
  `scale=8000:8000` this closes and the ~2 GB `scale=20000:20000`
  extrapolates to. (ffmpeg 9.0.1, three frames from a 64x48 source,
  `/usr/bin/time -l`; the same harness reads 18.8 MB for `scale=64:48` and
  32.8 MB for `scale=1920:1080`.)

Bigger than the canvas is never useful — the delivered frame IS the canvas, so
anything past it is resampled straight back down — which is why a *small*
multiple is the whole of the legitimate range.

### muvid.footage.edl.MEASURED *= 'measured'*

the aligner found the offset, or a person set it.

* **Type:**
  `FootageAlignment.source` values

### muvid.footage.edl.MIN_CONFIDENCE *= 0.1*

Below this, a whole-clip correlation coefficient does not vouch for its offset
(env `MUVID_FOOTAGE_MIN_CONFIDENCE`). Calibrated for the clean-master-vs-phone
regime the connector actually sees, on the onset-envelope feature (muvid#15):
measured against a studio master, four provably-correct clips scored 0.173-0.603
while the one genuinely unrelated clip scored 0.021 — so 0.1 separates them ~2x/5x,
where the old 0.3 (a defensible number for the EASIER clip-to-clip regime) flagged
five of six correct alignments as suspect.

**Read it as the weak instrument it is.** muvid#59 is the demonstration: on a
repetitive track the three wrong offsets scored 0.086, 0.121 and 0.086, so this
threshold catches two of the three and lets the third — 83 s wrong — through. A
coefficient cannot see the runner-up peak that makes it a coin flip; only
[`FootageAlignment.support`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.FootageAlignment.support) can. This is the fallback for an aligner that
reports no support, not the measure of record.

**0.1 is kept, not re-derived, and that is a deliberate refusal to invent a number.**
The consensus estimator changed what the coefficient IS — a median over the winning
window group rather than one whole-clip argmax’s score — so the muvid#15 calibration
no longer describes it, and 8 s of white noise that used to fall under 0.1 now scores
0.102-0.139 across ten seeds. The obvious response is to raise the threshold past
that floor. **It does not work**, measured on clips of the muvid#59 master under the
30 s vote boundary: worst CORRECT alignment 0.129, worst NOISE 0.139. The two
distributions overlap, so every threshold either admits noise or refuses correct
footage, and one picked to pass a noise-floor test would refuse real clips while
looking green. See [`vouches_for()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.vouches_for) for the rest of the evidence and muvid#91 for
the decision this leaves open.

### muvid.footage.edl.MIN_MARGIN *= 0.0*

Margin must EXCEED this for an offset to be vouched for (env
`MUVID_FOOTAGE_MIN_MARGIN`). This is the SEPARATOR, and zero is the meaningful
value rather than a tunable one: `mixing`’s margin is the graded tally at the
chosen offset minus the tally at the best offset outside the tolerance, so
`> 0` means “the clip’s own evidence prefers THIS offset over every other one it
considered”, `== 0` means it is indifferent, and `< 0` means \*\*the evidence
actually prefers somewhere else\*\*.

A negative margin is therefore a refusal in its own right, not merely a failure to
endorse — and it is the cleanest signal measured anywhere in muvid#59: five of six
pure-noise clips came back negative (-0.167, -0.172, -0.176, -0.218, -0.317) and not
one correct alignment did.

**Why this is the quantity that was missing.** muvid#59’s diagnosis was near-tied
peaks — the second-to-first ratios on that shoot measured 0.993, 0.989 and 0.987 —
and no support fraction, however graded, can see a runner-up at all. Three scalars
failed to separate this population before this one: the raw coefficient, the
envelope coefficient, and support.

One boundary worth knowing before tuning it: a rival closer than `mixing`’s
`offset_tolerance_s` is the same hypothesis by construction and never subtracts
(measured upstream: a rival 0.20 s away leaves the margin at 1.000, one 0.30 s away
takes it to 0.505). \*\*Margin separates different offsets; it is not a precision
claim about the one it chose.\*\*

### muvid.footage.edl.MIN_SUPPORT *= 0.5*

Support must EXCEED this for an offset to be vouched for (env
`MUVID_FOOTAGE_MIN_SUPPORT`). Strictly greater, and the value is the meaning:
`mixing` grades a window 1.0 when its own argmax reached the offset and at most
`BALLOT_VOTE_WEIGHT` (0.5) when the offset was merely on its ballot, so \*\*0.5 is
exactly the ceiling of ballot-only evidence\*\* — at 0.5 nothing found the offset
unaided; above it, something did.

\*\*This nearly went to 0.25, and the measurement that stopped it is the one worth
keeping.\*\* On the muvid#59 master — 24 correct alignments against six pure-noise
clips — `margin > 0` alone scored 24/24 and 0/6 where this threshold scores 18/24,
so dropping the floor and letting [`MIN_MARGIN`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MIN_MARGIN) separate looked strictly better.
It is not. **Every wrong case in that set was pure NOISE; not one was an ALIAS** — a
bar-multiple repeat of the true offset — which is the case muvid#59 is actually made
of. Measured on a tiled fixture, clips of the same source at a true offset of 28.0 s:

| clip     | offset     | correct?   | support   | margin     |
|----------|------------|------------|-----------|------------|
| 8 s      | 27.988     | yes        | 0.572     | +0.237     |
| **10 s** | **35.974** | **NO**     | **0.487** | **+0.115** |
| 12 s     | 27.990     | yes        | 0.659     | +0.217     |
| **14 s** | **11.956** | **NO**     | 0.451     | **-0.349** |
| 16 s     | 27.986     | yes        | 0.722     | +0.353     |
| 20 s     | 27.990     | yes        | 0.638     | +0.197     |

The 10 s clip lands on a repeat with BOTH numbers positive, so a 0.25 floor vouches
for an offset 7.97 s wrong that this one refuses. The 14 s repeat *is* caught by a
negative margin — margin catches some aliases and not others, which is worse than a
clean signal because it looks like one. Across both data sets:

So the floor is load-bearing on repeating references, which is the material this
whole issue is about, and it costs six correct short clips (support 0.30-0.38) to
keep. That is the safe direction: a refusal is visible and recoverable, a repeat
rendered as if true is the failure muvid#59 exists to prevent.

Thirty real cases and six synthetic ones is encouraging and not proof. Re-run BOTH
tables before moving this — and make sure the set you re-run contains aliases, not
only noise, which is the mistake that nearly lowered it.

### muvid.footage.edl.MIN_TRANSITION_S *= 0.04*

Shortest transition that is not a lie. Below roughly one frame the xfade emits
no blended frames at all and the “transition” is a hard cut wearing a label —
the same class of silent no-op as muvid#44’s `camera: {move: static}`, which
`an` refused precisely so it could not happen quietly. The renderer ALSO warns
if a transition rounds to zero frames at the actual render fps, which this
song-time floor cannot know.

### muvid.footage.edl.NO_VOUCHED_COVERAGE *= 'no_vouched_coverage'*

nothing the aligner vouches for covers it at all. The
honest answer is a gap, and the remedy is to re-align or re-shoot.

* **Type:**
  The span had no alternative

### muvid.footage.edl.RATE_MAX_DEV *= 0.08*

8 %, a nudge that keeps a
dancer looking like themselves. A larger change is a slow-motion or fast-forward
effect, a different tool.

* **Type:**
  How far a cut’s speed may move from 1, either way

### muvid.footage.edl.SLIP_MAX_S *= 0.5*

about one beat at 128 BPM. A
slip is a local correction on top of the clip’s measured alignment; a shift
larger than a beat is a different alignment, which is `set_offset`’s job.

* **Type:**
  The largest slip a cut may carry, either way (s)

### muvid.footage.edl.STILL_DURATION_S *= 3600.0*

it can be held for any span of song, so
its duration is a bound rather than a measurement — longer than any song muvid takes.

* **Type:**
  How long a still image “lasts” as a source

### muvid.footage.edl.TRANSITION_CURVES *= frozenset({'circleclose', 'circleopen', 'dissolve', 'fade', 'fadeblack', 'fadewhite', 'slidedown', 'slideleft', 'slideright', 'slideup', 'smoothleft', 'smoothright', 'wipedown', 'wipeleft', 'wiperight', 'wipeup'})*

The transition curves muvid offers. A curated subset of ffmpeg’s 58 `xfade`
transitions, not all of them: this is a vocabulary we own and must keep working
across ffmpeg builds, and an unrecognised name is refused at
[`validate_edl()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.validate_edl) rather than discovered as an ffmpeg error three stages
later. Same posture as the `an` camera-move table — translate at the boundary,
never pass a name through and hope.

### muvid.footage.edl.TRANSITION_SPLIT *= 0.5*

the fraction taken from
BEFORE the boundary. `0.5` is centred — each side supplies `duration/2` of
extra source, and the perceptual midpoint of the blend lands exactly on the
authored boundary.

Not a tuning knob. A trailing transition (`1.0`) would need spare coverage on
only one side and is therefore satisfiable at more boundaries — but it puts the
perceived cut `duration/2` LATE on every transition, which is precisely what
the beat-snapped Viterbi selector in [`muvid.footage.select_score`](_autosummary/muvid.footage.select_score.html.md#module-muvid.footage.select_score) exists to
prevent. Centring is also the NLE convention (“centered on cut”).

* **Type:**
  Where a transition sits relative to the cut it is on

### *class* muvid.footage.edl.Transition(duration_s, curve='fade')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

How this entry blends IN from its predecessor.

A small frozen record rather than a bare float, so `curve` (and anything
after it) is an additive field rather than another shape change on a wire
record.

The transition belongs to the entry it is *on* — an annotation of that entry’s
**entrance**. That is what keeps the EDL’s defining invariant intact: spans
stay one-per-song-span, so the thing you can read is still the thing that
renders. (The two rejected shapes both break it: silently widening a span
makes the EDL stop meaning what it says, and a synthetic third entry between
two real ones makes authoring one transition a three-entry edit — the class of
mistake muvid#35 was filed about — while doubling the wire record count of a
heavily-cut edit for a purely presentational reason.)

A transition on the FIRST entry is rejected, not ignored: there is no
predecessor to blend from, so it is a request that cannot be honoured, and
honouring nothing quietly is how a direction gets lost.

### muvid.footage.edl.UNPLACED *= 'unplaced'*

Two more `source` values, for records that are NEVER persisted. `UNPLACED` stands
for a clip (or a still) nobody has placed on the song — [`unplaced()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.unplaced) — so that a
free cut ([`EdlEntry.source_in`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry.source_in)) can still find its clip’s duration through the
same table every other cut uses. `FREE` is what [`placed_as()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.placed_as) derives for one
free cut: the placement the cut itself implies.

### muvid.footage.edl.UNVOUCHED_SELECTION *= 'unvouched_selection'*

A vouched clip DOES cover the span and the strategy cut to an unvouched one anyway —
so the loss is the SELECTOR’s, not the footage’s. The two are separate values because
they were measured to be separate events and they have different remedies: this one
says try another strategy or another selection config. `weighted` produces it
structurally — see `_absorb_neighbour()`, which repairs the repairable half.

### *exception* muvid.footage.edl.UnreliableAlignmentError(unvouched)

Bases: [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError)

An edit cuts to a clip whose OFFSET the aligner could not vouch for (muvid#59).

The defect this exists to make impossible: on a rigidly programmed, repetitive
track, a whole-clip cross-correlation surface has near-tied peaks spaced at
musical periods (measured on one real shoot: second-peak-to-first ratios of
0.993, 0.989, 0.987), so the estimator picks the argmax of a coin-flip and
reports the coefficient of whichever side it landed on. That number is *low*
but never looks like failure — and every stage after it (`derive_cuts`, the
score tracks, the assembled mp4) is built on the offset, so the failure
presented as a silently desynced video rather than as an error. Three clips
came back 83 s, 174 s and 83 s wrong, all with `overlaps=True`.

So an offset the aligner will not vouch for is a **refusal**, not a number: a
caller who renders anyway must say so with `allow_unreliable=True`, exactly
as an unpriceable shot must be passed with `--allow-unpriced` rather than
being read as free.

Note what this does NOT yet claim. The verdict is only as good as
[`vouches_for()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.vouches_for), which falls back to the confidence coefficient wherever
`support` was never measured — which is every alignment today’s `mixing`
produces. That fallback would have caught two of muvid#59’s three wrong
offsets and missed the third. The refusal is the half that makes a bad
measurement stop being silent; the half that makes the measurement good is
the windowed consensus in thorwhalen/mixing#30.

A refusal is NOT a removal: the clip keeps its record, its coverage and its
place in the project (see [`FootageAlignment`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.FootageAlignment)). What it loses is the
right to be cut to without the caller saying so.

#### alignments

The offending clips, in EDL order — so a caller can re-align exactly these.

### muvid.footage.edl.clip_in_of(e, a)

Where cut `e` starts in its clip `a`’s own time — THE sign convention:
`song_start - offset + slip` — or, for a free cut, `source_in + slip`, the
alignment playing no part. UNCLAMPED (a caller checking containment must see a
negative in-point); [`derive_cuts()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.derive_cuts) clamps for the renderer.

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

### muvid.footage.edl.clip_time_at(e, a, t)

The moment of clip `a` cut `e` shows at song time `t` — the cut’s affine
map, `clip_in_of(e) + rate * (t - song_start)`.

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

### muvid.footage.edl.derive_cuts(edl, alignments, clip_paths)

Turn a *validated* EDL into render-ready cuts — the ONE place `clip_in` is derived.

Strategies emit only `{song_start, song_end, clip_id}`; the sign convention
`clip_in = song_start - offset + slip` lives in [`clip_in_of()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.clip_in_of) (SSOT), so no
strategy can desync the cut.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`AssemblyCut`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.AssemblyCut)]

### muvid.footage.edl.exclude_unvouched(edl, alignments)

Set aside the spans of an AUTO edit whose only footage is unvouched (muvid#88).

The auto path’s recovery, and deliberately NOT a second gate. A strategy already
prefers a vouched clip wherever one covers the span (see
[`muvid.footage.strategy`](_autosummary/muvid.footage.strategy.html.md#module-muvid.footage.strategy)), so an unvouched entry surviving into its output means
exactly one thing: **nothing else covered that span**. This drops those entries, which
[`fill_gaps()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.fill_gaps) then turns into explicit gap entries — the encoding muvid already
uses for “no footage here” — and returns them as [`ExcludedSpan`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.ExcludedSpan) records so the
loss is reported rather than silent.

Run it BEFORE [`fill_gaps()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.fill_gaps) and pass the result through [`validate_edl()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.validate_edl) as
usual. It is a transform, not a verdict: it re-decides nothing about trust (it reads
`FootageAlignment.reliable`, which [`vouches_for()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.vouches_for) set) and it refuses nothing —
[`validate_edl()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.validate_edl) remains the ONE gate.

**It declines to empty the edit, and that is what keeps the refusal alive.** If every
footage entry would be excluded, the original list is returned unchanged with an empty
excluded list, so [`validate_edl()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.validate_edl) sees an edit that still cuts to unvouched clips
and raises [`UnreliableAlignmentError`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.UnreliableAlignmentError) exactly as before. A shoot where nothing
is trustworthy must not come back as a black video reported as success — that is the
plausible-artifact failure muvid#59 is about, one level up. A *smaller* edit is a
recovery; an *empty* one is the same wrong answer wearing a coverage report.

**A gap is the last resort, not the first.** Before dropping an entry it is offered
to its neighbours: if the cut on either side is a vouched clip that ALREADY covers the
span, the span is absorbed into that cut instead. That case is not hypothetical and it
is not the aligner’s fault — `weighted`’s DP produces it structurally. Its
transition window is capped at `max_seg_s` (4x `l_max`, a performance bound) and
consecutive segments must be different clips, so a single vouched take longer than the
cap CANNOT be one segment and the optimizer is forced to cut away and back. Measured
on a 60 s song under the DEFAULT config, a 2 s opener plus one 58 s vouched clip
yields `[(0,2,O), (2,34,V), (34,36,BAD), (36,60,V)]` — and gapping that 2 s would
punch an avoidable black hole through the middle of a continuous take, blamed on an
alignment that had nothing to do with it. A caller-raised `l_max_overrun_penalty`
does the same thing at every `l_max` boundary. Absorbing loses nothing: the frames
come from a clip the aligner vouches for, which the caller’s own edit already used on
both sides, and `validate_edl` re-checks containment afterwards either way.

Absorption is refused into a cut whose meaning depends on its length or on the
boundary it starts at — one carrying a `transition`, a `crop_end` pan or a
`look` — and across a following `transition`, since the blend’s other side would
change clip underneath it. Strategies emit none of those, so the auto path always
absorbs; a hand-written EDL degrades to the gap rather than to a silently restretched
pan.

Returns `(entries, excluded)`.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry)], [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`ExcludedSpan`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.ExcludedSpan)]]

### muvid.footage.edl.fill_gaps(entries, song_duration, , start=0.0)

Make an edit span the WHOLE song by inserting explicit gap entries.

`start` (default 0) and `song_duration` bound the span being filled: an edit
that covers only PART of the song (a named edit’s `span`) fills `[start,
song_duration]` with `song_duration` passed as the span’s END, and an entry
outside that span is refused like one outside the song.

Three holes become gap entries (muvid#21 items 1+2, one mechanism): the head
(`[0, first.song_start]` — without this, footage starting at t=5 s silently loses
the intro), interior holes between consecutive entries, and the tail
(`[last.song_end, song_duration]`). Entries are sorted by start; overlap and
containment stay [`validate_edl()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.validate_edl)’s business — call this BEFORE it, never after.

An empty selection stays empty (an all-black video is not a first cut worth
rendering silently — the caller decides what “no usable footage” means).

\*\*A transition blends in from whatever precedes it on the timeline, which after
this function may be an inserted gap\*\* — i.e. a fade from black. That is a
consequence worth stating rather than a bug to guard: a transition annotates its
entry’s ENTRANCE, and the entrance is wherever the entry actually starts once the
edit spans the whole song. So a transition on the caller’s first entry is
REJECTED when footage starts at t=0 (nothing precedes it) and becomes a fade-in
from black when it does not (the head gap precedes it). Both follow from the same
rule; the tests pin both so the coherence stays deliberate.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry)]

### muvid.footage.edl.placed_as(e, a)

The placement cut `e` reads its clip `a` through — THE free-cut rule.

An anchored cut reads the clip’s own alignment, unchanged. A free cut reads the
placement it implies itself — offset `song_start - source_in` — which it vouches
for by construction (it claims no sync with the song), so every containment and
blend check below applies to it with no second code path, and the trust gate has
nothing to refuse.

* **Return type:**
  [`FootageAlignment`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.FootageAlignment)

```pycon
>>> a = unplaced('c1', 10.0)
>>> e = EdlEntry(song_start=30.0, song_end=34.0, clip_id='c1', source_in=2.0)
>>> p = placed_as(e, a)
>>> p.offset_s, p.reliable, p.source, p.duration_s
(28.0, True, 'free', 10.0)
>>> clip_in_of(e, a)
2.0
```

### muvid.footage.edl.unplaced(clip_id, duration_s)

The record of a clip with no place on the song: usable by free cuts only.

Not vouched (an anchored cut to it is refused exactly like a cut to a clip the
aligner could not place), never overlapping, never persisted.

* **Return type:**
  [`FootageAlignment`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.FootageAlignment)

```pycon
>>> a = unplaced('c1', 12.0)
>>> a.reliable, a.overlaps, a.source
(False, False, 'unplaced')
```

### muvid.footage.edl.validate_edl(edl, alignments, song_duration, , canvas=(1920, 1920), allow_unreliable=False)

Validate an EDL (from a strategy OR a caller) — the ONE gate before any cutting.

Enforces, raising `ValueError` with a specific message otherwise:

- non-empty; every non-gap `clip_id` is a known alignment;
- each span is positive and lies within `[0, song_duration]`;
- spans are in ascending order and **non-overlapping**;
- spans are **contiguous** (gapless) — a hole must be an explicit gap entry
  (`clip_id` empty/null, rendered as fill), which [`fill_gaps()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.fill_gaps) inserts; an
  *implicit* hole is still an error, so nothing goes missing silently;
- each non-gap span lies within its clip’s aligned coverage, AND the derived
  `clip_in = song_start - offset` satisfies `0 <= clip_in` and
  `clip_in + span_duration <= clip_duration` (the clip actually contains that span);
- a `look` (the `looks` seam) names only filters in [`LOOK_FILTERS`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.LOOK_FILTERS),
  is ONE lexically-closed linear filter chain, names no container input, is
  not on a gap, sets only options `_LOOK_GEOMETRY_FILTERS` classifies on
  the four filters that can change the output frame, and asks for a frame no
  more than [`MAX_LOOK_SCALE`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MAX_LOOK_SCALE) times `canvas` — see
  `_validate_look()`, which is the trust boundary for a caller-supplied
  > filter string;
- `look_time_varying` is a boolean, and is not set on an entry with no
  `look` (a declaration about a look that is not there is a request that
  cannot be honoured);
- every non-gap entry cuts to a clip whose alignment the aligner VOUCHES for
  (`FootageAlignment.reliable`), unless `allow_unreliable=True` — see
  [`UnreliableAlignmentError`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.UnreliableAlignmentError). This is a trust check rather than a
  > structural one, so it runs LAST: an EDL that does not even parse should be
  > reported as the malformed EDL it is, not blamed on the footage;
- a [`Transition`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.Transition) is on an entry that HAS a predecessor, names a known curve,
  is at least [`MIN_TRANSITION_S`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MIN_TRANSITION_S) long, fits in song time counting BOTH
  transitions an entry can carry, and fits in each side’s aligned coverage — see
  `_validate_transition()`.

`canvas` is the DELIVERY canvas the assembler will render onto — the only
thing a look’s output frame can honestly be bounded against, since the look is
spliced after the assembler’s own `scale`/`pad` onto it. Every muvid path
passes the real one; [`DFLT_LOOK_CANVAS`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.DFLT_LOOK_CANVAS) covers a direct caller and is
the loosest bound rather than an absent one.

Returns the normalized list of [`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry)]

### muvid.footage.edl.vouches_for(, confidence, support, margin=None, window_s=None)

Does the aligner vouch for this offset? The ONE place that verdict is reached.

Lives here, beside the record it judges and the gate that enforces it, rather than
with the aligner: `FootageAlignment.from_dict()` has to reach it to derive a
verdict for a record written before the field existed, and a module that owns a
dataclass should not need its own consumer to interpret one.

`support` (how much of the clip agrees) decides when the aligner measured it,
because it is the only one of the two numbers that can tell a clear winner from a
coin flip — see [`FootageAlignment.support`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.FootageAlignment.support).

\*\*When support is absent, this falls back to `confidence`, and that fallback is
known to be inadequate.\*\* It is the same instrument muvid#59 was filed about: on
that shoot it would have refused two of the three wrong offsets and passed the
third, which was 83 s out. So this is not “unknown forces approval” — an unmeasured
support is judged by the weaker test rather than refused outright.

The estimator fits its window to the clip and grades each window’s evidence, so a
vote is held and is READABLE for almost everything — `support is None` now only
for a clip too short to hold two windows at the 3 s floor (measured: 4 s yes, 6 s
no). The support is used **at whatever window it was measured at**, which is a
deliberate reversal: an earlier revision of this function refused to read a support
from a fitted window, on the reasoning that an argmax headcount over few short
windows is a weaker statistic than the same fraction at 20 s. That was true of the
ungraded tally and is now actively wrong — see the note above
[`MIN_SUPPORT`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MIN_SUPPORT). Measured on six pure-noise clips at a fitted 6.67 s window,
the window guard vouches for FOUR of them by routing to the coefficient, while
reading the graded support refuses all six.

\*\*Where it still falls back to the coefficient, it is guessing, and the
measurements say so plainly.\*\* On heavily degraded cross-device clips of the
muvid#59 master, the worst CORRECT alignment scored 0.129 while the worst pure-noise
clip scored 0.139 — the distributions overlap, so no threshold admits the correct
ones and refuses the noise. Worse, the wrong offsets scored 0.183-0.252, *higher*
than the correct ones. The real material agrees: of the three 10 s excerpts, the
WRONG one carried the highest coefficient of the three (0.834 against 0.566 and
0.621).

So [`MIN_CONFIDENCE`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MIN_CONFIDENCE) was deliberately NOT re-tuned. There is no value to tune it
to: raising it past the noise floor refuses correct footage, and a number chosen to
make a noise-floor test pass would hide that behind a green run. That fallback is now
reached only by a clip too short to vote at all, which is the narrowest it has been.

**Two numbers, asking different questions, and BOTH are required.**
[`MIN_SUPPORT`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MIN_SUPPORT) asks whether enough of the clip’s evidence reached this offset;
[`MIN_MARGIN`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MIN_MARGIN) asks whether that evidence prefers this offset over every other
one it considered. Neither subsumes the other, and the measurement that proves it is
in [`MIN_SUPPORT`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.MIN_SUPPORT)’s note: margin alone scores 24/24 on real material where the
support threshold scores 18/24, and then vouches for a bar-multiple REPEAT that the
support threshold refuses. Aliases and noise fail differently; margin catches noise
and only some aliases.

So this is a strict tightening of what muvid 0.0.53 shipped — it can only refuse
more, never less.

**What muvid#91 becomes.** Since the estimator fits its window down to a 3 s floor,
the regime with no vote at all is now exactly clips shorter than
`window_floor + hop` — measured, **4.5 s**: 4.4 s gives `support is None` and
4.5 s is the first with a number. Those still fall back to the coefficient, which
produces an inversion worth naming: a 4.4 s clip is VOUCHED while a 4.5 s one is
refused, because falling off the bottom of the vote hands the decision to the weaker
instrument. That is not defensible on its own terms, and it is left alone HERE only
because changing it means separating a fresh verdict from a DERIVED one — a record
predating these fields also has `support is None`, and refusing those would
re-break what muvid#87 fixed. muvid#91 is now that specific decision, over a band too
short to be usable music-video footage, rather than the open question about a third
of a shoot that it started as.

* **Parameters:**
  * **confidence** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – The estimator’s correlation coefficient at the chosen lag.
  * **support** ([`float`](https://docs.python.org/3/builtins/functions.html#float) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Graded fraction of window evidence reaching the offset, or `None`
    when no vote could be held.
  * **margin** ([`float`](https://docs.python.org/3/builtins/functions.html#float) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – That tally minus the tally at the best offset outside the tolerance.
    `None` on exactly the same quorum as `support`. Required to vouch when a
    vote WAS held: an aligner reporting support without it has not answered the
    separating question, and unknown does not vouch. The `mixing>=0.0.51` floor
    guarantees both, and `tests/test_ci_extras_canary.py` asserts the
    capability so a mis-resolved floor fails loudly rather than quietly refusing
    every clip.
  * **window_s** ([`float`](https://docs.python.org/3/builtins/functions.html#float) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – The window support was measured at. Recorded and reported as a
    diagnostic; it does **not** enter the verdict, for the reason above.
* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)
* **Returns:**
  True when the offset may be cut to without the caller opting in.

### muvid.footage.edl.with_start(e, start, , bounded=True)

`e` starting at song time `start` and showing, at every moment it still
covers, exactly the footage it showed before — its map from song time to clip
time kept. At speed 1 that is only the new start (the slip carries over); at any
other speed the slip absorbs `(rate - 1) * (start - song_start)`. Raises when
that would take the slip past [`SLIP_MAX_S`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.SLIP_MAX_S) — unless `bounded=False`, for a
render-time window that is never stored.

* **Return type:**
  [`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry)


# _autosummary/muvid.footage.errors.html.md

# muvid.footage.errors

The refusal and cancellation types of the footage operations ([`muvid.footage.service`](_autosummary/muvid.footage.service.html.md#module-muvid.footage.service)).

An operation that will not do what it was asked — no song yet, an unknown clip, an
edit that does not validate, an alignment nobody vouches for — raises
[`FootageError`](_autosummary/muvid.footage.errors.html.md#muvid.footage.errors.FootageError) with a message that names the next action. Every surface turns it
into its own refusal: the MCP tools into a fastmcp `ToolError`, a host’s HTTP route
into a `422`. The operations never import a transport’s error type, which is what
lets one function serve every surface.

An operation a host asked to stop (its `should_cancel` said so) raises
[`FootageCancelled`](_autosummary/muvid.footage.errors.html.md#muvid.footage.errors.FootageCancelled) — not a refusal and not a failure.

**How a host tells them apart.** nw defines the contract a host reads:
`nw.GenreOpRefused` (a deliberate refusal; everything else out of an op is a bug) and
`nw.GenreOpCancelled`. These two derive from them *when nw is importable* — the only
case in which anything serves the ops generically — and otherwise from `ValueError`
and `Exception`, because muvid’s core does not depend on nw (it is the `mcp`
extra’s). Either way `FootageError` is a `ValueError`, so code already catching that
keeps working.

### Exceptions

| [`FootageError`](_autosummary/muvid.footage.errors.html.md#muvid.footage.errors.FootageError)     | An operation refused — the message says why and what to do next.   |
|-------------------------------------------------------------------|--------------------------------------------------------------------|
| [`FootageCancelled`](_autosummary/muvid.footage.errors.html.md#muvid.footage.errors.FootageCancelled) | An operation stopped between steps because its host asked it to.   |

### *exception* muvid.footage.errors.FootageCancelled

Bases: [`Exception`](https://docs.python.org/3/builtins/exceptions.html#Exception)

An operation stopped between steps because its host asked it to.

### *exception* muvid.footage.errors.FootageError

Bases: [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError)

An operation refused — the message says why and what to do next.


# _autosummary/muvid.footage.explain.html.md

# muvid.footage.explain

Why each cut is where it is — the post-mortem of an edit, in plain words.

A person watching an edit asks “why did it cut THERE?”, “why this clip?”, “why is this
one slightly off the beat?”. This module answers from the edit itself and the same
measurements the cutting used — the song’s beat grid and sections, each video’s picture
signal — so the answer is about the edit as it stands now (a cut a person changed is
explained as changed), and the screen, the assistant and a reader all get one record.

Each cut gets a few sentences in a fixed order — *where and on what* → \*how the picture
changes\* → *what picture and why that stretch* → *what is worth a look* — plus the
facts behind them (song times, the beat it lands on, the fade’s real start) and
`flags` for the ones worth a look: `straddles_the_beat`, `off_the_beat`,
`long_hold`, `repeated_stretch`. Every clock time in a sentence is also listed in
`times` with its second, so a screen can turn it into a link that seeks the player.

Plain nouns only (beat, bar, fade, picture, photo, clip); clock times, never floats.
The explanation says what the system did by default when that is the reason — the
post-mortem is only useful if it admits its defaults.

Pure: the measurements come in as arguments ([`explain()`](_autosummary/muvid.footage.explain.html.md#muvid.footage.explain.explain)); the service builds
them (`service.explain_edit`).

### Module Attributes

| [`FLAG_WORDS`](_autosummary/muvid.footage.explain.html.md#muvid.footage.explain.FLAG_WORDS)   | What each flag says, for a chip.   |
|---------------------------------------------------------------|------------------------------------|

### Functions

| [`explain`](_autosummary/muvid.footage.explain.html.md#muvid.footage.explain.explain)(entries, \*, analysis, song_duration, ...)   | The post-mortem of an edit: `{summary, times, cuts: [...]}`.   |
|-------------------------------------------------------------------------------------------------------|----------------------------------------------------------------|
| [`clock`](_autosummary/muvid.footage.explain.html.md#muvid.footage.explain.clock)(t)                                             | `72.41` -> `"1:12.4"` — how a time is said.                    |

### muvid.footage.explain.FLAG_WORDS *= {'long_hold': 'long hold', 'off_the_beat': 'not on a beat', 'repeated_stretch': 'shown before', 'straddles_the_beat': 'starts before the beat'}*

What each flag says, for a chip.

### muvid.footage.explain.clock(t)

`72.41` -> `"1:12.4"` — how a time is said.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> clock(72.41), clock(5.0)
('1:12.4', '0:05.0')
```

### muvid.footage.explain.explain(entries, \*, analysis, song_duration, clips, alignments={}, envelope_of=<function <lambda>>, selection={}, grid_note=None, window=None, scope=None)

The post-mortem of an edit: `{summary, times, cuts: [...]}`.

`entries` are the edit’s validated [`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry) cuts;
`analysis` the song’s (extended) beat grid and sections; `clips` maps a clip
id to `{name, kind ('video'|'still'), role}`; `alignments` the clip
placements (for synced cuts); `envelope_of(clip_id)` a video’s picture signal
([`muvid.footage.music_cut.Envelope`](_autosummary/muvid.footage.music_cut.html.md#muvid.footage.music_cut.Envelope)) or `None`; `selection` how the
edit was made (`pace`, `style`, `strategy`). `window` (song seconds)
keeps only the cuts that overlap it; the summary is of the whole edit — or of
`scope`, the part of the song a trimmed edit covers (its `span`), so the
black outside it is not counted as shots.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)


# _autosummary/muvid.footage.html.md

# muvid.footage

Footage-aligned music video — align several device recordings of one song, assemble.

The engine behind muvid’s `music_video` genre (thorwhalen/reelee#229): a user uploads
several video clips, each a *different-device* recording of the SAME fixed song (so the
captured audio is time-shifted, noisy, drifting — never a perfect match). Each clip is
aligned to the clean song’s timeline by audio cross-correlation (`mixing.audio`), and a
chosen or auto-selected edit is assembled into a music video over the clean song audio.

Modules:

- [`muvid.footage.align`](_autosummary/muvid.footage.align.html.md#module-muvid.footage.align) — align a clip set to the song (thin over `mixing.audio`).
- [`muvid.footage.strategy`](_autosummary/muvid.footage.strategy.html.md#module-muvid.footage.strategy) — the pluggable `SelectionStrategy` registry that turns
  alignments into an EDL (which clip covers which span of the song), preferring a clip
  the aligner vouches for wherever one covers the span (muvid#88).
- [`muvid.footage.edl`](_autosummary/muvid.footage.edl.html.md#module-muvid.footage.edl) — the `validate_edl` SSOT + EDL/alignment data types,
  including the per-cut `CropWindow` (the EDL’s spatial half, muvid#60).
- [`muvid.footage.look`](_autosummary/muvid.footage.look.html.md#module-muvid.footage.look) — the `looks` seam: compile a grade, a LUT or an
  in-shot punch-in (muvid#66) into the per-cut `-vf` fragment the assembler splices.
- [`muvid.footage.assemble`](_autosummary/muvid.footage.assemble.html.md#module-muvid.footage.assemble) — the bounded single-ffmpeg-pass assembler.
- [`muvid.footage.workspace`](_autosummary/muvid.footage.workspace.html.md#module-muvid.footage.workspace) — the per-user stateful project (song + clips + manifest).

### Functions

| [`exclude_unvouched`](_autosummary/muvid.footage.html.md#muvid.footage.exclude_unvouched)(edl, alignments)                 | Set aside the spans of an AUTO edit whose only footage is unvouched (muvid#88).     |
|-----------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------|
| [`validate_edl`](_autosummary/muvid.footage.html.md#muvid.footage.validate_edl)(edl, alignments, song_duration, \*)   | Validate an EDL (from a strategy OR a caller) — the ONE gate before any cutting.    |
| [`derive_cuts`](_autosummary/muvid.footage.html.md#muvid.footage.derive_cuts)(edl, alignments, clip_paths)           | Turn a *validated* EDL into render-ready cuts — the ONE place `clip_in` is derived. |
| [`chain`](_autosummary/muvid.footage.html.md#muvid.footage.chain)(\*fragments)                                 | Join look fragments into one, dropping the empty ones.                              |
| [`is_time_varying`](_autosummary/muvid.footage.html.md#muvid.footage.is_time_varying)(fragment)                          | Whether `fragment` declares that it reads the clock.                                |
| [`motion`](_autosummary/muvid.footage.html.md#muvid.footage.motion)(keyframes, \*, canvas, fps)                 | A camera path over the cut, as a filter fragment.                                   |
| [`punch_in`](_autosummary/muvid.footage.html.md#muvid.footage.punch_in)(\*, canvas, fps, duration_s[, zoom, ...]) | An in-shot punch-in: hold, then push in, WITHOUT leaving the shot (muvid#66).       |
| [`punch_in_cuts`](_autosummary/muvid.footage.html.md#muvid.footage.punch_in_cuts)(entries, \*, canvas, fps[, ...])     | Put a punch-in on every `every`-th footage entry — muvid#66's other half.           |
| [`stylize`](_autosummary/muvid.footage.html.md#muvid.footage.stylize)(look, \*, canvas, fps[, duration_s, ...])  | A `looks.Look` compiled against the binary muvid will run.                          |
| [`register_selection_strategy`](_autosummary/muvid.footage.html.md#muvid.footage.register_selection_strategy)(slug, fn)              | Register a selection strategy under `slug` (returns it, for inline use).            |
| [`list_strategies`](_autosummary/muvid.footage.html.md#muvid.footage.list_strategies)()                                  | All strategy slugs (eager + lazy), sorted.                                          |
| [`resolve_strategy`](_autosummary/muvid.footage.html.md#muvid.footage.resolve_strategy)(strategy)                         | Resolve a strategy name OR a bare callable to a `SelectionStrategy`.                |
| [`select_edl`](_autosummary/muvid.footage.html.md#muvid.footage.select_edl)(strategy, alignments, ...[, context])   | Run `strategy` (name or callable) to produce an EDL from `alignments`.              |

### Classes

| [`FootageAlignment`](_autosummary/muvid.footage.html.md#muvid.footage.FootageAlignment)(clip_id, offset_s, ...[, ...])   | Where one uploaded clip sits on the song timeline (muvid's per-clip record).            |
|----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------|
| [`EdlEntry`](_autosummary/muvid.footage.html.md#muvid.footage.EdlEntry)(song_start, song_end, clip_id[, ...])    | One cut: show `clip_id` over the song span `[song_start, song_end]`.                    |
| [`CropWindow`](_autosummary/muvid.footage.html.md#muvid.footage.CropWindow)(x, y, w, h)                            | A rectangle to take from the source frame, as fractions of its width/height.            |
| [`ExcludedSpan`](_autosummary/muvid.footage.html.md#muvid.footage.ExcludedSpan)(clip_id, song_start, song_end)       | One span an AUTO-selected edit gave up rather than cut to unvouched footage (muvid#88). |
| [`LookFragment`](_autosummary/muvid.footage.html.md#muvid.footage.LookFragment)(fragment, \*, time_varying)          | A compiled filter chain that remembers whether it READS THE CLOCK.                      |

### Exceptions

| [`UnreliableAlignmentError`](_autosummary/muvid.footage.html.md#muvid.footage.UnreliableAlignmentError)(unvouched)   | An edit cuts to a clip whose OFFSET the aligner could not vouch for (muvid#59).   |
|----------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------|
| [`LookError`](_autosummary/muvid.footage.html.md#muvid.footage.LookError)                             | A look could not be compiled.                                                     |

### *class* muvid.footage.CropWindow(x, y, w, h)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A rectangle to take from the source frame, as fractions of its width/height.

Normalised rather than pixels so one window is valid for every clip in a
multi-device edit regardless of its resolution, and so an EDL survives a source
being re-encoded at a different size. `(0, 0, 1, 1)` is the whole frame.

The convention is `burns.Rect`’s, deliberately — top-left origin, window
fraction — so a crop authored here and a Ken Burns path computed there
interoperate with no rename table.

This is the spatial half the EDL lacked: without it every source is letterboxed
onto the canvas, so a portrait clip in a landscape edit is ~68% black bars and a
caller has no way to say which two-thirds of the frame to keep. That choice is
editorial — on a real 478x850 clip of dancers a whole body does not fit in a
full-width 16:9 window at all (315-380px of subject into 269px), so “heads or
feet” is a decision per cut, not a default.

### *class* muvid.footage.EdlEntry(song_start, song_end, clip_id, transition=None, crop=None, crop_end=None, look=None, look_time_varying=False, look_spec=None, slip_s=0.0, rate=1.0, source_in=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One cut: show `clip_id` over the song span `[song_start, song_end]`.

`clip_id == ""` is a **gap entry** — no footage covers this span, and the renderer
fills it (black in v1). Gaps are explicit entries rather than absences so that an EDL
is always contiguous over its span, every span of the song is accounted for by
exactly one entry, and “no clip here” survives a JSON round trip (`clip_id: null`).

#### crop *: [CropWindow](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.CropWindow) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

Take only this rectangle of the source frame. `None` keeps the whole frame
letterboxed onto the canvas, which is what every EDL written before this
field existed means — additive in both directions, like `transition`.

#### crop_end *: [CropWindow](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.CropWindow) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

With `crop`, makes the window MOVE linearly from `crop` to `crop_end`
across the cut — a pan. Same size as `crop` (see [`validate_edl()`](_autosummary/muvid.footage.html.md#muvid.footage.validate_edl)): a
window that changes size mid-cut resizes the filter’s output every frame,
which is a different and much less robust thing than a pan. A push-in is
expressed as a *different* fixed window on the *next* cut, or — since the
`looks` seam below — as a `look` carrying a `zoompan` ramp, which is
the one filter that CAN resize its window mid-cut (muvid#66).

#### *property* is_free *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

A free cut ([`source_in`](_autosummary/muvid.footage.html.md#muvid.footage.EdlEntry.source_in) set) — cut to the music, not synced to it.

#### look *: [str](https://docs.python.org/3/builtins/stdtypes.html#str) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

\*\*The `looks` seam.\*\* A compiled ffmpeg filter-chain fragment applied to
this cut’s picture once it has been normalised onto the canvas. `None`
(the default) emits nothing at all, so an EDL written before this field
existed renders byte-identically — additive in both directions, like
`transition` and `crop`.

muvid does not author this string: [`muvid.footage.look`](_autosummary/muvid.footage.look.html.md#module-muvid.footage.look) compiles it
from a `looks.Look` or from a punch-in request. That split is the
whole point of the seam — `looks` decides what a pixel becomes, muvid
keeps `-c:v` and the process shape.

**A caller may still hand one over, so this field is a trust boundary.**
`assemble_music_video` is a live per-caller MCP tool taking free-form
`edl` dicts, and this string becomes ffmpeg the renderer runs. So
`_validate_look()` gates it against the ALLOWLIST
`LOOK_FILTERS` — not against a list of refusals — and also refuses a
fragment that names a container input, that is more than ONE linear chain,
that is not lexically closed, that sets an option muvid has not classified
on one of the four filters that can change the output geometry, or that
asks for a frame more than `MAX_LOOK_SCALE` times the delivery
canvas. The first two of those would break the bounded-memory invariant
the assembler rests on; the allowlist is what keeps a look from writing
this machine’s disk; and the last two are what keep an allowlisted filter
from spending 900 MB of it (muvid#75).

#### look_spec *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

WHICH named look produced [`look`](_autosummary/muvid.footage.look.html.md#module-muvid.footage.look) — `{"name": "slow_push", "zoom": 1.08}`
([`muvid.footage.named_looks`](_autosummary/muvid.footage.named_looks.html.md#module-muvid.footage.named_looks)) — so a screen can show and re-edit the choice
rather than a filter string. Descriptive only: `look` is what renders, and the
two are set together by `service.set_cut`. `None` (the default, and always
for a hand-written filter) emits nothing — additive in both directions. Excluded
from the hash because a dict is not hashable; equality still compares it.

#### look_time_varying *: [bool](https://docs.python.org/3/builtins/functions.html#bool)* *= False*

Whether [`look`](_autosummary/muvid.footage.look.html.md#module-muvid.footage.look) READS THE FILTER CLOCK — a punch-in, a pan, anything
whose expressions mention `t` / `in_time` / `n`. `False` (the
default) means a grade, a LUT, a posterise: a look that draws every frame
the same way and is therefore unaffected by where the clock starts.

**It exists because the string throws that answer away** (muvid#73). A
transitioned boundary renders as a separate two-input invocation whose
inputs are input-side-seeked to the blend window, and input-side `-ss`
rebases the filter timeline to 0 — so a moving look **restarts its ramp**
for the length of the blend. Measured on a 3.0 s cut at 25 fps with a
0.4 s fade and `punch_in(zoom=1.12)`: the solo part’s last frame is
drawn at zoom 1.109 (mean 

```
|diff|
```

 28.1/255 against the same frame rendered
with no look), the blend part’s first frame at zoom 1.000 (0.7/255 —
indistinguishable from no punch at all). muvid cannot rebase the fragment
without rewriting an arbitrary ffmpeg expression, which is exactly what
`looks` refuses to do for itself (its rule 27), so the assembler WARNS
rather than silently rendering the hitch
(`muvid.footage.assemble._part_plan()`) — the same “never a silent
no-op” posture as the zero-frame-transition warning beside it.

\*\*A caller-supplied look defaults to `False`, so an UNDECLARED moving
look stays silent.\*\* That is the known limit of the chosen shape, not an
oversight: the alternative is muvid deciding by reading the fragment,
which is the same rewriting-an-arbitrary-expression problem one step
earlier. muvid’s own compilers declare it for you —
[`muvid.footage.look.punch_in()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.punch_in) and
[`motion()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.motion) return a fragment that says `True`,
[`stylize()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.stylize) one that answers from the compiled
plan, and [`punch_in_cuts()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.punch_in_cuts) sets this field FROM
the fragment rather than hardcoding it.

#### rate *: [float](https://docs.python.org/3/builtins/functions.html#float)* *= 1.0*

how fast this cut plays its video — `1.03` shows 3 % more footage
over the same span of song, so the dancers move 3 % faster; the song is never
touched. With [`slip_s`](_autosummary/muvid.footage.html.md#muvid.footage.EdlEntry.slip_s) it makes the cut’s footage time an AFFINE map of
song time, `clip_in_of(e) + rate * (t - song_start)` (`clip_time_at()`);
short cuts, each with its own, make a piecewise-linear time-warp. Bounded to
`1 +- RATE_MAX_DEV`; `1.0` (the default) emits nothing.

* **Type:**
  **Speed**

#### slip_s *: [float](https://docs.python.org/3/builtins/functions.html#float)* *= 0.0*

shift WHICH moment of the video this cut shows, by `slip_s`
seconds, without moving the cut on the song. The clip’s alignment offset is
the coarse sync (measured from its own soundtrack); a slip is the local
correction on top of it, per cut — a dancer who is a little late on this
stretch, shown a little earlier (`slip_s > 0` reads LATER footage). A jump
in footage time at a cut boundary is invisible, which is why this is a cut
property and needs no speed change. Bounded by `SLIP_MAX_S`; `0.0`
(the default) emits nothing — additive in both directions, like the rest.

* **Type:**
  **Slip**

#### source_in *: [float](https://docs.python.org/3/builtins/functions.html#float) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

**Free placement** — the cut shows its clip from `source_in` seconds of the
clip’s OWN time, whatever the clip’s alignment says. `None` (the default) is an
ANCHORED cut: footage time follows from where the clip sits on the song
(`song_start - offset + slip`), which is what a recording of the song needs.
A free cut is what footage that does NOT contain the song needs — holiday clips,
b-roll, stills — cut to the music rather than synced to it: its map is
`source_in + slip + rate * (t - song_start)` (`clip_in_of()`), and since it
claims no sync there is nothing for the aligner to vouch for (see
`placed_as()`). Additive in both directions, like the rest: an EDL without it
means what it always meant.

#### transition *: [Transition](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.Transition) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

Blend in from the predecessor rather than hard-cutting. `None` (the
default) is a hard cut, so an EDL written before this field existed is a
valid EDL now, and one written with it, read by older code, renders hard
cuts — degraded, never wrong, in both directions.

### *class* muvid.footage.ExcludedSpan(clip_id, song_start, song_end, reason='no_vouched_coverage', confidence=0.0, support=None, margin=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One span an AUTO-selected edit gave up rather than cut to unvouched footage (muvid#88).

A typed record, not a log line: it is returned to the caller (and over the MCP wire),
because “your video is shorter than the song” is only actionable next to *which* clip
was set aside, *where*, and *on what numbers*. The numbers are the same three
`vouches_for()` reached its verdict on, so the report and the gate can never
disagree about why.

#### to_dict()

JSON-ready. `support`/`margin` stay `None` — “not measured” is not zero.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### *class* muvid.footage.FootageAlignment(clip_id, offset_s, confidence, duration_s, coverage, overlaps=True, support=None, reliable=True, window_s=None, hop_s=None, margin=None, source='measured')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Where one uploaded clip sits on the song timeline (muvid’s per-clip record).

Mirrors `mixing.audio.ClipAlignment` but keyed by the caller-facing `clip_id` and
JSON-round-trippable (persisted in the project manifest).

#### margin *: [float](https://docs.python.org/3/builtins/functions.html#float) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

How far `support`’s tally for this offset sits ABOVE the tally for the best
offset outside the tolerance — `None` on the same quorum as `support`.

`support` says how much evidence reaches this offset; this says whether
anything disputes it, and they are different questions. A NEGATIVE value means
the clip’s own evidence prefers somewhere else, which is a refusal rather than a
weak endorsement. See `MIN_MARGIN` — this is the number muvid#59 was
actually missing, since its near-ties (0.993/0.989/0.987) are invisible to any
fraction that does not look at the runner-up.

#### overlaps *: [bool](https://docs.python.org/3/builtins/functions.html#bool)* *= True*

Whether the clip intersects the song timeline at all. A clip that does NOT is
still recorded — a source must never leave the addressable set as a side effect
of being measured. Selection filters on this; reporting shows it with a reason.

#### reliable *: [bool](https://docs.python.org/3/builtins/functions.html#bool)* *= True*

Whether the aligner VOUCHES for `offset_s`. False means “measured, recorded,
and not to be cut to without the caller saying so” — see
[`UnreliableAlignmentError`](_autosummary/muvid.footage.html.md#muvid.footage.UnreliableAlignmentError), which [`validate_edl()`](_autosummary/muvid.footage.html.md#muvid.footage.validate_edl) raises.

Defaults True for a record built in code; a record read from disk that predates
the field gets its verdict DERIVED instead (see `from_dict()`), never
assumed.

#### source *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)* *= 'measured'*

`MEASURED` (the aligner found it by audio) or
`DECLARED` (a person set it — `muvid.footage.service.set_offset`). A
declared record carries `reliable=True` because a person vouched for it, and
`confidence=1.0` / `support=None` because no measurement was made; read
`source` before reading either as evidence. Records on disk that predate the
field were all written by the aligner, so it defaults to measured.

* **Type:**
  How the offset is KNOWN

#### support *: [float](https://docs.python.org/3/builtins/functions.html#float) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

Fraction of the clip’s independent analysis windows that agree on `offset_s`.
`None` means no vote was held — since `mixing>=0.0.48` fits the window to the
clip, that is now only a clip shorter than `window_floor + hop` = 4.5 s
(measured: 4.4 s unvoted, 4.5 s the first with a number). It is a different fact from “the windows disagreed”,
which is why it is not 0.0. `window_s` records the grid it was measured on and
is reported as a diagnostic; it does NOT qualify the number — see the note above
`MIN_SUPPORT` for why a window-based qualifier was removed rather than
re-tuned.

\*\*This, not `confidence`, is the honest number\*\* (muvid#59). A correlation
coefficient says how well the winning lag scored; it cannot say whether the
runner-up scored the same, which is the entire failure mode on repetitive
music. A support fraction says how much of the clip actually agrees — on the
shoot that produced this issue the three correct offsets carried 10/24, 17/37
and 45/61, while the confidently-wrong ones the old estimator returned had no
agreement to speak of at all.

#### window_s *: [float](https://docs.python.org/3/builtins/functions.html#float) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

The analysis grid `support` was measured on — `None` in exactly the cases
`support` is. Recorded because since `mixing>=0.0.48` the estimator fits the
window to the clip, so the SCALE of `support` is a per-clip quantity: without
these, comparing two clips’ support means comparing numbers that answer different
questions. `vouches_for()` reads `window_s`; `hop_s` is carried because
the pair is what makes a support fraction reproducible (which windows were
eligible to agree depends on the hop), and a record that cannot reproduce its own
measurement is a record you have to take on faith.

### *exception* muvid.footage.LookError

Bases: [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError)

A look could not be compiled. Carries what to do about it.

### *class* muvid.footage.LookFragment(fragment, , time_varying)

Bases: [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

A compiled filter chain that remembers whether it READS THE CLOCK.

A plain `str` in every way that matters — it splices, compares, serialises
and JSON-encodes identically, so nothing downstream needs to know it exists —
that additionally answers `time_varying`. That is what lets
[`punch_in_cuts()`](_autosummary/muvid.footage.html.md#muvid.footage.punch_in_cuts) set
[`muvid.footage.edl.EdlEntry.look_time_varying`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry.look_time_varying) *from the fragment*
instead of hardcoding a value beside the call that produced it, and what lets
[`chain()`](_autosummary/muvid.footage.html.md#muvid.footage.chain) combine two fragments without either caller re-deriving the
answer.

A subclass rather than a `(str, bool)` pair because the fragment is already
a wire value: the EDL field is a string, the MCP reply is a string, and the
`looks` API returns a string. Changing that shape would push a rename table
into every consumer to carry one bit that only muvid’s own compilers can
know.

**The bit does not survive a round trip, and is not meant to.** `str`
operations return plain `str`, and JSON has no place to put it — the
durable home is the EDL entry’s own field, which is exactly why muvid#73 put
it there. [`is_time_varying()`](_autosummary/muvid.footage.html.md#muvid.footage.is_time_varying) reads a fragment of either kind, treating a
plain string as static, which is the same default the field has.

```pycon
>>> frag = LookFragment("zoompan=d=1:s=64x48:fps=25", time_varying=True)
>>> frag.time_varying, frag == "zoompan=d=1:s=64x48:fps=25"
(True, True)
>>> is_time_varying("hue=s=0")
False
```

Copyable and picklable, which needs saying because it is not free: `str`
subclasses are reconstructed through `__new__`, and without
`__getnewargs_ex__` both `copy.deepcopy` and `pickle` raise
`TypeError: __new__() missing 1 required keyword-only argument` — measured.
An `EdlEntry` carrying one is an ordinary dataclass a caller may well copy.

```pycon
>>> import copy, pickle
>>> copy.deepcopy(frag).time_varying, pickle.loads(pickle.dumps(frag)) == frag
(True, True)
```

### *exception* muvid.footage.UnreliableAlignmentError(unvouched)

Bases: [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError)

An edit cuts to a clip whose OFFSET the aligner could not vouch for (muvid#59).

The defect this exists to make impossible: on a rigidly programmed, repetitive
track, a whole-clip cross-correlation surface has near-tied peaks spaced at
musical periods (measured on one real shoot: second-peak-to-first ratios of
0.993, 0.989, 0.987), so the estimator picks the argmax of a coin-flip and
reports the coefficient of whichever side it landed on. That number is *low*
but never looks like failure — and every stage after it (`derive_cuts`, the
score tracks, the assembled mp4) is built on the offset, so the failure
presented as a silently desynced video rather than as an error. Three clips
came back 83 s, 174 s and 83 s wrong, all with `overlaps=True`.

So an offset the aligner will not vouch for is a **refusal**, not a number: a
caller who renders anyway must say so with `allow_unreliable=True`, exactly
as an unpriceable shot must be passed with `--allow-unpriced` rather than
being read as free.

Note what this does NOT yet claim. The verdict is only as good as
`vouches_for()`, which falls back to the confidence coefficient wherever
`support` was never measured — which is every alignment today’s `mixing`
produces. That fallback would have caught two of muvid#59’s three wrong
offsets and missed the third. The refusal is the half that makes a bad
measurement stop being silent; the half that makes the measurement good is
the windowed consensus in thorwhalen/mixing#30.

A refusal is NOT a removal: the clip keeps its record, its coverage and its
place in the project (see [`FootageAlignment`](_autosummary/muvid.footage.html.md#muvid.footage.FootageAlignment)). What it loses is the
right to be cut to without the caller saying so.

#### alignments

The offending clips, in EDL order — so a caller can re-align exactly these.

### muvid.footage.chain(\*fragments)

Join look fragments into one, dropping the empty ones.

A cut carries at most one look, so two effects on one cut are one chain.
Returns `None` when nothing survives, which is the value the EDL field
wants for “no look” — an empty string is refused by `validate_edl`
deliberately, so this does not produce one.

**The result is time-varying if ANY component is** — the OR, not the AND and
not the last one’s answer. A chain runs every link on every frame, so one
moving link makes the whole fragment move, and muvid#73’s restart hits it.
Getting this wrong in the safe-looking direction (AND) would silence the
warning on exactly the chains most likely to have one: a punch composed with
a grade.

A plain `str` component contributes `False`, matching
[`is_time_varying()`](_autosummary/muvid.footage.html.md#muvid.footage.is_time_varying) and the EDL field’s own default — so hand-writing one
half of a chain quietly downgrades only that half’s claim, never the other’s.

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`LookFragment`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.LookFragment)]

```pycon
>>> chain("hue=s=0", None, "", "unsharp=5:5:1")
'hue=s=0,unsharp=5:5:1'
>>> chain(None, "") is None
True
>>> chain("hue=s=0", LookFragment("zoompan=d=1", time_varying=True)).time_varying
True
>>> chain("hue=s=0", "unsharp=5:5:1").time_varying
False
```

### muvid.footage.derive_cuts(edl, alignments, clip_paths)

Turn a *validated* EDL into render-ready cuts — the ONE place `clip_in` is derived.

Strategies emit only `{song_start, song_end, clip_id}`; the sign convention
`clip_in = song_start - offset + slip` lives in `clip_in_of()` (SSOT), so no
strategy can desync the cut.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`AssemblyCut`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.AssemblyCut)]

### muvid.footage.exclude_unvouched(edl, alignments)

Set aside the spans of an AUTO edit whose only footage is unvouched (muvid#88).

The auto path’s recovery, and deliberately NOT a second gate. A strategy already
prefers a vouched clip wherever one covers the span (see
[`muvid.footage.strategy`](_autosummary/muvid.footage.strategy.html.md#module-muvid.footage.strategy)), so an unvouched entry surviving into its output means
exactly one thing: **nothing else covered that span**. This drops those entries, which
`fill_gaps()` then turns into explicit gap entries — the encoding muvid already
uses for “no footage here” — and returns them as [`ExcludedSpan`](_autosummary/muvid.footage.html.md#muvid.footage.ExcludedSpan) records so the
loss is reported rather than silent.

Run it BEFORE `fill_gaps()` and pass the result through [`validate_edl()`](_autosummary/muvid.footage.html.md#muvid.footage.validate_edl) as
usual. It is a transform, not a verdict: it re-decides nothing about trust (it reads
`FootageAlignment.reliable`, which `vouches_for()` set) and it refuses nothing —
[`validate_edl()`](_autosummary/muvid.footage.html.md#muvid.footage.validate_edl) remains the ONE gate.

**It declines to empty the edit, and that is what keeps the refusal alive.** If every
footage entry would be excluded, the original list is returned unchanged with an empty
excluded list, so [`validate_edl()`](_autosummary/muvid.footage.html.md#muvid.footage.validate_edl) sees an edit that still cuts to unvouched clips
and raises [`UnreliableAlignmentError`](_autosummary/muvid.footage.html.md#muvid.footage.UnreliableAlignmentError) exactly as before. A shoot where nothing
is trustworthy must not come back as a black video reported as success — that is the
plausible-artifact failure muvid#59 is about, one level up. A *smaller* edit is a
recovery; an *empty* one is the same wrong answer wearing a coverage report.

**A gap is the last resort, not the first.** Before dropping an entry it is offered
to its neighbours: if the cut on either side is a vouched clip that ALREADY covers the
span, the span is absorbed into that cut instead. That case is not hypothetical and it
is not the aligner’s fault — `weighted`’s DP produces it structurally. Its
transition window is capped at `max_seg_s` (4x `l_max`, a performance bound) and
consecutive segments must be different clips, so a single vouched take longer than the
cap CANNOT be one segment and the optimizer is forced to cut away and back. Measured
on a 60 s song under the DEFAULT config, a 2 s opener plus one 58 s vouched clip
yields `[(0,2,O), (2,34,V), (34,36,BAD), (36,60,V)]` — and gapping that 2 s would
punch an avoidable black hole through the middle of a continuous take, blamed on an
alignment that had nothing to do with it. A caller-raised `l_max_overrun_penalty`
does the same thing at every `l_max` boundary. Absorbing loses nothing: the frames
come from a clip the aligner vouches for, which the caller’s own edit already used on
both sides, and `validate_edl` re-checks containment afterwards either way.

Absorption is refused into a cut whose meaning depends on its length or on the
boundary it starts at — one carrying a `transition`, a `crop_end` pan or a
`look` — and across a following `transition`, since the blend’s other side would
change clip underneath it. Strategies emit none of those, so the auto path always
absorbs; a hand-written EDL degrades to the gap rather than to a silently restretched
pan.

Returns `(entries, excluded)`.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry)], [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`ExcludedSpan`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.ExcludedSpan)]]

### muvid.footage.is_time_varying(fragment)

Whether `fragment` declares that it reads the clock. Plain strings: no.

The one place that default lives, so a caller reading a fragment and the EDL
field’s own default cannot drift apart.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

```pycon
>>> is_time_varying(LookFragment("zoompan=d=1", time_varying=True))
True
>>> is_time_varying("hue=s=0"), is_time_varying(None)
(False, False)
```

### muvid.footage.list_strategies()

All strategy slugs (eager + lazy), sorted. Lazy slugs are NOT imported to list them.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### muvid.footage.motion(keyframes, , canvas, fps)

A camera path over the cut, as a filter fragment. `looks` picks the filter.

* **Parameters:**
  * **keyframes** ([`Sequence`](https://docs.python.org/3/library/typing.html#typing.Sequence)) – `(t_seconds, window)` pairs, or `looks.Keyframe`s.
    The window is anything with ``x``/`y`/`w`/`h` as fractions —
    [`muvid.footage.edl.CropWindow`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.CropWindow) satisfies that structurally,
    with no adapter, because both packages use `burns.Rect`’s
    convention on purpose.
  * **canvas** – `(width, height)` — the assembler’s delivery canvas.
  * **fps** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – the assembler’s delivery frame rate.
* **Return type:**
  [`LookFragment`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.LookFragment)
* **Returns:**
  One linear ffmpeg filter chain, ready for the `look` field, declaring
  itself **time-varying** — a camera path is a ramp in the filter’s own
  clock (`in_time` under `zoompan`, `t` under `crop`), whichever
  filter `looks` picks, so it is muvid#73’s affected kind. A path whose
  keyframes happen to hold still is still declared moving: the fragment
  reads the clock either way, and that is the property the warning is
  about.

**The windows are fractions of the CANVAS, not of the source.** That follows
from where the fragment is spliced: after `scale`/`pad`, so the frame it
sees is the canvas with the source letterboxed into it. It is also what makes
the move well-defined across a mixed-device edit — the same punch reads the
same on a portrait phone clip and a landscape one, where a source-relative
window would not. Use `crop`/`crop_end` on the EDL entry for the
source-relative framing decision; the two compose, crop first.

Which ffmpeg filter this becomes is `looks`’ decision and not a matter of
taste: a constant-size window is `crop`, a resizing one is `zoompan`, and
`crop` cannot resize at all (its `w`/`h` are evaluated once, at
configure time, when `t` is NAN — it either refuses to configure or, worse,
exits 0 having rendered every frame at one wrong size).

* **Raises:**
  [**LookError**](_autosummary/muvid.footage.html.md#muvid.footage.LookError) – If the path needs something that was not supplied.
* **Return type:**
  [*LookFragment*](_autosummary/muvid.footage.look.html.md#muvid.footage.look.LookFragment)

### muvid.footage.punch_in(, canvas, fps, duration_s, zoom=1.12, anchor=(0.5, 0.5), start_s=0.0, end_s=None)

An in-shot punch-in: hold, then push in, WITHOUT leaving the shot (muvid#66).

The design partner asked for “roughly 2N” of these and was explicit that it
is **not** a transition between two clips — it stays on the same shot. Before
this there was no punch-in, zoom or Ken Burns move anywhere in the footage
path; the only two mentions of `zoom` under `footage/` were comments
explaining why `zoompan` was unusable, and that reading has since been
measured wrong: `t` is undefined inside `zoompan`, but `in_time` works
and `d=1` is exactly 1:1 rather than the frame-duplicating default. Both
corrections live in `looks.compile_motion`, which is why this function is
six lines of geometry and no ffmpeg.

* **Parameters:**
  * **canvas** – `(width, height)` — the assembler’s delivery canvas.
  * **fps** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – the assembler’s delivery frame rate.
  * **duration_s** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – the cut’s length in seconds. The move ends here by default.
  * **zoom** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – final magnification. `1.12` shows ~89% of the frame.
  * **anchor** ([`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)]) – what stays put, as a fraction of the canvas. `(0.5, 0.5)`
    centres it; `(0.5, 0.35)` pushes toward a face in the upper third.
  * **start_s** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – hold the full frame until here, then move.
  * **end_s** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]) – reach the final framing here and hold. Defaults to `duration_s`.
* **Return type:**
  [`LookFragment`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.LookFragment)
* **Returns:**
  One linear ffmpeg filter chain, ready for the `look` field, declaring
  itself **time-varying** (it goes through [`motion()`](_autosummary/muvid.footage.html.md#muvid.footage.motion)). Put that on the
  entry as `look_time_varying` — [`punch_in_cuts()`](_autosummary/muvid.footage.html.md#muvid.footage.punch_in_cuts) does — or the
  assembler cannot warn you when the cut borders a transition and the move
  restarts (muvid#73).

A move is a ramp between two windows, and the windows are fractions of the
canvas (see [`motion()`](_autosummary/muvid.footage.html.md#muvid.footage.motion)). The end window keeps the canvas’s aspect ratio,
which is what lets `zoompan` deliver at the canvas size with no stretch and
no reframing crop.

```pycon
>>> frag = punch_in(canvas=(640, 360), fps=25, duration_s=3.0)
>>> frag.startswith("zoompan=d=1:s=640x360:fps=25:")
True
>>> frag.time_varying
True
```

A pull-out is this move backwards, so it goes through [`motion()`](_autosummary/muvid.footage.html.md#muvid.footage.motion) with the
windows in the order you want them, rather than a boolean here.

* **Raises:**
  [**LookError**](_autosummary/muvid.footage.html.md#muvid.footage.LookError) – If `zoom`, the timings or the anchor are out of range.
* **Return type:**
  [*LookFragment*](_autosummary/muvid.footage.look.html.md#muvid.footage.look.LookFragment)

### muvid.footage.punch_in_cuts(entries, , canvas, fps, every=2, zoom=1.12, anchor=(0.5, 0.5), offset=0)

Put a punch-in on every `every`-th footage entry — muvid#66’s other half.

The request was for *roughly 2N* punch-ins “evenly redistributed so they
occur about twice as often”, explicitly **not** two extra tacked on the end.
Redistribution is therefore the whole job, and it is a stride over the cuts
rather than a wall-clock interval: cuts are already beat-snapped by the
selector, so a stride lands the moves on musical time for free, where a
seconds-based interval would drift off it.

* **Parameters:**
  * **entries** ([`Sequence`](https://docs.python.org/3/library/typing.html#typing.Sequence)) – validated [`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry) objects.
  * **canvas** – `(width, height)` — the delivery canvas.
  * **fps** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – the delivery frame rate.
  * **every** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – stride. `2` punches every other footage cut; `1` punches all.
  * **zoom** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – passed to [`punch_in()`](_autosummary/muvid.footage.html.md#muvid.footage.punch_in).
  * **anchor** ([`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)]) – passed to [`punch_in()`](_autosummary/muvid.footage.html.md#muvid.footage.punch_in).
  * **offset** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – which footage cut in each stride gets the move.
* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]
* **Returns:**
  A NEW list of entries. Gaps are skipped (they have no footage to punch
  into, and `validate_edl` refuses a look on one), and an entry that
  already carries a look keeps it — this composes with hand-authoring
  rather than overwriting it, because silently replacing an authored
  direction is the failure this package guards against elsewhere.

  Each punched entry also carries `look_time_varying`, taken FROM the
  fragment via [`is_time_varying()`](_autosummary/muvid.footage.html.md#muvid.footage.is_time_varying) rather than written as a literal
  `True` beside the `punch_in` call. The two would agree today and the
  literal is the one that would stop agreeing — this is the same reason
  `_edl_json` is a table rather than four `if`s. Without it the
  assembler cannot warn that a punch bordering a transition restarts its
  ramp (muvid#73).

### muvid.footage.register_selection_strategy(slug, fn)

Register a selection strategy under `slug` (returns it, for inline use).

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis), [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry)]]

### muvid.footage.resolve_strategy(strategy)

Resolve a strategy name OR a bare callable to a `SelectionStrategy`.

A lazy slug is imported here (and cached into the eager table) on first resolution.

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis), [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry)]]

### muvid.footage.select_edl(strategy, alignments, song_duration, , context=None)

Run `strategy` (name or callable) to produce an EDL from `alignments`.

`context` (a `SelectionContext`) is passed to score-driven strategies that declare it;
the alignment-only built-ins ignore it. See `SelectionStrategy`.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry)]

### muvid.footage.stylize(look, , canvas, fps, duration_s=None, ffmpeg='ffmpeg', env=None, policy=None)

A `looks.Look` compiled against the binary muvid will run.

* **Parameters:**
  * **look** – a `looks.Look` — an ordered stack of named effects.
  * **canvas** – `(width, height)` — the assembler’s delivery canvas.
  * **fps** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – the assembler’s delivery frame rate.
  * **duration_s** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]) – the cut’s length, when a step needs to know it.
  * **ffmpeg** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – which binary to probe. Defaults to the bare name muvid runs.
  * **env** – a `looks.FfmpegEnv` to compile against, instead of probing.
  * **policy** – a `looks.Policy` — the licence ceiling. `looks`’ default
    applies when omitted.
* **Return type:**
  [`LookFragment`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.LookFragment)
* **Returns:**
  One linear ffmpeg filter chain, ready for the `look` field, declaring
  whether it is time-varying **from the compiled plan** rather than by
  assumption. A grade, a LUT, a posterise is static; two shapes are not,
  and both are reachable from here:
  - a step naming a `TIME_VARYING_EFFECTS` member — `motion` is one
    of `looks`’ registered effects, so `stylize` can emit exactly the
    `zoompan` ramp [`punch_in()`](_autosummary/muvid.footage.html.md#muvid.footage.punch_in) does (verified by compiling it);
  - a step with an `at` `looks.Span`, which compiles to
    `enable='between(t,…)'` — measured: `Effect("blur", at=Span(0.5,
    1.5))` becomes `gblur=sigma=2:enable='between(t,0.5,1.5)'`. Reading
    the clock to decide *whether* to apply is the same restart, and the
    effect is not on any list of moving ones.

  A blanket `False` here — the obvious reading, since `stylize` is the
  grade-shaped door — would have been wrong for both.

**The clip is declared, not measured.** `looks` compiles against a
*declared* clip, and at this splice point muvid knows the geometry and the
rate exactly — they are the canvas and the delivery rate the assembler is
about to impose, not properties of whichever source is underneath. That is
the payoff of splicing after `scale`/`pad`/`fps` rather than before it.

`origin_s=0.0` is declared for the same kind of reason and is true for a
stated cause rather than by default: `looks` treats a span as being in the
host’s decoder time, and *input-side* `-ss` rebases the filter timeline to
0 where output-side `-ss` does not. `muvid.footage.assemble._render_part()`
seeks input-side. Move that seek and this declaration becomes false.

**A refusal is the feature.** `looks` resolves each step’s licence tier
against the probed binary, so a look reaching a GPL-only filter on an
LGPL build is either substituted or refused by name — rather than working on
a laptop and quietly raising the licence tier of a shipped product.

* **Raises:**
  [**LookError**](_autosummary/muvid.footage.html.md#muvid.footage.LookError) – If the look cannot be compiled for this binary, this ceiling
      or this clip. The message is `looks`’, which names the remedy.
* **Return type:**
  [*LookFragment*](_autosummary/muvid.footage.look.html.md#muvid.footage.look.LookFragment)

### muvid.footage.validate_edl(edl, alignments, song_duration, , canvas=(1920, 1920), allow_unreliable=False)

Validate an EDL (from a strategy OR a caller) — the ONE gate before any cutting.

Enforces, raising `ValueError` with a specific message otherwise:

- non-empty; every non-gap `clip_id` is a known alignment;
- each span is positive and lies within `[0, song_duration]`;
- spans are in ascending order and **non-overlapping**;
- spans are **contiguous** (gapless) — a hole must be an explicit gap entry
  (`clip_id` empty/null, rendered as fill), which `fill_gaps()` inserts; an
  *implicit* hole is still an error, so nothing goes missing silently;
- each non-gap span lies within its clip’s aligned coverage, AND the derived
  `clip_in = song_start - offset` satisfies `0 <= clip_in` and
  `clip_in + span_duration <= clip_duration` (the clip actually contains that span);
- a `look` (the `looks` seam) names only filters in `LOOK_FILTERS`,
  is ONE lexically-closed linear filter chain, names no container input, is
  not on a gap, sets only options `_LOOK_GEOMETRY_FILTERS` classifies on
  the four filters that can change the output frame, and asks for a frame no
  more than `MAX_LOOK_SCALE` times `canvas` — see
  `_validate_look()`, which is the trust boundary for a caller-supplied
  > filter string;
- `look_time_varying` is a boolean, and is not set on an entry with no
  `look` (a declaration about a look that is not there is a request that
  cannot be honoured);
- every non-gap entry cuts to a clip whose alignment the aligner VOUCHES for
  (`FootageAlignment.reliable`), unless `allow_unreliable=True` — see
  [`UnreliableAlignmentError`](_autosummary/muvid.footage.html.md#muvid.footage.UnreliableAlignmentError). This is a trust check rather than a
  > structural one, so it runs LAST: an EDL that does not even parse should be
  > reported as the malformed EDL it is, not blamed on the footage;
- a `Transition` is on an entry that HAS a predecessor, names a known curve,
  is at least `MIN_TRANSITION_S` long, fits in song time counting BOTH
  transitions an entry can carry, and fits in each side’s aligned coverage — see
  `_validate_transition()`.

`canvas` is the DELIVERY canvas the assembler will render onto — the only
thing a look’s output frame can honestly be bounded against, since the look is
spliced after the assembler’s own `scale`/`pad` onto it. Every muvid path
passes the real one; `DFLT_LOOK_CANVAS` covers a direct caller and is
the loosest bound rather than an absent one.

Returns the normalized list of [`EdlEntry`](_autosummary/muvid.footage.html.md#muvid.footage.EdlEntry).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry)]

### Modules

| [`align`](_autosummary/muvid.footage.align.html.md#module-muvid.footage.align)                 | Align a set of footage clips to the song — a thin wrapper over `mixing.audio`.                                                                                      |
|---------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`assemble`](_autosummary/muvid.footage.assemble.html.md#module-muvid.footage.assemble)           | Assemble validated cuts into a music video, in BOUNDED memory.                                                                                                      |
| [`beat_fit`](_autosummary/muvid.footage.beat_fit.html.md#module-muvid.footage.beat_fit)           | Fitting a cut's timing to the beat — the pure numerics behind `service.fit_to_beat`.                                                                                |
| [`beats`](_autosummary/muvid.footage.beats.html.md#module-muvid.footage.beats)                 | Beat signals — continuous envelopes of where the beat is, in the song and in each video.                                                                            |
| [`edl`](_autosummary/muvid.footage.edl.html.md#module-muvid.footage.edl)                     | EDL data types + the `validate_edl` single-source-of-truth gate.                                                                                                    |
| [`errors`](_autosummary/muvid.footage.errors.html.md#module-muvid.footage.errors)               | The refusal and cancellation types of the footage operations ([`muvid.footage.service`](_autosummary/muvid.footage.service.html.md#module-muvid.footage.service)). |
| [`explain`](_autosummary/muvid.footage.explain.html.md#module-muvid.footage.explain)             | Why each cut is where it is — the post-mortem of an edit, in plain words.                                                                                           |
| [`lacing_bridge`](_autosummary/muvid.footage.lacing_bridge.html.md#module-muvid.footage.lacing_bridge) | muvid project → lacing standoff records, and the DECISION tier back to an EDL.                                                                                      |
| [`look`](_autosummary/muvid.footage.look.html.md#module-muvid.footage.look)                   | Compile a `looks` artifact into the fragment the assembler splices.                                                                                                 |
| [`media_views`](_autosummary/muvid.footage.media_views.html.md#module-muvid.footage.media_views)     | What an editor draws: per-camera filmstrips and the song's waveform peaks.                                                                                          |
| [`music_cut`](_autosummary/muvid.footage.music_cut.html.md#module-muvid.footage.music_cut)         | Cut footage to the music — the montage half of a music video.                                                                                                       |
| [`named_looks`](_autosummary/muvid.footage.named_looks.html.md#module-muvid.footage.named_looks)     | Named looks — the camera moves and grades a person can pick for a cut.                                                                                              |
| [`scoring`](_autosummary/muvid.footage.scoring.html.md#module-muvid.footage.scoring)             | Footage scoring — per-clip score tracks on the shared song-time grid (thorwhalen/muvid#13).                                                                         |
| [`select_score`](_autosummary/muvid.footage.select_score.html.md#module-muvid.footage.select_score)   | The score-driven `weighted` selection strategy: a beat-snapped semi-Markov Viterbi DP.                                                                              |
| [`service`](_autosummary/muvid.footage.service.html.md#module-muvid.footage.service)             | The footage operations — one function per thing you can do to a music-video project.                                                                                |
| [`strategy`](_autosummary/muvid.footage.strategy.html.md#module-muvid.footage.strategy)           | The pluggable `SelectionStrategy` registry — alignments → an EDL.                                                                                                   |
| [`workspace`](_autosummary/muvid.footage.workspace.html.md#module-muvid.footage.workspace)         | Per-user, STATEFUL project for the footage-aligned `music_video` genre.                                                                                             |


# _autosummary/muvid.footage.lacing_bridge.html.md

# muvid.footage.lacing_bridge

muvid project → lacing standoff records, and the DECISION tier back to an EDL.

The multichannel editor (thorwhalen/reelee-web#203) renders three record kinds that
until now existed only as prose in the design docs (muvid#31): `clip-alignment/v1`
(where each clip sits on the song), `clip-score-track/v1` (one (clip, metric) curve on
the shared song-time grid), and `music-video-edl/v1` (the DECISION lane — one entry
per cut, gaps included). This module is the bridge, both directions:

- [`editor_document()`](_autosummary/muvid.footage.lacing_bridge.html.md#muvid.footage.lacing_bridge.editor_document) — a muvid project as `{tiers, annotations}`, everything in
  SONG TIME on one shared axis, referenced to the song’s content hash (`MediaRef`), so
  any lacing-native surface (lacing-ui’s multitrack Timeline first) renders it without
  knowing muvid exists.
- [`edl_from_annotations()`](_autosummary/muvid.footage.lacing_bridge.html.md#muvid.footage.lacing_bridge.edl_from_annotations) — the timeline-to-EDL half: a DECISION tier, after human
  edits, exports verbatim as `assemble_music_video(edl=...)` input.

Times quantize to lacing’s rational grid at `TIME_RATE` (μs): far finer than
`validate_edl`’s 1 ms tolerance and the frame grid, so annotate → edit → export →
render reproduces the same cuts.

Score arrays are inlined (a 205 s song at the current hop is ~2k floats per metric);
they move behind a ContentRef when they outgrow JSON — a body-schema major bump.

### Module Attributes

| [`TIME_RATE`](_autosummary/muvid.footage.lacing_bridge.html.md#muvid.footage.lacing_bridge.TIME_RATE)             | microseconds.                                                              |
|------------------------------------------------------------------------|----------------------------------------------------------------------------|
| [`CLIP_ALIGNMENT_SCHEMA`](_autosummary/muvid.footage.lacing_bridge.html.md#muvid.footage.lacing_bridge.CLIP_ALIGNMENT_SCHEMA) | Body-schema URIs this bridge emits (single source of truth for the names). |
| [`DECISION_TIER`](_autosummary/muvid.footage.lacing_bridge.html.md#muvid.footage.lacing_bridge.DECISION_TIER)         | Tier names.                                                                |

### Functions

| [`alignment_annotations`](_autosummary/muvid.footage.lacing_bridge.html.md#muvid.footage.lacing_bridge.alignment_annotations)(aligns, \*, ...)           | One `clip-alignment/v1` per clip, spanning the clip's coverage of the song.   |
|---------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------|
| [`editor_document`](_autosummary/muvid.footage.lacing_bridge.html.md#muvid.footage.lacing_bridge.editor_document)(proj, \*[, attributed_to])       | The whole project as one lacing-native document for a multitrack editor.      |
| [`edl_annotations`](_autosummary/muvid.footage.lacing_bridge.html.md#muvid.footage.lacing_bridge.edl_annotations)(entries, \*, song_asset_id, ...) | The DECISION lane: one `music-video-edl/v1` per EDL entry, gaps included.     |
| [`edl_from_annotations`](_autosummary/muvid.footage.lacing_bridge.html.md#muvid.footage.lacing_bridge.edl_from_annotations)(annotations, \*[, ...])     | DECISION-tier annotations → the `edl=` argument, verbatim.                    |
| [`score_track_annotations`](_autosummary/muvid.footage.lacing_bridge.html.md#muvid.footage.lacing_bridge.score_track_annotations)(tensor, \*, ...)         | One `clip-score-track/v1` per (clip, metric): the whole curve as one record.  |

### muvid.footage.lacing_bridge.CLIP_ALIGNMENT_SCHEMA *= 'annot://schema/clip-alignment/v1'*

Body-schema URIs this bridge emits (single source of truth for the names).

### muvid.footage.lacing_bridge.DECISION_TIER *= 'DECISION'*

Tier names. Clip lanes are per-clip (`clip:<id>`); these are the shared ones.

### muvid.footage.lacing_bridge.TIME_RATE *= 1000000*

microseconds.

* **Type:**
  Rational-time rate for all bridge annotations

### muvid.footage.lacing_bridge.alignment_annotations(aligns, , song_asset_id, attributed_to)

One `clip-alignment/v1` per clip, spanning the clip’s coverage of the song.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)

### muvid.footage.lacing_bridge.editor_document(proj, , attributed_to='')

The whole project as one lacing-native document for a multitrack editor.

Tiers: one lane group per clip (alignment + its score sub-tracks) + the DECISION
lane. The EDL rendered into DECISION is the current default proposal; an editor
mutates that tier and exports it back through [`edl_from_annotations()`](_autosummary/muvid.footage.lacing_bridge.html.md#muvid.footage.lacing_bridge.edl_from_annotations).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.lacing_bridge.edl_annotations(entries, , song_asset_id, attributed_to)

The DECISION lane: one `music-video-edl/v1` per EDL entry, gaps included.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)

### muvid.footage.lacing_bridge.edl_from_annotations(annotations, , expected_song_asset_id=None)

DECISION-tier annotations → the `edl=` argument, verbatim.

The timeline-to-EDL half: whatever the editor did to the DECISION lane — moved,
split, retargeted, deleted — exports as plain `{song_start, song_end, clip_id}`
dicts (plus `transition`/`crop`/`crop_end`/`look`/`look_time_varying`
where the editor set one) ready for `assemble_music_video`. Sorting and
validation stay the render path’s business (`fill_gaps` + `validate_edl`);
this is a faithful read.

Annotations are untrusted editor input, so anything shaped wrong is SKIPPED rather
than crashing the export: wrong schema/tier, or (an editor could in principle attach
a `music-video-edl/v1` body to an `AnnotationRef`, whose `interval` is
optional) a reference with no interval to read a span from.

`expected_song_asset_id` is the one thing that RAISES instead (muvid#35). A
DECISION record pointing at a different song is not another record to filter past —
it is the whole export being about the wrong project (a stale clipboard, the easy
mistake in a copy-paste editor UI). Skipping it silently yields an empty or
half-empty EDL whose eventual `validate_edl` complaint names a symptom, never the
cause. The check is opt-in and evidence-based: no expected id, or a reference kind
carrying no `asset_id` at all (only `MediaRef` has one), is nothing to
contradict — it reports a WRONG song, it does not demand proof of the right one.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

### muvid.footage.lacing_bridge.score_track_annotations(tensor, , song_asset_id, attributed_to)

One `clip-score-track/v1` per (clip, metric): the whole curve as one record.

Dense JAMS-style arrays on the shared grid — values normalized to [0,1], `mask`
saying where the clip actually covers the song (blank, never flat-zero, in the UI).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)


# _autosummary/muvid.footage.look.html.md

# muvid.footage.look

Compile a `looks` artifact into the fragment the assembler splices.

This is muvid’s half of the `looks` seam — `looks`’ build-order item 10, and
the first real consumer of that package. The division of labour is the whole
point and it is worth stating before any code:

> **\`\`looks\`\` decides what a pixel becomes. muvid decides which pixels exist,
> when they appear, and how the process is run.**

So `looks` emits a `-vf` chain fragment and nothing else. It owns no encode
setting: `-c:v`, `-crf`, `-preset`, `-pix_fmt`, the frame counts, the
concat, the mux and the per-invocation timeout all stay in
[`muvid.footage.assemble`](_autosummary/muvid.footage.assemble.html.md#module-muvid.footage.assemble), where the bounded-memory invariant lives. The
fragment rides in [`muvid.footage.edl.EdlEntry.look`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry.look) and is spliced by
`muvid.footage.assemble._part_filter()`.

Why a `-vf` fragment is the *cheapest possible* seam, which is why it was
chosen: it adds no `-i`. muvid’s assembler was rewritten (muvid#21/#24) because
one `-filter_complex` over all cuts held a decoder per cut and was OOM-killed at
30 cuts on a 3.7 GB box; the guarantee it bought is O(1) decoders per invocation.
A filter fragment cannot touch that.

\*\*A look cannot reach a second source at all, and the earlier claim that it could
via `movie=` was wrong twice over.\*\* `movie=` is a zero-input source filter,
so at the solo site — a *simple* filtergraph, one input and one output — it leaves
the preceding chain unconsumed and ffmpeg refuses the whole graph before decoding
a frame ( *“had 1 input(s) and 2 output(s)”*, measured on ffmpeg 9.0.1; every
composited form, `movie=X,overlay=10:10` included, fails identically, and the
forms that DO render all need `[`/`]`/`;`). At the transition site it does
run, and what it does there is call `avformat_open_input` on a path from inside
the fragment — a second container decoder that no invocation accounted for, which
is the muvid#21/#24 invariant going out the back door. So
`muvid.footage.edl._validate_look()` refuses it, along with every other filter
outside [`muvid.footage.edl.LOOK_FILTERS`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.LOOK_FILTERS). Compositing needs a second splice
site the assembler does not have; that is a change to the assembler, not a look.

Three functions, in the order you are likely to want them:

- [`punch_in()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.punch_in) — the in-shot zoom the design partner asked for (muvid#66).
- [`motion()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.motion) — an arbitrary camera path over the cut; `punch_in` is a
  named case of it.
- [`stylize()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.stylize) — a whole `looks.Look` (grade, LUT, posterise …)
  compiled against the binary muvid will actually run.

[`chain()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.chain) puts two of them on one cut. Each returns a [`LookFragment`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.LookFragment) —
a `str` that also answers `time_varying` — except that
[`chain()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.chain) returns `None` when nothing survives. Read the answer back with
[`is_time_varying()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.is_time_varying), which treats a plain `str` as static.

**Which binary.** The fleet has two ffmpegs in routine use and neither is a
superset of the other (`looks`’ research note 11 §3.4 measured 484 vs 481
filters, with `zscale` in one and `drawtext` in the other). `looks` refuses
to resolve one — the environment is an argument — and muvid is the party entitled
to answer, because muvid owns the invocation. muvid runs the bare name `ffmpeg`
from `PATH` ([`muvid.visualize.ffmpeg.run_ffmpeg()`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.run_ffmpeg)), so that is what
[`stylize()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.stylize) probes. Pass `env=` to compile against a different one.

\*\*One limitation, measured rather than guessed at, and not fixable here — but no
longer silent.\*\* A *time-varying* look on a cut that borders a
[`Transition`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.Transition) **restarts its ramp on the blended part**.
The assembler renders a transitioned boundary as a separate two-input invocation
whose inputs are input-side-seeked to the blend window, so the filter clock there
begins at 0 again instead of continuing the cut’s.

Measured on a 3.0 s cut at 25 fps with a 0.4 s fade and a 1.12x punch: the solo
part’s last frame is drawn at zoom 1.109 (mean 

```
|diff|
```

 28.1/255 against the same
frame rendered without the look), while the blend part’s first frame is drawn at
zoom 1.000 (mean 

```
|diff|
```

 0.7/255 against its own unlooked twin — i.e.
indistinguishable from no punch at all). The move snaps back for the length of
the blend and then the next cut begins.

A **static** look — a grade, a LUT, a posterise — is unaffected, because it never
reads the clock, and it is what the seam is mostly for. muvid still cannot rebase
the fragment: that means rewriting an arbitrary ffmpeg expression, which is
exactly what `looks` refuses to do for itself (its rule 27), and a wrong rebase
is worse than a documented one — it would exit 0 having moved the effect to a
different second of the clip.

What muvid *can* do, and now does (muvid#73), is **say which kind a look is** so
the assembler can warn instead of rendering the hitch quietly. Every function
here returns a [`LookFragment`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.LookFragment) — a plain `str` that also answers
`.time_varying` — and [`punch_in_cuts()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.punch_in_cuts) copies that answer onto
[`look_time_varying`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry.look_time_varying), which is what the
assembler reads. A caller assembling entries by hand should do the same; the flag
defaults to `False`, so an *undeclared* moving look is still silent, and that
limit is stated on the field rather than left to be discovered.

### Module Attributes

| [`DEFAULT_PUNCH_ZOOM`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.DEFAULT_PUNCH_ZOOM)   | the design partner described what he wanted as a "slight zoom/punch-in jump" (muvid#66), and a punch-in is a punctuation mark, not a transition.   |
|-----------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------|
| [`DEFAULT_PUNCH_ANCHOR`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.DEFAULT_PUNCH_ANCHOR) | Where [`punch_in()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.punch_in) zooms toward, as a fraction of the canvas.                                       |
| [`TIME_VARYING_EFFECTS`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.TIME_VARYING_EFFECTS) | `looks` effects whose compiled fragment READS THE FILTER CLOCK, so a cut carrying one is affected by muvid#73's restart on a blended boundary.     |

### Functions

| [`chain`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.chain)(\*fragments)                                 | Join look fragments into one, dropping the empty ones.                        |
|-----------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------|
| [`is_time_varying`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.is_time_varying)(fragment)                          | Whether `fragment` declares that it reads the clock.                          |
| [`motion`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.motion)(keyframes, \*, canvas, fps)                 | A camera path over the cut, as a filter fragment.                             |
| [`punch_in`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.punch_in)(\*, canvas, fps, duration_s[, zoom, ...]) | An in-shot punch-in: hold, then push in, WITHOUT leaving the shot (muvid#66). |
| [`punch_in_cuts`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.punch_in_cuts)(entries, \*, canvas, fps[, ...])     | Put a punch-in on every `every`-th footage entry — muvid#66's other half.     |
| [`stylize`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.stylize)(look, \*, canvas, fps[, duration_s, ...])  | A `looks.Look` compiled against the binary muvid will run.                    |

### Classes

| [`LookFragment`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.LookFragment)(fragment, \*, time_varying)   | A compiled filter chain that remembers whether it READS THE CLOCK.   |
|---------------------------------------------------------------------------------------------|----------------------------------------------------------------------|

### Exceptions

| [`LookError`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.LookError)   | A look could not be compiled.   |
|--------------------------------------------------------------|---------------------------------|

### muvid.footage.look.DEFAULT_PUNCH_ANCHOR *= (0.5, 0.5)*

Where [`punch_in()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.punch_in) zooms toward, as a fraction of the canvas. Centre.

### muvid.footage.look.DEFAULT_PUNCH_ZOOM *= 1.12*

the design partner
described what he wanted as a “slight zoom/punch-in jump” (muvid#66), and a
punch-in is a punctuation mark, not a transition. Larger values magnify the
source, so a value that looks fine on a 4K clip is soft on a 720p one — which
is why it is a named constant a caller overrides rather than a hidden literal.

* **Type:**
  How far [`punch_in()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.punch_in) zooms by default. A *slight* move

### *exception* muvid.footage.look.LookError

Bases: [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError)

A look could not be compiled. Carries what to do about it.

### *class* muvid.footage.look.LookFragment(fragment, , time_varying)

Bases: [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

A compiled filter chain that remembers whether it READS THE CLOCK.

A plain `str` in every way that matters — it splices, compares, serialises
and JSON-encodes identically, so nothing downstream needs to know it exists —
that additionally answers `time_varying`. That is what lets
[`punch_in_cuts()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.punch_in_cuts) set
[`muvid.footage.edl.EdlEntry.look_time_varying`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry.look_time_varying) *from the fragment*
instead of hardcoding a value beside the call that produced it, and what lets
[`chain()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.chain) combine two fragments without either caller re-deriving the
answer.

A subclass rather than a `(str, bool)` pair because the fragment is already
a wire value: the EDL field is a string, the MCP reply is a string, and the
`looks` API returns a string. Changing that shape would push a rename table
into every consumer to carry one bit that only muvid’s own compilers can
know.

**The bit does not survive a round trip, and is not meant to.** `str`
operations return plain `str`, and JSON has no place to put it — the
durable home is the EDL entry’s own field, which is exactly why muvid#73 put
it there. [`is_time_varying()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.is_time_varying) reads a fragment of either kind, treating a
plain string as static, which is the same default the field has.

```pycon
>>> frag = LookFragment("zoompan=d=1:s=64x48:fps=25", time_varying=True)
>>> frag.time_varying, frag == "zoompan=d=1:s=64x48:fps=25"
(True, True)
>>> is_time_varying("hue=s=0")
False
```

Copyable and picklable, which needs saying because it is not free: `str`
subclasses are reconstructed through `__new__`, and without
`__getnewargs_ex__` both `copy.deepcopy` and `pickle` raise
`TypeError: __new__() missing 1 required keyword-only argument` — measured.
An `EdlEntry` carrying one is an ordinary dataclass a caller may well copy.

```pycon
>>> import copy, pickle
>>> copy.deepcopy(frag).time_varying, pickle.loads(pickle.dumps(frag)) == frag
(True, True)
```

### muvid.footage.look.TIME_VARYING_EFFECTS *= frozenset({'motion'})*

`looks` effects whose compiled fragment READS THE FILTER CLOCK, so a cut
carrying one is affected by muvid#73’s restart on a blended boundary.

\*\*Pinned here rather than derived from `looks`, for the same reason
[`muvid.footage.edl.LOOK_FILTERS`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.LOOK_FILTERS) is\*\* — a new `looks` effect must be a
decision someone records, not a silent change to what muvid warns about. The
drift test in `tests/test_look_time_varying.py` fails when
`looks.effects()` grows,
which is what forces the classification.

`ImplRef.timeline` is NOT this property and must not be mistaken for it: it
says the implementation supports ffmpeg’s `enable=` option. `motion` — the
one genuinely moving effect — declares `timeline=False`, and every static
grade declares `True`, so reading it would get every row backwards.

### muvid.footage.look.chain(\*fragments)

Join look fragments into one, dropping the empty ones.

A cut carries at most one look, so two effects on one cut are one chain.
Returns `None` when nothing survives, which is the value the EDL field
wants for “no look” — an empty string is refused by `validate_edl`
deliberately, so this does not produce one.

**The result is time-varying if ANY component is** — the OR, not the AND and
not the last one’s answer. A chain runs every link on every frame, so one
moving link makes the whole fragment move, and muvid#73’s restart hits it.
Getting this wrong in the safe-looking direction (AND) would silence the
warning on exactly the chains most likely to have one: a punch composed with
a grade.

A plain `str` component contributes `False`, matching
[`is_time_varying()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.is_time_varying) and the EDL field’s own default — so hand-writing one
half of a chain quietly downgrades only that half’s claim, never the other’s.

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`LookFragment`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.LookFragment)]

```pycon
>>> chain("hue=s=0", None, "", "unsharp=5:5:1")
'hue=s=0,unsharp=5:5:1'
>>> chain(None, "") is None
True
>>> chain("hue=s=0", LookFragment("zoompan=d=1", time_varying=True)).time_varying
True
>>> chain("hue=s=0", "unsharp=5:5:1").time_varying
False
```

### muvid.footage.look.is_time_varying(fragment)

Whether `fragment` declares that it reads the clock. Plain strings: no.

The one place that default lives, so a caller reading a fragment and the EDL
field’s own default cannot drift apart.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

```pycon
>>> is_time_varying(LookFragment("zoompan=d=1", time_varying=True))
True
>>> is_time_varying("hue=s=0"), is_time_varying(None)
(False, False)
```

### muvid.footage.look.motion(keyframes, , canvas, fps)

A camera path over the cut, as a filter fragment. `looks` picks the filter.

* **Parameters:**
  * **keyframes** ([`Sequence`](https://docs.python.org/3/library/typing.html#typing.Sequence)) – `(t_seconds, window)` pairs, or `looks.Keyframe`s.
    The window is anything with ``x``/`y`/`w`/`h` as fractions —
    [`muvid.footage.edl.CropWindow`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.CropWindow) satisfies that structurally,
    with no adapter, because both packages use `burns.Rect`’s
    convention on purpose.
  * **canvas** – `(width, height)` — the assembler’s delivery canvas.
  * **fps** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – the assembler’s delivery frame rate.
* **Return type:**
  [`LookFragment`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.LookFragment)
* **Returns:**
  One linear ffmpeg filter chain, ready for the `look` field, declaring
  itself **time-varying** — a camera path is a ramp in the filter’s own
  clock (`in_time` under `zoompan`, `t` under `crop`), whichever
  filter `looks` picks, so it is muvid#73’s affected kind. A path whose
  keyframes happen to hold still is still declared moving: the fragment
  reads the clock either way, and that is the property the warning is
  about.

**The windows are fractions of the CANVAS, not of the source.** That follows
from where the fragment is spliced: after `scale`/`pad`, so the frame it
sees is the canvas with the source letterboxed into it. It is also what makes
the move well-defined across a mixed-device edit — the same punch reads the
same on a portrait phone clip and a landscape one, where a source-relative
window would not. Use `crop`/`crop_end` on the EDL entry for the
source-relative framing decision; the two compose, crop first.

Which ffmpeg filter this becomes is `looks`’ decision and not a matter of
taste: a constant-size window is `crop`, a resizing one is `zoompan`, and
`crop` cannot resize at all (its `w`/`h` are evaluated once, at
configure time, when `t` is NAN — it either refuses to configure or, worse,
exits 0 having rendered every frame at one wrong size).

* **Raises:**
  [**LookError**](_autosummary/muvid.footage.look.html.md#muvid.footage.look.LookError) – If the path needs something that was not supplied.
* **Return type:**
  [*LookFragment*](_autosummary/muvid.footage.look.html.md#muvid.footage.look.LookFragment)

### muvid.footage.look.punch_in(, canvas, fps, duration_s, zoom=1.12, anchor=(0.5, 0.5), start_s=0.0, end_s=None)

An in-shot punch-in: hold, then push in, WITHOUT leaving the shot (muvid#66).

The design partner asked for “roughly 2N” of these and was explicit that it
is **not** a transition between two clips — it stays on the same shot. Before
this there was no punch-in, zoom or Ken Burns move anywhere in the footage
path; the only two mentions of `zoom` under `footage/` were comments
explaining why `zoompan` was unusable, and that reading has since been
measured wrong: `t` is undefined inside `zoompan`, but `in_time` works
and `d=1` is exactly 1:1 rather than the frame-duplicating default. Both
corrections live in `looks.compile_motion`, which is why this function is
six lines of geometry and no ffmpeg.

* **Parameters:**
  * **canvas** – `(width, height)` — the assembler’s delivery canvas.
  * **fps** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – the assembler’s delivery frame rate.
  * **duration_s** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – the cut’s length in seconds. The move ends here by default.
  * **zoom** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – final magnification. `1.12` shows ~89% of the frame.
  * **anchor** ([`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)]) – what stays put, as a fraction of the canvas. `(0.5, 0.5)`
    centres it; `(0.5, 0.35)` pushes toward a face in the upper third.
  * **start_s** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – hold the full frame until here, then move.
  * **end_s** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]) – reach the final framing here and hold. Defaults to `duration_s`.
* **Return type:**
  [`LookFragment`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.LookFragment)
* **Returns:**
  One linear ffmpeg filter chain, ready for the `look` field, declaring
  itself **time-varying** (it goes through [`motion()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.motion)). Put that on the
  entry as `look_time_varying` — [`punch_in_cuts()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.punch_in_cuts) does — or the
  assembler cannot warn you when the cut borders a transition and the move
  restarts (muvid#73).

A move is a ramp between two windows, and the windows are fractions of the
canvas (see [`motion()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.motion)). The end window keeps the canvas’s aspect ratio,
which is what lets `zoompan` deliver at the canvas size with no stretch and
no reframing crop.

```pycon
>>> frag = punch_in(canvas=(640, 360), fps=25, duration_s=3.0)
>>> frag.startswith("zoompan=d=1:s=640x360:fps=25:")
True
>>> frag.time_varying
True
```

A pull-out is this move backwards, so it goes through [`motion()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.motion) with the
windows in the order you want them, rather than a boolean here.

* **Raises:**
  [**LookError**](_autosummary/muvid.footage.look.html.md#muvid.footage.look.LookError) – If `zoom`, the timings or the anchor are out of range.
* **Return type:**
  [*LookFragment*](_autosummary/muvid.footage.look.html.md#muvid.footage.look.LookFragment)

### muvid.footage.look.punch_in_cuts(entries, , canvas, fps, every=2, zoom=1.12, anchor=(0.5, 0.5), offset=0)

Put a punch-in on every `every`-th footage entry — muvid#66’s other half.

The request was for *roughly 2N* punch-ins “evenly redistributed so they
occur about twice as often”, explicitly **not** two extra tacked on the end.
Redistribution is therefore the whole job, and it is a stride over the cuts
rather than a wall-clock interval: cuts are already beat-snapped by the
selector, so a stride lands the moves on musical time for free, where a
seconds-based interval would drift off it.

* **Parameters:**
  * **entries** ([`Sequence`](https://docs.python.org/3/library/typing.html#typing.Sequence)) – validated [`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry) objects.
  * **canvas** – `(width, height)` — the delivery canvas.
  * **fps** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – the delivery frame rate.
  * **every** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – stride. `2` punches every other footage cut; `1` punches all.
  * **zoom** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – passed to [`punch_in()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.punch_in).
  * **anchor** ([`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)]) – passed to [`punch_in()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.punch_in).
  * **offset** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – which footage cut in each stride gets the move.
* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]
* **Returns:**
  A NEW list of entries. Gaps are skipped (they have no footage to punch
  into, and `validate_edl` refuses a look on one), and an entry that
  already carries a look keeps it — this composes with hand-authoring
  rather than overwriting it, because silently replacing an authored
  direction is the failure this package guards against elsewhere.

  Each punched entry also carries `look_time_varying`, taken FROM the
  fragment via [`is_time_varying()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.is_time_varying) rather than written as a literal
  `True` beside the `punch_in` call. The two would agree today and the
  literal is the one that would stop agreeing — this is the same reason
  `_edl_json` is a table rather than four `if`s. Without it the
  assembler cannot warn that a punch bordering a transition restarts its
  ramp (muvid#73).

### muvid.footage.look.stylize(look, , canvas, fps, duration_s=None, ffmpeg='ffmpeg', env=None, policy=None)

A `looks.Look` compiled against the binary muvid will run.

* **Parameters:**
  * **look** – a `looks.Look` — an ordered stack of named effects.
  * **canvas** – `(width, height)` — the assembler’s delivery canvas.
  * **fps** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – the assembler’s delivery frame rate.
  * **duration_s** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]) – the cut’s length, when a step needs to know it.
  * **ffmpeg** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – which binary to probe. Defaults to the bare name muvid runs.
  * **env** – a `looks.FfmpegEnv` to compile against, instead of probing.
  * **policy** – a `looks.Policy` — the licence ceiling. `looks`’ default
    applies when omitted.
* **Return type:**
  [`LookFragment`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.LookFragment)
* **Returns:**
  One linear ffmpeg filter chain, ready for the `look` field, declaring
  whether it is time-varying **from the compiled plan** rather than by
  assumption. A grade, a LUT, a posterise is static; two shapes are not,
  and both are reachable from here:
  - a step naming a [`TIME_VARYING_EFFECTS`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.TIME_VARYING_EFFECTS) member — `motion` is one
    of `looks`’ registered effects, so `stylize` can emit exactly the
    `zoompan` ramp [`punch_in()`](_autosummary/muvid.footage.look.html.md#muvid.footage.look.punch_in) does (verified by compiling it);
  - a step with an `at` `looks.Span`, which compiles to
    `enable='between(t,…)'` — measured: `Effect("blur", at=Span(0.5,
    1.5))` becomes `gblur=sigma=2:enable='between(t,0.5,1.5)'`. Reading
    the clock to decide *whether* to apply is the same restart, and the
    effect is not on any list of moving ones.

  A blanket `False` here — the obvious reading, since `stylize` is the
  grade-shaped door — would have been wrong for both.

**The clip is declared, not measured.** `looks` compiles against a
*declared* clip, and at this splice point muvid knows the geometry and the
rate exactly — they are the canvas and the delivery rate the assembler is
about to impose, not properties of whichever source is underneath. That is
the payoff of splicing after `scale`/`pad`/`fps` rather than before it.

`origin_s=0.0` is declared for the same kind of reason and is true for a
stated cause rather than by default: `looks` treats a span as being in the
host’s decoder time, and *input-side* `-ss` rebases the filter timeline to
0 where output-side `-ss` does not. `muvid.footage.assemble._render_part()`
seeks input-side. Move that seek and this declaration becomes false.

**A refusal is the feature.** `looks` resolves each step’s licence tier
against the probed binary, so a look reaching a GPL-only filter on an
LGPL build is either substituted or refused by name — rather than working on
a laptop and quietly raising the licence tier of a shipped product.

* **Raises:**
  [**LookError**](_autosummary/muvid.footage.look.html.md#muvid.footage.look.LookError) – If the look cannot be compiled for this binary, this ceiling
      or this clip. The message is `looks`’, which names the remedy.
* **Return type:**
  [*LookFragment*](_autosummary/muvid.footage.look.html.md#muvid.footage.look.LookFragment)


# _autosummary/muvid.footage.media_views.html.md

# muvid.footage.media_views

What an editor draws: per-camera filmstrips and the song’s waveform peaks.

The studio’s Edit tab (the redesign in the plan of record’s *Edit tab* note, §5) draws
every camera as a strip of thumbnails on the song’s timeline and the song as a
waveform. Both are derived from media the project already holds, both are expensive
to make and cheap to keep, so both are CACHED by content and parameters:

- **filmstrips** — one or more JPEG sprite sheets per clip (`cols x rows` frames
  each, `frame_h` pixels tall, width from the clip’s own aspect), sampled at `fps`
  frames per second of CLIP time: frame `i` of a clip is its picture at
  `i / fps` s. Cached at `footage/filmstrips/<clip-hash>-<params>/` with an
  `index.json`, so a re-call is a file read and a re-uploaded clip with other bytes
  gets new sheets. Each sheet is registered in the host’s catalog (content-addressed)
  when the project is hosted, so the screen loads it by `artifact_id`.
- **peaks** — the song’s per-bucket peak absolute amplitude, mono, normalised to
  `0..1` over `n` equal buckets of the song. Cached at
  `footage/peaks/<song-hash>-<n>.json`.

Every file is written as a NEW file (temp + rename) — the catalog hardlinks them.

### Module Attributes

| [`FILMSTRIP_FPS`](_autosummary/muvid.footage.media_views.html.md#muvid.footage.media_views.FILMSTRIP_FPS)   | frames per second of clip time, frame height in pixels, frames per sheet (columns x rows), JPEG quality (ffmpeg's 2-31, lower is better).   |
|------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------|

### Functions

| [`clip_filmstrip`](_autosummary/muvid.footage.media_views.html.md#muvid.footage.media_views.clip_filmstrip)(fp, clip_id, \*[, fps, ...])    | One clip's filmstrip record, generating (and caching) it when missing.                                                                                                                  |
|-------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`song_peaks`](_autosummary/muvid.footage.media_views.html.md#muvid.footage.media_views.song_peaks)(fp, \*, n)                          | `{duration_s, n, peaks}` — `n` buckets of the song's peak <br/><br/>```<br/>|amplitude|<br/>```<br/><br/>, mono, normalised so the loudest bucket is 1.0 (all zeros for a silent song). |
| [`filmstrip_key`](_autosummary/muvid.footage.media_views.html.md#muvid.footage.media_views.filmstrip_key)(clip_hash, \*, fps, height, ...) | The cache directory name: the clip's content and every parameter.                                                                                                                       |
| `read_json`(path)                                                                               |                                                                                                                                                                                         |
| `write_json`(path, record)                                                                      |                                                                                                                                                                                         |

### muvid.footage.media_views.FILMSTRIP_FPS *= 2.0*

frames per second of clip time, frame height in
pixels, frames per sheet (columns x rows), JPEG quality (ffmpeg’s 2-31, lower is
better). 2 fps x 90 px x 10x10 puts 50 s of a clip on one ~100-200 KB sheet.

* **Type:**
  Default sampling of a filmstrip

### muvid.footage.media_views.clip_filmstrip(fp, clip_id, , fps=2.0, height=90, cols=10, rows=10)

One clip’s filmstrip record, generating (and caching) it when missing.

Returns `{duration_s, n_frames, frame_w, frame_h, sheets: [{artifact_id, cols,
rows, first_frame, n_frames}]}`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.media_views.filmstrip_key(clip_hash, , fps, height, cols, rows)

The cache directory name: the clip’s content and every parameter.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> filmstrip_key("ab" * 32, fps=2.0, height=90, cols=10, rows=10)
'abababababababab-f2-h90-10x10-v2'
```

### muvid.footage.media_views.song_peaks(fp, , n)

`{duration_s, n, peaks}` — `n` buckets of the song’s peak 

```
|amplitude|
```

, mono,
normalised so the loudest bucket is 1.0 (all zeros for a silent song).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)


# _autosummary/muvid.footage.music_cut.html.md

# muvid.footage.music_cut

Cut footage to the music — the montage half of a music video.

A music video in muvid has always assumed its footage is a RECORDING OF THE SONG — a
concert, a dance, a lip-sync — so every clip is *synced*: placed on the song by
listening to its own soundtrack ([`muvid.footage.align`](_autosummary/muvid.footage.align.html.md#module-muvid.footage.align)) and shown at exactly the
moment it was filmed. That is the right answer for that footage and the wrong one for
the other common case, which is the one phone galleries sell as “memories”: a day out
filmed in short clips and photos, plus a song that was never playing. There is nothing
to sync to, so the aligner rightly cannot place those clips, and an edit built only from
synced clips is mostly black.

This module is the other answer. Footage that does not contain the song is cut TO the
music instead of synced WITH it:

* **where the cuts fall** is the montage planner’s decision
  ([`muvid.montage.plan.plan_montage()`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.plan_montage)): every cut on a beat, bar or section
  boundary, denser in loud sections, with the reuse policy that lets a few clips carry a
  whole song and keeps a revisited clip from showing the same stretch twice;
* **which stretch of a video each cut shows** is decided here, by the picture: the
  stretch whose visual hits ([`muvid.footage.beats.activity_signal()`](_autosummary/muvid.footage.beats.html.md#muvid.footage.beats.activity_signal) — the onsets of
  picture change, camera moves included) land on the beats inside the cut, and whose
  liveliness suits the section’s loudness. That is the alignment phone “memories” do
  not do;
* **how a still moves** is a named camera look (push, pull, pan) toward the photo’s
  salient region (`burns.salient_box` when `burns` is installed).

The output is ordinary [`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry) cuts with
[`source_in`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry.source_in) set — *free* cuts — so a montage is an
edit like any other: saved, changed cut by cut, rendered by the same assembler over the
clean song. Synced and free cuts mix in one edit: [`fill_spans()`](_autosummary/muvid.footage.music_cut.html.md#muvid.footage.music_cut.fill_spans) fills only the
spans it is given, which the service uses to put free cuts in the gaps a synced edit
leaves.

Pure planning: no file is written; the measurements (song analysis, picture-change
envelopes, salient boxes) come in through keyword seams with working defaults.

### Module Attributes

| [`ARCHETYPE_AUTO`](_autosummary/muvid.footage.music_cut.html.md#muvid.footage.music_cut.ARCHETYPE_AUTO)   | Pick the cutting style from the song (see [`pick_archetype()`](_autosummary/muvid.footage.music_cut.html.md#muvid.footage.music_cut.pick_archetype)).   |
|-------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------|

### Functions

| [`choose_source_in`](_autosummary/muvid.footage.music_cut.html.md#muvid.footage.music_cut.choose_source_in)(env, \*, clip_duration, length)   | Where in a video a cut of `length` seconds should start: `(source_in, fit)`.                           |
|-----------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------|
| [`fill_spans`](_autosummary/muvid.footage.music_cut.html.md#muvid.footage.music_cut.fill_spans)(song_path, sources, \*[, spans, ...])   | Free cuts of `sources` covering `spans` of the song (default: all of it).                              |
| [`pick_archetype`](_autosummary/muvid.footage.music_cut.html.md#muvid.footage.music_cut.pick_archetype)(analysis)                           | The cutting style for a song: a slow dissolve below `SLOW_TEMPO_BPM`, hard cuts on the beat otherwise. |

### Classes

| [`Envelope`](_autosummary/muvid.footage.music_cut.html.md#muvid.footage.music_cut.Envelope)(hop_s, activity, hits[, sharpness])   | A video's picture-change signal on a regular grid of its own time.                                                                                       |
|-------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`FreeSource`](_autosummary/muvid.footage.music_cut.html.md#muvid.footage.music_cut.FreeSource)(clip_id, path[, kind, duration_s])  | One picture source a montage may cut to: a video (`duration_s` its length) or a still (`kind="still"`, `duration_s=None` — it can be held for any span). |
| [`MusicCut`](_autosummary/muvid.footage.music_cut.html.md#muvid.footage.music_cut.MusicCut)([entries, report])                    | The result: free cuts for the requested spans, and an account of how they were chosen (tempo, style, how well each video's picture landed on the beat).  |

### muvid.footage.music_cut.ARCHETYPE_AUTO *= 'auto'*

Pick the cutting style from the song (see [`pick_archetype()`](_autosummary/muvid.footage.music_cut.html.md#muvid.footage.music_cut.pick_archetype)).

### *class* muvid.footage.music_cut.Envelope(hop_s, activity, hits, sharpness=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A video’s picture-change signal on a regular grid of its own time.

#### *classmethod* from_signals(record)

From [`muvid.footage.beats.activity_signal()`](_autosummary/muvid.footage.beats.html.md#muvid.footage.beats.activity_signal)’s `signals` mapping.

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`Envelope`](_autosummary/muvid.footage.music_cut.html.md#muvid.footage.music_cut.Envelope)]

### *class* muvid.footage.music_cut.FreeSource(clip_id, path, kind='video', duration_s=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One picture source a montage may cut to: a video (`duration_s` its length) or a
still (`kind="still"`, `duration_s=None` — it can be held for any span).

### *class* muvid.footage.music_cut.MusicCut(entries=<factory>, report=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The result: free cuts for the requested spans, and an account of how they were
chosen (tempo, style, how well each video’s picture landed on the beat).

### muvid.footage.music_cut.choose_source_in(env, , clip_duration, length, beat_offsets=(), used=(), energy=0.0, lead_s=0.0, trail_s=0.0, ordinal=0)

Where in a video a cut of `length` seconds should start: `(source_in, fit)`.

Scores every in-point on the envelope’s grid (`lead_s`/`trail_s` of extra
footage kept free on either side, for a blend), by three terms:

* **hits on the beat** — the mean picture-change hit at each `beat_offsets` (beats
  inside the cut, in seconds from its start), relative to the video’s own mean hit:
  above 1 means the picture changes ON the beat more than it does anywhere;
* **liveliness matched to loudness** — `energy` in [-1, 1] (quiet to loud
  section) times how lively the stretch is relative to the whole video;
* **reuse** — the fraction of the stretch already shown by another cut, penalised.

`fit` is the hits term at the chosen point (0 when there are no inner beats or no
envelope). Without an envelope it falls back to a golden-ratio stride over the
room, the montage planner’s own blind rule, with reuse still avoided.

```pycon
>>> hits = np.zeros(100); hits[[20, 30]] = 1.0   # changes at 2.0 s and 3.0 s
>>> env = Envelope(hop_s=0.1, activity=np.full(100, 0.1), hits=hits)
>>> s, fit = choose_source_in(env, clip_duration=10.0, length=2.0, beat_offsets=[0.5, 1.5])
>>> round(s, 2), fit > 1
(1.5, True)
```

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)]

### muvid.footage.music_cut.fill_spans(song_path, sources, \*, spans=None, song_duration=None, analysis=None, archetype='auto', cut_feel='', envelope_of=None, anchor_of=<function \_default_anchor>, canvas=(1920, 1080), fps=30.0, fit='cover', size_of=<function display_size>)

Free cuts of `sources` covering `spans` of the song (default: all of it).

Seams, each with a default that works: `analysis` (the song’s beats, bars and
sections — [`muvid.montage.analysis.analyze()`](_autosummary/muvid.montage.analysis.html.md#muvid.montage.analysis.analyze)), `envelope_of` (a video’s
picture-change [`Envelope`](_autosummary/muvid.footage.music_cut.html.md#muvid.footage.music_cut.Envelope); `None` falls back to the planner’s blind stride),
`anchor_of` (a still’s focal point). `archetype` is a montage archetype with one
picture per slot, or `"auto"`; `cut_feel` a montage pace (`slow`, `steady`,
`driving`, `frantic`). `fit` is one of `FITS`; `size_of` reads a
picture’s displayed size (for `cover`).

Returns [`MusicCut`](_autosummary/muvid.footage.music_cut.html.md#muvid.footage.music_cut.MusicCut) whose `entries` are validated-shape
[`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry) free cuts, contiguous over each span, in song
order (spans with nothing in them are simply not covered — the caller’s
`fill_gaps` makes that explicit).

* **Return type:**
  [`MusicCut`](_autosummary/muvid.footage.music_cut.html.md#muvid.footage.music_cut.MusicCut)

### muvid.footage.music_cut.pick_archetype(analysis)

The cutting style for a song: a slow dissolve below `SLOW_TEMPO_BPM`, hard
cuts on the beat otherwise.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> class A: tempo_bpm = 80.0
>>> pick_archetype(A())
'ballad_dissolve'
```


# _autosummary/muvid.footage.named_looks.html.md

# muvid.footage.named_looks

Named looks — the camera moves and grades a person can pick for a cut.

A cut’s `look` is one ffmpeg filter chain ([`muvid.footage.edl.EdlEntry.look`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry.look)),
which is the right thing to RENDER and the wrong thing to OFFER: a screen or an
assistant can choose among named effects, not write filter strings. This is the menu,
each entry a function of a few bounded parameters compiled through
[`muvid.footage.look`](_autosummary/muvid.footage.look.html.md#module-muvid.footage.look) (so every fragment is `looks`’ and passes
`validate_edl`’s allowlist):

- camera moves, which read the clock (`look_time_varying`): `punch_in` (a steady
  tighter framing), `slow_push` / `slow_pull` (zoom in / out across the cut),
  `pan_left` / `pan_right` (drift sideways at a slight zoom);
- grades, which do not: `vivid`, `black_and_white`, `posterize` and `cartoon`
  (flatten + posterize + a little colour — in the spirit of Que Calor’s V2, whose real
  stylizer was a Python mean-shift and a palette LUT that no filter chain reproduces).

**Tasteful by construction**: every zoom is bounded at [`MAX_ZOOM`](_autosummary/muvid.footage.named_looks.html.md#muvid.footage.named_looks.MAX_ZOOM) (1.15) by the
parameter’s own schema and again when compiled — a move bigger than that reads as a
mistake on phone footage, where the frame has no resolution to spare.

A move is compiled for the cut’s length and the project’s canvas at the moment it is
set, and carried as the fragment: lengthening the cut later holds the last framing;
rendering on another canvas re-uses the fragment as compiled.

Import-light: the menu is data, and `looks` is imported only to compile.

### Module Attributes

| [`MAX_ZOOM`](_autosummary/muvid.footage.named_looks.html.md#muvid.footage.named_looks.MAX_ZOOM)   | The largest magnification any named move may ask for.   |
|-------------------------------------------------------------|---------------------------------------------------------|

### Functions

| [`named_look_catalogue`](_autosummary/muvid.footage.named_looks.html.md#muvid.footage.named_looks.named_look_catalogue)()                         | The menu as JSON rows (name, title, description, kind, params_schema).                                                                                      |
|-------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`resolve_named_look`](_autosummary/muvid.footage.named_looks.html.md#muvid.footage.named_looks.resolve_named_look)(spec)                       | `{"name": ..., **params}` checked, with every parameter's value filled in (the defaults included) — the spec a cut records so a screen can show the choice. |
| [`compile_named_look`](_autosummary/muvid.footage.named_looks.html.md#muvid.footage.named_looks.compile_named_look)(spec, \*, canvas, fps, ...) | `{"name": ..., **params}` → the cut's filter fragment (a `LookFragment`, which says whether it is time-varying).                                            |

### Classes

| [`NamedLook`](_autosummary/muvid.footage.named_looks.html.md#muvid.footage.named_looks.NamedLook)(name, title, description, kind, build)   | A menu entry: `build(canvas=, fps=, duration_s=, **params) -> fragment`.   |
|-----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------|

### Exceptions

| [`NamedLookError`](_autosummary/muvid.footage.named_looks.html.md#muvid.footage.named_looks.NamedLookError)   | A named look that does not exist, or parameters it does not take.   |
|-------------------------------------------------------------------|---------------------------------------------------------------------|

### muvid.footage.named_looks.MAX_ZOOM *= 1.15*

The largest magnification any named move may ask for.

### *class* muvid.footage.named_looks.NamedLook(name, title, description, kind, build, params=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A menu entry: `build(canvas=, fps=, duration_s=, **params) -> fragment`.

### *exception* muvid.footage.named_looks.NamedLookError

Bases: [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError)

A named look that does not exist, or parameters it does not take.

### muvid.footage.named_looks.compile_named_look(spec, , canvas, fps, duration_s)

`{"name": ..., **params}` → the cut’s filter fragment (a `LookFragment`,
which says whether it is time-varying). Unknown names and parameters are refused.

### muvid.footage.named_looks.named_look_catalogue()

The menu as JSON rows (name, title, description, kind, params_schema).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

### muvid.footage.named_looks.resolve_named_look(spec)

`{"name": ..., **params}` checked, with every parameter’s value filled in (the
defaults included) — the spec a cut records so a screen can show the choice.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)


# _autosummary/muvid.footage.scoring.frames.html.md

# muvid.footage.scoring.frames

One decode pass per clip → the shared per-frame artifacts the quality + motion extractors
both consume.

The design review flagged that quality.py and motionbeat.py must NOT each decode the clip and
each estimate camera motion — that doubles IO/CPU on the memory-fragile box and makes the
“camera-motion computed once” claim false. So a single sequential [`sample_clip_frames()`](_autosummary/muvid.footage.scoring.frames.html.md#muvid.footage.scoring.frames.sample_clip_frames)
pass computes, per sampled frame: sharpness, exposure, an (injected) face score, the
camera-compensated motion residual, and the global-motion (shake) magnitude — in clip time.
The orchestrator maps clip time → song time via the clip’s offset and hands this
[`FramePass`](_autosummary/muvid.footage.scoring.frames.html.md#muvid.footage.scoring.frames.FramePass) to both extractors.

cv2 is lazy-imported (`muvid[scoring]` extra). Hard caps (`max_frames`) bound the work;
`should_cancel` is polled so a cancel lands within a few frames.

### Module Attributes

| [`DEFAULT_SAMPLE_FPS`](_autosummary/muvid.footage.scoring.frames.html.md#muvid.footage.scoring.frames.DEFAULT_SAMPLE_FPS)   | Default frame sample rate (Hz) — plenty for quality + a motion envelope onto a 10 Hz grid.   |
|-----------------------------------------------------------------------|----------------------------------------------------------------------------------------------|
| [`DEFAULT_MAX_FRAMES`](_autosummary/muvid.footage.scoring.frames.html.md#muvid.footage.scoring.frames.DEFAULT_MAX_FRAMES)   | Default hard cap on sampled frames per clip (bounds CPU + memory regardless of duration).    |

### Functions

| [`make_face_scorer`](_autosummary/muvid.footage.scoring.frames.html.md#muvid.footage.scoring.frames.make_face_scorer)()                       | Build a `bgr_frame -> face_framing_score | None` (or `None` if unavailable).                                                 |
|-------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------|
| [`sample_clip_frames`](_autosummary/muvid.footage.scoring.frames.html.md#muvid.footage.scoring.frames.sample_clip_frames)(clip_path, \*[, ...]) | Decode `clip_path` ONCE, sampling ~\`\`sample_fps\`\` frames → a [`FramePass`](_autosummary/muvid.footage.scoring.frames.html.md#muvid.footage.scoring.frames.FramePass). |

### Classes

| [`FramePass`](_autosummary/muvid.footage.scoring.frames.html.md#muvid.footage.scoring.frames.FramePass)(clip_times, sharpness, exposure, ...)   | Per-sampled-frame metrics for one clip, in CLIP time (seconds from the clip start).   |
|----------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------|

### muvid.footage.scoring.frames.DEFAULT_MAX_FRAMES *= 1200*

Default hard cap on sampled frames per clip (bounds CPU + memory regardless of duration).

### muvid.footage.scoring.frames.DEFAULT_SAMPLE_FPS *= 5.0*

Default frame sample rate (Hz) — plenty for quality + a motion envelope onto a 10 Hz grid.

### *class* muvid.footage.scoring.frames.FramePass(clip_times, sharpness, exposure, face, motion_residual, global_dx, global_dy, fps, n_sampled)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Per-sampled-frame metrics for one clip, in CLIP time (seconds from the clip start).

### muvid.footage.scoring.frames.make_face_scorer()

Build a `bgr_frame -> face_framing_score | None` (or `None` if unavailable).

Best-effort and never fatal: face_framing is a soft metric (not one of the two named
signals), so ANY failure → `None` and the metric reads as *unmeasured* (NaN →
masked → not counted in the composite denominator), never as a zero score. Tries the
classic `mp.solutions.face_detection` (mediapipe 0.10 full build); falls back to the
Tasks `FaceDetector` ONLY when the operator supplies a model via
`MUVID_MEDIAPIPE_FACE_MODEL` (no auto-download). Score = best face’s area×centering.

### muvid.footage.scoring.frames.sample_clip_frames(clip_path, , sample_fps=5.0, max_frames=1200, face_fn=None, flow_downscale=4, should_cancel=None)

Decode `clip_path` ONCE, sampling ~\`\`sample_fps\`\` frames → a [`FramePass`](_autosummary/muvid.footage.scoring.frames.html.md#muvid.footage.scoring.frames.FramePass).

* **Parameters:**
  * **clip_path** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – the video file.
  * **sample_fps** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – target frames/second to analyze (strided over the native fps).
  * **max_frames** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – hard cap on analyzed frames (bounds cost).
  * **face_fn** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[`ndarray`], [`float`](https://docs.python.org/3/builtins/functions.html#float)]]) – optional `bgr_frame -> face_score | None` (mediapipe, injected by the
    caller so this module stays cv2-only). `None` → face score is NaN
    everywhere, i.e. *not measured* rather than *measured as worst*.
  * **flow_downscale** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – downscale factor for the Farneback flow (cost bound).
  * **should_cancel** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[], [`bool`](https://docs.python.org/3/builtins/functions.html#bool)]]) – polled every frame; returns early (a partial pass) when it goes True.
* **Return type:**
  [`FramePass`](_autosummary/muvid.footage.scoring.frames.html.md#muvid.footage.scoring.frames.FramePass)


# _autosummary/muvid.footage.scoring.grid.html.md

# muvid.footage.scoring.grid

The song-time score grid: `ScoreTrack`, resample-to-grid, robust normalization,
the fused `ScoreTensor`, and crash-consistent persistence.

The keystone of the footage-scoring layer (thorwhalen/muvid#13): every clip’s every metric
is resolved onto ONE fixed-rate song-time grid (`t0=0`, `hop_s≈0.1` → ~10 Hz), so tracks
stack into a tensor `S[clip, frame, metric]` that BOTH the auto composer (the Viterbi
selector) and the Phase-2 multichannel editor read. Frame `k` ↔ song time `t0 + k*hop_s`,
identical across clips.

Design decisions (see `misc/docs/footage_scoring_design.md` — LOCKED post-critique):

- **Raw is the SSOT.** Extractors emit `raw_values` + a coverage `mask` (NaN where
  masked). Normalization is **per-metric-global across all clips** (robust median/IQR,
  percentile-clipped), so it can only be computed once every clip’s raw track for a metric
  is in hand — it is a tensor-assembly step, not a per-extractor one. The normalized
  `values` are derived from `raw` + the stored `norm` params, so re-normalizing is free
  and the editor can show raw *and* normalized.
- **NaN never reaches a serializer.** The manifest stores `null` for an all-masked metric’s
  norm params; the MCP wire maps masked entries to `null` and relies on the `mask` array.
- **The manifest is the SSOT for grid geometry** (`t0/hop_s/n`); a per-track geometry
  mismatch is a load-time assertion, never a silent 2× misalignment.
- **Persistence is crash-consistent**: each clip’s `.npz` is written to a temp then
  `os.replace`d; `manifest.json` is written LAST via tmp+rename, so a reader sees
  either the whole prior state or the whole new one. The dance itself is
  `muvid.footage.workspace.atomic_write_bytes` — the ONE implementation the project
  manifest, the alignments and the render metadata share (muvid#17 item 4); this module
  used to carry its own copy, which is how the other records came to have none.

Pure numpy — no cv2/torch/ffmpeg here. Imported only under the `muvid[scoring]` extra
(never on the import-light `muvid.genre_music_video` path).

### Module Attributes

| [`DEFAULT_HOP_S`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.DEFAULT_HOP_S)   | Default grid step (seconds) → 10 Hz.   |
|------------------------------------------------------------------|----------------------------------------|

### Functions

| [`align_fingerprint`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.align_fingerprint)(alignments)                     | A stable, ORDER-INDEPENDENT fingerprint of a set of alignments (offsets + durations).                                                                               |
|----------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`apply_norm`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.apply_norm)(raw, norm, \*, direction)              | Map raw values to a robust [0,1] score (higher = better), preserving NaN.                                                                                           |
| [`build_tensor`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.build_tensor)(tracks, \*, t0, hop_s, n[, ...])     | Assemble per-clip [`ScoreTrack`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.ScoreTrack) lists into a normalized [`ScoreTensor`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.ScoreTensor). |
| [`compute_norm`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.compute_norm)(raw_tracks, masks, \*, direction)    | Per-metric-GLOBAL robust norm params from every clip's raw track for one metric.                                                                                    |
| [`grid_len`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.grid_len)(song_duration[, hop_s])                  | Number of grid frames spanning `[0, song_duration]` at `hop_s`.                                                                                                     |
| `load_manifest`(project_root)                                                                      |                                                                                                                                                                     |
| [`load_tensor`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.load_tensor)(project_root)                         | Reconstruct the [`ScoreTensor`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.ScoreTensor) from persisted arrays + manifest, or `None`.                                           |
| [`manifest_is_current`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.manifest_is_current)(manifest, \*, song_hash, ...) | Whether persisted scores match the CURRENT song + alignments (else they are stale).                                                                                 |
| [`resample_to_grid`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.resample_to_grid)(sample_times, ...[, max_gap_s])  | Resample irregular `(song_time, value)` samples onto the fixed grid.                                                                                                |
| [`save_scores`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.save_scores)(project_root, tracks, \*, t0, ...)    | Persist per-clip raw arrays + a manifest, crash-consistently.                                                                                                       |
| `scores_dir`(project_root)                                                                         |                                                                                                                                                                     |
| `scores_present`(project_root)                                                                     |                                                                                                                                                                     |

### Classes

| [`ScoreTensor`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.ScoreTensor)(clip_ids, metrics, t0, hop_s, n, ...)   | The fused `S[clip, frame, metric]` (normalized) + `M[clip, frame]` mask.   |
|------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------|
| [`ScoreTrack`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.ScoreTrack)(clip_id, metric, t0, hop_s, ...)         | One `(clip, metric)` curve on the shared song-time grid.                   |

### muvid.footage.scoring.grid.DEFAULT_HOP_S *= 0.1*

Default grid step (seconds) → 10 Hz. Ample for a UI and beat-level selection.

### *class* muvid.footage.scoring.grid.ScoreTensor(clip_ids, metrics, t0, hop_s, n, S, M, raw, norms)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The fused `S[clip, frame, metric]` (normalized) + `M[clip, frame]` mask.

`S` carries NaN where masked (never used for reward — the selector reads `M`).
`raw` keeps the un-normalized values for the editor/tooltips. Geometry
(`t0/hop_s/n`) is authoritative here (validated against the manifest at load).

### *class* muvid.footage.scoring.grid.ScoreTrack(clip_id, metric, t0, hop_s, raw_values, mask, direction='higher_better')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One `(clip, metric)` curve on the shared song-time grid.

Extractors produce `raw_values` (NaN where masked) + `mask`; `direction` says
whether higher raw = better (`"higher_better"`) or lower = better
(`"lower_better"`, e.g. a distance like LSE-D — inverted at normalization). The
normalized `values` are NOT stored here; they are derived at tensor assembly from
`raw_values` + the per-metric global `norm` params (see [`apply_norm()`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.apply_norm)).

#### coverage_fraction()

Fraction of frames that are valid — surfaced so an all-NA metric is visible.

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

#### to_meta()

Everything except the arrays (arrays live in the `.npz`).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.scoring.grid.align_fingerprint(alignments)

A stable, ORDER-INDEPENDENT fingerprint of a set of alignments (offsets + durations).

The SSOT (shared by the scoring job’s idempotency key AND the read-time staleness guard):
a re-align that moves any offset changes this fingerprint, so persisted scores computed
against the old offsets are detected as stale. Sorting the full triples (not just by
clip_id) makes it independent of alignment order.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.footage.scoring.grid.apply_norm(raw, norm, , direction)

Map raw values to a robust [0,1] score (higher = better), preserving NaN.

`z = (raw − median)/IQR` (IQR==0 → neutral 0.5), clipped to `[p5,p95]` mapped to
[0,1]; `lower_better` metrics are inverted so the output is always higher_better.

* **Return type:**
  `ndarray`

### muvid.footage.scoring.grid.build_tensor(tracks, , t0, hop_s, n, metrics=None, clip_ids=None, norms=None)

Assemble per-clip [`ScoreTrack`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.ScoreTrack) lists into a normalized [`ScoreTensor`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.ScoreTensor).

`tracks` maps `clip_id -> [ScoreTrack, ...]`. The metric axis is the union of metrics
present (or the explicit `metrics` order); a clip missing a listed metric contributes
an all-masked column (so the tensor is rectangular and a missing extractor never shifts
columns). Normalization is per-metric-global (computed here if `norms` is not supplied).
Every track’s geometry must match `(t0, hop_s, n)` — a mismatch raises (the SSOT rule).

* **Return type:**
  [`ScoreTensor`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.ScoreTensor)

### muvid.footage.scoring.grid.compute_norm(raw_tracks, masks, , direction)

Per-metric-GLOBAL robust norm params from every clip’s raw track for one metric.

Pools all valid (unmasked, finite) raw values across clips → `{median, iqr, p5, p95}`.
Returns `None` if there are no valid values (an all-masked metric) — the caller stores
`null` and [`apply_norm()`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.apply_norm) then yields all-NaN (never a divide-by-zero or a NaN in
the manifest).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

### muvid.footage.scoring.grid.grid_len(song_duration, hop_s=0.1)

Number of grid frames spanning `[0, song_duration]` at `hop_s`.

* **Return type:**
  [`int`](https://docs.python.org/3/builtins/functions.html#int)

### muvid.footage.scoring.grid.load_tensor(project_root)

Reconstruct the [`ScoreTensor`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.ScoreTensor) from persisted arrays + manifest, or `None`.

Geometry + norms come from the manifest (SSOT); each clip’s `.npz` supplies raw+mask.
A clip/metric absent from a `.npz` is filled as an all-masked column.

* **Return type:**
  [`ScoreTensor`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.ScoreTensor) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

### muvid.footage.scoring.grid.manifest_is_current(manifest, , song_hash, align_fingerprint)

Whether persisted scores match the CURRENT song + alignments (else they are stale).

Guards the read path: a re-align (or a new song) that raced the score job’s rmtree, or a
manifest predating the fingerprint, is detected here so no stale/mislabeled scores are
served to the editor or the weighted selector.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

### muvid.footage.scoring.grid.resample_to_grid(sample_times, sample_values, , t0, hop_s, n, max_gap_s=None)

Resample irregular `(song_time, value)` samples onto the fixed grid.

Vectorized (O(n_samples + n)): samples are mean-binned onto the grid, then linearly
interpolated across bins that have no sample — but ONLY within the sampled span and
ONLY across gaps ≤ `max_gap_s` (so a real coverage gap stays masked, never invented).
Outside `[first_sample, last_sample]` the grid is masked (no extrapolation).

* **Parameters:**
  * **sample_times** ([`Sequence`](https://docs.python.org/3/library/typing.html#typing.Sequence)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]) – song-time (s) of each sample (any order; NaN values dropped).
  * **sample_values** ([`Sequence`](https://docs.python.org/3/library/typing.html#typing.Sequence)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]) – the sample values (parallel to `sample_times`).
  * **t0/hop_s/n** – the grid geometry (frame k ↔ `t0 + k*hop_s`, k in `[0, n)`).
  * **max_gap_s** ([`float`](https://docs.python.org/3/builtins/functions.html#float) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – bridge gaps up to this many seconds (default `4*hop_s`).
* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[`ndarray`, `ndarray`]
* **Returns:**
  `(values, mask)` — `values` float32[n] (NaN where masked), `mask` bool[n].

### muvid.footage.scoring.grid.save_scores(project_root, tracks, , t0, hop_s, n, metrics, song_hash, align_fingerprint='', beat_times=(), downbeat_times=(), extra=None)

Persist per-clip raw arrays + a manifest, crash-consistently.

Each clip → `scores/{clip_id}.npz` (raw + mask per metric), written tmp+\`\`os.replace\`\`.
The per-metric global `norm` params + grid geometry + beats + `song_hash` go in
`scores/manifest.json`, written LAST via tmp+rename — so a concurrent reader sees a
whole consistent state or the prior one, never a torn mix. NaN never enters the manifest
(all-masked metric → `null` norm).

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)


# _autosummary/muvid.footage.scoring.html.md

# muvid.footage.scoring

Footage scoring — per-clip score tracks on the shared song-time grid (thorwhalen/muvid#13).

Resolve every clip’s every metric onto ONE fixed-rate song-time grid → a tensor
`S[clip, frame, metric]` that BOTH the auto composer (the `weighted` Viterbi selector in
[`muvid.footage.select_score`](_autosummary/muvid.footage.select_score.html.md#module-muvid.footage.select_score)) and the Phase-2 multichannel editor read. This subpackage
holds the grid/normalization data model ([`grid`](_autosummary/muvid.footage.scoring.grid.html.md#module-muvid.footage.scoring.grid)), the per-metric
extractors, and the orchestrator that runs them.

**Import-safe.** This `__init__` imports nothing heavy — cv2/mediapipe/librosa/torch live
behind the `muvid[scoring]` (and `muvid[scoring-lipsync]`) extras and are lazy-imported
inside function bodies. The orchestrator `score_project()` is exposed lazily so
`import muvid.footage.scoring` stays light.

See `misc/docs/footage_scoring_design.md` (LOCKED decisions) for the architecture: the
torch-free core (quality + motion-beat + segment + the selector) is the default, prod-safe
tier; the lip-sync tier (Demucs + SyncNet) is opt-in and off by default (CC-BY-NC weights +
OOM risk on the memory-fragile prod box).

### Functions

| `score_project`(project, \*[, metrics, hop_s, ...])   | Score every aligned clip of `project` → persist the tensor; return a summary dict.    |
|-------------------------------------------------------|---------------------------------------------------------------------------------------|
| `list_available_extractors`()                         | Which tiers can run here (import + weight availability) — for diagnostics / the tool. |

### Modules

| [`frames`](_autosummary/muvid.footage.scoring.frames.html.md#module-muvid.footage.scoring.frames)             | One decode pass per clip → the shared per-frame artifacts the quality + motion extractors both consume.                                    |
|---------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
| [`grid`](_autosummary/muvid.footage.scoring.grid.html.md#module-muvid.footage.scoring.grid)                 | The song-time score grid: `ScoreTrack`, resample-to-grid, robust normalization, the fused `ScoreTensor`, and crash-consistent persistence. |
| [`lipsync`](_autosummary/muvid.footage.scoring.lipsync.html.md#module-muvid.footage.scoring.lipsync)           | Lip-sync tier (OPT-IN, OFF BY DEFAULT): SyncNet LSE-C vs the master's Demucs vocal stem.                                                   |
| [`motionbeat`](_autosummary/muvid.footage.scoring.motionbeat.html.md#module-muvid.footage.scoring.motionbeat)     | Motion-to-beat tier: `motion_beat_bas` + `motion_onset_xcorr` → song-grid tracks.                                                          |
| [`orchestrator`](_autosummary/muvid.footage.scoring.orchestrator.html.md#module-muvid.footage.scoring.orchestrator) | The scoring orchestrator: a project (song + aligned clips) → the persisted score tensor.                                                   |
| [`quality`](_autosummary/muvid.footage.scoring.quality.html.md#module-muvid.footage.scoring.quality)           | Quality tier: sharpness / exposure / stability_shake / face_framing → song-grid tracks.                                                    |
| [`segment`](_autosummary/muvid.footage.scoring.segment.html.md#module-muvid.footage.scoring.segment)           | Shot boundaries (PySceneDetect) + the per-clip coverage mask.                                                                              |


# _autosummary/muvid.footage.scoring.lipsync.html.md

# muvid.footage.scoring.lipsync

Lip-sync tier (OPT-IN, OFF BY DEFAULT): SyncNet LSE-C vs the master’s Demucs vocal stem.

**Not on the prod path.** The design’s LOCKED decision: this tier is behind the
`muvid[scoring-lipsync]` extra and disabled by default because (a) Demucs + SyncNet on CPU
peak ~2–3 GB and would OOM the memory-fragile connector, and (b) the htdemucs weights are
**CC-BY-NC (research-only)** — not commercial-clean. So it runs only on a local/worker box the
operator opts into, and it requires the operator to POINT AT weights via env vars (rather than
this package downloading questionable weights at runtime):

- `MUVID_SYNCNET_S3FD_WEIGHTS` — the S3FD face-detector weights.
- `MUVID_SYNCNET_WEIGHTS` — the SyncNet model weights.

If either is unset, or `demucs`/`syncnet-python` is not installed, the extractor is
**skipped** (returns `[]` + a reason) — never a crash, never a silent 0.

Pipeline (design §3c): separate the master vocal stem ONCE (orchestrator), then per clip run
SyncNet’s face-detect → track → mouth-crop → per-window LSE-C \*\*against the master vocal stem
at the known offset\*\* (a validation, not a search). Emit `lip_sync_lse_c` + `lse_d_offset`,
gated to NA where no singing face is present (never 0).

⚠ This module’s heavy glue (Demucs + SyncNetPipeline) cannot run in CI or on the dev box
(deps absent) — it needs a live validation pass on a machine with the extra + weights before
first real use. It is structured to fail safe (skip) everywhere else.

### Functions

| [`lipsync_available`](_autosummary/muvid.footage.scoring.lipsync.html.md#muvid.footage.scoring.lipsync.lipsync_available)()                                | `(ok, reason)` — whether the opt-in lip-sync tier can run here.                 |
|-----------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------|
| [`lipsync_tracks`](_autosummary/muvid.footage.scoring.lipsync.html.md#muvid.footage.scoring.lipsync.lipsync_tracks)(clip_path, \*, clip_id, ...[, ...]) | Per-clip LSE-C / LSE-D tracks vs the OFFSET-ALIGNED master vocal stem, or `[]`. |
| [`separate_master_vocals`](_autosummary/muvid.footage.scoring.lipsync.html.md#muvid.footage.scoring.lipsync.separate_master_vocals)(song_path, \*, out_dir)     | Demucs → the master vocal stem as a wav (`out_dir/vocals.wav`), or `None`.      |

### muvid.footage.scoring.lipsync.lipsync_available()

`(ok, reason)` — whether the opt-in lip-sync tier can run here.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`bool`](https://docs.python.org/3/builtins/functions.html#bool), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### muvid.footage.scoring.lipsync.lipsync_tracks(clip_path, , clip_id, offset_s, duration_s, coverage, vocal_stem_path, t0, hop_s, n, device='cpu')

Per-clip LSE-C / LSE-D tracks vs the OFFSET-ALIGNED master vocal stem, or `[]`.

Feeds SyncNet the co-temporal slice of the master vocals (`[offset_s, offset_s+dur]`) so
the score is meaningful for clips whose offset exceeds SyncNet’s tiny internal search;
holds the per-face-track LSE-C over that track’s span, clamped to the clip’s coverage
(never fabricates lip-sync beyond the clip); spans with no detected face are NA.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`ScoreTrack`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.ScoreTrack)]

### muvid.footage.scoring.lipsync.separate_master_vocals(song_path, , out_dir)

Demucs → the master vocal stem as a wav (`out_dir/vocals.wav`), or `None`.

Computed ONCE on the clean master; the resulting stem is passed to every clip’s SyncNet
call as the co-temporal reference audio. Returns `None` (never raises) if Demucs is
absent — the caller then skips lip-sync.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`None`](https://docs.python.org/3/builtins/constants.html#None)


# _autosummary/muvid.footage.scoring.motionbeat.html.md

# muvid.footage.scoring.motionbeat

Motion-to-beat tier: `motion_beat_bas` + `motion_onset_xcorr` → song-grid tracks.

Pure given a [`FramePass`](_autosummary/muvid.footage.scoring.frames.html.md#muvid.footage.scoring.frames.FramePass) (the camera-compensated motion
envelope) + the master `mixing.audio.BeatGrid` (computed ONCE on the clean song). No
per-clip beat/onset recomputation — everything maps to song time via the clip’s offset.

- `motion_beat_bas` — a per-beat Beat Alignment Score (AIST++ [11] idea, localized): each
  audio beat scores how close its nearest MOTION peak is (`exp(−(Δt/σ)²/2)`); the score is
  held over that beat’s interval. NA where the clip has no motion (static / no person) — never
  0 (a 0 would penalize valid instrumental footage). Person-present signal.
- `motion_onset_xcorr` — the clip-level normalized cross-correlation of the motion envelope
  vs the master onset envelope at the best lag within a bounded A/V-latency window (the lag
  refines per-clip capture latency). Content-agnostic (covers no-person clips). Held over the
  clip’s coverage.

Commercial-clean (librosa beats via `mixing[beats]`, numpy motion). See the design §3b.

### Functions

| [`motionbeat_tracks`](_autosummary/muvid.footage.scoring.motionbeat.html.md#muvid.footage.scoring.motionbeat.motionbeat_tracks)(frame_pass, \*, clip_id, ...)   | The two motion-to-beat tracks for one clip.   |
|----------------------------------------------------------------------------------------------------|-----------------------------------------------|

### muvid.footage.scoring.motionbeat.motionbeat_tracks(frame_pass, , clip_id, offset_s, beat_times, onset_env, onset_hop_s, t0, hop_s, n)

The two motion-to-beat tracks for one clip.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`ScoreTrack`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.ScoreTrack)]


# _autosummary/muvid.footage.scoring.orchestrator.html.md

# muvid.footage.scoring.orchestrator

The scoring orchestrator: a project (song + aligned clips) → the persisted score tensor.

Owns the shared, compute-once artifacts and the single decode pass per clip (the design
review’s fix for the double-decode / camera-motion-sharing problem):

1. **Master beat grid** (`mixing.audio.beat_grid`) — computed ONCE; every clip maps to it
   via its offset.
2. (opt-in) **Master vocal stem** (Demucs) — ONCE, for the lip-sync tier.
3. **Per clip: ONE decode pass** ([`sample_clip_frames()`](_autosummary/muvid.footage.scoring.frames.html.md#muvid.footage.scoring.frames.sample_clip_frames))
   feeds BOTH quality and motion-beat (sharpness/exposure/face + the camera-compensated motion
   envelope come out of the same loop). Plus shot boundaries (PySceneDetect) and, when enabled,
   the lip-sync tracks.
4. Assemble + **persist** the tensor (per-metric-global normalization, atomic writes).

Resource safety (LOCKED decisions): a **process-wide concurrency=1 semaphore**
(`MUVID_SCORING_MAX_CONCURRENT`) serializes scoring runs; the lip-sync tier is \*\*off by
default\*\* (`MUVID_SCORING_ENABLE_LIPSYNC`); `should_cancel` is polled between every clip
and stage (cancel latency ≈ one clip); progress is emitted as `{'kind':'progress',
'stage_index','stage_count','current_transform'}` dicts (the nw.jobs mirror contract).

Import-safe: every heavy import (cv2/mediapipe/librosa/torch) is inside a function body.

### Module Attributes

| [`DEFAULT_METRICS`](_autosummary/muvid.footage.scoring.orchestrator.html.md#muvid.footage.scoring.orchestrator.DEFAULT_METRICS)   | The torch-free CORE metric set (prod-safe).   |
|--------------------------------------------------------------------|-----------------------------------------------|

### Functions

| [`list_available_extractors`](_autosummary/muvid.footage.scoring.orchestrator.html.md#muvid.footage.scoring.orchestrator.list_available_extractors)()                       | Which tiers can run here (import + weight availability) — for diagnostics / the tool.   |
|----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------|
| [`score_project`](_autosummary/muvid.footage.scoring.orchestrator.html.md#muvid.footage.scoring.orchestrator.score_project)(project, \*[, metrics, hop_s, ...]) | Score every aligned clip of `project` → persist the tensor; return a summary dict.      |

### muvid.footage.scoring.orchestrator.DEFAULT_METRICS *= ('sharpness', 'exposure', 'stability_shake', 'face_framing', 'motion_beat_bas', 'motion_onset_xcorr')*

The torch-free CORE metric set (prod-safe). Lip-sync metrics are added only when the
opt-in tier is enabled.

### muvid.footage.scoring.orchestrator.list_available_extractors()

Which tiers can run here (import + weight availability) — for diagnostics / the tool.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.scoring.orchestrator.score_project(project, , metrics=None, hop_s=0.1, sample_fps=None, enable_lipsync=None, progress_cb=None, should_cancel=None)

Score every aligned clip of `project` → persist the tensor; return a summary dict.

* **Parameters:**
  * **project** – a `MusicVideoFootageProject` (needs `song_path`/`song_duration`/
    `load_alignments`/`clip_paths`/`root`/`song_hash`).
  * **metrics** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`Sequence`](https://docs.python.org/3/library/typing.html#typing.Sequence)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]) – restrict to these metric names (default: the core set, + lip-sync if enabled).
  * **hop_s** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – grid step (default 10 Hz).
  * **sample_fps** ([`float`](https://docs.python.org/3/builtins/functions.html#float) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – frame analysis rate (default from `frames.DEFAULT_SAMPLE_FPS`).
  * **enable_lipsync** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – force the opt-in tier on/off (default: the env flag).
  * **progress_cb** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)], [`None`](https://docs.python.org/3/builtins/constants.html#None)]]) – sink for `{'kind':'progress', ...}` dict events (nw.jobs mirror shape).
  * **should_cancel** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[], [`bool`](https://docs.python.org/3/builtins/functions.html#bool)]]) – polled between clips/stages; a True short-circuits to a clean cancel.
* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)


# _autosummary/muvid.footage.scoring.quality.html.md

# muvid.footage.scoring.quality

Quality tier: sharpness / exposure / stability_shake / face_framing → song-grid tracks.

Pure given a [`FramePass`](_autosummary/muvid.footage.scoring.frames.html.md#muvid.footage.scoring.frames.FramePass) (one decode pass, shared with
[`motionbeat`](_autosummary/muvid.footage.scoring.motionbeat.html.md#module-muvid.footage.scoring.motionbeat)). Each per-frame signal is mapped from clip time to
song time (`song_t = clip_t + offset_s`) and resampled onto the shared grid. All cheap-CPU
(OpenCV/MediaPipe), commercial-clean (Apache-2.0).

Gating: a per-frame `quality_ok` mask (sharpness/exposure above env-tunable FLOORS) is
computed so the orchestrator can AND it into coverage; the FLOORS default to disabled
(0.0) — an absolute blur/exposure threshold is fragile across cameras, so v1 prefers the
soft signal (sharpness/exposure as weighted metrics) and only hard-gates when the owner sets
a floor. See `misc/docs/footage_scoring_design.md` §3a.

### Module Attributes

| [`SHARPNESS_FLOOR`](_autosummary/muvid.footage.scoring.quality.html.md#muvid.footage.scoring.quality.SHARPNESS_FLOOR)   | Env-tunable hard gate floors (default disabled → no hard gate; soft metric only).   |
|--------------------------------------------------------------------|-------------------------------------------------------------------------------------|

### Functions

| [`quality_gate_mask`](_autosummary/muvid.footage.scoring.quality.html.md#muvid.footage.scoring.quality.quality_gate_mask)(frame_pass, \*, offset_s, ...)   | A `quality_ok` grid mask (True = usable), from the env FLOORS (default: all True).   |
|-----------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------|
| [`quality_tracks`](_autosummary/muvid.footage.scoring.quality.html.md#muvid.footage.scoring.quality.quality_tracks)(frame_pass, \*, clip_id, ...)       | Sharpness / exposure / stability_shake / face_framing tracks for one clip.           |

### muvid.footage.scoring.quality.SHARPNESS_FLOOR *= 0.0*

Env-tunable hard gate floors (default disabled → no hard gate; soft metric only).

### muvid.footage.scoring.quality.quality_gate_mask(frame_pass, , offset_s, t0, hop_s, n)

A `quality_ok` grid mask (True = usable), from the env FLOORS (default: all True).

The orchestrator ANDs this into the composite mask so a truly-unusable (black / blown /
frozen) frame is excluded from selection. Disabled by default to avoid over-masking.

* **Return type:**
  `ndarray`

### muvid.footage.scoring.quality.quality_tracks(frame_pass, , clip_id, offset_s, t0, hop_s, n)

Sharpness / exposure / stability_shake / face_framing tracks for one clip.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`ScoreTrack`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.ScoreTrack)]


# _autosummary/muvid.footage.scoring.segment.html.md

# muvid.footage.scoring.segment

Shot boundaries (PySceneDetect) + the per-clip coverage mask.

- `coverage_mask` is dependency-free: the boolean of song-grid frames a clip actually
  covers (from its offset + duration). It is the base mask every metric ANDs with, and the
  authority for “does this clip exist at song time t”.
- `shot_boundaries` returns song-times of within-clip scene cuts (PySceneDetect, BSD-3).
  Consumed by the selector ONLY when `boundary_mode="beats+shots"` (they become *inter-clip*
  cut candidates at shot-boundary times — a within-clip jump cut is unrepresentable in the
  single-offset EDL model). Optional dep: absent → `[]` and the mode silently degrades to
  beats-only.

### Functions

| [`coverage_mask`](_autosummary/muvid.footage.scoring.segment.html.md#muvid.footage.scoring.segment.coverage_mask)(\*, offset_s, duration_s, ...)   | Grid frames (bool[n]) the clip covers — using its clamped `coverage` span.     |
|-------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------|
| [`shot_boundaries`](_autosummary/muvid.footage.scoring.segment.html.md#muvid.footage.scoring.segment.shot_boundaries)(clip_path, \*, offset_s)       | Song-times of within-clip scene cuts (PySceneDetect ContentDetector), or `[]`. |

### muvid.footage.scoring.segment.coverage_mask(, offset_s, duration_s, coverage, t0, hop_s, n)

Grid frames (bool[n]) the clip covers — using its clamped `coverage` span.

* **Return type:**
  `ndarray`

### muvid.footage.scoring.segment.shot_boundaries(clip_path, , offset_s)

Song-times of within-clip scene cuts (PySceneDetect ContentDetector), or `[]`.

Returns `[]` (never raises) if PySceneDetect is not installed, so the selector’s
`beats+shots` mode degrades cleanly to beats-only.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]


# _autosummary/muvid.footage.select_score.html.md

# muvid.footage.select_score

The score-driven `weighted` selection strategy: a beat-snapped semi-Markov Viterbi DP.

Given the fused score tensor `S[clip, frame, metric]` (from [`muvid.footage.scoring.grid`](_autosummary/muvid.footage.scoring.grid.html.md#module-muvid.footage.scoring.grid))
and the master beat grid, choose which clip is on-air over each span of the song so that the
weighted composite reward is maximized, cuts land only on beat (or beat∪shot) boundaries,
shot lengths obey `[L_min, L_max]`, and each switch pays a Potts penalty `λ_switch`. The
same optimizer the Phase-2 editor steers (a manual pin becomes a hard constraint a re-solve
fills around).

The recurrence is the corrected form from the design’s algorithm review
(`misc/docs/footage_scoring_design.md` §4c′) — the load-bearing details:

- **Feasibility is CONTINUOUS containment** (`b_i ≥ offset_c − _EPS` and
  `b_j ≤ offset_c + duration_c + _EPS`), the *exact* check `validate_edl` runs — NOT a
  grid-frame mask (a sub-hop overhang would slip past a 10 Hz mask and make `validate_edl`
  raise). So the emitted EDL passes `validate_edl` by construction.
- **Reward is a TIME integral of the normalized composite** `ĝ = Σ_m w_m·S·M / W ∈ [0,1]`:
  `R(c,i,j) = hop_s·(P[c][κ(b_j)] − P[c][κ(b_i)])` (prefix-summed → O(1) per segment,
  hop/tempo-independent). A switch then costs `λ_switch` directly in those units
  (“a cut must earn `λ_switch` seconds of perfect-footage reward”).
- **L_min is relaxed on the first & last segment**; an infeasible timeline is *classified*
  (coverage gap vs dwell-infeasible) and retried with `L_min→0` before any fallback —
  never a silent junk EDL.
- The `allowed(i,j)` clip-domain hook + the injectable boundary set make the Phase-2
  manual-pin re-solve a *pruning*, not a reformulation.
- **An unvouched alignment is a reward penalty, not a pruning** (muvid#88): a clip the
  aligner will not vouch for is charged [`UNVOUCHED_REWARD_PENALTY`](_autosummary/muvid.footage.select_score.html.md#muvid.footage.select_score.UNVOUCHED_REWARD_PENALTY) reward-seconds
  per second, which is larger than the composite’s whole range and therefore ranks trust
  above every METRIC, while leaving the clip reachable where it is the only coverage.
  Pruning it would be a second trust gate; scoring it as a metric column would let
  sharpness outbid it. It does **not** dominate the DP’s non-composite terms — see the
  constant, which says what it does and does not buy.

numpy only (no cv2/torch): registered LAZILY in [`muvid.footage.strategy`](_autosummary/muvid.footage.strategy.html.md#module-muvid.footage.strategy) so
`import muvid.footage` never pulls numpy.

### Module Attributes

| [`DEFAULT_WEIGHTS`](_autosummary/muvid.footage.select_score.html.md#muvid.footage.select_score.DEFAULT_WEIGHTS)          | Default per-metric weights.                                                 |
|---------------------------------------------------------------------------|-----------------------------------------------------------------------------|
| [`PRESETS`](_autosummary/muvid.footage.select_score.html.md#muvid.footage.select_score.PRESETS)                  | Named presets — a filled-in config.                                         |
| [`UNVOUCHED_REWARD_PENALTY`](_autosummary/muvid.footage.select_score.html.md#muvid.footage.select_score.UNVOUCHED_REWARD_PENALTY) | Reward-seconds per second charged to a clip the aligner will not vouch for. |

### Functions

| [`resolve_config`](_autosummary/muvid.footage.select_score.html.md#muvid.footage.select_score.resolve_config)(\*[, preset, weights, config])     | Build a [`WeightedSelectionConfig`](_autosummary/muvid.footage.select_score.html.md#muvid.footage.select_score.WeightedSelectionConfig) from an optional preset + overrides.                         |
|----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------|
| [`run_weighted`](_autosummary/muvid.footage.select_score.html.md#muvid.footage.select_score.run_weighted)(alignments, song_duration, context)  | Run the DP and return `(edl, meta)`; `meta` carries any fallback + its cause.                                                                 |
| [`selection_margin`](_autosummary/muvid.footage.select_score.html.md#muvid.footage.select_score.selection_margin)(alignments, tensor, \*[, ...])   | Per-frame `best_composite − 2nd_best_composite` over clips COVERING that frame.                                                               |
| [`weighted_selection`](_autosummary/muvid.footage.select_score.html.md#muvid.footage.select_score.weighted_selection)(alignments, song_duration, \*) | The `weighted` [`SelectionStrategy`](_autosummary/muvid.footage.strategy.html.md#muvid.footage.strategy.SelectionStrategy) (score-driven DP). |

### Classes

| [`SelectionContext`](_autosummary/muvid.footage.select_score.html.md#muvid.footage.select_score.SelectionContext)(tensor[, beat_times, ...])   | Everything the score-driven strategy needs beyond `(alignments, song_duration)`.   |
|------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------|
| [`WeightedSelectionConfig`](_autosummary/muvid.footage.select_score.html.md#muvid.footage.select_score.WeightedSelectionConfig)([weights, ...])       | The score-driven "strategy" as a pure config object (open-closed).                 |

### muvid.footage.select_score.DEFAULT_WEIGHTS *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [float](https://docs.python.org/3/builtins/functions.html#float)]* *= {'exposure': 0.3, 'face_framing': 0.4, 'lip_sync_lse_c': 1.0, 'motion_beat_bas': 0.8, 'motion_onset_xcorr': 0.5, 'sharpness': 0.4, 'stability_shake': 0.3}*

Default per-metric weights. A metric absent from the tensor collapses to weight 0
(its column simply doesn’t exist), so a project scored without the lip-sync tier still
selects cleanly on the metrics it has.

### muvid.footage.select_score.PRESETS *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [WeightedSelectionConfig](_autosummary/muvid.footage.select_score.html.md#muvid.footage.select_score.WeightedSelectionConfig)]* *= {'contemplative': WeightedSelectionConfig(weights={'lip_sync_lse_c': 1.0, 'motion_beat_bas': 0.8, 'motion_onset_xcorr': 0.5, 'sharpness': 0.6, 'exposure': 0.3, 'face_framing': 0.6, 'stability_shake': 0.3}, lambda_switch=0.6, l_min_s=3.0, l_max_s=12.0, l_max_overrun_penalty=0.05, boundary_mode='beats', beat_unit='beat'), 'energetic': WeightedSelectionConfig(weights={'lip_sync_lse_c': 1.0, 'motion_beat_bas': 1.0, 'motion_onset_xcorr': 0.8, 'sharpness': 0.4, 'exposure': 0.3, 'face_framing': 0.4, 'stability_shake': 0.3}, lambda_switch=0.2, l_min_s=0.8, l_max_s=4.0, l_max_overrun_penalty=0.25, boundary_mode='beats', beat_unit='beat')}*

Named presets — a filled-in config. “energetic” = many short cuts; “contemplative” = long
dwells, few cuts.

### *class* muvid.footage.select_score.SelectionContext(tensor, beat_times=(), downbeat_times=(), shot_boundaries=None, pins=None, config=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Everything the score-driven strategy needs beyond `(alignments, song_duration)`.

`tensor` is the fused [`ScoreTensor`](_autosummary/muvid.footage.scoring.grid.html.md#muvid.footage.scoring.grid.ScoreTensor) (or `None` → the
strategy raises a clear “run scoring first”). `shot_boundaries` (clip_id → song-times)
is consumed only when `config.boundary_mode == "beats+shots"`. `pins` (Phase 2) is a
list of `(song_start, song_end, clip_id)` hard constraints.

### muvid.footage.select_score.UNVOUCHED_REWARD_PENALTY *= 2.0*

Reward-seconds per second charged to a clip the aligner will not vouch for. The
composite ĝ is normalized to `[0, 1]`, so anything `> 1.0` dominates it: over the
same span a vouched clip at ĝ=0 outscores an unvouched one at ĝ=1, whatever the
weights say. It is a demotion and not a prohibition — where nothing else covers the
span the DP must still use the clip, pays the penalty on every path equally, and the
choice is unaffected.

**What it does NOT buy, measured rather than assumed.** “No weighting can buy an
unvouched clip a span another clip covers” is true of the reward INTEGRAL and false of
the DP as a whole, because two of the DP’s terms are not composite reward at all:

- `l_max_overrun_penalty` is caller-settable through `config`. At 0.9, a single
  40 s vouched clip is cut away from every 10 s: measured, `BAD` at 10-12, 20-22 and
  30-32 s, all of them spans the vouched clip covers.
- `_viterbi`’s transition window `max_seg_s` (4x `l_max` = 32 s at the defaults)
  is a PERFORMANCE bound, and consecutive segments must be different clips — so a
  vouched take longer than the cap cannot be one segment and the optimizer is forced
  to cut away and back. Measured under the default config, a 2 s opener plus one 58 s
  vouched clip gives `[(0,2,O), (2,34,V), (34,36,BAD), (36,60,V)]`.

Raising the penalty does not fix either: both are structural, not a scoring tie the
penalty is competing in. What repairs them is
`_absorb_neighbour()`, which hands such a span back to the
vouched cut on either side of it — losslessly, because that clip already covers it —
rather than letting the recovery gap footage the shoot actually has. Keep that pairing
in mind before changing either half.

Not a config field, and not a tensor column, deliberately. A column would be
*commensurate* with sharpness and lip-sync — a sharp unvouched clip could outrank a
dull vouched one, which is exactly the trade muvid#88 says must not be available —
and a config field would let the preference be tuned away silently by a caller
passing `config=` to the MCP tool.

### *class* muvid.footage.select_score.WeightedSelectionConfig(weights=<factory>, lambda_switch=0.35, l_min_s=1.2, l_max_s=8.0, l_max_overrun_penalty=0.15, boundary_mode='beats', beat_unit='beat')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The score-driven “strategy” as a pure config object (open-closed).

Adding a metric = a tensor column + a weight; no new strategy code.

`l_max_s` is a SOFT target: a shot longer than it pays `l_max_overrun_penalty` per
second of overrun (in the same “reward-seconds” units as `lambda_switch`), rather than
being forbidden. So a lone/best clip rolls past `l_max_s` when the only alternative is
materially worse (no forced cutaway to bad footage), yet comparable clips still cut around
`l_max_s` for variety. Segments are always different-clip (each = one shot).

### muvid.footage.select_score.resolve_config(, preset=None, weights=None, config=None)

Build a [`WeightedSelectionConfig`](_autosummary/muvid.footage.select_score.html.md#muvid.footage.select_score.WeightedSelectionConfig) from an optional preset + overrides.

Precedence: preset (or the default) < `config` dict fields < explicit `weights`.

* **Return type:**
  [`WeightedSelectionConfig`](_autosummary/muvid.footage.select_score.html.md#muvid.footage.select_score.WeightedSelectionConfig)

### muvid.footage.select_score.run_weighted(alignments, song_duration, context)

Run the DP and return `(edl, meta)`; `meta` carries any fallback + its cause.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry)], [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

### muvid.footage.select_score.selection_margin(alignments, tensor, , weights=None)

Per-frame `best_composite − 2nd_best_composite` over clips COVERING that frame.

A LOCAL “where a human should decide” proxy (small margin = a toss-up), distinct from the
DP’s global path optimality. `NaN` where fewer than 2 clips cover the frame.

* **Return type:**
  `ndarray`

### muvid.footage.select_score.weighted_selection(alignments, song_duration, , context=None)

The `weighted` [`SelectionStrategy`](_autosummary/muvid.footage.strategy.html.md#muvid.footage.strategy.SelectionStrategy) (score-driven DP).

Requires a [`SelectionContext`](_autosummary/muvid.footage.select_score.html.md#muvid.footage.select_score.SelectionContext) carrying a score tensor. Raises a clear error if
scores are absent so the MCP layer can say “run muvid_score_footage first”.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry)]


# _autosummary/muvid.footage.service.html.md

# muvid.footage.service

The footage operations — one function per thing you can do to a music-video project.

\*\*This module is the single source of truth for the `music_video` genre’s operations.\*\*
Every surface reaches them from here and lists them from `FOOTAGE_OP_SPECS`, never
again by hand:

- the MCP connector ([`muvid.mcp.footage_tools`](_autosummary/muvid.mcp.footage_tools.html.md#module-muvid.mcp.footage_tools), [`muvid.mcp.scoring_tools`](_autosummary/muvid.mcp.scoring_tools.html.md#module-muvid.mcp.scoring_tools))
  resolves `project_id` to the caller’s workspace project, calls the function, and
  turns a [`FootageError`](_autosummary/muvid.footage.errors.html.md#muvid.footage.errors.FootageError) into a `ToolError`;
- a host that serves the genre (reelee’s studio) reads the catalogue nw holds —
  `nw.genre_ops("music_video")`, registered from `FOOTAGE_OP_SPECS` by
  `muvid.genre_music_video` — and calls the same functions on a host-placed
  `muvid.Project`’s `footage`.

Contract of every operation here:

- the first argument is a [`MusicVideoFootageProject`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.MusicVideoFootageProject)
  (`fp`), everything else is keyword-only and JSON-able;
- the result is a JSON-able `dict` (no `project_id` — the transport knows which
  project it opened);
- a refusal raises [`FootageError`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.FootageError) with a message naming the next action — never a
  transport’s error type, never a raw traceback for a caller mistake;
- the docstring is **model-facing**: it is what an assistant reads to decide whether and
  how to call the operation, so it says what the operation changes and what to read in
  its reply.

Media arrives as a LOCAL file (`set_song(fp, path=...)`, `add_clip(fp, path=...)`):
fetching a URL is the MCP connector’s business, streaming an upload the host’s. Either
way the file is COPIED into the project, so the caller may delete it afterwards.

**Import-light by design** (stdlib + [`muvid.footage.edl`](_autosummary/muvid.footage.edl.html.md#module-muvid.footage.edl) at module top): a host
imports the genre module, which imports this one to read the operations’ signatures, and
must not pay for numpy/ffmpeg/fastmcp to build a catalogue.

The named-edit operations (`save_edit`, `set_cut`, `split_cut`, `merge_cut`,
`replace_edit`, `render(edit_id=...)`) are what make “change cut 7 and render again”
possible: before them, an EDL was persisted only inside a render’s `meta.json`. An edit
lives at `footage/edits/<edit_id>.json` and every change to it goes through
`validate_edl` — structurally, with `allow_unreliable=True` (an edit is a plan; the
trust refusal belongs where the encode does, in [`render()`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.render)).

### Module Attributes

| [`EDL_OPTIONAL_FIELDS`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.EDL_OPTIONAL_FIELDS)   | Every optional [`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry) field [`edl_json()`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.edl_json) carries, and how to render it.   |
|------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|

### Functions

| [`status`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.status)(fp)                                       | Where the music video stands: the song, the videos and where each sits on the song, the saved edits, the finished videos, and the next useful step.                                                                                                                     |
|---------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`set_song`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.set_song)(fp, \*, path[, ext, filename, ...])     | Set the project's song — the clean master every video is aligned to and whose audio the finished video uses.                                                                                                                                                            |
| [`add_clip`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.add_clip)(fp, \*, path[, name, filename, ...])    | Add one video or photo to the project.                                                                                                                                                                                                                                  |
| [`remove_clip`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.remove_clip)(fp, \*, clip_id)                     | Remove one footage video from the project — its stored file and its entry.                                                                                                                                                                                              |
| [`align`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.align)(fp)                                        | Find where each video sits on the song by listening to its own audio, and save it.                                                                                                                                                                                      |
| [`set_offset`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.set_offset)(fp, \*, clip_id, offset_s)            | Place one video on the song BY HAND: the song time at which the video's own first frame plays (negative = the video starts before the song does).                                                                                                                       |
| [`clear_offset`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.clear_offset)(fp, \*, clip_id)                    | Forget where I placed this video: remove a hand-declared offset, so the next `align` measures the clip by its audio instead.                                                                                                                                            |
| [`set_has_song`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.set_has_song)(fp, \*, clip_id, has_song)          | Say whether a video has the song in its own sound — overriding what listening found.                                                                                                                                                                                    |
| [`timeline`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.timeline)(fp)                                     | Which videos cover which spans of the song (overlaps shown), from the saved alignment — the map for choosing what to cut to.                                                                                                                                            |
| [`beat_grid`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.beat_grid)(fp)                                    | The song's beat grid — tempo and beat instants on the song timeline — without looking at the footage.                                                                                                                                                                   |
| [`peaks`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.peaks)(fp, \*[, n])                               | The song's waveform, to draw under the timeline: `n` equal slices of the song, each the loudest moment in it (mono), scaled so the loudest slice is 1.0.                                                                                                                |
| [`beat_signals`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.beat_signals)(fp, \*[, source, max_points])       | Where the beat is in the song or in one video — CONTINUOUS signals, to look at, threshold and bend, not only beat instants.                                                                                                                                             |
| [`filmstrips`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.filmstrips)(fp)                                   | Every video's filmstrip — thumbnails to draw each camera's lane.                                                                                                                                                                                                        |
| [`filmstrip`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.filmstrip)(fp, \*, clip_id)                       | One video's filmstrip (the same record `filmstrips` gives per clip, with its `clip_id` and `fps`).                                                                                                                                                                      |
| [`score`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.score)(fp, \*[, hop_s, metrics, should_cancel])   | Look at the footage: score every placed video, on the song's own timeline — picture quality and how its movement sits on the beat — and save the curves.                                                                                                                |
| [`scores`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.scores)(fp, \*[, clip_id, metrics, max_points])   | The saved footage curves — for the lanes under each video, and for inspection.                                                                                                                                                                                          |
| [`strategies`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.strategies)([fp])                                 | The ways to cut on offer — the selection strategies `propose_edit` accepts (`weighted` reads the footage scores; the rest use only the alignment).                                                                                                                      |
| [`propose_edit`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.propose_edit)(fp, \*[, strategy, preset, ...])    | Cut it for me: build an edit of the whole song — or of `span` (`[start_s, end_s]`, the part of the song the video covers) — from the placed videos, and (by default) save it as a named edit, without rendering anything.                                               |
| [`save_edit`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.save_edit)(fp, \*, edl[, name, how_made, ...])    | Save a cut list as a new named edit.                                                                                                                                                                                                                                    |
| [`edits`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.edits)(fp)                                        | The saved edits, oldest first: each one's `edit_id`, `name`, how it was made, how many cuts it has, and `problem` — why it would not validate against the current alignment (`null` when it does).                                                                      |
| [`explain_edit`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.explain_edit)(fp, \*, edit_id[, start_s, end_s])  | Why each cut of an edit is where it is — the post-mortem, in plain words.                                                                                                                                                                                               |
| [`get_edit`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.get_edit)(fp, \*, edit_id)                        | One saved edit: its cut list (`edl`, every span of the song, gaps as `clip_id: null`), its name and history, and a `coverage` report.                                                                                                                                   |
| [`replace_edit`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.replace_edit)(fp, \*, edit_id, edl)               | Replace a saved edit's whole cut list — the power tool for rewriting an edit at once.                                                                                                                                                                                   |
| [`set_cut`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.set_cut)(fp, \*, edit_id, index[, clip_id, ...])  | Change one cut of a saved edit (`index` is its position in `get_edit`'s edl).                                                                                                                                                                                           |
| [`split_cut`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.split_cut)(fp, \*, edit_id, at_s)                 | Split the cut playing at song time `at_s` into two cuts of the same video.                                                                                                                                                                                              |
| [`merge_cut`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.merge_cut)(fp, \*, edit_id, index[, into])        | Join cut `index` to its neighbour: the neighbour (`into` "previous" or "next") takes over its span, so the neighbour's video must cover it.                                                                                                                             |
| [`fit_to_beat`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.fit_to_beat)(fp, \*, edit_id[, indices, ...])     | Fit the moves to the beat: for each cut that shows a video in the part of the song the edit covers (its span) — or the cuts at `indices` — find the slip and speed that put its video's movement accents on the song's beat, and apply it where the evidence is strong. |
| [`set_span`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.set_span)(fp, \*, edit_id, start_s, end_s)        | Choose which part of the song the video covers — where it starts and ends.                                                                                                                                                                                              |
| [`rename_edit`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.rename_edit)(fp, \*, edit_id, name)               | Give an edit a new name — what the edit picker and the renders made from it show.                                                                                                                                                                                       |
| [`looks`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.looks)([fp])                                      | The looks a cut can take — camera moves (punch in, slow push, slow pull, pans) and grades (vivid, black and white, posterize, cartoon) — each with its `params_schema`.                                                                                                 |
| [`undo_edit`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.undo_edit)(fp, \*, edit_id)                       | Undo the last change to a saved edit (a cut changed, split, joined, the span, a whole replacement — by a person or by the assistant).                                                                                                                                   |
| [`redo_edit`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.redo_edit)(fp, \*, edit_id)                       | Redo the change `undo_edit` last took back.                                                                                                                                                                                                                             |
| [`delete_edit`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.delete_edit)(fp, \*, edit_id)                     | Delete a saved edit.                                                                                                                                                                                                                                                    |
| [`render`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.render)(fp, \*, edit_id[, canvas, ...])           | Make the video: render a saved edit onto the canvas, over the clean song.                                                                                                                                                                                               |
| [`renders`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.renders)(fp)                                      | The finished videos, newest first: each one's `render_id`, speakable `ref`, the `edit_id` it was made from, its `label`, canvas, `ok`, the number of `warnings`, and `artifact_id` to play it by when the project is hosted.                                            |
| [`editor_document`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.editor_document)(fp)                              | The project as lacing-native standoff annotations, for a multitrack editor.                                                                                                                                                                                             |
| [`assemble`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.assemble)(fp, \*[, strategy, edl, preset, ...])   | Assemble and render a music video — auto (a `strategy`) or an explicit `edl`.                                                                                                                                                                                           |
| [`declared_alignment`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.declared_alignment)(clip_id, offset_s, \*, ...)   | The alignment record of a DECLARED offset — coverage computed, nothing measured.                                                                                                                                                                                        |
| [`edl_from_annotations`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.edl_from_annotations)(fp, \*, annotations)        | The editor's DECISION-tier annotations turned back into a cut list (`edl`), ready for `save_edit` / `replace_edit` — a faithful read, not a re-selection.                                                                                                               |
| [`edl_json`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.edl_json)(e)                                      | One EDL entry as JSON — full precision (it must feed back verbatim), gaps as null.                                                                                                                                                                                      |
| [`coverage_report`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.coverage_report)(entries, aligns, song_dur, \*)   | What the song's timeline looks like under `entries` — covered, weak, MISSING.                                                                                                                                                                                           |
| [`exclusion_note`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.exclusion_note)(x)                                | One `warnings` line per span the auto path set aside (muvid#88).                                                                                                                                                                                                        |
| [`assemble_refusal`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.assemble_refusal)(entries, aligns, song_dur, ...) | `None` if rendering this edit would go ahead; the refusal if it would not — put to the GATE rather than re-implemented here.                                                                                                                                            |
| [`resolve_canvas`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.resolve_canvas)(fp, canvas)                       | The render canvas: an explicit per-render override, else the project's.                                                                                                                                                                                                 |
| [`refresh_cover`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.refresh_cover)(fp)                                | Take the project's cover frame again — from the newest render, else from the first clip — and register it.                                                                                                                                                              |
| [`grab_cover_frame`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.grab_cover_frame)(video, dest)                    | Write one JPEG frame of `video` to `dest` — `COVER_AT_FRACTION` of the way in, `COVER_WIDTH` wide.                                                                                                                                                                      |
| [`import_render`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.import_render)(fp, \*, path, render_id[, ...])    | Bring a video finished ELSEWHERE into the project as a render (the importer's).                                                                                                                                                                                         |
| [`frame_size`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.frame_size)(video)                                | `[width, height]` of a video as DISPLAYED (a ±90° rotation swaps them).                                                                                                                                                                                                 |
| [`frame_rate`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.frame_rate)(video)                                | A video's average frame rate (frames per second), or `None` if unreadable.                                                                                                                                                                                              |
| [`public_render`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.public_render)(fp, meta)                          | A render record as a HOSTED surface may return it: `video` made relative to the project (`renders/<id>/final.mp4`) — never an absolute server path; play it by its `artifact_id`.                                                                                       |
| [`require_scorable`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.require_scorable)(fp)                             | The alignments a scoring run would use; refuses without a song or an alignment.                                                                                                                                                                                         |
| [`run_scoring`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.run_scoring)(fp, \*[, hop_s, metrics, ...])       | Score every aligned clip and persist the tensor (the engine behind [`score()`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.score), with the job hooks a background runner passes).                                                                                            |

### Classes

| [`OpSpec`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.OpSpec)(name, title, effect[, runs, hide, ...])   | One operation's catalogue row: the function (by `name` in this module), a plain-language `title` (it becomes a button and a command title), what it does to the project (`effect`: read | write | render | destroy) and how a host runs it (`runs`: now | job).   |
|---------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|

### Exceptions

| [`FootageError`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.FootageError)     | An operation refused — the message says why and what to do next.   |
|-------------------------------------------------------------------|--------------------------------------------------------------------|
| [`FootageCancelled`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.FootageCancelled) | An operation stopped between steps because its host asked it to.   |

### muvid.footage.service.EDL_OPTIONAL_FIELDS *= (('transition', <function <lambda>>, None), ('crop', <function <lambda>>, None), ('crop_end', <function <lambda>>, None), ('look', <class 'str'>, None), ('look_time_varying', <class 'bool'>, False), ('look_spec', <class 'dict'>, None), ('slip_s', <class 'float'>, 0.0), ('rate', <class 'float'>, 1.0), ('source_in', <class 'float'>, None))*

Every optional [`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry) field [`edl_json()`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.edl_json) carries,
and how to render it. **The list is the round trip.** `_as_entry` reads all of
these back by name, so a field missing from here is a direction the caller gave, the
renderer honoured, and the returned/persisted edit does not contain. Each row is
`(field, render, absent)`, where `absent` is the value that means “omit this
key” — a column rather than a hardcoded `None` because `look_time_varying`
(muvid#73) is a boolean whose absent value is `False`.

### *exception* muvid.footage.service.FootageCancelled

Bases: [`Exception`](https://docs.python.org/3/builtins/exceptions.html#Exception)

An operation stopped between steps because its host asked it to.

### *exception* muvid.footage.service.FootageError

Bases: [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError)

An operation refused — the message says why and what to do next.

### *class* muvid.footage.service.OpSpec(name, title, effect, runs='now', hide=(), host_params=(), max_upload_bytes=None, limits=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One operation’s catalogue row: the function (by `name` in this module), a
plain-language `title` (it becomes a button and a command title), what it does to
the project (`effect`: read | write | render | destroy) and how a host runs it
(`runs`: now | job). `hide` names parameters a transport fills that a caller
must not (`duration_s` — a probed fact, not a claim a caller makes; `annotate` —
a transport’s hook).

Host-agnostic data: `muvid.genre_music_video` turns these into `nw.GenreOp`
rows, and [`muvid.mcp`](_autosummary/muvid.mcp.html.md#module-muvid.mcp) derives its footage tools from the same names.

#### host_params *: [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), ...]* *= ()*

an upload’s
server-side `path` and original `filename`. Never in a client’s schema.

* **Type:**
  Parameters only the HOST supplies (`nw.GenreOp.host_params`)

#### limits *: [Mapping](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [int](https://docs.python.org/3/builtins/functions.html#int)]*

Count ceilings the op enforces (`max_videos`, `max_photos`), so a client can
show “8 of 12” and refuse the extra files before sending a byte (muvid#170).

#### max_upload_bytes *: [int](https://docs.python.org/3/builtins/functions.html#int) | [None](https://docs.python.org/3/builtins/constants.html#None)* *= None*

The op’s own ceiling for a host-streamed upload (`nw.GenreOp.max_upload_bytes`).

### muvid.footage.service.add_clip(fp, , path, name='', filename='', clip_id='', ext='', duration_s=None)

Add one video or photo to the project.

A video that is a recording of the song (a concert, a dance) is synced to it once
`align` has listened to it; any other video, and every photo, is cut to the music
instead (`propose_edit`). A photo (a file ending in one of
`STILL_SUFFIXES`) is stored upright and at most `STILL_MAX_SIDE` pixels
on its longest side, and moves on screen with a pan or zoom.

The file arrives from the host (an upload): `path` is where the host put it and
`filename` its original name; it is copied into the project. `name` is what the
video is called on screen (default: the original file name without its extension). `clip_id` fixes the id (default: a fresh one); an id already in the
project is refused. Size-, duration- and count-capped.

Run `align` afterwards: listening is how a video with the song in it gets its
place on the song. Returns the `clip_id`, its `name` and `duration` (and
`artifact_id` when hosted); a photo also `kind: "still"`, `width`, `height`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.align(fp)

Find where each video sits on the song by listening to its own audio, and save it.

Returns each clip’s offset, a confidence in [0,1], its `support` (the fraction of
the clip that agrees on that offset, `null` when the aligner took a single
whole-clip measurement), and its coverage of the song, plus these lists:

- `low_confidence` — clips that matched weakly, for reporting;
- `unreliable` — clips whose offset the aligner will NOT vouch for: most often a
  video that simply does not have the song in it (a day out, b-roll). These stay
  in the project, and `propose_edit` cuts them TO the music instead of syncing
  them (`footage_roles`) — unless listening heard the song in them without being
  able to place it (`heard_the_song`), or a person said they have the song
  (`set_has_song yes`). Those stay synced: the auto path prefers other clips over
  them and sets aside a span only they cover (`coverage.excluded`, muvid#88),
  which is then filled from the footage cut to the music, if there is any. Rendering still REFUSES an explicit edit that cuts to one, and still
  refuses an auto edit when NO clip is trustworthy, unless called with
  `allow_unreliable=true` — because a wrong offset does not fail, it renders a
  video out of sync with the song (muvid#59). Re-align, place the clip by hand
  (`set_offset`), accept the smaller edit, or opt in deliberately;
- `no_consensus` — clips too short to be put to a vote at all (under about 4.5 s).
  **A clip in this list can be marked reliable and still be wrong**, and no other
  field will say so: its offset rests on one measurement, judged by a confidence
  score that does not rank correctness in this band — measured on the muvid#59
  shoot, the WRONG offset scored highest of three (0.834 against 0.566 and 0.621),
  and on a repeating fixture a 4.4 s clip landing 8 s out is vouched at 0.381.
  Nothing is refused on this basis, because refusing would take the correct short
  clips with it. So if a short clip looks out of sync in the render, this list is
  the first place to look — and muvid#91 is where that trade-off is being decided.

A clip whose offset a person DECLARED (`set_offset`) is ALWAYS left alone — a
person placed it, usually because the aligner got it wrong (muvid#59) — and is named
in `kept_declared`. To have one measured again, `clear_offset` it first. Every
measured record says `source: "measured"`.

Run this after adding/removing clips and before cutting.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.assemble(fp, , strategy='', edl=None, preset='', weights=None, config=None, canvas='', allow_unreliable=False, edit_id=None, label='', annotate=None, span=None, should_cancel=None)

Assemble and render a music video — auto (a `strategy`) or an explicit `edl`.

The engine behind [`render()`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.render) and the MCP `assemble_music_video` tool (whose
docstring is the full caller-facing contract). Validation is the ONE gate
(`validate_edl`), with the trust refusal ON unless `allow_unreliable`. Writes
`renders/<render_id>/final.mp4` + `meta.json` and returns the meta.

`edit_id` / `label` are recorded in the meta; `annotate(render_id, ref_n)` is
the transport’s hook for keys only it can fill (the MCP download claim) — merged into
the meta before it is written. `span` (an explicit `edl`’s part of the song)
renders only that stretch: the video AND the song cut to it, the song faded out over
`TAIL_FADE_S` when the span ends before the song does.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.assemble_refusal(entries, aligns, song_dur, canvas)

`None` if rendering this edit would go ahead; the refusal if it would not —
put to the GATE rather than re-implemented here.

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

### muvid.footage.service.beat_grid(fp)

The song’s beat grid — tempo and beat instants on the song timeline — without
looking at the footage.

Computed once on the song (never per clip — clips map to it through their offsets)
and cached under the project keyed on the song’s content hash, so the second call is
a file read; a project that has been scored is served from that run instead.
`source` says which (`computed` | `cache` | `scores`).

Needs the `scoring` extra (librosa); without it the refusal names the install.
Needs a song; no alignment is required.

Returns `tempo_bpm`, `beats` (seconds, ascending), `n_beats`,
`song_duration` and `source`, and for numbering bars: `downbeats` (the beats
that start a bar), `downbeats_source` — `measured` (the estimator found them),
`derived` (the beat phase carrying the most onset energy, `beats_per_bar` beats
to a bar — muvid.montage’s rule) or `first_beat` (no onset energy to vote with, so
bars start on the first beat) — `beats_per_bar` and `bar_of_beat` (each beat’s
bar number, 1 for the first bar, 0 for a pickup before it).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.beat_signals(fp, , source='song', max_points=1000)

Where the beat is in the song or in one video — CONTINUOUS signals, to look at,
threshold and bend, not only beat instants.

`source` is `"song"` or a clip id. The song gets its sound (`audio_onset`: the
onset envelope the beat grid is estimated from). A video gets its own soundtrack’s
`audio_onset` when it has one, and three visual signals: `motion` (how much the
people in the picture move, the camera’s own move taken out), `motion_stops` (moves
stopping dead and turning — the movement accents) and `motion_stops_local` (the same,
place by place).

Each signal is in the media’s OWN time: sample `i` is at `t0 + i * hop_s` s of the
song, or of the clip (song time `offset + t`). Values are unnormalised, with
`min`, `max` and `p99` beside them; `None` is a sample that was not measured.
`max_points` pools each signal to at most that many samples by their maximum, so
a peak survives (0 = every sample; an editor drawing it wants that).

Measured once per media and kept (a video’s first call reads every frame and takes
tens of seconds; a second call for the same video waits for the first rather than
measuring again). Needs the `scoring` extra.

Returns `{source, kind: audio|video, duration_s, tempo_bpm, beats, signals:
{name: {name, label, domain, t0, hop_s, n, min, max, p99, values}}}` — `beats`
and `tempo_bpm` are the soundtrack’s (`[]` / `None` without one).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.clear_offset(fp, , clip_id)

Forget where I placed this video: remove a hand-declared offset, so the next
`align` measures the clip by its audio instead.

Only a DECLARED offset can be forgotten (a measured one is replaced by aligning
again); an unknown clip, or one with no declared offset, is refused. Until `align`
runs again the clip has no place on the song, and footage scores made with the old
offset are dropped.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.coverage_report(entries, aligns, song_dur, , excluded=(), span=None)

What the song’s timeline looks like under `entries` — covered, weak, MISSING.

Pass only FOOTAGE entries: a gap renders fill, and filled is not covered. Uncovered
audio is named with explicit start/end times; a span whose only footage is weakly
aligned is listed with the numbers that make it weak; `excluded` (muvid#88) names
spans the auto path gave up because only an unvouched clip covered them.
`span` (a trimmed edit’s `(start, end)`) bounds what counts as uncovered.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.declared_alignment(clip_id, offset_s, , clip_duration, song_duration)

The alignment record of a DECLARED offset — coverage computed, nothing measured.

`confidence` is 1.0 and `reliable` True because a person vouched for the offset;
`support`/`margin` stay `None` because no vote was held. `source` says so.

* **Return type:**
  [`FootageAlignment`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.FootageAlignment)

```pycon
>>> a = declared_alignment("c1", -8.5, clip_duration=260.0, song_duration=249.6)
>>> a.coverage, a.overlaps, a.source
((0.0, 249.6), True, 'declared')
```

### muvid.footage.service.delete_edit(fp, , edit_id)

Delete a saved edit. Videos already rendered from it are kept (they still name
the edit they came from). An unknown `edit_id` is refused, naming the edits.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.editor_document(fp)

The project as lacing-native standoff annotations, for a multitrack editor.

One tier per clip (its `clip-alignment/v1` and, once scored, its
`clip-score-track/v1` curves) plus a `DECISION` tier holding the current default
proposal as `music-video-edl/v1` entries — everything referenced to the song by
content hash, on one shared song-time axis. Needs the `editor` extra (lacing) and
an alignment.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.edits(fp)

The saved edits, oldest first: each one’s `edit_id`, `name`, how it was made,
how many cuts it has, and `problem` — why it would not validate against the
current alignment (`null` when it does). `unreliable` names clips it cuts to
whose offsets rendering would refuse.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.edl_from_annotations(fp, , annotations)

The editor’s DECISION-tier annotations turned back into a cut list (`edl`),
ready for `save_edit` / `replace_edit` — a faithful read, not a re-selection.
Annotations referencing another song are refused (muvid#35).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.edl_json(e)

One EDL entry as JSON — full precision (it must feed back verbatim), gaps as null.

Optional fields are emitted ONLY when set ([`EDL_OPTIONAL_FIELDS`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.EDL_OPTIONAL_FIELDS)), which keeps
every existing `renders/*/meta.json` byte-identical and the render -> edit ->
re-render round trip (muvid#21 item 3) exact.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.exclusion_note(x)

One `warnings` line per span the auto path set aside (muvid#88).

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.footage.service.explain_edit(fp, , edit_id, start_s=None, end_s=None)

Why each cut of an edit is where it is — the post-mortem, in plain words.

For every cut: where it lands (on which beat of which bar, or how far off one),
how the picture changes (a hard cut, or a fade and where that fade really
starts), what picture it shows and why that stretch (its picture changes on the
beats inside the cut; a photo’s camera move; a video synced where it was
filmed), and `flags` for the ones worth a look (`straddles_the_beat`,
`off_the_beat`, `long_hold`, `repeated_stretch`). Plus a `summary` of the
whole edit (fades vs cuts, the song’s pace and shape, the longest shot).

Every clock time in a sentence is listed in `times` (`{label, s}`) so a screen
can link it to the player. `start_s`/`end_s` keep only the cuts overlapping
that part of the song (the summary stays whole). Explains the edit as it stands
now, hand changes included. Free; reads only.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.filmstrip(fp, , clip_id)

One video’s filmstrip (the same record `filmstrips` gives per clip, with its
`clip_id` and `fps`).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.filmstrips(fp)

Every video’s filmstrip — thumbnails to draw each camera’s lane.

Per clip: sprite sheets of `frame_w` x `frame_h` frames (`cols` x `rows` to
a sheet, left to right then down), sampled at `fps` frames per second of the
CLIP’s own time — frame `i` is the clip at `i / fps` s, which sits at song time
`offset + i / fps`. Each sheet is an `artifact_id` (when the project is hosted),
with its `first_frame` and `n_frames`. Made once per clip and kept; a clip that
has none yet takes a few seconds the first time.

Returns `{fps, clips: {clip_id: {duration_s, n_frames, frame_w, frame_h, sheets:
[{artifact_id, cols, rows, first_frame, n_frames}]}}}`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.fit_to_beat(fp, , edit_id, indices=None, min_z=2.5, apply=True)

Fit the moves to the beat: for each cut that shows a video in the part of the song
the edit covers (its span) — or the cuts at `indices` — find the slip and speed
that put its video’s movement accents on the song’s beat, and apply it where the
evidence is strong.

Per cut, every slip within +-0.25 s (a frame at 60 fps apart) and every speed from
x0.92 to x1.08 (1 % apart) is tried; the best is scored against a null of the same
search on the video’s accents with their relation to the beat destroyed
(`muvid.footage.beat_fit`), giving a `z`. A cut changes only when `z >= min_z`
AND the fit is better than its current timing — and the video still holds the
footage it then reads. Cuts shorter than three beats, gaps and videos with nothing
measurable are left alone, each with its reason. `apply=False` reports without
changing anything. All the changes are ONE step of the edit’s history, so one
`undo_edit` takes them all back.

**How much to expect.** On three phone videos of a crowd dancing, the accents locked
to the beat on one of them only; each video’s own SOUND locked on all three. So on
footage like that most cuts will be left as they are, which is the right answer when
the picture does not show the beat. It works best on a clearly visible dancer.

Needs the `scoring` extra: the first call measures each video’s movement (tens of
seconds a video; kept for next time). Returns the edit (`get_edit`’s shape) plus
`fit`: `cuts` (`{index, slip_s, rate, z, applied, reason}` per cut looked at),
`fitted`, `kept` and `min_z`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.frame_rate(video)

A video’s average frame rate (frames per second), or `None` if unreadable.

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]

### muvid.footage.service.frame_size(video)

`[width, height]` of a video as DISPLAYED (a ±90° rotation swaps them).

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`list`](https://docs.python.org/3/builtins/stdtypes.html#list)]

### muvid.footage.service.get_edit(fp, , edit_id)

One saved edit: its cut list (`edl`, every span of the song, gaps as
`clip_id: null`), its name and history, and a `coverage` report. Cut indexes in
`set_cut`/`merge_cut` refer to positions in this `edl`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.grab_cover_frame(video, dest)

Write one JPEG frame of `video` to `dest` — `COVER_AT_FRACTION` of the
way in, `COVER_WIDTH` wide. The cover of every hosted muvid production.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

### muvid.footage.service.import_render(fp, , path, render_id, label='', edit_id='')

Bring a video finished ELSEWHERE into the project as a render (the importer’s).

Copied to `renders/<render_id>/final.mp4` with a meta that says it was imported:
`edit_id` names the saved edit it was cut from (when known), and there are no
`checks` — muvid did not make it and does not claim to have verified it.
Idempotent: the same bytes under the same id change nothing.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.looks(fp=None)

The looks a cut can take — camera moves (punch in, slow push, slow pull, pans)
and grades (vivid, black and white, posterize, cartoon) — each with its
`params_schema`. Give one to `set_cut` as `look={"name": ..., **params}`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.merge_cut(fp, , edit_id, index, into='previous')

Join cut `index` to its neighbour: the neighbour (`into` “previous” or
“next”) takes over its span, so the neighbour’s video must cover it. The joined
cut keeps the neighbour’s video, framing and look. Returns the changed edit.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.peaks(fp, , n=2000)

The song’s waveform, to draw under the timeline: `n` equal slices of the song,
each the loudest moment in it (mono), scaled so the loudest slice is 1.0.

Returns `{duration_s, n, peaks: [0..1, ...]}`; slice `i` covers song time
`i * duration_s / n` to `(i + 1) * duration_s / n`. Kept per song and `n`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.propose_edit(fp, , strategy='', preset='', weights=None, config=None, save=True, name='', span=None, pace='', style='auto')

Cut it for me: build an edit of the whole song — or of `span` (`[start_s,
end_s]`, the part of the song the video covers) — from the placed videos, and (by
default) save it as a named edit, without rendering anything.

`strategy` picks how (see `strategies`; default `best_confidence`). Giving a
`preset` (“energetic”/”contemplative”), per-metric `weights` or a `config`
(`lambda_switch`/`l_min_s`/`l_max_s`/`boundary_mode`) selects the
score-driven `weighted` strategy, which needs `score` first.

Returns the `edl` (spans the WHOLE song; spans no footage covers are explicit gap
entries, `clip_id: null`, rendered as black), the `strategy` used, a
`coverage` report naming every uncovered span and every weakly-aligned segment,
`warnings`, and `assemble_refusal` (non-null when rendering it would be refused
because no clip is trustworthy). With `save` it also returns the `edit_id` to
change it (`set_cut` …) and render it (`render`).

**Footage that does not contain the song is cut to the music.** The strategy cuts
only the videos that are recordings of the song (placed confidently by `align`,
or marked `set_has_song yes`); every span they leave is filled from the other
videos and the photos, cut on the song’s beats and bars, each video shown at the
stretch whose picture changes land on the beat (`music` in the reply says how
that went). With no recording of the song at all, the whole song is cut to the
music — no `align` needed. `pace` (`slow`/`steady`/`driving`/`frantic`)
sets how often those cuts come; `style` how the picture changes: `cuts` (hard,
on the beat), `fades` (each starting on the beat), or `auto` (the song’s
choice — fades for a slow song). `explain_edit` says why
each cut is where it is.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.public_render(fp, meta)

A render record as a HOSTED surface may return it: `video` made relative to the
project (`renders/<id>/final.mp4`) — never an absolute server path; play it by its
`artifact_id`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.redo_edit(fp, , edit_id)

Redo the change `undo_edit` last took back. A new change after an undo
discards what could be redone. Returns the edit as it now is.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.refresh_cover(fp)

Take the project’s cover frame again — from the newest render, else from the
first clip — and register it. Only for hosted projects (`None` otherwise).

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### muvid.footage.service.remove_clip(fp, , clip_id)

Remove one footage video from the project — its stored file and its entry.

Irreversible for the clip (add it again if it was a mistake); existing renders are
untouched. Removal INVALIDATES every measured offset and every footage score, as
changing the song does — the alignment describes the clip set it was measured on —
so run `align` again before cutting. Offsets a person DECLARED for the remaining
clips are kept. An unknown `clip_id` is refused, naming the project’s clips.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.rename_edit(fp, , edit_id, name)

Give an edit a new name — what the edit picker and the renders made from it show.

Only the name changes; the cuts, the span and the edit’s id stay as they are, and
the rename can be undone like any other change. Returns the edit.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.render(fp, , edit_id, canvas='', allow_unreliable=False, annotate=None, should_cancel=None)

Make the video: render a saved edit onto the canvas, over the clean song.

Slow (minutes of encoding for a full song), so hosts run it in the background. The
video is exactly as long as the part of the song the edit covers (its `span`,
default the whole song; see `set_span`), with the song cut to match and faded out
at the end when it stops early; gaps render black. `canvas` (“landscape” /
“portrait” / “square”) re-renders the same edit in another shape; default: the
project’s.

Refused when the edit cuts to a clip whose offset the aligner will not vouch for —
a wrong offset renders a video out of sync with the song (muvid#59) — unless
`allow_unreliable`; fix it with `set_offset` or by changing those cuts. Returns
the render record: `render_id`, `edit_id`, its `coverage`, `ok` and the
`checks` behind it, `warnings` (read them — they are what the render plan found),
and `artifact_id` to play it by when the project is hosted.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.renders(fp)

The finished videos, newest first: each one’s `render_id`, speakable `ref`,
the `edit_id` it was made from, its `label`, canvas, `ok`, the number of
`warnings`, and `artifact_id` to play it by when the project is hosted.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.replace_edit(fp, , edit_id, edl)

Replace a saved edit’s whole cut list — the power tool for rewriting an edit at
once. The new list is checked exactly as `save_edit` checks one; on refusal the
edit is left as it was. The previous list is not kept.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.require_scorable(fp)

The alignments a scoring run would use; refuses without a song or an alignment.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)

### muvid.footage.service.resolve_canvas(fp, canvas)

The render canvas: an explicit per-render override, else the project’s.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)]

### muvid.footage.service.run_scoring(fp, , hop_s=0.1, metrics=None, enable_lipsync=None, progress_cb=None, should_cancel=None)

Score every aligned clip and persist the tensor (the engine behind [`score()`](_autosummary/muvid.footage.service.html.md#muvid.footage.service.score),
with the job hooks a background runner passes).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.save_edit(fp, , edl, name='', how_made='by hand', edit_id='', span=None)

Save a cut list as a new named edit.

`edl` is a list of `{song_start, song_end, clip_id}` spans (plus optional
`transition`/`crop`/`crop_end`/`look`/`look_time_varying`), in the same
form `get_edit` returns and `propose_edit` produces. Holes are filled with gap
entries; the list is checked (order, overlap, every span inside its clip’s coverage)
and refused with the reason if it does not hold. `edit_id` fixes the id (an
existing one is refused — use `replace_edit`). `span` (`[start_s, end_s]`)
makes the edit cover only that part of the song — its render is that long, the song
cut to it; default the whole song. Returns the saved edit.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.score(fp, , hop_s=0.1, metrics=None, should_cancel=None)

Look at the footage: score every placed video, on the song’s own timeline —
picture quality and how its movement sits on the beat — and save the curves.

Slow (it decodes every clip), so hosts run it in the background. Needs a song and an
alignment. Every core metric is computed; weighting happens later, when cutting, so
re-weighting never re-scores. The lip-sync tier is off unless the operator enabled
it. Returns what was scored and what was skipped (and why).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.scores(fp, , clip_id='', metrics=None, max_points=1500)

The saved footage curves — for the lanes under each video, and for inspection.

- no `clip_id` → a SUMMARY (metrics, per-clip coverage, beats, tempo, the decimated
  `selection_margin`, grid geometry) — bounded, safe as the default;
- `clip_id` → that clip’s curves (values as `null`-masked arrays, decimated to
  `max_points` per metric).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.set_cut(fp, , edit_id, index, clip_id=None, song_start=None, song_end=None, look=None, look_time_varying=None, slip_s=None, rate=None)

Change one cut of a saved edit (`index` is its position in `get_edit`’s edl).

- `clip_id`: show another video over this span (`""` makes it a gap). The new
  video must cover the span. Its framing (`crop`) is dropped, since it was chosen
  for the old video’s frame; its `look` is kept. A video cut to the music (or a
  photo) is shown from its start; a video with the song in it, where it was filmed.
- `song_start` / `song_end`: move the cut’s boundaries. The neighbouring cut’s
  boundary moves with it, so the edit stays one continuous timeline; a move that
  would swallow a neighbour whole is refused (join them with `merge_cut`).
- `look`: a NAMED look from `looks` — `{"name": "slow_push", "zoom": 1.08}`,
  compiled for this cut’s length and the project’s canvas and kept on the cut as
  `look_spec` (with every parameter’s value) so it can be shown and changed —
  or, for power users,
  one raw ffmpeg filter chain (allowlisted; set `look_time_varying` for one that
  moves). `""` removes it.
- `slip_s`: show a slightly different moment of the same video over the same
  span — `0.1` reads the footage 0.1 s later — to put a dancer’s moves on the
  beat where the clip’s alignment is right overall but a little off here. At
  most one beat either way (`SLIP_MAX_S`); `0` removes it. A new video
  (`clip_id`) starts unslipped.
- `rate`: play this cut’s video a little faster or slower — `1.03` shows 3 %
  more footage over the same span, so the moves run 3 % faster (the song is never
  touched). Within `1 +- RATE_MAX_DEV`; `1` removes it. The video must hold the
  footage the cut then reads. A new video starts at speed 1.

Parameters left out are unchanged. The changed edit is checked and saved; returns it.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.set_has_song(fp, , clip_id, has_song)

Say whether a video has the song in its own sound — overriding what listening found.

`"yes"`: it is a recording of the song (a concert, a dance); it is synced to the
song and never cut freely to the music. `"no"`: it is not (a day out, b-roll); it
is always cut to the music. `"auto"` (the default) believes the listening: a
video `align` placed confidently is synced, any other is cut to the music. A
photo is always cut to the music. Like a hand-placed offset, the choice survives
listening again. Takes effect at the next `propose_edit`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.set_offset(fp, , clip_id, offset_s)

Place one video on the song BY HAND: the song time at which the video’s own
first frame plays (negative = the video starts before the song does).

Use it when `align` gets a clip wrong — on long, repetitive songs it can land a
whole chorus away (muvid#59) — or when you already know the offset. The offset is
recorded as `source: "declared"` and trusted for rendering (a person vouched for
it); how much of the song the clip covers is computed from the two durations.
`align` keeps it unless told otherwise. Changing an offset makes the footage
scores stale, so they are dropped.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.set_song(fp, , path, ext='', filename='', duration_s=None)

Set the project’s song — the clean master every video is aligned to and whose
audio the finished video uses. Replaces any previous song.

The file arrives from the host (an upload): `path` is where the host put it and
`filename` its original name, so the song keeps its name and extension; it is
copied into the project, so the host may delete its copy afterwards.
Replacing the song THROWS AWAY every clip’s offset and every footage score, because
they were measured against the old song — re-run `align` afterwards. Size- and
duration-capped.

Returns `song_duration` (seconds), the stored `song` (name, and the
`artifact_id` to play it by when the project is hosted) and whether an alignment
was dropped.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.set_span(fp, , edit_id, start_s, end_s)

Choose which part of the song the video covers — where it starts and ends.

**Trimming loses nothing.** The span is a window on the edit, not a cut of it: every
cut is kept whole, and only what is RENDERED is limited to `start_s`..\`\`end_s\`\`
(the song cut to match, faded out at the end when it stops before the song does;
cuts across an edge are shortened in the render only). Widening the span again —
`start_s=0` and `end_s` = the song’s length is the whole song — brings back
exactly what was there. Returns the edit, with its `span`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.split_cut(fp, , edit_id, at_s)

Split the cut playing at song time `at_s` into two cuts of the same video.

The two halves keep the cut’s video, framing and look; a moving framing (a pan) is
divided where it was at `at_s`. Refused on a boundary (nothing to split). Returns
the changed edit; `changed` is the index of the second half.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.status(fp)

Where the music video stands: the song, the videos and where each sits on the
song, the saved edits, the finished videos, and the next useful step.

`aligned` lists the clips that have an offset; `alignments` says for each one
whether the offset was `measured` (by `align`) or `declared` (`set_offset`)
and whether it is trusted for rendering (`reliable`). `renders` is newest first
(the same rows `renders` gives — no server paths).
`next_step` names the operation that moves the project forward and why.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.strategies(fp=None)

The ways to cut on offer — the selection strategies `propose_edit` accepts
(`weighted` reads the footage scores; the rest use only the alignment).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.timeline(fp)

Which videos cover which spans of the song (overlaps shown), from the saved
alignment — the map for choosing what to cut to. Run `align` first.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.footage.service.undo_edit(fp, , edit_id)

Undo the last change to a saved edit (a cut changed, split, joined, the span, a
whole replacement — by a person or by the assistant). Returns the edit as it now
is; `redo_edit` puts the change back. Up to 100 changes are kept per edit.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)


# _autosummary/muvid.footage.strategy.html.md

# muvid.footage.strategy

The pluggable `SelectionStrategy` registry — alignments → an EDL.

The user-extensible strategy pattern behind full-auto assembly: a strategy turns a set of
[`FootageAlignment`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.FootageAlignment)s (which clip covers which span of the
song, with a confidence) into an EDL (which clip to SHOW for each span). Strategies emit
only `{song_start, song_end, clip_id}` — the `clip_in` sign convention lives in
[`muvid.footage.edl.derive_cuts()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.derive_cuts) (SSOT), so a third-party strategy can’t desync the
cut. Every strategy’s output still passes [`validate_edl()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.validate_edl) before
any cutting, so a strategy that leaves a gap fails loudly with the exact uncovered span.

\*\*A strategy sees `reliable` the way it already sees `overlaps`: as a preference\*\*
(muvid#88). Every built-in ranks a vouched clip above an unvouched one for the same span
via `_prefer_vouched()`, and falls back to an unvouched clip only where nothing else
covers the span — so one badly-aligned clip in a shoot costs the spans only it covered,
not the whole edit. The strategies still decide nothing about trust: they never drop a
source, and the verdict they read (`FootageAlignment.reliable`) is
[`vouches_for()`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.vouches_for)’s. A third-party strategy that ranks clips itself
should carry the same preference; `weighted` does it as a reward penalty rather than a
special case (see [`muvid.footage.select_score`](_autosummary/muvid.footage.select_score.html.md#module-muvid.footage.select_score)).

Registry idiom mirrors `mixing.audio.segmentation` (a `strategy: str | callable` param

+ a `_STRATEGIES` dict + [`resolve_strategy()`](_autosummary/muvid.footage.strategy.html.md#muvid.footage.strategy.resolve_strategy)) — the federation’s established shape.

Register your own with [`register_selection_strategy()`](_autosummary/muvid.footage.strategy.html.md#muvid.footage.strategy.register_selection_strategy).

### Module Attributes

| [`SelectionStrategy`](_autosummary/muvid.footage.strategy.html.md#muvid.footage.strategy.SelectionStrategy)   | alignments + song duration → an EDL (built-ins ignore song_duration but it is passed so a strategy MAY reason about the full timeline).   |
|----------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------|
| [`DEFAULT_STRATEGY`](_autosummary/muvid.footage.strategy.html.md#muvid.footage.strategy.DEFAULT_STRATEGY)    | The default auto-strategy when none is chosen.                                                                                            |

### Functions

| [`best_confidence`](_autosummary/muvid.footage.strategy.html.md#muvid.footage.strategy.best_confidence)(alignments, song_duration)       | For each covered span, show the highest-confidence clip (ties: longest coverage).                                            |
|---------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------|
| [`fewest_cuts`](_autosummary/muvid.footage.strategy.html.md#muvid.footage.strategy.fewest_cuts)(alignments, song_duration)           | Stay on the current clip as long as it covers; only switch when it runs out (then to the clip extending furthest).           |
| [`list_strategies`](_autosummary/muvid.footage.strategy.html.md#muvid.footage.strategy.list_strategies)()                                | All strategy slugs (eager + lazy), sorted.                                                                                   |
| [`longest_take`](_autosummary/muvid.footage.strategy.html.md#muvid.footage.strategy.longest_take)(alignments, song_duration)          | Prefer the clip that keeps rolling longest — pick the one whose coverage extends furthest forward (ties: higher confidence). |
| [`register_lazy_strategy`](_autosummary/muvid.footage.strategy.html.md#muvid.footage.strategy.register_lazy_strategy)(slug, target_ref)         | Register a strategy by a `"module:func"` reference, imported only on first use.                                              |
| [`register_selection_strategy`](_autosummary/muvid.footage.strategy.html.md#muvid.footage.strategy.register_selection_strategy)(slug, fn)            | Register a selection strategy under `slug` (returns it, for inline use).                                                     |
| [`resolve_strategy`](_autosummary/muvid.footage.strategy.html.md#muvid.footage.strategy.resolve_strategy)(strategy)                       | Resolve a strategy name OR a bare callable to a [`SelectionStrategy`](_autosummary/muvid.footage.strategy.html.md#muvid.footage.strategy.SelectionStrategy).          |
| [`select_edl`](_autosummary/muvid.footage.strategy.html.md#muvid.footage.strategy.select_edl)(strategy, alignments, ...[, context]) | Run `strategy` (name or callable) to produce an EDL from `alignments`.                                                       |

### muvid.footage.strategy.DEFAULT_STRATEGY *= 'best_confidence'*

The default auto-strategy when none is chosen.

### muvid.footage.strategy.SelectionStrategy

alignments + song duration → an EDL (built-ins ignore song_duration but it
is passed so a strategy MAY reason about the full timeline).

**Score-driven opt-in (progressive disclosure).** A strategy that needs the score tensor

+ beats declares a keyword-only `context` parameter (or `**kwargs`); [`select_edl()`](_autosummary/muvid.footage.strategy.html.md#muvid.footage.strategy.select_edl)
  then passes a `SelectionContext` (defined in [`muvid.footage.select_score`](_autosummary/muvid.footage.select_score.html.md#module-muvid.footage.select_score)) as
  `context=`. The built-ins take only `(alignments, song_duration)` and never see it —
  the exact same dispatch idiom as `nw.jobs._call_dispatch`. The magic parameter name is
  literally `context`.

* **Type:**
  A strategy

alias of `Callable`[[…], `list[EdlEntry]`]

### muvid.footage.strategy.best_confidence(alignments, song_duration)

For each covered span, show the highest-confidence clip (ties: longest coverage).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry)]

### muvid.footage.strategy.fewest_cuts(alignments, song_duration)

Stay on the current clip as long as it covers; only switch when it runs out
(then to the clip extending furthest). Minimizes the number of cuts.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry)]

### muvid.footage.strategy.list_strategies()

All strategy slugs (eager + lazy), sorted. Lazy slugs are NOT imported to list them.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### muvid.footage.strategy.longest_take(alignments, song_duration)

Prefer the clip that keeps rolling longest — pick the one whose coverage extends
furthest forward (ties: higher confidence). Yields long, continuous takes.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry)]

### muvid.footage.strategy.register_lazy_strategy(slug, target_ref)

Register a strategy by a `"module:func"` reference, imported only on first use.

Lets a heavy strategy (numpy DP, cv2, …) be *listed* and *named* without importing its
module at registration time — the import happens in [`resolve_strategy()`](_autosummary/muvid.footage.strategy.html.md#muvid.footage.strategy.resolve_strategy).

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

### muvid.footage.strategy.register_selection_strategy(slug, fn)

Register a selection strategy under `slug` (returns it, for inline use).

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis), [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry)]]

### muvid.footage.strategy.resolve_strategy(strategy)

Resolve a strategy name OR a bare callable to a [`SelectionStrategy`](_autosummary/muvid.footage.strategy.html.md#muvid.footage.strategy.SelectionStrategy).

A lazy slug is imported here (and cached into the eager table) on first resolution.

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis), [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry)]]

### muvid.footage.strategy.select_edl(strategy, alignments, song_duration, , context=None)

Run `strategy` (name or callable) to produce an EDL from `alignments`.

`context` (a `SelectionContext`) is passed to score-driven strategies that declare it;
the alignment-only built-ins ignore it. See [`SelectionStrategy`](_autosummary/muvid.footage.strategy.html.md#muvid.footage.strategy.SelectionStrategy).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`EdlEntry`](_autosummary/muvid.footage.edl.html.md#muvid.footage.edl.EdlEntry)]


# _autosummary/muvid.footage.workspace.html.md

# muvid.footage.workspace

Per-user, STATEFUL project for the footage-aligned `music_video` genre.

Unlike the visualizer (a pure function of audio+cover), a music-video project accumulates
state across calls: one fixed song, several uploaded clips, a persisted alignment
manifest, and rendered outputs. This is deliberately NEW infrastructure (not the stateless
`VisualizerWorkspace`), reusing only the identity + fetch + tool-aggregation seams.

Layout (default root `~/.local/share/muvid`; override via `MUVID_DATA_HOME` — shared
with the visualizer’s root, different subtree). **Never** inside the app/deploy tree:

- `{root}/music_video/projects/{email}/{project_id}/manifest.json` — title, canvas, song
- `.../song/song.<ext>` — the one fixed clean song
- `.../clips/{clip_id}.<ext>` — an uploaded footage clip
- `.../alignments.json` — the persisted per-clip alignment
- `.../renders/{render_id}/` — an assembled music video
- `.../edits/{edit_id}.json` — a named, persisted edit (an EDL plus how it was made)
- `.../cover.jpg` — the project’s cover frame (hosted projects only)

**The catalog seam.** `MusicVideoFootageProject(..., media_catalog=None)`: when a host
places the project (`muvid.Project`), every song, clip, render and cover that
lands here is also registered in the host’s artifact catalog
(`muvid.catalog.HostArtifactCatalog`) and its `artifact_id` recorded beside it,
so the host can serve the bytes. `None` — the MCP connector’s per-caller workspace —
registers nothing and records nothing, so its on-disk records are unchanged.

**Every JSON record here is replaced, never truncated in place** (muvid#17 item 4).
`manifest.json` and `alignments.json` used to be bare `write_text` calls — a
truncate-then-write — while `manifest()` deliberately swallows `OSError`/`ValueError`
so an unreadable project reads as an EMPTY one. Put together, a process killed between
the truncate and the write left a project that presented as having no song and no clips:
not an error a caller could act on, but a plausible state a caller would act on wrongly
(re-uploading everything, or rendering nothing). The scoring layer three directories
away already wrote tmp + `os.replace`; [`atomic_write_text()`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.atomic_write_text) is the ONE such dance
now, shared by every manifest/alignment/meta write here, `scoring/grid.py` (through
[`atomic_write_bytes()`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.atomic_write_bytes), its `.npz` primitive) and `downloads.organise`. A reader
sees the whole prior record or the whole new one. The on-disk FORMAT is untouched: that
would be a migration.

**Invalidation runs BEFORE the write that would make the stale artifact look current.**
`set_song` drops `alignments.json` and the score tracks first and replaces the manifest
LAST, so no crash window leaves a manifest naming the new song beside an alignment measured
against the old one — the offsets would then be read as if they belonged to it, which is
muvid#59’s silently-out-of-sync video by another route.

Invalidation is deliberate and lives here, beside the state it protects: `set_song`
and `remove_clip` both drop `alignments.json` and every persisted score track,
because the alignment is a measurement of exactly the song and the clip set that were
present when it was taken (muvid#22 — a removed clip’s offset must not outlive the
clip, and the score tensor is keyed on that alignment’s fingerprint).

### Module Attributes

| [`DATA_HOME_ENV_VAR`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.DATA_HOME_ENV_VAR)   | Re-exported from [`muvid.paths`](_autosummary/muvid.paths.html.md#module-muvid.paths), the SSOT for where muvid's data lives.   |
|----------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
| [`CANVASES`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.CANVASES)            | Named output canvases a project may choose at create (the genre Templates).                                                                |
| [`ID_PATTERN`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.ID_PATTERN)          | What a caller-chosen id (a clip's, an edit's) may be — after surrounding whitespace is dropped.                                            |

### Functions

| [`atomic_write_bytes`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.atomic_write_bytes)(path, data)                    | Replace `path`'s content with `data` so a reader never sees a torn file.                                                                                                                                                               |
|----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`atomic_write_text`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.atomic_write_text)(path, text)                     | Atomically replace `path` with `text` (UTF-8) — see [`atomic_write_bytes()`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.atomic_write_bytes).                                                                                                             |
| [`data_root`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.data_root)()                                       | The muvid data root: `$MUVID_DATA_HOME` or `~/.local/share/muvid`.                                                                                                                                                                     |
| [`file_lock`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.file_lock)(path)                                   | An exclusive advisory lock on `path` for a read-modify-write (POSIX `flock`; `msvcrt.locking` on Windows).                                                                                                                             |
| [`fresh_output`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.fresh_output)(dest)                                | A temp path beside `dest` for a writer that cannot be told to be careful (ffmpeg); on success it is renamed onto `dest` — a new inode, as in [`replace_file()`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.replace_file) — and on failure removed. |
| [`init_footage_project`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.init_footage_project)(root, \*, project_id[, ...]) | A footage project rooted at `root`, writing its manifest if there is none yet.                                                                                                                                                         |
| [`normalise_id`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.normalise_id)(value, \*, label)                    | `value` stripped, and refused (`ValueError`) unless it matches [`ID_PATTERN`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.ID_PATTERN).                                                                                                            |
| [`replace_file`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.replace_file)(src, dest)                           | Put a copy of `src` at `dest` as a NEW file (temp sibling + `os.replace`).                                                                                                                                                             |

### Classes

| [`FootageWorkspace`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.FootageWorkspace)(email, root)                     | A caller's private music-video area, addressed by `email`.                       |
|----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------|
| [`MusicVideoFootageProject`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.MusicVideoFootageProject)(email, project_id, root) | One caller's stateful music-video project (song + clips + alignments + renders). |

### muvid.footage.workspace.CANVASES *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[int](https://docs.python.org/3/builtins/functions.html#int), [int](https://docs.python.org/3/builtins/functions.html#int)]]* *= {'landscape': (1920, 1080), 'portrait': (1080, 1920), 'square': (1080, 1080)}*

Named output canvases a project may choose at create (the genre Templates).

### muvid.footage.workspace.DATA_HOME_ENV_VAR *= 'MUVID_DATA_HOME'*

Re-exported from [`muvid.paths`](_autosummary/muvid.paths.html.md#module-muvid.paths), the SSOT for where muvid’s data lives. Kept as
names here because both are public API of this module (`muvid.mcp` re-exports
`data_root`, and `downloads` imports `safe_component` from here).

### *class* muvid.footage.workspace.FootageWorkspace(email, root)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A caller’s private music-video area, addressed by `email`.

### muvid.footage.workspace.ID_PATTERN *= re.compile('^[A-Za-z0-9_-]{1,64}$')*

What a caller-chosen id (a clip’s, an edit’s) may be — after surrounding whitespace is
dropped. Letters, digits, `_` and `-` only: an id names a FILE (`clips/<id>.mp4`,
`edits/<id>.json`), and a looser rule let `*` through to a glob that deleted every
clip, and `" c1"` replace `c1`’s bytes under a different manifest key.

### *class* muvid.footage.workspace.MusicVideoFootageProject(email, project_id, root, media_catalog=None, defaults=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One caller’s stateful music-video project (song + clips + alignments + renders).

#### add_clip(clip_id, src_path, , ext, name='', kind='')

Store a footage clip from a local file; returns its `clip_id`.

`kind="still"` stores a photo (no sound, no duration); the default is a video,
and writes no `kind` key, so a manifest written before photos existed reads
the same.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

#### cover_info()

`{file, artifact_id, taken_from}` of the cover frame, or `None`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

#### defaults *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)*

What `manifest()` reads before anything is written — a hosted project’s
title and canvas come from its genre envelope, so READING its footage creates
nothing; the first write persists them.

#### delete_edit(edit_id)

Remove one edit record (and its undo history); whether it existed.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

#### edits_lock()

Serialise a read-modify-write of this project’s edits (see [`file_lock()`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.file_lock)).

#### ensure_render_refs()

Give every render a stable, speakable reference; return `{id: n}`.

A render id is a uuid4 slice (`b02fc05417ea`) — fine for a URL, useless
in a sentence. Nobody can ask for “a bit less of the wide shot in
b02fc05417ea”. So each render also carries a small ordinal, rendered as
`cut 4` by `nw.delivery.format_ref()` at the delivery boundary.

This module stores the INTEGER only. The word “cut” belongs to
`nw.delivery` and is spelled in exactly one place; core muvid does not
depend on nw (it is in the `mcp` extra, so `muvid.visualize` and
downstreams like `yb` stay lightweight), and a local second spelling
is precisely the drift `nw.delivery` exists to prevent.

Two properties make it worth persisting rather than deriving:

- **Stable.** Assigned once, at creation, and never renumbered. A
  position in a sorted list would shift under the user every time they
  rendered again, so the reference they wrote down would rot.
- **Chronological.** Backfill runs OLDEST first, so `cut 1` is the
  first thing they made, which is what someone means by “the first cut”.

Self-healing on read, in the same spirit as an open-time schema
migration: renders made before refs existed acquire one the first time
anything lists or resolves them, and the assignment is written back so
it never moves again.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

#### invalidate_scores()

Delete persisted score tracks — the primary invalidation on song/offset change.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

#### list_clips()

`[{clip_id, name}]` — plus `artifact_id` when the host catalog holds it,
`kind: "still"` for a photo, and `has_song` when a person said (`set_has_song`).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

#### list_edit_records()

Every readable edit record, oldest first (by its `created` stamp).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

#### media_catalog *: [object](https://docs.python.org/3/builtins/functions.html#object)* *= None*

Where this project’s media is registered for a host to serve it — a
`muvid.catalog.HostArtifactCatalog` (anything with its `register`), or
`None` for the MCP workspace, which registers nothing.

#### next_render_ref()

The ordinal the next render will carry (1-based, never reused).

* **Return type:**
  [`int`](https://docs.python.org/3/builtins/functions.html#int)

#### read_edit(edit_id)

One edit record; `KeyError` if there is no such edit.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

#### read_edit_history(edit_id)

`{"undo": [...], "redo": [...]}` — earlier/later versions of one edit.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

#### remove_clip(clip_id)

Delete one clip — its file(s) and its manifest entry — and invalidate what
was measured against it (muvid#22).

A removal changes the clip SET the alignment describes, so `alignments.json`
is dropped and, with it, every persisted score track — the same invalidation
`set_song` performs, and for the same reason: scores are keyed on the alignment
fingerprint, which no longer matches. Whole-artifact, not surgical: pruning one
record from the alignment would still move the fingerprint and strand the
score manifest, and editing that manifest in place is a migration-surface change
this method deliberately does not make.

The steps run in an order where every crash-intermediate state is a valid
project: alignment and scores go first (leaving “present, unaligned”), then the
manifest (leaving at worst an orphan file `add_clip` would sweep), then the
file — never a manifest entry pointing at a file that is gone.

Returns what happened: `{clip_id, name, files, alignment_invalidated,
scores_invalidated}`. Raises `KeyError` for a `clip_id` the manifest does
not hold; the MCP tool turns that into a refusal naming the known ids.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

#### *property* renders_dir *: [Path](https://docs.python.org/3/library/pathlib.html#pathlib.Path)*

Where this project’s renders live.

Named to match `VisualizerProject.renders_dir` so anything that spans
both muvid genres — `muvid.downloads` — sees one shape instead of
branching on which drawer it is looking in.

#### set_cover(image_path, , taken_from)

Store `image_path` as `cover.jpg`, register it; return its artifact id.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

#### set_song(src_path, , ext, name='', duration_s=None)

Store (replacing) the project’s one clean song from a local file.

The order of operations is the contract (muvid#17 item 4), in three phases:

1. **Everything that can fail runs first, against a staged copy** — the copy
   itself, the duration probe, the content hash. A missing source or an
   un-probeable file raises here and the project is exactly as it was: the old
   song, its alignment and its scores all still stand.
2. **What the old song vouched for is dropped** — `alignments.json` and the
   score tracks — BEFORE the manifest can name the new song. The song is the
   alignment reference, so an offset measured against the old one is meaningless
   against the new one; but nothing on disk ties an alignment record to a song,
   so a manifest that named the new song beside the old alignment would have
   those offsets read as its own and cut a video silently out of sync (the
   muvid#59 failure shape, by a different route). Dropping first means the worst
   a crash can leave is a project that demands a fresh `align_footage`.
3. **The song file lands, then the manifest is replaced LAST** — it names the
   file, so the file must exist before any reader can be pointed at it.

`name` is the display name (an uploaded file’s original name); `duration_s`
is a duration the caller already probed, so a 100 MB song is not probed twice.
With a host catalog the song is registered too, and its id recorded as
`song_artifact_id` (it equals `song_hash`: both are the SHA-256 of the bytes).

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

#### song_hash()

The clean song’s content hash (cached in the manifest; computed if missing).

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

#### song_info()

The song’s display facts (`None` before one is set).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

#### write_edit(edit_id, record)

Persist one named edit record (replacing it atomically).

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

#### write_edit_history(edit_id, history)

Persist one edit’s undo/redo stacks (atomically; callers hold the lock).

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

### muvid.footage.workspace.atomic_write_bytes(path, data)

Replace `path`’s content with `data` so a reader never sees a torn file.

The ONE tmp + fsync + `os.replace` dance in the package (muvid#17 item 4); every
JSON record in this module goes through [`atomic_write_text()`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.atomic_write_text), and the
scoring layer’s `.npz` arrays come here directly. The pieces, and why each is
load-bearing:

- \*\*The temp file is created in `path`’s own directory\*\* (`tempfile.mkstemp`,
  so a concurrent writer gets its own name rather than racing on a fixed
  `.tmp`). `os.replace` is only a rename — and only atomic — within one
  filesystem; a temp under `/tmp` would make it a copy with a torn window.
- **The temp is fsync’d before the rename.** Without it the rename can reach the
  journal before the data reaches the disk, and a power cut leaves the new name
  pointing at zeros — the failure mode a rename alone is wrongly believed to
  exclude.
- **The directory is fsync’d after the rename, best-effort.** That is what makes
  the rename itself — and any `unlink` a caller did in the same directory just
  before it, which is how [`MusicVideoFootageProject.set_song()`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.MusicVideoFootageProject.set_song) orders its
  invalidation — durable. Directories cannot be opened for fsync on every platform
  (Windows), so an `OSError` there is the one exception swallowed: the data is
  already safe on disk; only the metadata’s promptness is lost.
- **On any failure the temp is removed and the error propagates.** `write_text`
  raised too; the difference is that `path` still holds the previous complete
  record instead of a truncated one.

`mkstemp` creates the file `0600`, which is what these per-user records want and
what `downloads.organise` already did for `meta.json`; the mode of an existing
file is not carried over, deliberately, so the outcome does not depend on history.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

### muvid.footage.workspace.atomic_write_text(path, text)

Atomically replace `path` with `text` (UTF-8) — see [`atomic_write_bytes()`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.atomic_write_bytes).

Every `manifest.json`, `alignments.json` and render `meta.json` write goes
through here. UTF-8 is a superset of the ASCII `json.dumps` emits by default, so
the bytes on disk are identical to what `write_text` produced and the locale-default
`read_text` on the read side is untouched — the FORMAT does not change, only the
way it gets there.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

### muvid.footage.workspace.data_root()

The muvid data root: `$MUVID_DATA_HOME` or `~/.local/share/muvid`.

A thin forwarder rather than `data_root = data_home`: a plain alias keeps
`__module__ == "muvid.paths"`, which drops the name out of anything that filters
a module’s members by where they were defined — `automodule :members:` (so this
docstring would not render here) and this repo’s own drift-test idiom in
`tests/test_mcp.py`. Costs one call; keeps the module’s surface introspectable.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### muvid.footage.workspace.file_lock(path)

An exclusive advisory lock on `path` for a read-modify-write (POSIX `flock`;
`msvcrt.locking` on Windows). Serialises concurrent edits of one project’s files;
each write is also atomic, so a reader never needs the lock.

### muvid.footage.workspace.fresh_output(dest)

A temp path beside `dest` for a writer that cannot be told to be careful
(ffmpeg); on success it is renamed onto `dest` — a new inode, as in
[`replace_file()`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.replace_file) — and on failure removed.

### muvid.footage.workspace.init_footage_project(root, , project_id, title='', canvas='landscape', media_catalog=None, email='')

A footage project rooted at `root`, writing its manifest if there is none yet.

The one place a footage manifest is born, for both the MCP workspace (below) and a
host-placed `muvid.Project` (whose footage lives at `<project>/footage`).
Idempotent: an existing manifest is left exactly as it is.

* **Return type:**
  [`MusicVideoFootageProject`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.MusicVideoFootageProject)

### muvid.footage.workspace.normalise_id(value, , label)

`value` stripped, and refused (`ValueError`) unless it matches
[`ID_PATTERN`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.ID_PATTERN).

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> normalise_id(" c01 ", label="clip_id")
'c01'
>>> normalise_id("*", label="clip_id")
Traceback (most recent call last):
    ...
ValueError: invalid clip_id '*': use 1-64 letters, digits, '_' or '-'
```

### muvid.footage.workspace.replace_file(src, dest)

Put a copy of `src` at `dest` as a NEW file (temp sibling + `os.replace`).

Never writes into an existing `dest`: a project’s media is hardlinked into the
host’s content-addressed catalog (`blobs/<sha256>`), so an in-place overwrite would
silently change the bytes behind an id that names the old ones. A rename gives
`dest` a new inode and leaves the blob exactly as it was.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)


# _autosummary/muvid.html.md

# muvid

muvid — tools to make music videos.

Two independent halves:

- **Narrative pipeline** (needs the AI extras: `falaw`, `lacing`,
  `lookbook`): transcribe a song, align lyrics, define characters and
  environments, write a shot script, render and compose. The verbs below are
  also the CLI. Project model: [`MusicVideoProject`](_autosummary/muvid.html.md#muvid.MusicVideoProject) and the schema
  dataclasses.
- **Hosted productions** (`muvid.Project`, needs `nw`): a music video or a
  lyric video a host (the reelee studio) places and serves — an `nw.Project` whose
  footage operations are [`muvid.footage.service`](_autosummary/muvid.footage.service.html.md#module-muvid.footage.service).
- **Visualizer** ([`muvid.visualize`](_autosummary/muvid.visualize.html.md#module-muvid.visualize), needs only `ffmpeg` + `mixing`):
  turn a song and a cover into a still / Ken Burns / audio-reactive music video,
  plus a thumbnail. Deterministic, no AI, no network.
  ```pycon
  >>> from muvid.visualize import render_audio_video
  >>> render_audio_video("song.wav", image="cover.png")
  ```

The narrative-pipeline names are imported **lazily** so that `import muvid` (and
hence `import muvid.visualize`) does not require the heavy AI extras — the
import of a given name only pulls its dependencies when you actually use it.

### *class* muvid.CharacterRef(, name, description='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Pointer to a character folder under `characters/<name>/`.

The folder contains the canonical card.json + curated reference
images. We only carry the name + a quick description here so the
project SSOT stays small.

### *class* muvid.EnvironmentRef(, name, description='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Pointer to an environment folder under `environments/<name>/`.

### *class* muvid.MusicVideoProject(root)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Filesystem-backed music video project.

All write methods touch the disk immediately; readers always re-read
the SSOT (no in-memory cache) so external edits are picked up.

#### *classmethod* init(root, , title='', song_path=None, copy_song=True, exist_ok=False)

Create a fresh project directory.

If `song_path` is given, the audio is copied (or moved if
`copy_song=False`) into `song/`, probed for duration, and
registered in `project.json`.

* **Return type:**
  [`MusicVideoProject`](_autosummary/muvid.project.html.md#muvid.project.MusicVideoProject)

#### log_decision(kind, \*\*payload)

Append a one-line JSON entry to `.muvid/decisions.jsonl`.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

#### set_song(source, , copy=True)

Register an audio file as this project’s song.

The file is copied (or moved) to `song/`, probed for duration
with ffprobe, and recorded in `project.json`.

* **Return type:**
  [`SongInfo`](_autosummary/muvid.schema.html.md#muvid.schema.SongInfo)

#### update_spec(\*\*changes)

Read, replace, write. Returns the new spec.

* **Return type:**
  [`ProjectSpec`](_autosummary/muvid.schema.html.md#muvid.schema.ProjectSpec)

### *class* muvid.ProjectSpec(, schema_version=1, title='', song=None, characters=(), environments=(), sections=(), shots=(), global_style='', notes='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The top-level project SSOT, persisted as `project.json`.

### *class* muvid.SectionSpec(, id, start_s, end_s, label='', energy='', mood='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A non-overlapping span of the song with a label.

`label` is free-form (“intro”, “verse”, “chorus”, “bridge”,
“outro”) so users can use whatever taxonomy fits their song.

### *class* muvid.ShotSpec(, id, start_s, end_s, section_id='', render_strategy='image_to_video', environment='', characters=(), description='', camera='', framing='medium', notes='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A timeline-locked visual unit of the music video.

`[start_s, end_s)` is half-open. Shots within a project are
sorted by `start_s` and should be non-overlapping (the validator
warns otherwise — overlap can be intentional for transitions but
isn’t supported by the basic compositor).

### *class* muvid.SongInfo(, audio_path, duration_s, sample_rate=0, bitrate=0, bpm=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Metadata for the master audio file.

### muvid.align_lyrics(root, , aligner='scribe-greedy', \*\*aligner_kwargs)

Build `lyrics/alignment.annot` from transcript + lyrics.md.

`aligner` selects the alignment strategy (see
[`muvid.align.list_aligners()`](_autosummary/muvid.align.html.md#muvid.align.list_aligners)); extra kwargs are forwarded to
the chosen aligner. Defaults to `scribe-greedy`.

Returns the path to the alignment store.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.default_project_root(name)

Where a project called `name` belongs by default, as an absolute path.

`root` is a required positional on every other verb here and on
[`muvid.project.MusicVideoProject`](_autosummary/muvid.project.html.md#muvid.project.MusicVideoProject), deliberately — a pipeline that guessed
which project it was operating on would be worse than one that asks. But required
*everywhere* left **no** code answering where a NEW project should go, so the only
written-down answer lived in prose: the `muvid` skill and the `README` both said
`~/muvid/<song-stem>`, which put a real project’s 36 MB in an app-named directory
under `$HOME` that nothing owns. This is that answer in code, where it can be
tested and where callers — the skill, the README, the CLI, a script — can *ask*
instead of restating it. It creates nothing.

The CLI’s `serve` is the one exception to “required”: it defaults `root` to the
cwd. See `muvid/paths.py` — the default is right, and it was the UI’s missing
is-this-a-project precondition that made it write.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.init_project(root, , title='', song=None)

Create a new music video project. Returns the absolute root path.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.render(root, , quality='balanced', force=False, budget=None, allow_unpriced=False)

Render every shot. Returns the produced mp4 paths.

`budget` (USD): when set, refuses to start if
`estimate_render_cost()` exceeds it — \*\*or if any part of the project
could not be priced at all\*\*. Pass `None` (CLI: `--budget=-1`) to skip
the gate entirely.

`allow_unpriced`: proceed despite unpriceable work, after reading what it
is. The gate is otherwise TWO conditions, and the second is the one muvid#47
was filed about: a price this code could not determine contributes nothing to
`total_amount`, so comparing the number alone let an unpriceable shot clear
**any** budget. Unknown is not zero.

The escape exists because a threshold and an approval are different things. A
hard refusal with no way past it would make `--budget` unusable for a
project containing one exotic model — so the default refuses, and a caller
who has read the names can accept them. What it must never become is a
silent default.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### muvid.render_audio_video(audio, image=None, , visual='auto', saveas=None, size=(1920, 1080), fps=24, title=None, layout=None, title_style=None, normalize=False, loudness=None, crf=18, preset='medium', audio_bitrate='384k', gop_seconds=2.0, options=None, workdir=None)

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** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – The song (`.wav` is preferred when you have it — YouTube
    re-encodes regardless, so give it the cleanest input).
  * **image** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Cover art. Used for the picture, and composed onto a 16:9 canvas.
  * **visual** (`Union`[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`VisualContext`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualContext)], [`VisualPlan`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualPlan) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]) – A registered strategy name (`"still"`, `"ken_burns"`,
    `"cqt"`, `"bars"`, `"spectrum"`, `"waves"`, `"scope"`),
    `"auto"`, or any callable (see [`muvid.visualize.visuals`](_autosummary/muvid.visualize.visuals.html.md#module-muvid.visualize.visuals)).
  * **saveas** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Output path (default: `<audio-stem>.mp4`).
  * **size** ([`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)]) – Canvas size; the default is 1080p.
  * **fps** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – Frame rate.
  * **title** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Burn this title into the frame.
  * **layout** ([`CoverLayout`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.CoverLayout) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – How the cover sits on the canvas.
  * **title_style** ([`TitleStyle`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.TitleStyle) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – How the title is drawn.
  * **normalize** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – Loudness-normalize the audio (two-pass EBU R128).
  * **loudness** ([`Loudness`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.Loudness) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – The loudness target; a YouTube-appropriate default is used
    when omitted.
  * **gop_seconds** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – Encoder knobs.
  * **options** ([`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Strategy-specific options, passed to the visual.
  * **workdir** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Where intermediates go (a temporary directory by default).
* **Return type:**
  [`RenderResult`](_autosummary/muvid.visualize.video.html.md#muvid.visualize.video.RenderResult)
* **Returns:**
  A `RenderResult`.
* **Raises:**
  [**ValueError**](https://docs.python.org/3/builtins/exceptions.html#ValueError) – `size` has an odd dimension — H.264 at yuv420p (the only
      pixel format every player decodes) cannot encode one.

### muvid.status(root)

Return a summary dict of the project’s current state.

Useful for the skill / UI to show the user where they are in the
pipeline. No side effects.

Returns a structured shape with stage progression, per-shot render
status, and (when an alignment store exists) a word-confidence
histogram. Stable enough to be programmatic; pass through
`format_status()` for human-readable text.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.transcribe_song(root, , api_key=None)

Run ElevenLabs Scribe on the project’s song.

Writes the raw response to `lyrics/transcript.json` and a draft
`lyrics/lyrics.md` with auto-detected line breaks. The user is
expected to edit `lyrics.md` to fix mishears and add real section
tags. Returns the path to the lyrics markdown.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### Modules

| [`align`](_autosummary/muvid.align.html.md#module-muvid.align)               | Lyric → audio alignment.                                                             |
|-----------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------|
| [`characters`](_autosummary/muvid.characters.html.md#module-muvid.characters)     | Character cards + reference image curation via lookbook.                             |
| [`choreo`](_autosummary/muvid.choreo.html.md#module-muvid.choreo)             | Choreo — event-driven visual music, muvid's second subgenre plugin.                  |
| [`compose`](_autosummary/muvid.compose.html.md#module-muvid.compose)           | Compose all rendered shots into the final music video.                               |
| [`contracts`](_autosummary/muvid.contracts.html.md#module-muvid.contracts)       | Adapters between muvid's SSOT and sibling-package shapes.                            |
| [`cost`](_autosummary/muvid.cost.html.md#module-muvid.cost)                 | Cost estimation for a muvid project.                                                 |
| [`data`](_autosummary/muvid.data.html.md#module-muvid.data)                 |                                                                                      |
| [`environments`](_autosummary/muvid.environments.html.md#module-muvid.environments) | Environment cards + canonical establishing image generation.                         |
| [`events`](_autosummary/muvid.events.html.md#module-muvid.events)             | Surface fal progress events into the muvid project.                                  |
| [`facade`](_autosummary/muvid.facade.html.md#module-muvid.facade)             | Top-level facade — the verbs the CLI / skill / UI all call.                          |
| [`footage`](_autosummary/muvid.footage.html.md#module-muvid.footage)           | Footage-aligned music video — align several device recordings of one song, assemble. |
| [`importing`](_autosummary/muvid.importing.html.md#module-muvid.importing)       | Bring a finished production into a host's projects dir as a `muvid.Project`.         |
| [`lyrics`](_autosummary/muvid.lyrics.html.md#module-muvid.lyrics)             | Lyrics — transcription and markdown round-trip.                                      |
| [`lyricvid`](_autosummary/muvid.lyricvid.html.md#module-muvid.lyricvid)         | Lyric video (kinetic typography) — muvid's first subgenre plugin.                    |
| [`mcp`](_autosummary/muvid.mcp.html.md#module-muvid.mcp)                   | muvid MCP server — the `music-visualizer` tool surface for a remote connector.       |
| [`montage`](_autosummary/muvid.montage.html.md#module-muvid.montage)           | Montage — a beat-cut video from a pool of stills and clips (subgenre plugin).        |
| [`paths`](_autosummary/muvid.paths.html.md#module-muvid.paths)               | The ONE place muvid decides where its data lives.                                    |
| [`project`](_autosummary/muvid.project.html.md#module-muvid.project)           | Project facade — folder layout, persistence, and a small `dol`-backed mall.          |
| [`renderers`](_autosummary/muvid.renderers.html.md#module-muvid.renderers)       | Render dispatch — turn a single ShotSpec into `shots/<id>/output.mp4`.               |
| [`schema`](_autosummary/muvid.schema.html.md#module-muvid.schema)             | Schema for an muvid project — the SSOT data shapes.                                  |
| [`script`](_autosummary/muvid.script.html.md#module-muvid.script)             | Script (screenplay) markdown ↔ ShotSpec list.                                        |
| [`subgenres`](_autosummary/muvid.subgenres.html.md#module-muvid.subgenres)       | muvid subgenres — the plugin surface for *another kind of video*.                    |
| [`ui`](_autosummary/muvid.ui.html.md#module-muvid.ui)                     | Minimal local web UI for an muvid project.                                           |
| [`visualize`](_autosummary/muvid.visualize.html.md#module-muvid.visualize)       | Turn a song (+ optional cover) into a visualizer music video.                        |


# _autosummary/muvid.importing.html.md

# muvid.importing

Bring a finished production into a host’s projects dir as a `muvid.Project`.

The verb is [`import_production()`](_autosummary/muvid.importing.html.md#muvid.importing.import_production); the CLI door is
`python -m muvid.importing MANIFEST PROJECTS_DIR [--dry-run]`. One JSON manifest per
production says where its pieces are; the importer creates (or reopens) the project at
`PROJECTS_DIR/<id>` exactly as a host would (`nw.create_genre_project` with
placement, so the genre envelope is recorded and the host opens it as a
`muvid.Project`), and fills it through the SAME operations the studio and the
connector use ([`muvid.footage.service`](_autosummary/muvid.footage.service.html.md#module-muvid.footage.service)) — so an imported edit is validated by
`validate_edl` like any other, and every file lands in the host’s artifact catalog.

**Idempotent**: run it twice and you have one project. The song, a clip or a render
whose bytes are already there is left alone; an offset or an edit that already says the
same thing is not rewritten.

Two kinds of manifest (paths absolute, `~`-expanded, or relative to the manifest):

`footage` — a music video cut from footage:

```default
{"kind": "footage", "id": "que_calor", "title": "Que Calor", "canvas": "landscape",
 "song": {"path": "source/master.m4a"}              # or {"path": x.mp4, "extract_audio": true}
 "clips": [{"id": "c01", "path": "footage/01.mp4", "name": "Camera A",
            "offset_s": 28.854}],                    # offset_s = a DECLARED offset
 "edits": [{"id": "v1", "name": "V1", "edl_path": "work/edl_v1d.json",
            "how_made": "…",                         # or "edl": [...] inline, or
            "span": [0.162, 157.13]}],               # "whole_song": "c01" (one shot);
                                                     # span: default the whole song
 "renders": [{"id": "v1e", "path": "out/v1e.mp4", "edit_id": "v1", "label": "V1"}]}
```

`lyric-video` — a lyric video (view-only in v1):

```default
{"kind": "lyric-video", "id": "il_pleut", "title": "Il Pleut",
 "template": "calligram", "song": {"path": "…"},
 "sources": [{"role": "lyrics", "path": "…"}],     # role: lyrics|treatment|timings|poem|other
 "renders": [{"id": "readable_v3", "path": "…", "label": "…"}]}
```

Manifests name private paths, so they live beside the data or under
`~/.local/share/muvid/imports/` — never in a repository.

### Functions

| [`import_production`](_autosummary/muvid.importing.html.md#muvid.importing.import_production)(manifest, projects_dir, \*)   | Import one production; returns a report of what was done (or would be).        |
|--------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------|
| [`load_manifest`](_autosummary/muvid.importing.html.md#muvid.importing.load_manifest)(path)                             | `(manifest, base_dir)` — relative paths in it resolve against `base_dir`.      |
| [`edl_from_document`](_autosummary/muvid.importing.html.md#muvid.importing.edl_from_document)(doc, \*, source_sizes)        | The muvid EDL (a list of `EdlEntry` dicts) a production's edit document means. |

### Exceptions

| [`ImportRefused`](_autosummary/muvid.importing.html.md#muvid.importing.ImportRefused)   | The manifest cannot be imported as written (the message says which part).   |
|------------------------------------------------------------------|-----------------------------------------------------------------------------|

### *exception* muvid.importing.ImportRefused

Bases: [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError)

The manifest cannot be imported as written (the message says which part).

### muvid.importing.edl_from_document(doc, , source_sizes)

The muvid EDL (a list of `EdlEntry` dicts) a production’s edit document means.

`source_sizes` maps each clip id to its DISPLAYED `(width, height)` — needed only
for a framing document; a clip it does not name is refused rather than guessed.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

```pycon
>>> doc = {"edl": [
...     {"song_start": 0.0, "song_end": 2.0, "clip_id": "a",
...      "framing": {"w": 100, "h": 50, "x0": 0, "y0": 0, "x1": 0, "y1": 0}},
...     {"song_start": 2.0, "song_end": 4.0, "clip_id": "a",
...      "framing": {"w": 50, "h": 25, "x0": 10, "y0": 5, "x1": 40, "y1": 5}}]}
>>> edl = edl_from_document(doc, source_sizes={"a": (100, 50)})
>>> "crop" in edl[0], edl[1]["crop"], edl[1]["crop_end"]["x"]
(False, {'x': 0.1, 'y': 0.1, 'w': 0.5, 'h': 0.5}, 0.4)
```

### muvid.importing.import_production(manifest, projects_dir, , base_dir=None, dry_run=False, caller='importer')

Import one production; returns a report of what was done (or would be).

`manifest` is a dict or a path to one; `projects_dir` is the host’s projects
directory (the project lands at `projects_dir/<id>`). `dry_run` checks every
file and every edit conversion and writes nothing.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.importing.load_manifest(path)

`(manifest, base_dir)` — relative paths in it resolve against `base_dir`.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict), [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)]


# _autosummary/muvid.lyrics.html.md

# muvid.lyrics

Lyrics — transcription and markdown round-trip.

Two surfaces for the user:

1. `transcribe(audio_path)` — calls `mixing.transcript.transcribe`
   (ElevenLabs Scribe) and writes the raw word-timestamped JSON to
   `lyrics/transcript.json`. This is the *seed*; the user is expected
   to correct it.
2. `write_lyrics_md` / `parse_lyrics_md` — the canonical, editable
   form. A simple markdown with `[section]` headers, one line per
   sung line, and an optional `// <seconds>` end-of-line anchor.

The alignment module consumes both: it reads `lyrics.md` for the
*text* the user committed to, and `transcript.json` for the *timing*
to splice in.

### Functions

| [`indentation_varies`](_autosummary/muvid.lyrics.html.md#muvid.lyrics.indentation_varies)(indents)                | True when lyric lines are indented differently: a shape a parse would lose.          |
|---------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------|
| [`layout_dropped`](_autosummary/muvid.lyrics.html.md#muvid.lyrics.layout_dropped)(md)                         | What the default parse flattens, as data: `None` for ordinary lyrics.                |
| [`line_indents`](_autosummary/muvid.lyrics.html.md#muvid.lyrics.line_indents)(md)                           | The leading whitespace of each lyric line, aligned with `parse_lyrics_md(md).lines`. |
| [`lyrics_from_transcript`](_autosummary/muvid.lyrics.html.md#muvid.lyrics.lyrics_from_transcript)(transcript)         | Build a default LyricsDoc from a transcription response.                             |
| [`parse_lyrics_md`](_autosummary/muvid.lyrics.html.md#muvid.lyrics.parse_lyrics_md)(md, \*[, preserve_indent]) | Parse the user-editable lyrics markdown.                                             |
| `read_lyrics_md`(path, \*[, preserve_indent])                                               |                                                                                      |
| `read_transcript`(path)                                                                     |                                                                                      |
| [`render_lyrics_md`](_autosummary/muvid.lyrics.html.md#muvid.lyrics.render_lyrics_md)(doc)                      | Inverse of `parse_lyrics_md`.                                                        |
| [`transcribe`](_autosummary/muvid.lyrics.html.md#muvid.lyrics.transcribe)(audio_path, \*[, api_key, ...]) | Run ElevenLabs Scribe on the audio and (optionally) write the JSON.                  |
| [`words_from_transcript`](_autosummary/muvid.lyrics.html.md#muvid.lyrics.words_from_transcript)(transcript)          | Normalize Scribe's word entries: `[{text, start, end, confidence}, ...]`.            |
| `write_lyrics_md`(path, doc)                                                                |                                                                                      |

### Classes

| [`LyricLine`](_autosummary/muvid.lyrics.html.md#muvid.lyrics.LyricLine)(\*, text, line_index[, ...])         | One line of lyric text, optionally with a known start time.   |
|-------------------------------------------------------------------------------------------------|---------------------------------------------------------------|
| [`LyricSection`](_autosummary/muvid.lyrics.html.md#muvid.lyrics.LyricSection)(\*, label[, title, start_s, ...]) | A user-tagged section in the lyrics markdown.                 |
| [`LyricsDoc`](_autosummary/muvid.lyrics.html.md#muvid.lyrics.LyricsDoc)(\*, sections)                        | Full parsed view of the user's lyrics markdown.               |

### Exceptions

| [`ShapedLyricsWarning`](_autosummary/muvid.lyrics.html.md#muvid.lyrics.ShapedLyricsWarning)   | Lyric lines are indented differently, and the parse is about to drop it.   |
|------------------------------------------------------------------------|----------------------------------------------------------------------------|

### *class* muvid.lyrics.LyricLine(, text, line_index, section_label='', start_s=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One line of lyric text, optionally with a known start time.

### *class* muvid.lyrics.LyricSection(, label, title='', start_s=None, end_s=None, lines=())

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A user-tagged section in the lyrics markdown.

Times are *optional* — if not present, alignment is computed from
transcripts; if present, they override.

### *class* muvid.lyrics.LyricsDoc(, sections)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Full parsed view of the user’s lyrics markdown.

### *exception* muvid.lyrics.ShapedLyricsWarning

Bases: [`UserWarning`](https://docs.python.org/3/builtins/exceptions.html#UserWarning)

Lyric lines are indented differently, and the parse is about to drop it.

A lyrics file whose lines carry *differing* leading whitespace is almost
always a drawn layout (a concrete poem, an *Il pleut*), and the default
parse strips that whitespace — so the shape is gone before any archetype
sees it (muvid#109). This is the signal that it happened. Uniform
indentation (every line indented alike) carries no shape and does not warn.

### muvid.lyrics.indentation_varies(indents)

True when lyric lines are indented differently: a shape a parse would lose.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

```pycon
>>> indentation_varies(["", "  "]), indentation_varies(["  ", "  "])
(True, False)
```

### muvid.lyrics.layout_dropped(md)

What the default parse flattens, as data: `None` for ordinary lyrics.

The same test as [`ShapedLyricsWarning`](_autosummary/muvid.lyrics.html.md#muvid.lyrics.ShapedLyricsWarning), for a caller that cannot see
a Python warning (an MCP reply, a render’s meta; muvid#165). Does not warn.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`int`](https://docs.python.org/3/builtins/functions.html#int)] | [`None`](https://docs.python.org/3/builtins/constants.html#None)

```pycon
>>> layout_dropped("one\ntwo\n")
>>> layout_dropped("one\n  two\n    three\n")
{'lines': 3, 'indentations': 3}
```

### muvid.lyrics.line_indents(md)

The leading whitespace of each lyric line, aligned with `parse_lyrics_md(md).lines`.

Reads the shape the default parse strips, without changing the parse.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

```pycon
>>> line_indents("[v]\none\n  two\n(aside)\n\tthree\n")
('', '  ', '\t')
```

### muvid.lyrics.lyrics_from_transcript(transcript)

Build a default LyricsDoc from a transcription response.

Heuristic: split lines on punctuation (`. ? !`) or on long pauses
(>0.6 s gap between consecutive words). One `[transcribed]` section
holds everything; the user is expected to re-tag with real sections.

* **Return type:**
  [`LyricsDoc`](_autosummary/muvid.lyrics.html.md#muvid.lyrics.LyricsDoc)

### muvid.lyrics.parse_lyrics_md(md, , preserve_indent=False)

Parse the user-editable lyrics markdown.

Format:

```default
[section_label] optional title
line of lyric            // 12.5
another line

[next section]
...
```

Empty lines are separators between sections. `(instrumental)` or
any line starting with `(` and ending with `)` is treated as a
non-lyric placeholder (no LyricLine emitted).

Leading whitespace is stripped by default, and the result for every
existing input is unchanged by that default (pinned by a golden test). When
the lines are indented *differently* from one another — a drawn layout —
stripping destroys the shape, so a [`ShapedLyricsWarning`](_autosummary/muvid.lyrics.html.md#muvid.lyrics.ShapedLyricsWarning) says so.
`preserve_indent=True` keeps each line’s leading whitespace in
`LyricLine.text` instead (trailing whitespace and the `//` anchor are
still removed), which [`render_lyrics_md()`](_autosummary/muvid.lyrics.html.md#muvid.lyrics.render_lyrics_md) writes back verbatim and
which anything displaying that text (an alignment’s line text, say) then
carries. No
lyric-video archetype honours indentation yet; the word tokeniser skips
whitespace, so alignment is the same either way. [`line_indents()`](_autosummary/muvid.lyrics.html.md#muvid.lyrics.line_indents)
reads the indentation without changing the parse.

* **Return type:**
  [`LyricsDoc`](_autosummary/muvid.lyrics.html.md#muvid.lyrics.LyricsDoc)

```pycon
>>> md = "[v]\nIl pleut\n  des voix\n"
>>> [L.text for L in parse_lyrics_md(md, preserve_indent=True).lines]
['Il pleut', '  des voix']
```

### muvid.lyrics.render_lyrics_md(doc)

Inverse of `parse_lyrics_md`. Stable round-trip.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.lyrics.transcribe(audio_path, , api_key=None, out_path=None, cache=True)

Run ElevenLabs Scribe on the audio and (optionally) write the JSON.

Returns the raw response dict (which contains `words: [...]` with
per-word `text`, `start`, `end`, `confidence`).

The on-disk Scribe cache (in `mixing.transcript`) is enabled by
default, so re-running on the same audio is free. Pass
`cache=False` to force a fresh round-trip.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### muvid.lyrics.words_from_transcript(transcript)

Normalize Scribe’s word entries: `[{text, start, end, confidence}, ...]`.

Filters out non-word events (Scribe surfaces `(laughs)` etc. with
`type` ≠ `word`) and ones missing timing. `confidence` is
pass-through; absent → `None`.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]


# _autosummary/muvid.lyricvid.captions.html.md

# muvid.lyricvid.captions

Caption tracks: one subtitle file per language, timed per sung line (muvid#145).

A lyric video for learners wants toggleable caption tracks on YouTube — a
translation, a transliteration (Hepburn romaji for kana) — timed from the same
[`TimedText`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.TimedText) that drives the picture, so the
words on screen and the caption under them change together.

Each language is a **transform** of a line’s text:

- a mapping `{line text: caption}` — a hand-written translation. A sung line
  the mapping does not cover is an ERROR naming every such line: a caption track
  with silent holes is a plausible artifact nobody re-checks;
- a callable `line text -> caption`;
- a name from [`CAPTION_TRANSFORMS`](_autosummary/muvid.lyricvid.captions.html.md#muvid.lyricvid.captions.CAPTION_TRANSFORMS) (`"hepburn"`, `"original"`) — the
  form that survives JSON, so a render request or the CLI can ask for it.

A transform returning `""` omits that line from its track, on purpose.
Every check happens before any track is built — a remote render request
reaches this through the `captions` param, and the subgenre schema validator
does not read `anyOf` — so a malformed request fails as a `ValueError`
before the render, never after it.

Timing, per line: the caption appears `lead_s` before the line’s first sung
glyph (or word) — a reader needs the head start — stays at least `min_s`, and
leaves when the next line’s caption arrives (the last one `tail_s` after its
line ends). `offset_s` shifts everything, e.g. by a title card prepended to
the video. Lines are captioned in the order they are SUNG, whatever order the
timing lists them in; lines sung together get overlapping captions rather
than one of them a few milliseconds long. No caption outlasts the song. The
files hand straight to `yb.youtube.CaptionTrack(path, language)`.

`pysubs2` (the `lyricvid` extra, which the default renderer already needs)
is imported only when a track is built.

### Module Attributes

| [`CAPTION_TRANSFORMS`](_autosummary/muvid.lyricvid.captions.html.md#muvid.lyricvid.captions.CAPTION_TRANSFORMS)   | Named transforms — what a JSON caller (a render request, the CLI) can ask for.   |
|-----------------------------------------------------------------------|----------------------------------------------------------------------------------|

### Functions

| [`caption_tracks`](_autosummary/muvid.lyricvid.captions.html.md#muvid.lyricvid.captions.caption_tracks)(timed_text, transforms, \*[, ...])   | One subtitle track per language, a caption per sung line.                  |
|------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------|
| [`write_caption_tracks`](_autosummary/muvid.lyricvid.captions.html.md#muvid.lyricvid.captions.write_caption_tracks)(tracks, out_dir, \*[, ...])    | Save each track as `{out_dir}/{stem}.{lang}.{fmt}`; return `{lang: path}`. |

### muvid.lyricvid.captions.CAPTION_TRANSFORMS *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [Callable](https://docs.python.org/3/library/typing.html#typing.Callable)[[[str](https://docs.python.org/3/builtins/stdtypes.html#str)], [str](https://docs.python.org/3/builtins/stdtypes.html#str)]]* *= {'hepburn': <function \_hepburn>, 'original': <function <lambda>>}*

Named transforms — what a JSON caller (a render request, the CLI) can ask for.

### muvid.lyricvid.captions.caption_tracks(timed_text, transforms, , lead_s=0.15, min_s=1.2, tail_s=1.5, offset_s=0.0)

One subtitle track per language, a caption per sung line.

`transforms` maps a BCP-47 language tag to a `LineTransform`.
Every transform is checked (unknown names, uncovered lines) before any
track is built, so a bad request fails whole rather than half-written.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), `SSAFile`]

```pycon
>>> from muvid.lyricvid.timed_text import from_word_records
>>> tt = from_word_records([
...     {"text": "バス", "start": 1.0, "end": 1.5, "line_end": True},
...     {"text": "スタート", "start": 2.0, "end": 3.0, "line_end": True}])
>>> tracks = caption_tracks(tt, {"en": {"バス": "Bus", "スタート": "Start"},
...                              "ja-Latn": "hepburn"})
>>> [(e.start, e.end, e.text) for e in tracks["ja-Latn"]]
[(850, 1850, 'basu'), (1850, 3050, 'sutāto')]
```

### muvid.lyricvid.captions.write_caption_tracks(tracks, out_dir, , stem='captions', fmt='srt')

Save each track as `{out_dir}/{stem}.{lang}.{fmt}`; return `{lang: path}`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)]


# _autosummary/muvid.lyricvid.director.html.md

# muvid.lyricvid.director

The creative director — a song in, one or more `TreatmentSpec`s out.

This is the half of the subgenre that has taste, and it has to work in two
runtimes that look nothing alike:

**agentic**
: inside Claude Code or claude.ai, where a skill hands the *host* model the
  lyrics and the vocabularies and the host model writes the treatment;

**transactional**
: in production, as one bounded structured-output call from a web frontend,
  with a cost, a timeout and a schema constraint.

Duplicating the taste between a `SKILL.md` and a Python system prompt is how
those two runtimes silently diverge — one gets a new archetype, the other keeps
recommending the old one, and nobody notices until a render looks wrong. So the
shared artefacts are ranked, and there are only three of them:

1. the **schema** ([`muvid.lyricvid.spec`](_autosummary/muvid.lyricvid.spec.html.md#module-muvid.lyricvid.spec)), which constrains both runtimes;
2. the **pure functions** in this module, which have no runtime opinions at all;
3. the **prompt text**, which lives in `muvid/data/prompts/*.md` and is read
   from there by both. There is exactly one copy of every opinion, and the
   vocabularies are *interpolated into* the prompt from
   [`muvid.lyricvid.spec.vocabulary()`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.vocabulary) rather than restated in it.

`llm` is the seam, and it is one keyword argument: a callable
`(messages, schema) -> dict`. Its default is not a stub. [`heuristic_llm()`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.heuristic_llm)
is a real director that reads the lyrics — density, repetition, line shape,
vocabulary richness, section structure — and picks an archetype, a palette and a
motion vocabulary from measurement alone. The whole subgenre therefore runs end
to end with no API key, no network and no cost, and [`anthropic_llm()`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.anthropic_llm) is an
upgrade rather than a prerequisite.

Diversity is the other design decision worth stating. Asking one director for
`n` options and turning the temperature up yields `n` blurred copies of one
idea; the differences become noise rather than intent. So options come from
[`PERSONAS`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.PERSONAS) — several art directors who genuinely disagree about how much
of the frame belongs to reading and how much to feeling — each asked once, each
told which archetypes the other options already took.

Everything degrades honestly. A model that returns something unusable falls back
to the heuristic director, and the fallback is *recorded*: [`director_meta()`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.director_meta)
reads it back off the returned spec, and the reason is appended to the human-
facing rationale. A silent fallback that returns a plausible artifact is the
failure mode muvid has been bitten by before (muvid#46, muvid#38) and it is not
repeated here.

Import-safe: stdlib only at module scope. `anthropic` is imported inside
[`anthropic_llm()`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.anthropic_llm)’s returned callable.

### Module Attributes

| [`PERSONAS`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.PERSONAS)   | The persona registry.   |
|-------------------------------------------------------------|-------------------------|

### Functions

| [`register_persona`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.register_persona)(persona)                         | Register a persona under its slug (returns it, for inline use).                                                                             |
|----------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------|
| [`list_personas`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.list_personas)()                                   | Every registered persona, packaged ones first, in file order.                                                                               |
| [`resolve_persona`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.resolve_persona)(persona)                          | Resolve a slug or a [`Persona`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.Persona) to a [`Persona`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.Persona). |
| [`director_prompt`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.director_prompt)()                                 | The director's system prompt, with the vocabularies and schema filled in.                                                                   |
| [`output_schema`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.output_schema)()                                   | The JSON Schema a model generates against — a closed form of the spec's.                                                                    |
| [`prompt_context`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.prompt_context)(timed_text, \*[, song_title, ...]) | Everything the director should see, and nothing else.                                                                                       |
| [`build_messages`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.build_messages)(context, \*[, persona, ...])       | Assemble the director's messages: prompt files plus this song's context.                                                                    |
| [`heuristic_director`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.heuristic_director)(context, \*[, persona])        | Pick a treatment from measurement alone.                                                                                                    |
| [`heuristic_llm`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.heuristic_llm)()                                   | The zero-cost director, as an `llm` seam implementation.                                                                                    |
| [`anthropic_llm`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.anthropic_llm)(\*[, model, api_key, ...])          | The production `llm` seam: one structured-output call to Claude.                                                                            |
| [`propose_treatments`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.propose_treatments)(timed_text, \*[, n, ...])      | Propose `n` treatments for one song.                                                                                                        |
| [`rank_treatments`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.rank_treatments)(specs, timed_text)                | Order options without a human.                                                                                                              |
| [`director_meta`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.director_meta)(spec)                               | Read this module's provenance back off a spec it produced.                                                                                  |

### Classes

| [`Persona`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.Persona)(\*, slug, name[, doctrine, ...])   | One art director's doctrine, plus the dials that encode it.   |
|---------------------------------------------------------------------------------------------|---------------------------------------------------------------|

### muvid.lyricvid.director.PERSONAS *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [Persona](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.Persona)]* *= {}*

The persona registry. Populated from the packaged markdown on first use;
[`register_persona()`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.register_persona) adds more. Same idiom as `register_visual` /
`register_selection_strategy` / `register_archetype`.

### *class* muvid.lyricvid.director.Persona(\*, slug, name, doctrine='', legibility=0.5, prefers=(), mood='', palette=<factory>, typography=<factory>, motion=('fade', ), persistence='clear_on_line', quantize='word', cut_style='hard', attack_s=0.12, lead_s=0.0)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One art director’s doctrine, plus the dials that encode it.

* **Parameters:**
  * **doctrine** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – the prose a model reads. Never restated in Python.
  * **legibility** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – 0..1. How far this director bends toward “the words are
    the picture” (1.0) versus “the words are the texture of a picture” (0.0).
    It is the axis the personas actually disagree on, and it is what makes
    two of them choose differently from the *same* measurements.
  * **prefers** ([`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]) – archetypes in order of preference.

#### block()

The persona as prompt text: heading, doctrine, dials.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.lyricvid.director.anthropic_llm(, model='claude-opus-5', api_key=None, max_tokens=8000, effort=None, client=None)

The production `llm` seam: one structured-output call to Claude.

The response is constrained by [`output_schema()`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.output_schema) through
`output_config.format`, so the returned text is guaranteed-parseable JSON
in the treatment’s shape — the model cannot invent an archetype or hand back
prose, which removes the whole retry-on-malformed-JSON layer.

Usage rides back on the returned mapping under `"_usage"` (and accumulates
on the callable’s own `.usage` list, for a caller that drives the seam
directly). It carries `estimated_cost_usd` **and** `has_unknown_costs`,
because a price this module cannot determine is unknown, not zero.

`anthropic` is imported inside the call, not at module scope: `import
muvid` must not pull an SDK, and this seam is optional by construction.

* **Parameters:**
  **client** ([`Any`](https://docs.python.org/3/library/typing.html#typing.Any) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – an already-built SDK client, mostly for tests. When given,
  `api_key` is ignored.
* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`Sequence`](https://docs.python.org/3/library/typing.html#typing.Sequence)[[`Mapping`](https://docs.python.org/3/library/typing.html#typing.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]], [`Mapping`](https://docs.python.org/3/library/typing.html#typing.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]], [`Mapping`](https://docs.python.org/3/library/typing.html#typing.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]

### muvid.lyricvid.director.build_messages(context, , persona=None, reference_image=None)

Assemble the director’s messages: prompt files plus this song’s context.

The system prompt is emitted as *two* messages — the shared director prompt
then the persona — so a transactional caller can cache the first across all
`n` options while only the second varies. [`anthropic_llm()`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.anthropic_llm) does
exactly that.

The context travels as a fenced JSON block, which is also how
[`heuristic_llm()`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.heuristic_llm) reads it back: the zero-cost director and the model are
given literally the same message rather than two views of one idea.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]

```pycon
>>> from muvid.lyricvid.timed_text import from_words
>>> tt = from_words([('we', 0.0, .4), ('go', .4, .9)], duration=1.0)
>>> msgs = build_messages(prompt_context(tt), persona='minimalist')
>>> [m['role'] for m in msgs]
['system', 'system', 'user']
>>> 'The Minimalist' in msgs[1]['content']
True
>>> _context_from_messages(msgs)['signals']['n_words']
2
>>> img = build_messages(prompt_context(tt),
...                      reference_image={'type': 'image', 'source': {}})
>>> [b['type'] for b in img[-1]['content']]
['image', 'text']
```

### muvid.lyricvid.director.director_meta(spec)

Read this module’s provenance back off a spec it produced.

`source` is `'heuristic'`, `'llm'` or `'heuristic-fallback'` — the
last meaning a model was asked and its answer could not be used. That case
also carries `fallback_reason` and appends it to the rationale, because a
degraded result that presents as an intended one is the failure muvid keeps
paying for elsewhere.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> from muvid.lyricvid.timed_text import from_words
>>> tt = from_words([('go', 0.0, .5)], duration=1.0)
>>> director_meta(propose_treatments(tt, n=1)[0])['source']
'heuristic'
```

### muvid.lyricvid.director.director_prompt()

The director’s system prompt, with the vocabularies and schema filled in.

The same text is the skill’s guidance and the API call’s `system`. Nothing
in it is written twice: the vocabularies come from
[`muvid.lyricvid.spec.vocabulary()`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.vocabulary) and the schema from
[`output_schema()`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.output_schema).

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> text = director_prompt()
>>> VOCABULARIES_MARKER in text or SCHEMA_MARKER in text
False
>>> '`karaoke_wipe`' in text and '"one_word_centred"' in text
True
```

### muvid.lyricvid.director.heuristic_director(context, , persona=None)

Pick a treatment from measurement alone. No model, no network, no key.

The decision is two linear scores and one rule. `ARCHETYPE_CHARACTER`
asks what the lyric *is* — repetitive, long-lined, shaped, image-dominated,
sparse and various. `ARCHETYPE_CAPACITY_WPS` asks how fast it goes
past. The persona’s `legibility` decides how much each of those counts,
and its `prefers` order breaks the remaining ties — which is why six
personas over one song give six genuinely different treatments rather than
six samples of one. `context['avoid_archetypes']` (set by
[`propose_treatments()`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.propose_treatments)) then keeps a set of options from converging.

Sections are honoured when the lyrics carry real labels: a chorus-ish
section gets the runner-up archetype as a lift. In that case *every* section
gets its own scene and none is `"*"`, because the compiler renders a
`"*"` scene over the whole song and the words would double up.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> from muvid.lyricvid.timed_text import from_words
>>> words = [(w, i * .5, i * .5 + .45) for i, w in
...          enumerate('hold on hold on hold on'.split())]
>>> d = heuristic_director(prompt_context(from_words(words, duration=4.0)),
...                        persona='karaoke_host')
>>> d['scenes'][0]['archetype']
'karaoke_wipe'
>>> d['direction']['palette']['bg']
'#101828'
>>> spec_mod.validate(spec_mod.TreatmentSpec.from_dict(d))
[]
```

### muvid.lyricvid.director.heuristic_llm()

The zero-cost director, as an `llm` seam implementation.

Conforming to the seam rather than sitting beside it means the default path
and the production path run the *same* code in [`propose_treatments()`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.propose_treatments) —
same messages, same schema, same repair, same meta — so the free path is
exercised by every test the paid one would be.

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`Sequence`](https://docs.python.org/3/library/typing.html#typing.Sequence)[[`Mapping`](https://docs.python.org/3/library/typing.html#typing.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]], [`Mapping`](https://docs.python.org/3/library/typing.html#typing.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]], [`Mapping`](https://docs.python.org/3/library/typing.html#typing.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]

```pycon
>>> from muvid.lyricvid.timed_text import from_words
>>> tt = from_words([('all', 0.0, .5), ('night', .5, 1.2)], duration=2.0)
>>> call = heuristic_llm()
>>> out = call(build_messages(prompt_context(tt), persona='club_vj'),
...            output_schema())
>>> out['scenes'][0]['archetype']
'one_word_centred'
```

### muvid.lyricvid.director.list_personas()

Every registered persona, packaged ones first, in file order.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Persona`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.Persona)]

```pycon
>>> [p.slug for p in list_personas()]
['typographer', 'atmospherist', 'concrete_poet', 'club_vj', 'karaoke_host', 'minimalist']
>>> sorted({p.legibility for p in list_personas()}) ==         [0.2, 0.35, 0.55, 0.7, 0.9, 1.0]
True
```

### muvid.lyricvid.director.output_schema()

The JSON Schema a model generates against — a closed form of the spec’s.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> s = output_schema()
>>> s['required']
['title', 'direction', 'scenes']
>>> 'params' in s['properties']['scenes']['items']['properties']
False
>>> s['properties']['scenes']['items']['properties']['archetype']['enum'][0]
'one_word_centred'
```

### muvid.lyricvid.director.prompt_context(timed_text, , song_title='', extra=None)

Everything the director should see, and nothing else. No audio bytes.

The lyrics with their section labels, the measurements, whether the word
times were *measured* or interpolated, and the closed vocabularies. Anything
a caller has already measured elsewhere — tempo, key, a beat count — goes in
through `extra` and lands under `measurements`, so the director can use
a real number without this module growing an audio dependency.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> from muvid.lyricvid.timed_text import from_words
>>> tt = from_words([('hold', 0.0, .5), ('on', .5, 1.0)], duration=2.0)
>>> ctx = prompt_context(tt, song_title='Demo', extra={'tempo_bpm': 128})
>>> ctx['song_title'], ctx['measurements']['tempo_bpm']
('Demo', 128)
>>> ctx['sections'][0]['lines'][0]['text']
'hold on'
>>> ctx['timing']['measured'], sorted(ctx['vocabularies'])[0]
(True, 'archetypes')
```

### muvid.lyricvid.director.propose_treatments(timed_text, , n=3, song_title='', reference_image=None, llm=None, personas=None)

Propose `n` treatments for one song. Every one of them is renderable.

`llm=None` is the working default, not a stub: [`heuristic_llm()`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.heuristic_llm) reads
the lyrics and directs from measurement, so the subgenre runs end to end with
zero AI and zero cost. Pass [`anthropic_llm()`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.anthropic_llm) to upgrade the taste; pass
anything `(messages, schema) -> dict` to substitute your own.

Each option is directed by a different [`Persona`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.Persona) and is told which
archetypes the earlier options took, which is what makes `n` options
genuinely different rather than `n` samples of one.

Every returned spec has been through [`coerce()`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.coerce), so
`validate` is empty by construction; the substitutions repair made are
recorded in [`director_meta()`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.director_meta) under `repairs`.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`TreatmentSpec`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.TreatmentSpec)]

```pycon
>>> from muvid.lyricvid.timed_text import from_words
>>> words = 'we are the champions my friends'.split()
>>> tt = from_words([(w, i * .45, i * .45 + .4) for i, w in enumerate(words)],
...                 duration=4.0)
>>> options = propose_treatments(tt, n=3, song_title='Demo')
>>> [s.scenes[0].archetype for s in options]
['stacked_lines', 'scatter', 'concrete_page']
>>> [spec_mod.validate(s) for s in options]
[[], [], []]
>>> options[0].title
'Demo'
```

A model that returns something unusable degrades to the heuristic, and says
so rather than passing off a fallback as a decision:

```pycon
>>> import warnings
>>> with warnings.catch_warnings():
...     warnings.simplefilter('ignore')
...     broken = propose_treatments(tt, n=1, llm=lambda m, s: 'not a treatment')
>>> director_meta(broken[0])['source']
'heuristic-fallback'
>>> director_meta(broken[0])['fallback_reason']
'the director returned str, not a mapping'
>>> 'fell back to the heuristic director' in broken[0].direction.rationale
True
```

### muvid.lyricvid.director.rank_treatments(specs, timed_text)

Order options without a human. Deterministic, and it explains itself.

Four terms, combined by `_combine()` (a weighted *geometric* mean, so a
single disqualifying term cannot be averaged away by three good ones):
**legibility** (the song’s
word rate against the archetype’s capacity, penalised for word-level
quantisation the timing cannot support), **contrast** (WCAG-style ratios for
`fg`/`accent`/`dim`), **coverage** (does every section actually get a
scene) and **coherence** (declared motions, archetype count, and pairings
that defeat themselves).

Returns `(spec, score, why)`, best first, with ties broken on the leading
archetype so the order is stable across runs.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`TreatmentSpec`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.TreatmentSpec), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]

```pycon
>>> from muvid.lyricvid.timed_text import from_words
>>> tt = from_words([(w, i * .4, i * .4 + .35) for i, w in
...                  enumerate('one two three four'.split())], duration=3.0)
>>> readable = spec_mod.TreatmentSpec(
...     direction=spec_mod.Direction(mood='m', rationale='r'),
...     scenes=(spec_mod.Scene(archetype='karaoke_wipe', motion='fade'),))
>>> murky = spec_mod.TreatmentSpec(
...     direction=spec_mod.Direction(
...         mood='m', rationale='r',
...         palette=spec_mod.Palette(bg='#202020', fg='#2a2a2a',
...                                  accent='#242424', dim='#606060')),
...     scenes=(spec_mod.Scene(archetype='scatter', motion='fade'),))
>>> ordered = rank_treatments([murky, readable], tt)
>>> [round(score, 3) for _, score, _ in ordered]
[1.0, 0.358]
>>> ordered[0][0] is readable
True
>>> ordered[1][2]
'legibility 0.70 · contrast 0.03 · coverage 1.00 · coherence 1.00 — weakest is contrast'
```

### muvid.lyricvid.director.register_persona(persona)

Register a persona under its slug (returns it, for inline use).

* **Return type:**
  [`Persona`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.Persona)

### muvid.lyricvid.director.resolve_persona(persona)

Resolve a slug or a [`Persona`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.Persona) to a [`Persona`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.Persona).

* **Return type:**
  [`Persona`](_autosummary/muvid.lyricvid.director.html.md#muvid.lyricvid.director.Persona)

```pycon
>>> resolve_persona('karaoke_host').name
'The Karaoke Host'
```


# _autosummary/muvid.lyricvid.glyph_align.html.md

# muvid.lyricvid.glyph_align

Per-glyph timing: when was each written character sung? (muvid#142)

A word-level alignment says when `バカンス` was sung; a learner watching
katakana needs to see `バ`, `カ`, `ン` and `ス` light up one by one, each
at the moment its sound starts. This module refines any word-timed
[`TimedText`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.TimedText) (from Suno’s own timestamps, an
enhanced LRC, a whisper match…) into one with measured
[`glyphs`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.Word.glyphs).

How: each lyric line is cut out of the separated vocal stem, widened by
`margin_s` on both sides, and force-aligned with a CTC model (torchaudio’s
`MMS_FA`, trained on romanised speech in 1,000+ languages) against the line’s
romanisation, one romaji chunk per glyph, with a `<star>` token either side
to absorb breaths and ad-libs. Aligning per line, inside the coarse word
window, is what keeps one mis-sung line from dragging every later one.

Romanisation is a strategy slot (`romanize=`): [`romanize_kana()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.romanize_kana) covers
katakana and hiragana, including `ー` (lengthens the previous vowel), `ッ`
(doubles the next consonant) and the small `ャュョァィゥェォ` combinations.
Another script needs another romaniser, nothing else.

Verification lives here too, because a timing nobody checked is a guess:
[`onset_report()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.onset_report) measures how close glyph starts sit to acoustic onsets in
the vocal stem, against a random-jitter baseline, and [`word_agreement()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.word_agreement)
lists the words where two timings disagree. [`reconcile()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.reconcile) then acts on
what a list of [`Detector`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.Detector) s distrusts — each names words and a remedy
(`replace`: fall back to the vendor window; `drop`: it was never sung).
[`unsung()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.unsung) is the one that drops: a line the aligner could not place over
which the vocal stem is silent (muvid#144).

Licensing — read before shipping anything: the `MMS_FA` weights are
**CC-BY-NC-4.0** and htdemucs (vocal separation) is CC-BY-NC too. So this is an
opt-in extra (`muvid[lyricvid-glyphs]`), never a default, never on the prod
connector, and it never downloads weights unless the caller says
`allow_download=True` (or `MUVID_ALLOW_WEIGHT_DOWNLOAD=1`).

### Module Attributes

| [`MARGIN_S`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.MARGIN_S)              | Seconds of audio kept either side of a line's coarse window.                                                                                          |
|------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`ONSET_TOL_S`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.ONSET_TOL_S)           | Onset tolerance for [`onset_report()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.onset_report).                                                                  |
| [`DISAGREE_S`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.DISAGREE_S)            | Two timings of a word disagree when their starts differ by more than this.                                                                            |
| [`MIN_GLYPH_S`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.MIN_GLYPH_S)           | Two sounded glyphs whose STARTS are closer than this were squeezed together by the aligner, not sung (a mora takes ~0.1 s or more).                   |
| [`REMEDIES`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.REMEDIES)              | What [`reconcile()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.reconcile) may do with a word a detector distrusts.                                            |
| [`UNSUNG_QUIET_DB`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.UNSUNG_QUIET_DB)       | a frame is quiet below this many dB under the stem's own loud level...                                                                                |
| [`UNSUNG_QUIET_SHARE`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.UNSUNG_QUIET_SHARE)    | ...and a line is unsung when at least this share of its window is quiet...                                                                            |
| [`UNSUNG_MIN_QUIET_S`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.UNSUNG_MIN_QUIET_S)    | ...in one unbroken stretch at least this long.                                                                                                        |
| [`LINE_GAP_S`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.LINE_GAP_S)            | a line whose last glyph starts within this many seconds of the next line's first glyph (or after it) overlaps it.                                     |
| [`UNSUNG_FRAME_S`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.UNSUNG_FRAME_S)        | Frame length for the stem's RMS level.                                                                                                                |
| [`UNSUNG_REF_PERCENTILE`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.UNSUNG_REF_PERCENTILE) | The stem's "loud level" is this percentile of its frame RMS, so the threshold follows the mix rather than an absolute dBFS a quieter stem would fail. |
| [`TIMING_OVERRIDES`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.TIMING_OVERRIDES)      | What a recorded per-song timing override may say about a line.                                                                                        |
| [`SPILL_MIN_STEP_S`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.SPILL_MIN_STEP_S)      | a moved glyph starts at least this long after the glyph before it (a mora takes ~0.1 s)...                                                            |
| [`SPILL_CLEAR_S`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.SPILL_CLEAR_S)         | ...an onset it moves to is at least this long before the next line...                                                                                 |
| [`SPILL_MIN_TAIL_S`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.SPILL_MIN_TAIL_S)      | ...and a word whose glyph moved ends no earlier than this after its last glyph's start (CTC spans are spiky; a 0 s word would never light).           |

### Functions

| [`apply_timing_overrides`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.apply_timing_overrides)(reconciled, fine, ...)      | Put recorded human judgements back over [`reconcile()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.reconcile)'s result.      |
|-----------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------|
| [`check_timing_overrides`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.check_timing_overrides)(overrides, n_lines)         | Validate recorded timing overrides against a transcript of `n_lines`.                                               |
| [`default_detectors`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.default_detectors)(\*[, disagree_s, min_glyph_s])   | What [`reconcile()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.reconcile) runs when given none: the two that need no audio. |
| [`disagrees`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.disagrees)(\*[, disagree_s])                        | Words whose fine start is more than `disagree_s` from the coarse one.                                               |
| [`disordered`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.disordered)(among, \*[, gap_s])                     | Lines that overlap, or whose glyphs run backwards, once `among` is applied.                                         |
| [`glyph_rows`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.glyph_rows)(tt)                                     | One flat record per glyph, for a table or a quick look.                                                             |
| [`lost`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.lost)(vocals, \*[, disagree_s, min_glyph_s, tol_s]) | Words the aligner could not place, as [`unsung()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.unsung)'s `among`.          |
| [`misplaced`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.misplaced)(vocals, \*[, disagree_s, tol_s])         | Disagreeing words where the onsets side with the vendor.                                                            |
| [`onset_report`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.onset_report)(timed_text, vocals, \*[, tol_s, ...]) | How well glyph starts sit on acoustic onsets of the vocal stem.                                                     |
| [`reconcile`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.reconcile)(fine, coarse, \*[, disagree_s, ...])     | Distrust the fine timing where a detector says so; remedy it; say where.                                            |
| [`refine_glyphs`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.refine_glyphs)(timed_text, vocals, \*[, ...])       | Return `timed_text` with measured `Word.glyphs` on every word.                                                      |
| [`repair_spills`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.repair_spills)(tt, onsets, \*[, gap_s, ...])        | Pull a line's glyphs back off the NEXT line, where they aligned (muvid#155).                                        |
| [`romanize_hepburn`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.romanize_hepburn)(text)                             | Display romanisation (modified Hepburn) of kana text: what a learner reads.                                         |
| [`romanize_kana`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.romanize_kana)(word)                                | `(glyph, romaji)` per glyph, the romaji chunks concatenating to the word.                                           |
| [`squeezed`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.squeezed)(\*[, min_glyph_s])                        | Words with two sounded glyphs starting less than `min_glyph_s` apart.                                               |
| [`stem_detectors`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.stem_detectors)(vocals, \*[, disagree_s, ...])      | What to reconcile with when you have the vocal stem (muvid#150).                                                    |
| [`stem_onsets`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.stem_onsets)(vocals)                                | Acoustic onset times (seconds) of a vocal stem, as a numpy array.                                                   |
| [`unsung`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.unsung)(vocals, \*[, among, quiet_db, ...])         | Lines a vendor transcript lists but nobody sang.                                                                    |
| [`vocal_stem`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.vocal_stem)(audio, \*, out_dir[, allow_download])   | The song's separated vocal stem (Demucs), raising rather than degrading.                                            |
| [`word_agreement`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.word_agreement)(fine, coarse, \*[, disagree_s])     | Compare word starts (first glyph start when present) of two timings.                                                |

### Classes

| [`Detector`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.Detector)(\*, name, find[, remedy])   | Names the words of a fine timing not to trust, and what to do about them.   |
|---------------------------------------------------------------------------------------|-----------------------------------------------------------------------------|

### muvid.lyricvid.glyph_align.DISAGREE_S *= 0.3*

Two timings of a word disagree when their starts differ by more than this.

### *class* muvid.lyricvid.glyph_align.Detector(, name, find, remedy='replace')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Names the words of a fine timing not to trust, and what to do about them.

`find(fine, coarse)` gets both timings (same word sequence) and returns
`{word_index: reason}`, indices into `list(fine.words())`. `remedy`
is one of [`REMEDIES`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.REMEDIES); when several detectors flag one word, `drop`
wins — a word nobody sang has no window worth falling back to.

### muvid.lyricvid.glyph_align.LINE_GAP_S *= 0.05*

a line whose last glyph starts within this many seconds
of the next line’s first glyph (or after it) overlaps it.

* **Type:**
  [`disordered()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.disordered)

### muvid.lyricvid.glyph_align.MARGIN_S *= 0.5*

Seconds of audio kept either side of a line’s coarse window. Vendor word
times run late and miss leading consonants; too wide a window lets the
aligner wander into the neighbouring line.

### muvid.lyricvid.glyph_align.MIN_GLYPH_S *= 0.04*

Two sounded glyphs whose STARTS are closer than this were squeezed together
by the aligner, not sung (a mora takes ~0.1 s or more). Start-to-start, not
span length: CTC spans are spiky, so a perfectly sung `ン` can be 20 ms.

### muvid.lyricvid.glyph_align.ONSET_TOL_S *= 0.08*

Onset tolerance for [`onset_report()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.onset_report).

### muvid.lyricvid.glyph_align.REMEDIES *= ('replace', 'drop')*

What [`reconcile()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.reconcile) may do with a word a detector distrusts. `replace`:
fall back to the coarse window shifted by the measured bias, glyphs spread
evenly and marked unmeasured (the word was sung; the aligner could not place
it). `drop`: remove the word, and a line or section left empty (it was
never sung, so lighting it at ANY time is wrong).

### muvid.lyricvid.glyph_align.SPILL_CLEAR_S *= 0.1*

…an onset it moves to is at least this long before the next line…

### muvid.lyricvid.glyph_align.SPILL_MIN_STEP_S *= 0.08*

a moved glyph starts at least this long after the
glyph before it (a mora takes ~0.1 s)…

* **Type:**
  [`repair_spills()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.repair_spills)

### muvid.lyricvid.glyph_align.SPILL_MIN_TAIL_S *= 0.05*

…and a word whose glyph moved ends no earlier than this after its last
glyph’s start (CTC spans are spiky; a 0 s word would never light).

### muvid.lyricvid.glyph_align.TIMING_OVERRIDES *= ('fine',)*

What a recorded per-song timing override may say about a line. `fine`:
the vendor window is junk and the aligner’s times are right, so the line
gets its fine timing back, whatever [`reconcile()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.reconcile) did to it.

### muvid.lyricvid.glyph_align.UNSUNG_FRAME_S *= 0.05*

Frame length for the stem’s RMS level.

### muvid.lyricvid.glyph_align.UNSUNG_MIN_QUIET_S *= 1.0*

…in one unbroken stretch at least this long. A ghost repeat is a whole
line of silence (2 s on the measured song); a short sung line whose vendor
window is merely LATE lands on a quiet tail too, but a short one.

### muvid.lyricvid.glyph_align.UNSUNG_QUIET_DB *= -40.0*

a frame is
quiet below this many dB under the stem’s own loud level…

* **Type:**
  [`unsung()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.unsung)’s thresholds, measured on a real song (muvid#144)

### muvid.lyricvid.glyph_align.UNSUNG_QUIET_SHARE *= 0.5*

…and a line is unsung when at least this share of its window is quiet…

### muvid.lyricvid.glyph_align.UNSUNG_REF_PERCENTILE *= 95.0*

The stem’s “loud level” is this percentile of its frame RMS, so the threshold
follows the mix rather than an absolute dBFS a quieter stem would fail.

### muvid.lyricvid.glyph_align.apply_timing_overrides(reconciled, fine, records, , overrides)

Put recorded human judgements back over [`reconcile()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.reconcile)’s result.

`overrides` is per-song DATA, `{"<line index>": "fine"}` — the line’s
0-based position in the transcript `fine` was refined from (string keys,
as JSON writes them, or ints). It belongs in the song’s own spec beside
the lyrics, never in code: it says “on THIS song, this line’s vendor
window is junk and the aligner’s times sit on onsets” (muvid#150, used on
five songs). Each value is one of [`TIMING_OVERRIDES`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.TIMING_OVERRIDES).

`reconciled` and `records` are what `reconcile(fine, coarse, ...)`
returned; words are matched by position, so a line is restored exactly —
even one [`unsung()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.unsung) dropped, since a person said it was sung. Only
the named line changes: a neighbour [`disordered()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.disordered) flagged together
with it keeps its vendor window unless it is named too. `reconcile`’s
records still say what reconcile did; these say what undid it. Returns
the timing and one record per word whose timing changed (same keys as
[`reconcile()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.reconcile)’s; `why` is `"override"`). Empty or `None`
`overrides` return `reconciled` unchanged.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`TimedText`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.TimedText), [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]]

### muvid.lyricvid.glyph_align.check_timing_overrides(overrides, n_lines)

Validate recorded timing overrides against a transcript of `n_lines`.

Returns `{line index: how}`. Refuses (`ValueError`) a key that is not
an integer line index (a bool or a fractional number included), one out of
range, two spellings of one line that disagree, and a value outside
[`TIMING_OVERRIDES`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.TIMING_OVERRIDES). Cheap, so a caller can run it BEFORE the
expensive alignment it is about to correct.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### muvid.lyricvid.glyph_align.default_detectors(, disagree_s=0.3, min_glyph_s=0.04)

What [`reconcile()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.reconcile) runs when given none: the two that need no audio.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`Detector`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.Detector), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

### muvid.lyricvid.glyph_align.disagrees(, disagree_s=0.3)

Words whose fine start is more than `disagree_s` from the coarse one.

Remedy: `replace`.

* **Return type:**
  [`Detector`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.Detector)

### muvid.lyricvid.glyph_align.disordered(among, , gap_s=0.05)

Lines that overlap, or whose glyphs run backwards, once `among` is applied.

Seen on dense backing chants (muvid#150): the aligner loses the beat, and
after reconciling, a line’s last glyph starts after the next line’s first,
or glyphs inside one line go out of order. Neither timing can be right, and
the vendor windows at least keep the lines in order, so every word of such
a line (both lines, for an overlap) is flagged. Remedy: `replace`.

It judges the RESULT of reconciling with `among`, so it runs that
reconcile itself; pass it after the same detectors:

```default
dets = stem_detectors(stem)  # already ends with disordered(...)
```

* **Return type:**
  [`Detector`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.Detector)

### muvid.lyricvid.glyph_align.glyph_rows(tt)

One flat record per glyph, for a table or a quick look.

* **Return type:**
  [`Iterable`](https://docs.python.org/3/library/typing.html#typing.Iterable)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]

### muvid.lyricvid.glyph_align.lost(vocals, , disagree_s=0.3, min_glyph_s=0.04, tol_s=0.08)

Words the aligner could not place, as [`unsung()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.unsung)’s `among`.

A word is lost when it is [`squeezed()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.squeezed), or when it [`disagrees()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.disagrees),
its fine start is off every onset, AND that start lies inside ANOTHER
line’s vendor window (bias-shifted): the aligner, finding nothing to put
it on, pushed it onto a neighbour. That is the unsung intro repeat of
muvid#150, whose first word disagreed instead of squeezing, so a
squeeze-only `among` never saw the whole line as flagged.

Each condition guards against deleting sung lyrics, because the remedy
this feeds is `drop`:

- A disagreeing word the aligner put ON an onset was sung somewhere.
- A word left in its OWN line’s neighbourhood can be one the vendor merely
  placed late, by more than the aligner’s search margin (`MARGIN_S`).
  Its window is then quiet, and without the third condition the line is
  deleted (review of muvid#154).

Remedy: `replace` (it only names words; [`unsung()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.unsung) decides).

* **Return type:**
  [`Detector`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.Detector)

### muvid.lyricvid.glyph_align.misplaced(vocals, , disagree_s=0.3, tol_s=0.08)

Disagreeing words where the onsets side with the vendor. Remedy: `replace`.

[`disagrees()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.disagrees) falls back to the vendor time whenever the two timings
differ, but on a song where the fine aligner is good the vendor is usually
the one that is wrong (muvid#150: seven words flagged, and every fine time
sat within ~60 ms of a stem onset while the vendor times were up to 10 s
off, one of them before the end of the previous line). So a word is
flagged only when its fine start is off every onset (`tol_s`) AND its
bias-shifted vendor start is on one: the evidence has to point at the
vendor, not just away from the aligner. Use it in place of
[`disagrees()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.disagrees) when you have the stem (see [`stem_detectors()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.stem_detectors)).

* **Return type:**
  [`Detector`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.Detector)

### muvid.lyricvid.glyph_align.onset_report(timed_text, vocals, , tol_s=0.08, seed=0, trials=20)

How well glyph starts sit on acoustic onsets of the vocal stem.

`near_onset` is the share of glyph starts within `tol_s` of a detected
onset; `random_baseline` is the same share after jittering every start
by up to ±0.5 s — the gap between the two is the evidence, since a dense
onset track makes any time look “near” something.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### muvid.lyricvid.glyph_align.reconcile(fine, coarse, , disagree_s=None, min_glyph_s=None, detectors=None)

Distrust the fine timing where a detector says so; remedy it; say where.

`detectors` defaults to [`default_detectors()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.default_detectors) (`squeezed` and
`disagrees`, both `replace`; `disagree_s` and `min_glyph_s` tune
them, and are refused alongside explicit `detectors`, which they could
not reach). Add [`unsung()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.unsung) when you have the vocal stem:

```default
reconcile(fine, coarse, detectors=(*default_detectors(), unsung(stem)))
```

`replace` puts a word back in its COARSE window, shifted by the median
offset between the two timings over the words no detector flagged (vendor
times run late; the bias is measured, not assumed), glyphs spread evenly
and marked `measured=False`. `drop` removes it, and any line or section
left empty. Returns the timing and one record per affected word: `why`
(the deciding detector), `detail`, `remedy`, `flagged_by` (every
detector that flagged it) and `used` (the start it got; `None` when
dropped).

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`TimedText`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.TimedText), [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]]

### muvid.lyricvid.glyph_align.refine_glyphs(timed_text, vocals, \*, romanize=<function romanize_kana>, margin_s=0.5, allow_download=False)

Return `timed_text` with measured `Word.glyphs` on every word.

`vocals` should be a separated vocal stem (see [`vocal_stem()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.vocal_stem)): the
accompaniment confuses a CTC model far more than it confuses a listener.
The coarse word times are only used as per-line windows; the glyph times
replace nothing else. A line with nothing romanisable keeps evenly spread,
`measured=False` glyphs rather than invented precision.

* **Return type:**
  [`TimedText`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.TimedText)

### muvid.lyricvid.glyph_align.repair_spills(tt, onsets, , gap_s=0.05, min_step_s=0.08, clear_s=0.1, min_tail_s=0.05)

Pull a line’s glyphs back off the NEXT line, where they aligned (muvid#155).

Seen on a real song: `ハット`’s `ト` aligned onto the next line’s `ト`.
It also follows from [`reconcile()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.reconcile) itself: a line sent back to its
vendor window can end on a neighbour the aligner had right. A glyph (not
a line’s first) starting within `gap_s` of the next line’s first glyph,
or after it, has SPILLED. It moves to the first of `onsets` (the vocal
stem’s, see [`stem_onsets()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.stem_onsets)) at least `min_step_s` after the glyph
before it and `clear_s` before the next line — or, with none there,
midway between that floor and `gap_s` before the next line, so it is
never left spilled — and is marked `measured=False`. Every glyph after
the first spilled one moves with it: they share that room in equal
slices, each taking the first onset in its own slice, so no glyph is
left on the next line for want of room an earlier one took.

Then, in that line only, nothing stays lit into the next line or over a
moved glyph: the glyph before a moved one ends no later than it starts, a
moved glyph ends by the next line, and each word spans its glyphs (from its
first glyph’s start, to its last glyph’s end or `min_tail_s` past its
start — CTC spans are spiky) and ends by the next line too. A glyph with
no room before the next line (the line itself already sits on it) is left
alone: moving it would put it before the glyph it follows, and a whole
line on its neighbour is [`disordered()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.disordered)’s case, not a spill.

Run it on [`reconcile()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.reconcile)’s result (and after
[`apply_timing_overrides()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.apply_timing_overrides)). Returns the timing — the very object
passed in when nothing spilled — and one record per moved glyph: `text`,
`line`, `why` (`"spilled"`), `detail`, `remedy` (`"onset"` or
`"midpoint"`), `fine` (its start before) and `used` (its start now).

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`TimedText`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.TimedText), [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]]

### muvid.lyricvid.glyph_align.romanize_hepburn(text)

Display romanisation (modified Hepburn) of kana text: what a learner reads.

[`romanize_kana()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.romanize_kana) is an ALIGNMENT romaniser — `ー` is silent there,
because it has no onset. Read on screen it needs a macron. Also `ン`
before a vowel or `y` takes an apostrophe, `ッ` before `ch` is `t`,
and glides keep their `w`/`y` (muvid#145).

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> romanize_hepburn("バス バス スタート")
'basu basu sutāto'
>>> romanize_hepburn("ハンバーガー")
'hanbāgā'
>>> romanize_hepburn("カップ マッチ シャツ")
'kappu matchi shatsu'
>>> romanize_hepburn("キンエン ウィンドウ")
"kin'en windou"
```

Anything that is not kana is KEPT, not dropped — kanji, digits, Latin, an
unknown kana — because a caption that silently loses a word looks right:

```pycon
>>> romanize_hepburn("東京 バス、スタート! Café")
'東京 basu, sutāto! Café'
```

Input is NFKC-normalised first, so half-width (`ﾊﾞｽ`) and decomposed
(`ハ` + combining `゙`) kana read as the composed glyphs. Hiragana long
vowels spelt with a second vowel (`とうきょう`) keep both letters: telling
`ou` the long vowel from `o``+``u` needs a dictionary.

### muvid.lyricvid.glyph_align.romanize_kana(word)

`(glyph, romaji)` per glyph, the romaji chunks concatenating to the word.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]

```pycon
>>> romanize_kana("バカンス")
[('バ', 'ba'), ('カ', 'ka'), ('ン', 'n'), ('ス', 'su')]
>>> romanize_kana("スーパー")
[('ス', 'su'), ('ー', ''), ('パ', 'pa'), ('ー', '')]
>>> romanize_kana("カップ")
[('カ', 'ka'), ('ッ', 'p'), ('プ', 'pu')]
>>> romanize_kana("シャツ")
[('シ', 'sh'), ('ャ', 'a'), ('ツ', 'tsu')]
>>> romanize_kana("キャンディー")
[('キ', 'k'), ('ャ', 'ya'), ('ン', 'n'), ('デ', 'd'), ('ィ', 'i'), ('ー', '')]
>>> romanize_kana("ok!")
[('o', 'o'), ('k', 'k'), ('!', '')]
```

A glyph with an empty chunk (`ー`, punctuation) has no onset of its own;
the aligner places it between its neighbours and marks it unmeasured.

### muvid.lyricvid.glyph_align.squeezed(, min_glyph_s=0.04)

Words with two sounded glyphs starting less than `min_glyph_s` apart.

CTC squeezing a word it could not hear into a few frames. Judged on
syllabic glyphs (kana/CJK: one mora each) only — letters of an alphabet are
legitimately ~30 ms apart — and only between neighbours in the same line
(lines are aligned in overlapping windows). Remedy: `replace`.

* **Return type:**
  [`Detector`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.Detector)

### muvid.lyricvid.glyph_align.stem_detectors(vocals, , disagree_s=0.3, min_glyph_s=0.04, tol_s=0.08, drop_unsung=True)

What to reconcile with when you have the vocal stem (muvid#150).

[`squeezed()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.squeezed), [`misplaced()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.misplaced) (in place of [`disagrees()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.disagrees), which
trusts the vendor even when its time is impossible), [`unsung()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.unsung) with
`among=`[`lost()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.lost) (left out when `drop_unsung` is false), and
[`disordered()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.disordered) over the rest. Measured on five songs, this reproduced
both hand fixes the first one needed.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`Detector`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.Detector), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

### muvid.lyricvid.glyph_align.stem_onsets(vocals)

Acoustic onset times (seconds) of a vocal stem, as a numpy array.

### muvid.lyricvid.glyph_align.unsung(vocals, , among=None, quiet_db=-40.0, quiet_share=0.5, min_quiet_s=1.0, frame_s=0.05)

Lines a vendor transcript lists but nobody sang. Remedy: `drop`.

A LINE is unsung when both hold: `among` (default [`squeezed()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.squeezed))
distrusts every word in it — the aligner, searching the line’s window, found
nothing to put them on — and the vocal stem is quiet (`quiet_db` under its
own loud level) over at least `quiet_share` of the line’s coarse window,
including one unbroken quiet stretch of `min_quiet_s`. The window is
shifted by the vendor’s measured bias (median fine-minus-coarse start over
the words `among` trusts), and a window running past the end of the stem
is not judged at all.

Both, because neither instrument is enough alone. Measured on the song that
motivated this (muvid#144: a Suno transcript listing a repeated タン タン タン
at 36-38 s that the stem shows nobody sang), the stem alone puts a sung,
staccato ストップ ストップ at 0.63 quiet against the ghost’s 0.75 — vendor
windows run on into the silence after a word, so a quiet window is common —
while squeeze alone also flags a sung, fast スター ダンス (0.04 quiet).
Together they separate the ghost and nothing else.

What they do NOT separate unaided is one short sung line whose vendor
window is a second late: the aligner (searching ±\`\`MARGIN_S\`\`) misses it
and squeezes, and the window lands on the silence after it — measured on
the same song, the last line (フン) shifted +1 s was dropped. The unbroken
`min_quiet_s` is what refuses that case: a short window cannot hold a
second of silence, a ghost line is silence end to end.

The unit is the line, because a transcript repeats lines, and because the
first ghost word’s own window held the decaying tail of the previous held
note: sound, but not its sound. `among` is squeeze and not disagreement
on purpose — a line the vendor MISPLACED was sung somewhere else, and
dropping it would delete a sung line. With the stem, [`lost()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.lost) widens
`among` only by disagreeing words that are off every onset AND sit in
another line’s window, which a misplaced-but-sung line does not produce
(see [`stem_detectors()`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.stem_detectors)).

* **Return type:**
  [`Detector`](_autosummary/muvid.lyricvid.glyph_align.html.md#muvid.lyricvid.glyph_align.Detector)

### muvid.lyricvid.glyph_align.vocal_stem(audio, , out_dir, allow_download=False)

The song’s separated vocal stem (Demucs), raising rather than degrading.

Reuses the footage scorer’s separator; that one returns `None` on any
failure because lip-sync is optional there, but here no stem means no
trustworthy glyph times, so the absence is an error.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### muvid.lyricvid.glyph_align.word_agreement(fine, coarse, , disagree_s=0.3)

Compare word starts (first glyph start when present) of two timings.

The two must hold the same words in the same order (`fine` is normally
`refine_glyphs(coarse, ...)`). Returns summary statistics and the list
of words that disagree by more than `disagree_s` — the ones to look at.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]


# _autosummary/muvid.lyricvid.html.md

# muvid.lyricvid

Lyric video (kinetic typography) — muvid’s first subgenre plugin.

Turn a song into a typographic music video: the words appear in time with the
singing, laid out by an archetype chosen for the song.

> from muvid.lyricvid import tools
> tools.render_lyric_video(‘song.wav’, ‘out.mp4’, lyrics=’lyrics.md’)

or from the command line:

```default
python -m muvid.lyricvid render song.wav out.mp4 --lyrics lyrics.md
```

The pipeline is four seams, each with a default that genuinely works:

`timed_text`
: measured word times — reuses muvid’s own aligner and lacing store rather
  than growing a second transcription path.

`director`
: the treatment decision. Default is a heuristic that reads the lyrics and
  costs nothing; an LLM creative director is opt-in.

`scene`
: every position and time, computed in Python from measurement. No model ever
  emits a coordinate or a timestamp.

`renderer`
: `ass` by default (frame-exact, no browser, leaves an editable subtitle
  file); `web` for effects ASS cannot express.

This module is a PEP 562 lazy facade, like `muvid/__init__.py`: importing it
pulls nothing heavy, and each attribute is resolved on first use.

### *class* muvid.lyricvid.Canvas(, width=1920, height=1080, fps=30)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Output geometry. Sizes in the scene are relative to `height`.

### *class* muvid.lyricvid.Scene(\*, canvas, duration, background, cues, typography, meta=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Everything a renderer needs, and nothing it has to interpret.

#### meta *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [Any](https://docs.python.org/3/library/typing.html#typing.Any)]*

Provenance, for reporting and for tests.

### *class* muvid.lyricvid.TimedText(, sections, duration=0.0, source='unknown')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The whole song’s text, timed.

```pycon
>>> tt = from_words([('hello', 0.0, 0.5), ('world', 0.5, 1.0)], duration=1.0)
>>> [w.text for w in tt.words()]
['hello', 'world']
>>> tt.sections[0].label
'*'
```

#### *property* measured *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

True when every word time was measured rather than interpolated.

False for an empty text: “all of nothing was measured” is the kind of
vacuous truth that reads as reassurance in a report.

#### source *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

Where the timing came from, for provenance and for honest reporting.

### *class* muvid.lyricvid.TreatmentSpec(\*, spec_version='1.0', title='', direction=<factory>, scenes=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A complete, renderable treatment.

```pycon
>>> s = TreatmentSpec()
>>> validate(s)
[]
>>> TreatmentSpec.from_dict(s.to_dict()) == s
True
```

#### *classmethod* from_dict(d)

Build from a plain mapping — TOTAL over what a model or a remote caller sends.

Missing keys default. Wrong-shaped values are coerced toward the field’s
type rather than raised on: a string where a list was expected becomes
a one-element list (`applies_to: "chorus"` used to become
`('c','h','o','r','u','s')`), a numeric string becomes a number, an
unknown key is dropped, a `None` sub-object is the default. What
cannot be coerced falls to the default and `repair()` reports the
enum-level substitutions afterwards.

* **Return type:**
  [`TreatmentSpec`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.TreatmentSpec)

```pycon
>>> s = TreatmentSpec.from_dict({'scenes': [None, {'applies_to': 'chorus',
...     'timing': 'fast', 'shape': 'circle'}],
...     'direction': {'palette': {'foo': 1, 'bg': '#000000'},
...                   'motion_vocabulary': 'pop',
...                   'typography': {'weight': '700', 'tracking': None}}})
>>> s.scenes[1].applies_to, s.direction.motion_vocabulary
(('chorus',), ('pop',))
>>> s.direction.typography.weight, s.direction.typography.tracking
(700, 0.0)
>>> s.direction.palette.bg, s.scenes[1].shape
('#000000', None)
```

#### to_dict()

A JSON-native dict: tuples become lists, so what this emits is
exactly what `json_schema()` validates and what a file round-trips.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### muvid.lyricvid.compile_scene(treatment, timed_text, , canvas=None)

Turn a treatment plus timed text into a fully-resolved [`Scene`](_autosummary/muvid.lyricvid.html.md#muvid.lyricvid.Scene).

Every number in the result was computed here, from measurement. Nothing the
model wrote reaches a renderer as a coordinate or a time.

* **Return type:**
  [`Scene`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Scene)

```pycon
>>> from muvid.lyricvid.timed_text import from_words
>>> tt = from_words([('one', 0.0, .5), ('two', .5, 1.0)], duration=1.0)
>>> s = compile_scene(spec_mod.TreatmentSpec(), tt)
>>> len(s.cues), s.cues[0].text
(2, 'one')
```

### muvid.lyricvid.render_lyric_video(audio, output, , lyrics=None, subtitles=None, project=None, timed_text=None, treatment=None, renderer='auto', title='', persona=None, aligner=None, width=1920, height=1080, fps=30, workdir=None, captions=None)

Render a lyric video. The one verb that produces a file.

`captions` (`{language: transform}`, or that as JSON) also writes one
`.srt` per language beside it — see `export_captions()`.

Everything else in this module exists so that a caller can decide *what* to
render before paying for it.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### Modules

| [`captions`](_autosummary/muvid.lyricvid.captions.html.md#module-muvid.lyricvid.captions)       | Caption tracks: one subtitle file per language, timed per sung line (muvid#145).                                                                                                         |
|------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`director`](_autosummary/muvid.lyricvid.director.html.md#module-muvid.lyricvid.director)       | The creative director — a song in, one or more [`TreatmentSpec`](_autosummary/muvid.lyricvid.html.md#muvid.lyricvid.TreatmentSpec)s out.                                                                      |
| [`glyph_align`](_autosummary/muvid.lyricvid.glyph_align.html.md#module-muvid.lyricvid.glyph_align) | Per-glyph timing: when was each written character sung? (muvid#142)                                                                                                                      |
| [`manifest`](_autosummary/muvid.lyricvid.manifest.html.md#module-muvid.lyricvid.manifest)       | The lyric-video subgenre's manifest — muvid's own first plugin.                                                                                                                          |
| [`pictures`](_autosummary/muvid.lyricvid.pictures.html.md#module-muvid.lyricvid.pictures)       | Pictures for a lyric video: a mosaic that builds up as its words are sung (muvid#151).                                                                                                   |
| [`pipeline`](_autosummary/muvid.lyricvid.pipeline.html.md#module-muvid.lyricvid.pipeline)       | The lyric-video pipeline — the one path from a song to a finished video.                                                                                                                 |
| [`render_ass`](_autosummary/muvid.lyricvid.render_ass.html.md#module-muvid.lyricvid.render_ass)   | The default lyric-video renderer: a [`Scene`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Scene) as ASS.                                                            |
| [`render_web`](_autosummary/muvid.lyricvid.render_web.html.md#module-muvid.lyricvid.render_web)   | The web backend: a [`Scene`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Scene) as a deterministic HTML page, screenshotted frame by frame and muxed with the song. |
| [`scene`](_autosummary/muvid.lyricvid.scene.html.md#module-muvid.lyricvid.scene)             | The renderer-neutral scene — every number computed, no renderer opinions.                                                                                                                |
| [`shape`](_autosummary/muvid.lyricvid.shape.html.md#module-muvid.lyricvid.shape)             | Packing words INSIDE a shape — the shape-word-cloud construction.                                                                                                                        |
| [`spec`](_autosummary/muvid.lyricvid.spec.html.md#module-muvid.lyricvid.spec)               | The treatment spec — what a director (human or model) decides, as data.                                                                                                                  |
| [`timed_text`](_autosummary/muvid.lyricvid.timed_text.html.md#module-muvid.lyricvid.timed_text)   | The timed text tree — song → sections → lines → words, with measured times.                                                                                                              |
| [`tools`](_autosummary/muvid.lyricvid.tools.html.md#module-muvid.lyricvid.tools)             | The SSOT verbs for the lyric-video subgenre.                                                                                                                                             |


# _autosummary/muvid.lyricvid.manifest.html.md

# muvid.lyricvid.manifest

The lyric-video subgenre’s manifest — muvid’s own first plugin.

Deliberately stdlib-only and free of any import from the rest of
[`muvid.lyricvid`](_autosummary/muvid.lyricvid.html.md#module-muvid.lyricvid): this module is what a catalogue, a UI form, an MCP tool
definition or an LLM choosing among installed subgenres reads, and none of them
should pay for pysubs2, numpy or Playwright to do it. The renderer is named as
a string and imported only when a render actually runs.

A third-party plugin should look exactly like this file.


# _autosummary/muvid.lyricvid.pictures.html.md

# muvid.lyricvid.pictures

Pictures for a lyric video: a mosaic that builds up as its words are sung (muvid#151).

A learner’s lyric video wants a picture of what is being sung. This layer places
small pictures around the lyrics. Each one appears when its word is first sung,
so a mosaic builds up through the song, and it **bounces** (a quick enlarge and
back) every time the word is sung again. The lyrics stay readable because no
tile, even at the top of its bounce, enters the text block.

Four steps, each its own function so a caller can replace one:

1. [`text_block()`](_autosummary/muvid.lyricvid.pictures.html.md#muvid.lyricvid.pictures.text_block) finds where the lyrics are drawn: the union of
   non-background pixels over frames of the finished lyric render.
2. [`mosaic_cells()`](_autosummary/muvid.lyricvid.pictures.html.md#muvid.lyricvid.pictures.mosaic_cells) lays out cells in the bands above and below it.
3. [`plan_tiles()`](_autosummary/muvid.lyricvid.pictures.html.md#muvid.lyricvid.pictures.plan_tiles) times each picture from the sung word times
   ([`word_times()`](_autosummary/muvid.lyricvid.pictures.html.md#muvid.lyricvid.pictures.word_times)): it appears just before the first time its word is
   sung, and bounces on every sung occurrence.
4. [`render()`](_autosummary/muvid.lyricvid.pictures.html.md#muvid.lyricvid.pictures.render) composites the tiles onto the lyric render in one ffmpeg
   pass. Pictures are fitted inside their cell, never cropped, so a CC BY-SA
   picture is shown unmodified; the bounce is a per-frame `scale`.

Rights travel with each [`Picture`](_autosummary/muvid.lyricvid.pictures.html.md#muvid.lyricvid.pictures.Picture) (its `credit`), and [`credits()`](_autosummary/muvid.lyricvid.pictures.html.md#muvid.lyricvid.pictures.credits)
lists the credit of every tile actually shown, in order of appearance, so the
end card cannot forget one.

Needs `ffmpeg`/`ffprobe` on PATH and nothing beyond muvid’s core
dependencies (numpy).

### Functions

| [`bounce_expr`](_autosummary/muvid.lyricvid.pictures.html.md#muvid.lyricvid.pictures.bounce_expr)(beats, bounce)                    | An ffmpeg expression of `t`: 1 at rest, `1 + scale` at the peak of each pop.      |
|------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------|
| [`credits`](_autosummary/muvid.lyricvid.pictures.html.md#muvid.lyricvid.pictures.credits)(tiles)                                | The credit line of every shown picture, in order of appearance, without repeats.  |
| [`frame_size`](_autosummary/muvid.lyricvid.pictures.html.md#muvid.lyricvid.pictures.frame_size)(video)                             | `(width, height)` of `video`'s frames: the `size` to plan cells for.              |
| [`mosaic_cells`](_autosummary/muvid.lyricvid.pictures.html.md#muvid.lyricvid.pictures.mosaic_cells)(n, block, \*[, size, gap, ...])  | `n` cells in one row above and one row below the text block, centre-out.          |
| [`plan_tiles`](_autosummary/muvid.lyricvid.pictures.html.md#muvid.lyricvid.pictures.plan_tiles)(pictures, times, cells, \*[, ...]) | Place each sung picture in a cell, in order of its word's first sung time.        |
| [`render`](_autosummary/muvid.lyricvid.pictures.html.md#muvid.lyricvid.pictures.render)(video, tiles, out, \*[, bounce, ...])  | Composite `tiles` onto `video` in one ffmpeg pass; audio is copied.               |
| [`text_block`](_autosummary/muvid.lyricvid.pictures.html.md#muvid.lyricvid.pictures.text_block)(video, \*, background[, ...])      | Where the lyrics are drawn: the box around every non-background pixel of `video`. |
| [`unsung_pictures`](_autosummary/muvid.lyricvid.pictures.html.md#muvid.lyricvid.pictures.unsung_pictures)(pictures, times)              | The words that have a picture but are never sung (so the picture is never shown). |
| [`word_times`](_autosummary/muvid.lyricvid.pictures.html.md#muvid.lyricvid.pictures.word_times)(timed_text)                        | Every sung start of every word, from a `TimedText.to_dict()` (or its JSON file).  |

### Classes

| [`Bounce`](_autosummary/muvid.lyricvid.pictures.html.md#muvid.lyricvid.pictures.Bounce)([scale, duration, lead])     | A quick enlarge and back, timed to a sung word.                         |
|--------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| [`Picture`](_autosummary/muvid.lyricvid.pictures.html.md#muvid.lyricvid.pictures.Picture)(word, path[, credit])       | A picture for one sung word, with the credit line its licence requires. |
| [`Tile`](_autosummary/muvid.lyricvid.pictures.html.md#muvid.lyricvid.pictures.Tile)(picture, box, appear[, beats]) | A picture placed in a cell, with when it appears and when it bounces.   |

### *class* muvid.lyricvid.pictures.Bounce(scale=0.18, duration=0.32, lead=0.04)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A quick enlarge and back, timed to a sung word.

`scale` is the extra size at the peak (0.18 is 18 % larger), `duration`
the length of the whole pop in seconds, and `lead` how long before the
sung onset it starts, so the peak lands on the syllable.

### *class* muvid.lyricvid.pictures.Picture(word, path, credit=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A picture for one sung word, with the credit line its licence requires.

### *class* muvid.lyricvid.pictures.Tile(picture, box, appear, beats=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A picture placed in a cell, with when it appears and when it bounces.

### muvid.lyricvid.pictures.bounce_expr(beats, bounce)

An ffmpeg expression of `t`: 1 at rest, `1 + scale` at the peak of each pop.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.lyricvid.pictures.credits(tiles)

The credit line of every shown picture, in order of appearance, without repeats.

Raises when a shown picture has no credit: a dropped attribution is a licence
failure nobody downstream can see.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### muvid.lyricvid.pictures.frame_size(video)

`(width, height)` of `video`’s frames: the `size` to plan cells for.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)]

### muvid.lyricvid.pictures.mosaic_cells(n, block, , size=(1920, 1080), gap=24, aspect=1.3333333333333333, top=0.04, bottom=0.95, min_band=120, width=0.94)

`n` cells in one row above and one row below the text block, centre-out.

Bands thinner than `min_band` pixels are skipped. The first cells returned
are the most central, so a short list of pictures sits near the lyrics.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)]]

### muvid.lyricvid.pictures.plan_tiles(pictures, times, cells, , lead=0.15, bounce=Bounce(scale=0.18, duration=0.32, lead=0.04))

Place each sung picture in a cell, in order of its word’s first sung time.

A picture whose word is never sung is left out (see [`unsung_pictures()`](_autosummary/muvid.lyricvid.pictures.html.md#muvid.lyricvid.pictures.unsung_pictures)).
With a `bounce`, each picture shrinks inside its cell by the bounce’s peak,
so at full size it still fits the cell, and the cells already keep clear of
the text.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Tile`](_autosummary/muvid.lyricvid.pictures.html.md#muvid.lyricvid.pictures.Tile)]

### muvid.lyricvid.pictures.render(video, tiles, out, , bounce=Bounce(scale=0.18, duration=0.32, lead=0.04), fade_in=0.35, border=None, border_px=3, crf=18)

Composite `tiles` onto `video` in one ffmpeg pass; audio is copied.

Each picture is fitted inside its tile box (never cropped), framed by a
`border` of `border_px` when given, fades in at `appear` and pops on
each of its `beats` when `bounce` is set.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### muvid.lyricvid.pictures.text_block(video, , background, threshold=40, fps=1.0, downscale=4, margin=40)

Where the lyrics are drawn: the box around every non-background pixel of `video`.

Frames are sampled at `fps` and shrunk by `downscale` for speed; a pixel
is ink when its summed RGB distance from `background` exceeds `threshold`.
`margin` pixels are added on every side. Raises when nothing is drawn.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)]

### muvid.lyricvid.pictures.unsung_pictures(pictures, times)

The words that have a picture but are never sung (so the picture is never shown).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### muvid.lyricvid.pictures.word_times(timed_text)

Every sung start of every word, from a `TimedText.to_dict()` (or its JSON file).

A word’s time is its first glyph’s start when glyph times exist (the measured
onset), else the word’s own start.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]]


# _autosummary/muvid.lyricvid.pipeline.html.md

# muvid.lyricvid.pipeline

The lyric-video pipeline — the one path from a song to a finished video.

> audio (+ optional lyrics / subtitles / project)
> : -> TimedText          measured word times  (muvid.lyricvid.timed_text)
>   -> TreatmentSpec      the creative decision (muvid.lyricvid.director)
>   -> Scene              every number computed (muvid.lyricvid.scene)
>   -> mp4                a renderer            (render_ass | render_web)

Each arrow is a seam with a working default, so the whole thing runs on a bare
`song.wav` with no lyrics, no AI, no API key and no network — and every stage
can be replaced without touching its neighbours.

The renderer seam defaults to `auto`, which picks **ASS** where this ffmpeg can
burn subtitles in (frame-exact, no browser, and the `.ass` file it leaves behind
is an editable deliverable in its own right) and **web** otherwise. Asking for a
backend by name never falls back – it fails loudly.

### Module Attributes

| [`RENDERERS`](_autosummary/muvid.lyricvid.pipeline.html.md#muvid.lyricvid.pipeline.RENDERERS)   | function", resolved lazily so listing costs no import.   |
|--------------------------------------------------------------|----------------------------------------------------------|

### Functions

| [`register_renderer`](_autosummary/muvid.lyricvid.pipeline.html.md#muvid.lyricvid.pipeline.register_renderer)(name, target)            | Register a renderer backend as `"module:function"`.                   |
|---------------------------------------------------------------------------------------------|-----------------------------------------------------------------------|
| [`resolve_renderer`](_autosummary/muvid.lyricvid.pipeline.html.md#muvid.lyricvid.pipeline.resolve_renderer)(name)                     | Import and return a renderer backend.                                 |
| [`select_renderer`](_autosummary/muvid.lyricvid.pipeline.html.md#muvid.lyricvid.pipeline.select_renderer)([name])                    | Resolve `"auto"` to a backend that can actually run here.             |
| [`build_timed_text`](_autosummary/muvid.lyricvid.pipeline.html.md#muvid.lyricvid.pipeline.build_timed_text)(\*, audio[, lyrics, ...]) | Get measured word times from whichever input the caller actually has. |
| [`render`](_autosummary/muvid.lyricvid.pipeline.html.md#muvid.lyricvid.pipeline.render)(request)                            | Render one lyric video.                                               |

### muvid.lyricvid.pipeline.RENDERERS *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [str](https://docs.python.org/3/builtins/stdtypes.html#str)]* *= {'ass': 'muvid.lyricvid.render_ass:render', 'web': 'muvid.lyricvid.render_web:render'}*

function”, resolved lazily so listing costs no import.

* **Type:**
  name -> “module

### muvid.lyricvid.pipeline.build_timed_text(, audio, lyrics=None, subtitles=None, project=None, aligner=None, timed_text=None)

Get measured word times from whichever input the caller actually has.

Order of preference is by how much the input is *trusted*: a saved timing
file (`TimedText.to_dict` JSON — e.g. one refined to per-glyph times by
[`muvid.lyricvid.glyph_align`](_autosummary/muvid.lyricvid.glyph_align.html.md#module-muvid.lyricvid.glyph_align)) is used as is; then an existing muvid
alignment beats a subtitle file, which beats aligning lyrics ourselves,
which beats transcribing from nothing.

* **Return type:**
  [`TimedText`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.TimedText)

### muvid.lyricvid.pipeline.register_renderer(name, target)

Register a renderer backend as `"module:function"`.

The house lazy-registry idiom (cf. `muvid.footage.strategy`): the target
is a string so adding a backend costs the import path nothing.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

```pycon
>>> register_renderer('doctest-demo', 'muvid.lyricvid.render_ass:render')
>>> 'doctest-demo' in RENDERERS
True
>>> del RENDERERS['doctest-demo']
```

### muvid.lyricvid.pipeline.render(request)

Render one lyric video. Satisfies [`muvid.subgenres.Renderer`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.Renderer).

`request.inputs` takes `audio` (required) and any of `lyrics`,
`subtitles`, `project`. `request.params` takes `treatment` (a
treatment spec as a mapping, or omitted to have one proposed), `renderer`,
`width`, `height`, `fps`, `aligner` and `persona`, and
`captions` — `{language: transform}` (see [`muvid.lyricvid.captions`](_autosummary/muvid.lyricvid.captions.html.md#module-muvid.lyricvid.captions)),
each written beside the video as an `.srt` artifact `captions.<language>`.

* **Return type:**
  [`RenderResult`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.RenderResult)

### muvid.lyricvid.pipeline.resolve_renderer(name)

Import and return a renderer backend. Accepts a callable unchanged.

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)

### muvid.lyricvid.pipeline.select_renderer(name='auto')

Resolve `"auto"` to a backend that can actually run here.

`auto` is a **selector**, not a registered renderer — the same shape as
`visual="auto"` in [`muvid.visualize`](_autosummary/muvid.visualize.html.md#module-muvid.visualize). It picks `ass` when this
ffmpeg can burn subtitles in (it needs a libass-enabled build) and `web`
otherwise.

This is deliberately not a fallback inside `ass`. Asking for `ass` on a
build without libass **fails loudly**, because a renderer that silently
produces something other than what was asked for is the exact shape muvid
has been bitten by before. `auto` is the caller opting in to “whichever
works”, and the choice it made is recorded in the result’s `meta`.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> select_renderer('web')
'web'
>>> select_renderer('auto') in {'ass', 'web'}
True
```


# _autosummary/muvid.lyricvid.render_ass.html.md

# muvid.lyricvid.render_ass

The default lyric-video renderer: a [`Scene`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Scene) as ASS.

One [`Scene`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Scene) in, an `.ass` subtitle document and a
burnt-in mp4 out. This is the *default* backend for the lyric-video subgenre for
three reasons, in the order they mattered:

* **It is frame-exact.** libass positions and animates text against the video’s
  own clock, so a word that the aligner measured at 12.34 s is drawn at 12.34 s
  — not at “whatever frame the compositor got to”.
* **It adds no non-Python dependency.** `ffmpeg` is already a hard system
  requirement of muvid, and its `subtitles` filter *is* libass. The only new
  Python dependency is `pysubs2` (MIT, pure Python, zero dependencies of its
  own), behind the `lyricvid` extra.
* **The intermediate is a deliverable.** The `.ass` file is a plain text
  document a human can open, retime and restyle, then re-burn — so a render that
  is 95% right is *editable* rather than a reason to re-run the pipeline.

The whole of the renderer’s opinion lives in one table, `Cue` -> ASS:

Fonts are resolved through fontconfig when it is available: a family the machine
does not have falls back (to the next of [`FONT_FALLBACKS`](_autosummary/muvid.lyricvid.render_ass.html.md#muvid.lyricvid.render_ass.FONT_FALLBACKS)) rather than
failing the render, and the substitution is recorded in the result’s `meta` so
a caller can see that the type is not what the treatment asked for.

Import-safe: stdlib plus the two stdlib-only muvid modules it types against.
`pysubs2`, `ffmpeg` and the verifier are imported inside the functions that
need them, so importing this module costs a caller nothing.

### Module Attributes

| [`RENDERER_NAME`](_autosummary/muvid.lyricvid.render_ass.html.md#muvid.lyricvid.render_ass.RENDERER_NAME)   | What the result's `meta` reports as the renderer that made the file.     |
|------------------------------------------------------------------|--------------------------------------------------------------------------|
| [`STYLE_NAME`](_autosummary/muvid.lyricvid.render_ass.html.md#muvid.lyricvid.render_ass.STYLE_NAME)      | The single ASS style every event references.                             |
| [`FONT_FALLBACKS`](_autosummary/muvid.lyricvid.render_ass.html.md#muvid.lyricvid.render_ass.FONT_FALLBACKS)  | Families to try, in order, when the treatment's family is not installed. |
| [`MOTION_FNS`](_autosummary/muvid.lyricvid.render_ass.html.md#muvid.lyricvid.render_ass.MOTION_FNS)      | `motion` name -> the events it draws.                                    |

### Functions

| [`register_motion`](_autosummary/muvid.lyricvid.render_ass.html.md#muvid.lyricvid.render_ass.register_motion)(name)                            | Register how one `motion` family draws itself.                 |
|---------------------------------------------------------------------------------------------------|----------------------------------------------------------------|
| [`list_motions`](_autosummary/muvid.lyricvid.render_ass.html.md#muvid.lyricvid.render_ass.list_motions)()                                   | The motion families this renderer can draw.                    |
| [`scene_to_ass`](_autosummary/muvid.lyricvid.render_ass.html.md#muvid.lyricvid.render_ass.scene_to_ass)(scene, \*[, font])                  | Render a Scene as an ASS (Advanced SubStation Alpha) document. |
| [`render`](_autosummary/muvid.lyricvid.render_ass.html.md#muvid.lyricvid.render_ass.render)(scene, \*, audio, output, workdir[, ...]) | Burn the scene over a solid background and mux the song.       |

### muvid.lyricvid.render_ass.FONT_FALLBACKS *: [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), ...]* *= ('DejaVu Sans', 'Liberation Sans', 'Noto Sans', 'Arial', 'Helvetica', 'sans-serif')*

Families to try, in order, when the treatment’s family is not installed.
DejaVu first because it is what a Linux render box actually has.

### muvid.lyricvid.render_ass.MOTION_FNS *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [Callable](https://docs.python.org/3/library/typing.html#typing.Callable)[[\_Placed], [list](https://docs.python.org/3/builtins/stdtypes.html#list)[\_Event]]]* *= {'cut': <function \_motion_cut>, 'fade': <function \_motion_fade>, 'pop': <function \_motion_pop>, 'rise': <function \_motion_rise>, 'typewriter': <function \_motion_typewriter>, 'wipe': <function \_motion_wipe>}*

`motion` name -> the events it draws. muvid’s house registry idiom
(`register_visual`, `register_archetype`, `register_selection_strategy`):
a new motion is a function plus an entry in
[`muvid.lyricvid.spec.MOTIONS`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.MOTIONS), never a branch in the writer.

### muvid.lyricvid.render_ass.RENDERER_NAME *= 'lyricvid.render_ass'*

What the result’s `meta` reports as the renderer that made the file.

### muvid.lyricvid.render_ass.STYLE_NAME *= 'Lyric'*

The single ASS style every event references. One style plus per-event
`\fs`/`\c` overrides, rather than a style per (size, colour) pair: a
scene routinely holds hundreds of distinct sizes, and a styles table that
long is unreadable to the human this file is also written for.

### muvid.lyricvid.render_ass.list_motions()

The motion families this renderer can draw.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### Examples

```pycon
>>> list_motions()
['cut', 'fade', 'pop', 'rise', 'typewriter', 'wipe']
```

### muvid.lyricvid.render_ass.register_motion(name)

Register how one `motion` family draws itself.

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[`_Placed`], [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[`_Event`]]], [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[`_Placed`], [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[`_Event`]]]

### Examples

```pycon
>>> @register_motion('doctest-demo')
... def _demo(p): return []
>>> 'doctest-demo' in list_motions()
True
>>> del MOTION_FNS['doctest-demo']
```

### muvid.lyricvid.render_ass.render(scene, , audio, output, workdir, ass_path=None, crf=18)

Burn the scene over a solid background and mux the song.

Three steps, and the middle one is the whole renderer: write the `.ass`,
generate a flat `color` source at the canvas’s size and rate, and let
libass draw the document onto it while the song is mapped through untouched.

The video runs for the *song’s* measured duration rather than the scene’s, so
a treatment that stops short of the last bar still produces a video the
length of the track — which is what
[`verify_video()`](_autosummary/muvid.visualize.verify.html.md#muvid.visualize.verify.verify_video)’s duration check compares
against, and it is run here on the finished file.

* **Parameters:**
  * **scene** ([`Scene`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Scene)) – The compiled scene.
  * **audio** ([`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – The song. Its duration sets the video’s.
  * **output** ([`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – Where the mp4 goes.
  * **workdir** ([`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – Directory for intermediates — this render owns it.
  * **ass_path** ([`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Where to keep the subtitle document (default:
    `workdir/<output stem>.ass`). It is a deliverable, not a temp file.
  * **crf** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – x264 quality, lower is better. 18 is visually lossless for flat
    colour and text.
* **Return type:**
  [`RenderResult`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.RenderResult)
* **Returns:**
  A [`RenderResult`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.RenderResult) carrying the mp4, its
  measured duration, the `.ass` under `artifacts['ass']`, and a `meta`
  recording the renderer, the font actually used (and what was asked for,
  when they differ), the cue/event counts and any verification failure.
* **Raises:**
  [**FfmpegError**](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.FfmpegError) – This ffmpeg has no `subtitles`
      filter (it is a libass build option, so a working ffmpeg is not
      enough), or the burn failed.

### muvid.lyricvid.render_ass.scene_to_ass(scene, , font=None)

Render a Scene as an ASS (Advanced SubStation Alpha) document.

The document is built through `pysubs2` (MIT, pure Python, no dependencies
of its own) rather than by string-formatting the format’s header by hand: it
owns the `[Script Info]` / `[V4+ Styles]` field order and the
centisecond time format, and it parses back, so the output is round-trip
checkable rather than merely plausible.

`PlayResX`/`PlayResY` are the canvas, so libass’s coordinates are the
video’s pixels 1:1 and the same document burns correctly at 1080p or 4K only
by changing the canvas it was compiled for.

* **Parameters:**
  * **scene** ([`Scene`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Scene)) – The compiled scene. Every number in it is already resolved.
  * **font** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Family to typeset in. Defaults to the scene’s typography; an
    uninstalled family falls back (see `_resolve_font()`).
* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)
* **Returns:**
  The `.ass` document, as text.

### Examples

```pycon
>>> from muvid.lyricvid.scene import Canvas, Cue, Scene
>>> from muvid.lyricvid.spec import Typography
>>> scene = Scene(
...     canvas=Canvas(width=1920, height=1080, fps=30),
...     duration=2.0,
...     background='#101014',
...     cues=(
...         Cue(text='red', x=0.25, y=0.5, size=0.2, t_in=0.0, t_full=0.0,
...             t_out=1.0, t_gone=1.0, colour='#ff0000', motion='cut'),
...         Cue(text='fade', x=0.5, y=0.25, size=0.1, t_in=1.0, t_full=1.2,
...             t_out=1.8, t_gone=2.0, colour='#e0533d', layer=2),
...     ),
...     typography=Typography(family='DejaVu Sans'),
... )
>>> doc = scene_to_ass(scene)
```

The normalised centre becomes absolute pixels, and the style anchors at
centre-centre (alignment 5) so `\pos` means what `Cue` says it means:

```pycon
>>> r'\pos(480,540)' in doc      # 0.25*1920, 0.5*1080
True
>>> r'\pos(960,270)' in doc      # 0.5*1920, 0.25*1080
True
```

Colours reverse to `&HBBGGRR&` — red is `&H0000FF&`, not `&HFF0000&`:

```pycon
>>> r'\c&H0000FF&' in doc
True
>>> r'\c&HFF0000&' in doc
False
```

`size` is a fraction of canvas height, so 0.2 of 1080 is a 216px font,
and the motions emit their own tags:

```pycon
>>> r'\fs216' in doc, r'\fad(200,200)' in doc
(True, True)
```

It parses back — the round trip is what makes the document a deliverable
rather than a guess:

```pycon
>>> import pysubs2
>>> subs = pysubs2.SSAFile.from_string(doc)
>>> len(subs.events), subs.events[0].style, subs.events[1].layer
(2, 'Lyric', 2)
>>> subs.info['PlayResX'], subs.info['PlayResY']
('1920', '1080')
```


# _autosummary/muvid.lyricvid.render_web.html.md

# muvid.lyricvid.render_web

The web backend: a [`Scene`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Scene) as a deterministic
HTML page, screenshotted frame by frame and muxed with the song.

ASS ([`muvid.lyricvid.render_ass`](_autosummary/muvid.lyricvid.render_ass.html.md#module-muvid.lyricvid.render_ass)) is the default backend and should stay
that way — it is fast, has no browser to install, and libass is what every
player already runs. This module is the **escape hatch**: CSS filters, blend
modes, variable fonts and arbitrary easing are things a subtitle format cannot
express, and a treatment that wants them should not have to become a different
product. One compiler, two renderers.

**Determinism is the whole design, not a nice property.** The page computes
every visual property as a pure function of the time handed to
`window.setTime(t)`. There are no CSS declarations that interpolate over wall
time, no keyframes, no `requestAnimationFrame`-driven state, no `Date.now`,
no `Math.random` — so a screenshot at `t` is the same image on every run, on
every machine, and the capture loop can take as long as it likes per frame
without the video drifting against the audio. Anything that makes the page’s
appearance depend on *when* it was asked rather than *what* it was asked is a
bug in this module, and [`scene_to_html()`](_autosummary/muvid.lyricvid.render_web.html.md#muvid.lyricvid.render_web.scene_to_html)’s doctests assert the absence of
the usual offenders.

The technique (deterministic `setTime` + Playwright capture + an ffmpeg mux)
is ported from a working 81-second render; the poem-specific parts of that
prototype are not here.

**This renderer does no layout.** A cue’s centre, size and stacking come from
[`compile_scene()`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.compile_scene) and are written into the markup
once, unchanged; the page never re-measures text and never nudges a coordinate.
That is what makes the two backends comparable — the same scene, drawn twice —
and it means a layout mistake upstream shows up here as a visibly wrong frame
rather than being quietly absorbed. Absorbing it would be worse: it would make
the browser and ASS renders disagree, and would then be wrong again the day the
compiler changed.

Playwright is an **optional** dependency (`pip install 'muvid[lyricvid-web]'`
plus `playwright install chromium`). It is imported inside function bodies, so
importing this module costs nothing and stays on muvid’s import-safe path.

### Module Attributes

| [`INSTALL_HINT`](_autosummary/muvid.lyricvid.render_web.html.md#muvid.lyricvid.render_web.INSTALL_HINT)   | What to tell a caller who has not installed the optional backend.   |
|-----------------------------------------------------------------|---------------------------------------------------------------------|

### Functions

| [`scene_to_html`](_autosummary/muvid.lyricvid.render_web.html.md#muvid.lyricvid.render_web.scene_to_html)(scene)                             | A self-contained page exposing a deterministic `setTime(t)`.   |
|---------------------------------------------------------------------------------------------------|----------------------------------------------------------------|
| [`render`](_autosummary/muvid.lyricvid.render_web.html.md#muvid.lyricvid.render_web.render)(scene, \*, audio, output, workdir[, ...]) | Capture the scene's page with Playwright and mux the song.     |

### Exceptions

| [`WebRenderUnavailable`](_autosummary/muvid.lyricvid.render_web.html.md#muvid.lyricvid.render_web.WebRenderUnavailable)   | Playwright (or its Chromium build) is not installed.   |
|-------------------------------------------------------------------------|--------------------------------------------------------|

### muvid.lyricvid.render_web.INSTALL_HINT *= "muvid's web lyric-video backend needs Playwright and a Chromium build:\\n    pip install 'muvid[lyricvid-web]'\\n    playwright install chromium\\nThe ASS backend (muvid.lyricvid.render_ass) needs neither and is the default."*

What to tell a caller who has not installed the optional backend. Both lines
matter: the wheel and the browser binary are separate installs, and missing
the second is the failure people actually hit.

### *exception* muvid.lyricvid.render_web.WebRenderUnavailable

Bases: [`RuntimeError`](https://docs.python.org/3/builtins/exceptions.html#RuntimeError)

Playwright (or its Chromium build) is not installed. Carries the remedy.

### muvid.lyricvid.render_web.render(scene, , audio, output, workdir, crf=18, scale=1)

Capture the scene’s page with Playwright and mux the song.

* **Parameters:**
  * **scene** ([`Scene`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Scene)) – the compiled scene. Its canvas fixes the size and frame rate.
  * **audio** ([`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – the song. Its stream is copied in unmodified.
  * **output** ([`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – where the mp4 goes.
  * **workdir** ([`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – scratch space owned by this render. The page is left behind as
    an artifact; the frames are deleted.
  * **crf** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – libx264 quality (lower is better; 18 is visually lossless-ish).
  * **scale** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – device pixel ratio for the capture. The viewport is
    `canvas / scale` CSS pixels and the screenshot comes back at the
    canvas size either way, so the output resolution and the (all-`vh`)
    layout are unchanged — what moves is the CSS pixel grid the browser
    lays out and rasterises on. Leave it at `1` unless a font is
    rasterising badly at the canvas’s own grid.
* **Return type:**
  [`RenderResult`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.RenderResult)
* **Returns:**
  A [`RenderResult`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.RenderResult) whose `artifacts['page']` is
  the HTML that produced it — open it in a browser and call `setTime`.
* **Raises:**
  * [**WebRenderUnavailable**](_autosummary/muvid.lyricvid.render_web.html.md#muvid.lyricvid.render_web.WebRenderUnavailable) – Playwright or its Chromium build is missing.
  * [**FileNotFoundError**](https://docs.python.org/3/builtins/exceptions.html#FileNotFoundError) – `audio` does not exist.
  * [**ValueError**](https://docs.python.org/3/builtins/exceptions.html#ValueError) – the canvas cannot be captured or encoded as asked.

### muvid.lyricvid.render_web.scene_to_html(scene)

A self-contained page exposing a deterministic `setTime(t)`.

One absolutely-positioned element per cue, placed once from the cue’s
normalised centre and sized in `vh`; the script only ever changes
opacity, transform, colour and clip — never content, never position — so
words cannot reflow. Nothing in the page depends on wall-clock time.

* **Parameters:**
  **scene** ([`Scene`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Scene)) – the compiled scene. Colours must be `#rrggbb`.
* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)
* **Returns:**
  A complete HTML document, safe to write to a file and open.

```pycon
>>> from muvid.lyricvid import spec as spec_mod
>>> from muvid.lyricvid.scene import compile_scene
>>> from muvid.lyricvid.timed_text import from_words
>>> tt = from_words([('one', 0.0, 0.5), ('two', 0.5, 1.0)], duration=1.0)
>>> scene = compile_scene(spec_mod.TreatmentSpec(), tt)
>>> page = scene_to_html(scene)
```

A page, with a deterministic entry point:

```pycon
>>> page.startswith('<!doctype html>')
True
>>> 'function setTime(t)' in page and 'window.setTime = setTime' in page
True
>>> 'window.__ready' in page
True
```

One element per cue, and the words themselves are in the markup:

```pycon
>>> page.count('class="cue"') == len(scene.cues)
True
>>> '>one<' in page and '>two<' in page
True
```

And none of the ways a page stops being a pure function of `t`:

```pycon
>>> forbidden = ('Math.random', 'Date.now', 'requestAnimationFrame',
...              'performance.now', 'transition:', 'animation:',
...              '@keyframes', 'setInterval', 'setTimeout')
>>> [bad for bad in forbidden if bad in page]
[]
```


# _autosummary/muvid.lyricvid.scene.html.md

# muvid.lyricvid.scene

The renderer-neutral scene — every number computed, no renderer opinions.

A [`Scene`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Scene) is a flat list of [`Cue`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Cue)s: a piece of text, where it
sits, how big it is, what colour, and its time envelope. That is all. It says
nothing about ASS tags or CSS, which is exactly what lets `render_ass` and
`render_web` be two backends over one compiler instead of two half-products.

Positions are **normalised** (`0..1` of the canvas, origin top-left, anchor at
the text’s centre) and sizes are a **fraction of canvas height**, so one scene
renders correctly at 1080p, at 4K and in portrait without recomputation.

The compiler is where the treatment spec stops being advice and becomes
geometry. Each archetype is a small function `(spec, scene_spec, timed_text,
canvas) -> list[Cue]`; adding one is adding a function and a vocabulary entry,
which is the seam a plugin author or a future muvid uses to grow the vocabulary.

### Functions

| [`compile_scene`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.compile_scene)(treatment, timed_text, \*[, canvas])   | Turn a treatment plus timed text into a fully-resolved [`Scene`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Scene).   |
|-------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------|
| [`register_archetype`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.register_archetype)(name)                             | Register a layout archetype.                                                                                     |

### Classes

| [`Canvas`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Canvas)(\*[, width, height, fps])                   | Output geometry.                                              |
|-----------------------------------------------------------------------------------------------------|---------------------------------------------------------------|
| [`Cue`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Cue)(\*, text, x, y, size, t_in, t_full[, ...])     | One piece of text, placed and timed.                          |
| [`Scene`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Scene)(\*, canvas, duration, background, cues, ...) | Everything a renderer needs, and nothing it has to interpret. |

### *class* muvid.lyricvid.scene.Canvas(, width=1920, height=1080, fps=30)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Output geometry. Sizes in the scene are relative to `height`.

### *class* muvid.lyricvid.scene.Cue(\*, text, x, y, size, t_in, t_full, t_out=None, t_gone=None, colour='#ffffff', dim_colour=None, dim_from=None, motion='fade', layer=0, extra=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One piece of text, placed and timed.

* **Parameters:**
  * **y** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – centre of the text, normalised to the canvas (0..1).
  * **size** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – cap height as a fraction of canvas height.
  * **t_in** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – when it starts arriving.
  * **t_full** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – when it is fully arrived. `t_in == t_full` is a hard cut.
  * **t_out** ([`float`](https://docs.python.org/3/builtins/functions.html#float) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – when it starts leaving; `None` means it never leaves.
  * **t_gone** ([`float`](https://docs.python.org/3/builtins/functions.html#float) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – when it has fully left.
  * **dim_from** ([`float`](https://docs.python.org/3/builtins/functions.html#float) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – when it recedes to `dim_colour` (`persistence='dim'`).

#### extra *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [Any](https://docs.python.org/3/library/typing.html#typing.Any)]*

Free-form, for a renderer that can use it (e.g. per-word wipe fraction).

### *class* muvid.lyricvid.scene.Scene(\*, canvas, duration, background, cues, typography, meta=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Everything a renderer needs, and nothing it has to interpret.

#### meta *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [Any](https://docs.python.org/3/library/typing.html#typing.Any)]*

Provenance, for reporting and for tests.

### muvid.lyricvid.scene.compile_scene(treatment, timed_text, , canvas=None)

Turn a treatment plus timed text into a fully-resolved [`Scene`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Scene).

Every number in the result was computed here, from measurement. Nothing the
model wrote reaches a renderer as a coordinate or a time.

* **Return type:**
  [`Scene`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Scene)

```pycon
>>> from muvid.lyricvid.timed_text import from_words
>>> tt = from_words([('one', 0.0, .5), ('two', .5, 1.0)], duration=1.0)
>>> s = compile_scene(spec_mod.TreatmentSpec(), tt)
>>> len(s.cues), s.cues[0].text
(2, 'one')
```

### muvid.lyricvid.scene.register_archetype(name)

Register a layout archetype.

Follows muvid’s house registry idiom (`register_visual`,
`register_aligner`, `register_selection_strategy`). A new archetype is a
function plus an entry in [`muvid.lyricvid.spec.ARCHETYPES`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.ARCHETYPES) so the
model knows it exists.

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis), [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Cue`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Cue)]]], [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis), [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Cue`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Cue)]]]

```pycon
>>> @register_archetype('doctest-demo')
... def _demo(**kw): return []
>>> 'doctest-demo' in ARCHETYPE_FNS
True
>>> del ARCHETYPE_FNS['doctest-demo']
```


# _autosummary/muvid.lyricvid.shape.html.md

# muvid.lyricvid.shape

Packing words INSIDE a shape — the shape-word-cloud construction.

The `shape_fill` archetype has exactly one hard question: given the words of a
song, in the order they are sung, *where* does each one go so that the set of
them draws an apple? [`pack_words_into_shape()`](_autosummary/muvid.lyricvid.shape.html.md#muvid.lyricvid.shape.pack_words_into_shape) answers it the way the
word-cloud literature does — Wordle’s outward spiral, ShapeWordle’s distance
field — and that construction is worth naming, because the two properties this
package cares about fall out of it rather than being bolted on afterwards:

* **It is deterministic.** Same words, same shape, same picture — always. There
  is no `random` here and there must never be: a song that renders differently
  on the second run is a song whose video cannot be reviewed. Where the packing
  wants variety it takes it from the word’s *index* through a closed form (the
  golden angle), which is the same trick `muvid.lyricvid.scene._scatter()`
  uses one layer up.
* **It never overlaps.** A word is placed only where its whole box is inside the
  outline and clear of every word already placed; a word that has nowhere to go
  is **dropped** (`None` at its slot) rather than laid on top of its
  neighbour. Dropping a word is visible and survivable; overlapping two is the
  failure that makes a lyric video look broken.

The construction is four steps:

1. rasterise the shape to a boolean mask on a canvas-shaped pixel grid
   ([`shape_mask()`](_autosummary/muvid.lyricvid.shape.html.md#muvid.lyricvid.shape.shape_mask));
2. take a chamfer distance transform of that mask — how *deep* inside the
   outline each pixel is;
3. for each word, seed at the deepest still-free pixel (pulled gently toward the
   shape’s centroid, so the first words land in the middle) and walk an
   Archimedean spiral outward, testing an axis-aligned box for “wholly inside
   the mask” (O(1) per candidate, through a summed-area table) and “clear of
   everything already placed”;
4. size each word by a small salience weight — earlier and longer words a little
   larger — so the picture has some typographic rhythm without any word
   dominating.

Coordinates out are exactly [`muvid.lyricvid.scene.Cue`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Cue)’s: `x`/`y` are
normalised to the canvas (`0..1`, origin top-left, anchor at the text’s
centre) and `size` is a fraction of canvas **height**, so one packing renders
at 1080p, at 4K and in portrait without recomputation.

Three shape sources, one seam each ([`muvid.lyricvid.spec.ShapeRef`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.ShapeRef)
names them): `'named'` resolves a built-in outline from the
`NAMED_SHAPES` registry (`circle`, `heart`, `star`, `apple`,
`square` — add one with [`register_shape()`](_autosummary/muvid.lyricvid.shape.html.md#muvid.lyricvid.shape.register_shape)), `'svg_path'` flattens an
SVG path’s `M/L/H/V/C/S/Q/T/Z` subset and scanline-fills it (arcs, `A`, are
refused by name rather than ignored), and `'mask_image'` thresholds an image
through Pillow.

numpy is imported *inside* the functions rather than at module scope. This
module hangs off an import-safe path, and `import muvid` is asserted by
subprocess to pull no numpy; the packing is the only thing here that needs it.

### Module Attributes

| [`SHAPE_KINDS`](_autosummary/muvid.lyricvid.shape.html.md#muvid.lyricvid.shape.SHAPE_KINDS)   | The three sources a shape can come from.   |
|----------------------------------------------------------------|--------------------------------------------|

### Functions

| [`pack_words_into_shape`](_autosummary/muvid.lyricvid.shape.html.md#muvid.lyricvid.shape.pack_words_into_shape)(texts, \*[, ...])   | Pack `texts` inside a shape, in the order given.                          |
|--------------------------------------------------------------------------------------------|---------------------------------------------------------------------------|
| [`bounding_box`](_autosummary/muvid.lyricvid.shape.html.md#muvid.lyricvid.shape.bounding_box)(placement, \*[, aspect])     | `(x0, y0, x1, y1)` of a placement's layout box, normalised to the canvas. |
| [`shape_mask`](_autosummary/muvid.lyricvid.shape.html.md#muvid.lyricvid.shape.shape_mask)([kind, value, aspect, ...])    | Rasterise a shape to a boolean mask over the whole canvas.                |
| [`register_shape`](_autosummary/muvid.lyricvid.shape.html.md#muvid.lyricvid.shape.register_shape)(name)                      | Register a named outline, muvid's house registry idiom.                   |
| [`list_shapes`](_autosummary/muvid.lyricvid.shape.html.md#muvid.lyricvid.shape.list_shapes)()                             | Every registered outline name, sorted.                                    |
| [`resolve_shape`](_autosummary/muvid.lyricvid.shape.html.md#muvid.lyricvid.shape.resolve_shape)(name)                       | The outline function for `name`, or a ValueError naming the options.      |

### Classes

| [`Placement`](_autosummary/muvid.lyricvid.shape.html.md#muvid.lyricvid.shape.Placement)(\*, text, x, y, size[, rotated])   | Where one word goes, in [`muvid.lyricvid.scene.Cue`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Cue)'s coordinates.   |
|-----------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------|

### *class* muvid.lyricvid.shape.Placement(, text, x, y, size, rotated=False)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Where one word goes, in [`muvid.lyricvid.scene.Cue`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Cue)’s coordinates.

Keyword-only and frozen, like the [`Cue`](_autosummary/muvid.lyricvid.scene.html.md#muvid.lyricvid.scene.Cue) it
becomes: four of its five fields are geometry, and geometry read off a
positional tuple is geometry nobody can check at the call site.

* **Parameters:**
  * **text** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – the word, exactly as it should be drawn.
  * **y** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – centre of the text, normalised to the canvas (0..1, top-left).
  * **size** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – cap height as a fraction of canvas height.
  * **rotated** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – the word is set at 90 degrees (bottom-up). Only ever True
    when the caller asked for `allow_rotation`; `Cue` cannot express a
    rotation, so `shape_fill` does not ask for one.

### muvid.lyricvid.shape.SHAPE_KINDS *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [Callable](https://docs.python.org/3/library/typing.html#typing.Callable)[[...], [Any](https://docs.python.org/3/library/typing.html#typing.Any)]]* *= {'mask_image': <function \_image_ink>, 'named': <function \_named_ink>, 'svg_path': <function \_svg_ink>}*

The three sources a shape can come from. Closed, because
[`muvid.lyricvid.spec.ShapeRef`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.ShapeRef) is: the extension seam for a new
*outline* is [`register_shape()`](_autosummary/muvid.lyricvid.shape.html.md#muvid.lyricvid.shape.register_shape), not a fourth kind.

### muvid.lyricvid.shape.bounding_box(placement, , aspect=1.7777777777777777)

`(x0, y0, x1, y1)` of a placement’s layout box, normalised to the canvas.

The LAYOUT box — the room the packer reserved, glyphs plus leading and side
bearings — which is what a caller wants for a hit test, a debug overlay, or
for asserting that two placements do not collide.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)]

```pycon
>>> p = Placement(text='apple', x=0.5, y=0.5, size=0.1)
>>> [round(c, 3) for c in bounding_box(p, aspect=16 / 9)]
[0.408, 0.436, 0.592, 0.564]
```

### muvid.lyricvid.shape.list_shapes()

Every registered outline name, sorted.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

```pycon
>>> list_shapes()
['apple', 'circle', 'heart', 'square', 'star']
```

### muvid.lyricvid.shape.pack_words_into_shape(texts, , shape_kind='named', shape_value='circle', aspect=1.7777777777777777, base_size=0.06, grid_height=192, margin=0.04, allow_rotation=False, shrink_steps=2, mask_options=None)

Pack `texts` inside a shape, in the order given.

* **Parameters:**
  * **texts** ([`Sequence`](https://docs.python.org/3/library/typing.html#typing.Sequence)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]) – the words, in the order they are sung. Order matters twice:
    it decides who gets the roomy middle of the shape, and it feeds the
    salience weight.
  * **shape_kind** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – `'named'`, `'svg_path'` or `'mask_image'` — see
    [`SHAPE_KINDS`](_autosummary/muvid.lyricvid.shape.html.md#muvid.lyricvid.shape.SHAPE_KINDS) and [`muvid.lyricvid.spec.ShapeRef`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.ShapeRef).
  * **shape_value** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – the outline’s name, path data, or image path.
  * **aspect** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – canvas width / height.
  * **base_size** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – cap height as a fraction of canvas height, before salience.
  * **allow_rotation** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – also try each word turned 90 degrees.
    `muvid.lyricvid.scene.Cue` cannot express a rotation, so `shape_fill`
    leaves this off; a renderer that can should turn it on.
  * **shrink_steps** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – how many progressively smaller retries a word gets
    before it is dropped.
  * **mask_options** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`Mapping`](https://docs.python.org/3/library/typing.html#typing.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]) – forwarded to the kind’s builder (`threshold` and
    `invert` for `'mask_image'`, `curve_samples` for `'svg_path'`).
* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Placement`](_autosummary/muvid.lyricvid.shape.html.md#muvid.lyricvid.shape.Placement) | [`None`](https://docs.python.org/3/builtins/constants.html#None)]
* **Returns:**
  one entry per input, in the same order. `None` is “did not fit”
  — dropping a word beats overlapping two.

```pycon
>>> words = 'apple yum juicy crunchy red yellow green delicious'.split()
>>> placed = pack_words_into_shape(words, shape_value='apple')
>>> len(placed) == len(words)
True
```

Same input, same picture — every time, forever:

```pycon
>>> placed == pack_words_into_shape(words, shape_value='apple')
True
```

Every placed word is wholly inside the outline:

```pycon
>>> mask = shape_mask('named', 'apple')
>>> H, W = mask.shape
>>> def corners_in_mask(p):
...     x0, y0, x1, y1 = bounding_box(p)
...     xs = (int(x0 * W), int(x1 * W) - 1)
...     ys = (int(y0 * H), int(y1 * H) - 1)
...     return all(mask[y][x] for y in ys for x in xs)
>>> all(corners_in_mask(p) for p in placed if p is not None)
True
```

…and no two of them collide:

```pycon
>>> def collide(a, b):
...     ax0, ay0, ax1, ay1 = bounding_box(a)
...     bx0, by0, bx1, by1 = bounding_box(b)
...     return ax0 < bx1 and bx0 < ax1 and ay0 < by1 and by0 < ay1
>>> ok = [p for p in placed if p is not None]
>>> any(collide(a, b) for i, a in enumerate(ok) for b in ok[i + 1:])
False
```

An SVG outline works the same way (`M/L/H/V/C/S/Q/T/Z`; arcs are refused):

```pycon
>>> square = pack_words_into_shape(
...     ['one', 'two'], shape_kind='svg_path',
...     shape_value='M 0 0 L 10 0 L 10 10 L 0 10 Z')
>>> [p.text for p in square if p is not None]
['one', 'two']
```

### muvid.lyricvid.shape.register_shape(name)

Register a named outline, muvid’s house registry idiom.

The function receives `(u, v)` arrays in the shape box’s coordinates and
returns a boolean array. Implicit functions and polygons both fit; see
`polygon_shape()` for the latter.

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`Any`](https://docs.python.org/3/library/typing.html#typing.Any), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)], [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]], [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`Any`](https://docs.python.org/3/library/typing.html#typing.Any), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)], [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]

```pycon
>>> @register_shape('doctest-blob')
... def _blob(u, v): return (u - 0.5) ** 2 + (v - 0.5) ** 2 <= 0.1
>>> 'doctest-blob' in list_shapes()
True
>>> del NAMED_SHAPES['doctest-blob']
```

### muvid.lyricvid.shape.resolve_shape(name)

The outline function for `name`, or a ValueError naming the options.

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`Any`](https://docs.python.org/3/library/typing.html#typing.Any), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)], [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> resolve_shape('banana')
Traceback (most recent call last):
    ...
ValueError: unknown named shape 'banana'; known: apple, circle, heart, square, star
```

### muvid.lyricvid.shape.shape_mask(kind='named', value='circle', , aspect=1.7777777777777777, grid_height=192, margin=0.04, \*\*options)

Rasterise a shape to a boolean mask over the whole canvas.

The result has shape `(grid_height, round(grid_height * aspect))`, so a
normalised canvas point `(x, y)` is the pixel `[int(y * H), int(x * W)]`.
The outline is drawn into the largest centred SQUARE the canvas allows (less
`margin`), which is what keeps a circle round on a 16:9 frame.

* **Return type:**
  [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)

```pycon
>>> m = shape_mask('named', 'circle', aspect=1.0, grid_height=32)
>>> m.shape, bool(m[16, 16]), bool(m[0, 0])
((32, 32), True, False)
```


# _autosummary/muvid.lyricvid.spec.html.md

# muvid.lyricvid.spec

The treatment spec — what a director (human or model) decides, as data.

This is the SSOT both runtimes share: the Claude Code skill and the production
LLM call produce *this*, and every renderer consumes *this*. It is stdlib-only
and import-safe on purpose.

The shape is two layers, and the split is the single most important decision in
the whole subgenre:

`direction`
: WHAT and WHY — mood, palette, typography, motion vocabulary, and a
  rationale a human can read when choosing between options. This is the part
  a model is genuinely good at.

`scenes`
: HOW — an **archetype** from a closed set, plus parameters. Never raw
  geometry, never raw timings.

Three rules follow, and they are not stylistic:

* **No coordinates from the model.** Every archetype computes its own geometry
  in Python. The *Visual Lyrics* authors reached this the hard way and reported
  that LLM-generated bounding boxes “often result in layouts with misalignment
  and overlap issues”; the archetype removes the need to generate one at all.
* **No timings from the model.** Word onsets, the beat grid and section bounds
  come from muvid’s aligner. The model picks a *quantisation policy*
  (`Quantize`) and Python applies it. This kills the entire class of
  “the words drift out of sync” bugs by construction.
* **Closed vocabularies everywhere.** Archetypes, motion families, easings and
  cut styles are enums. Closed sets are what make the schema constrainable, two
  options diffable, and a renderer total rather than best-effort.

An invalid spec is not an error to hand back to a model when it can be
*projected* onto the valid space instead — see [`repair()`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.repair). Deterministic
repair is cheaper, faster and more predictable than a retry, and it means a
slightly-wrong model output still renders.

### Module Attributes

| [`ARCHETYPES`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.ARCHETYPES)         | How words are placed on screen.                                       |
|---------------------------------------------------------------------|-----------------------------------------------------------------------|
| [`MOTIONS`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.MOTIONS)            | How a single word arrives.                                            |
| [`PERSISTENCE`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.PERSISTENCE)        | What a word does when it is no longer current.                        |
| [`QUANTIZE`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.QUANTIZE)           | What the animation clock is quantised to.                             |
| [`SHAPE_KINDS`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.SHAPE_KINDS)        | Where a shape outline may come from.                                  |
| [`INLINE_SHAPE_KINDS`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.INLINE_SHAPE_KINDS) | The shape kinds that carry no reference to anything outside the spec. |

### Functions

| [`coerce`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.coerce)(obj)    | Take whatever a caller or a model produced and return a renderable spec.              |
|-----------------------------------------------------------------|---------------------------------------------------------------------------------------|
| [`json_schema`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.json_schema)()  | The JSON Schema for a [`TreatmentSpec`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.TreatmentSpec). |
| [`repair`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.repair)(spec)   | Project `spec` onto the valid space.                                                  |
| [`validate`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.validate)(spec) | Return a list of human-readable problems.                                             |
| [`vocabulary`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.vocabulary)()   | Every closed vocabulary, for prompts and UI.                                          |

### Classes

| [`Direction`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.Direction)(\*[, mood, palette, typography, ...])   | The song-level creative decision.                          |
|----------------------------------------------------------------------------------------------------|------------------------------------------------------------|
| [`Palette`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.Palette)(\*[, bg, fg, accent, dim])                | Colours, as `#rrggbb`.                                     |
| [`Scene`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.Scene)(\*[, applies_to, archetype, motion, ...])   | One treatment, applied to part of the song.                |
| [`ShapeRef`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.ShapeRef)(\*[, kind, value])                       | Where a `shape_fill` / `concrete_page` outline comes from. |
| [`Timing`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.Timing)(\*[, quantize_to, cut_style, ...])         | The quantisation POLICY.                                   |
| [`TreatmentSpec`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.TreatmentSpec)(\*[, spec_version, title, ...])     | A complete, renderable treatment.                          |
| [`Typography`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.Typography)(\*[, family, weight, case, ...])       | Type choices.                                              |

### muvid.lyricvid.spec.ARCHETYPES *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [str](https://docs.python.org/3/builtins/stdtypes.html#str)]* *= {'calligram': "Each line becomes a slanting streak of UPRIGHT letters, one letter per slot, the streaks fanning open as they descend — the Apollinaire 'Il pleut' construction. Use for a calligram or concrete poem whose shape is made by the run of the text itself rather than by an outline; prefer 'shape_fill' when the shape is a picture the words pour into, and 'concrete_page' when the layout is simply lines on a page.", 'concrete_page': "The whole lyric is typeset as a fixed page — one CENTRED HORIZONTAL ROW per line — and each word ignites in reading order as it is sung. The page never reflows. Use when the poem is lines on a page. It cannot slant, indent or shape anything: for a calligram or a concrete poem whose picture is made by the run of the text, use 'calligram'; for words poured into an outline, use 'shape_fill'.", 'glyph_pages': "A few lines per page, every CHARACTER shown dim from the page's start and each lighting up at the moment its own sound is sung. For learning to read a script (kana, an alphabet) along with a song. Needs per-glyph times (muvid.lyricvid.glyph_align); without them a word's characters are spread over the word and marked unmeasured. params: lines_on_screen, size, emphasis (the target characters, highlighted strongly; others get milder grey and highlight), fold_marks.", 'karaoke_wipe': 'Two lines at the bottom, the current one wiped syllable by syllable as it is sung. The classic karaoke treatment; the most legible option.', 'one_word_centred': 'One word at a time, large, centred. The default lyric-video look: unmissable, works at any aspect ratio, reads on a phone.', 'scatter': 'Words appear away from centre and drift, density rising with energy. Use for chaos, crowds, or an instrumental-heavy chorus.', 'shape_fill': 'Words packed into the outline of a shape, filling it as the song proceeds. Use when the song has one strong concrete image.', 'stacked_lines': 'Lines accumulate down the frame and hold, so the viewer can read back what has already been sung. Good for narrative or dense lyrics.', 'text_on_path': 'Words follow a curve across the frame. Cheap, distinctive, and good for a single repeated hook.'}*

How words are placed on screen. The renderer owns the geometry; the spec
only names the family and its knobs.

### *class* muvid.lyricvid.spec.Direction(\*, mood='', palette=<factory>, typography=<factory>, motion_vocabulary=('fade', ), rationale='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The song-level creative decision. The half a model is actually good at.

### muvid.lyricvid.spec.INLINE_SHAPE_KINDS *: [frozenset](https://docs.python.org/3/builtins/stdtypes.html#frozenset)[[str](https://docs.python.org/3/builtins/stdtypes.html#str)]* *= frozenset({'named', 'svg_path'})*

The shape kinds that carry no reference to anything outside the spec. A
surface serving untrusted callers (the MCP tools) admits ONLY these unless it
has itself fetched and scoped the image.

### muvid.lyricvid.spec.MOTIONS *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [str](https://docs.python.org/3/builtins/stdtypes.html#str)]* *= {'cut': 'Appears instantly. Hardest, most rhythmic.', 'fade': 'Fades up over a fraction of a beat.', 'pop': 'Fades up with a slight overshoot in scale, then settles.', 'rise': 'Fades up while moving a short distance upward.', 'typewriter': "Letters appear one at a time across the word's duration.", 'wipe': 'Revealed left-to-right, like a karaoke wipe.'}*

How a single word arrives. Composable with the archetype rather than part of it.

### muvid.lyricvid.spec.PERSISTENCE *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [str](https://docs.python.org/3/builtins/stdtypes.html#str)]* *= {'clear_on_line': 'Cleared when its line ends.', 'clear_on_section': 'Cleared when its section ends.', 'dim': 'Stays but recedes, so the current word leads. Keeps context readable.', 'hold': 'Stays exactly as it arrived, forever. The page fills up.'}*

What a word does when it is no longer current.

### *class* muvid.lyricvid.spec.Palette(, bg='#101014', fg='#f4f4f0', accent='#e0533d', dim='#4a4a52')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Colours, as `#rrggbb`. `dim` is the un-sung state where one exists.

### muvid.lyricvid.spec.QUANTIZE *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [str](https://docs.python.org/3/builtins/stdtypes.html#str)]* *= {'beat': 'Snapped to the nearest beat. Rhythmic, forgiving of alignment error.', 'downbeat': 'Snapped to the nearest bar start. Slow, deliberate.', 'line': "The whole line arrives together, at the line's start.", 'syllable': 'Sub-word timing, where the aligner provides it.', 'word': 'Each word ignites at its own measured onset. Tightest sync.'}*

What the animation clock is quantised to. The MODEL picks one of these; the
numbers behind them always come from measurement, never from the model.

### muvid.lyricvid.spec.SHAPE_KINDS *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [str](https://docs.python.org/3/builtins/stdtypes.html#str)]* *= {'mask_image': 'An image whose dark ink (or alpha) is the outline. Trusted callers only: the value names a file.', 'named': 'A built-in outline: circle, heart, star, apple, square.', 'svg_path': 'An SVG path string (M/L/H/V/C/S/Q/T/Z) supplied inline.'}*

Where a shape outline may come from. A closed set for the same reason the
others are — and additionally because `value` is INTERPRETED by the
renderer, which makes `kind` a trust boundary: `mask_image` names a file
to open, and a treatment that arrives from a remote caller must not be able
to point that at a host path. The spec only admits the kinds; where a
`mask_image` value is allowed to come FROM is the caller-facing surface’s
decision (see `MASK_IMAGE_TRUSTED`).

### *class* muvid.lyricvid.spec.Scene(\*, applies_to=('\*', ), archetype='one_word_centred', motion='fade', persistence='clear_on_line', timing=<factory>, shape=None, params=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One treatment, applied to part of the song.

`applies_to` names sections by label (as the lyrics document labels them),
or `"*"` for the whole song. Sections muvid found but the spec does not
mention fall back to the first `"*"` scene, so a one-scene spec is valid
and complete.

### *class* muvid.lyricvid.spec.ShapeRef(, kind='named', value='circle')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Where a `shape_fill` / `concrete_page` outline comes from.

`kind='named'` uses a built-in outline; `kind='mask_image'` traces an
image the caller supplied; `kind='svg_path'` takes a path directly. The
model may name a shape, but it never draws one.

### *class* muvid.lyricvid.spec.Timing(, quantize_to='word', cut_style='hard', attack_s=0.12, lead_s=0.0)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The quantisation POLICY. Never actual times.

#### attack_s *: [float](https://docs.python.org/3/builtins/functions.html#float)*

Seconds a word takes to arrive. Small, or it stops reading as on-the-beat.

#### lead_s *: [float](https://docs.python.org/3/builtins/functions.html#float)*

Seconds before a word’s onset to start it. Compensates for perceived lag.

### *class* muvid.lyricvid.spec.TreatmentSpec(\*, spec_version='1.0', title='', direction=<factory>, scenes=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A complete, renderable treatment.

```pycon
>>> s = TreatmentSpec()
>>> validate(s)
[]
>>> TreatmentSpec.from_dict(s.to_dict()) == s
True
```

#### *classmethod* from_dict(d)

Build from a plain mapping — TOTAL over what a model or a remote caller sends.

Missing keys default. Wrong-shaped values are coerced toward the field’s
type rather than raised on: a string where a list was expected becomes
a one-element list (`applies_to: "chorus"` used to become
`('c','h','o','r','u','s')`), a numeric string becomes a number, an
unknown key is dropped, a `None` sub-object is the default. What
cannot be coerced falls to the default and [`repair()`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.repair) reports the
enum-level substitutions afterwards.

* **Return type:**
  [`TreatmentSpec`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.TreatmentSpec)

```pycon
>>> s = TreatmentSpec.from_dict({'scenes': [None, {'applies_to': 'chorus',
...     'timing': 'fast', 'shape': 'circle'}],
...     'direction': {'palette': {'foo': 1, 'bg': '#000000'},
...                   'motion_vocabulary': 'pop',
...                   'typography': {'weight': '700', 'tracking': None}}})
>>> s.scenes[1].applies_to, s.direction.motion_vocabulary
(('chorus',), ('pop',))
>>> s.direction.typography.weight, s.direction.typography.tracking
(700, 0.0)
>>> s.direction.palette.bg, s.scenes[1].shape
('#000000', None)
```

#### to_dict()

A JSON-native dict: tuples become lists, so what this emits is
exactly what [`json_schema()`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.json_schema) validates and what a file round-trips.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### *class* muvid.lyricvid.spec.Typography(, family='DejaVu Sans', weight=700, case='as_written', tracking=0.0, max_line_chars=28)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Type choices. `family` is resolved against installed/bundled fonts, and
an unavailable family falls back rather than failing the render.

### muvid.lyricvid.spec.coerce(obj)

Take whatever a caller or a model produced and return a renderable spec.

Accepts a [`TreatmentSpec`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.TreatmentSpec), a mapping, or a JSON string — and repairs
it. This is the one entry point production code should use.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`TreatmentSpec`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.TreatmentSpec), [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]

### muvid.lyricvid.spec.json_schema()

The JSON Schema for a [`TreatmentSpec`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.TreatmentSpec).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> s = json_schema()
>>> s['properties']['scenes']['items']['properties']['archetype']['enum'][0]
'one_word_centred'
```

### muvid.lyricvid.spec.repair(spec)

Project `spec` onto the valid space. Returns `(spec, notes)`.

A model that names a motion that doesn’t exist has made a small, mechanical
mistake; projecting it onto the nearest legal value renders something good
now, where a retry costs a round trip and may fail the same way. Every
substitution is reported so the caller can show or log it.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`TreatmentSpec`](_autosummary/muvid.lyricvid.spec.html.md#muvid.lyricvid.spec.TreatmentSpec), [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]

```pycon
>>> fixed, notes = repair(TreatmentSpec(scenes=(Scene(archetype='swirl'),)))
>>> fixed.scenes[0].archetype
'one_word_centred'
>>> notes
["scenes[0].archetype 'swirl' -> 'one_word_centred'"]
```

### muvid.lyricvid.spec.validate(spec)

Return a list of human-readable problems. Empty means renderable.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

```pycon
>>> validate(TreatmentSpec(scenes=(Scene(archetype='nope'),)))
["scenes[0].archetype 'nope' is not one of the known archetypes"]
```

### muvid.lyricvid.spec.vocabulary()

Every closed vocabulary, for prompts and UI. One copy, several readers.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]


# _autosummary/muvid.lyricvid.timed_text.html.md

# muvid.lyricvid.timed_text

The timed text tree — song → sections → lines → words, with measured times.

Everything downstream reads this and nothing else, which is what lets the
subgenre accept four quite different kinds of input without the renderers
caring which one arrived:

| input the caller has    | how it gets here                                                                                                                                                                                                 |
|-------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| a muvid project         | [`from_alignment_store()`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.from_alignment_store) — muvid already owns<br/>the word-timing SSOT (`muvid.align` writes a<br/>three-tier lacing store); we read it, never<br/>re-transcribe. |
| an `.srt` / `.lrc` file | [`from_subtitles()`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.from_subtitles) — line-level times, words<br/>spread inside a line.                                                                                            |
| lyrics text + audio     | [`from_lyrics_and_audio()`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.from_lyrics_and_audio) — runs muvid’s<br/>registered aligner.                                                                                                  |
| audio only              | the aligner’s transcription path, same function.                                                                                                                                                                 |

Line-level input is not word-level input, and pretending otherwise is how lyric
videos end up subtly out of sync. When words are interpolated inside a line
rather than measured, [`Word.measured`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.Word.measured) is `False` — renderers and the
quantiser can then prefer `line` quantisation, and a caller can be told the
timing is approximate instead of discovering it in the render.

Import-safe: stdlib only at module scope. The muvid alignment machinery and any
ASR are imported inside the functions that need them.

### Functions

| [`from_alignment_store`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.from_alignment_store)(project_root, \*[, duration])   | Read muvid's own three-tier alignment (sections / lines / words).                                                                     |
|-------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------|
| [`from_alignment_result`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.from_alignment_result)(result, \*[, duration, ...])   | Convert a [`muvid.align.AlignmentResult`](_autosummary/muvid.align.html.md#muvid.align.AlignmentResult) into a timed tree. |
| [`from_subtitles`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.from_subtitles)(path, \*[, duration])                 | Read `.srt`, `.lrc` or enhanced `.lrc` into a timed tree.                                                                             |
| [`from_lyrics_and_audio`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.from_lyrics_and_audio)(audio, \*[, lyrics, ...])      | Align `lyrics` to `audio` through muvid's aligner registry.                                                                           |
| [`from_words`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.from_words)(words, \*[, duration, line_gap_s, ...])   | Build from a flat `(text, start, end)` stream, splitting lines on gaps.                                                               |

### Classes

| [`Word`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.Word)(\*, text, start, end[, measured, glyphs])   | One sung word on the song timeline.                                     |
|---------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| [`Line`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.Line)(\*, words[, index, text])                   | One sung line.                                                          |
| [`Section`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.Section)(\*, label, lines)                        | A labelled span — `verse`, `chorus`, whatever the lyrics document says. |
| [`TimedText`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.TimedText)(\*, sections[, duration, source])      | The whole song's text, timed.                                           |

### *class* muvid.lyricvid.timed_text.Line(, words, index=0, text='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One sung line.

### *class* muvid.lyricvid.timed_text.Section(, label, lines)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A labelled span — `verse`, `chorus`, whatever the lyrics document says.

### *class* muvid.lyricvid.timed_text.TimedText(, sections, duration=0.0, source='unknown')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The whole song’s text, timed.

```pycon
>>> tt = from_words([('hello', 0.0, 0.5), ('world', 0.5, 1.0)], duration=1.0)
>>> [w.text for w in tt.words()]
['hello', 'world']
>>> tt.sections[0].label
'*'
```

#### *property* measured *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

True when every word time was measured rather than interpolated.

False for an empty text: “all of nothing was measured” is the kind of
vacuous truth that reads as reassurance in a report.

#### source *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

Where the timing came from, for provenance and for honest reporting.

### *class* muvid.lyricvid.timed_text.Word(, text, start, end, measured=True, glyphs=())

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One sung word on the song timeline. Times are seconds, absolute.

#### glyphs *: [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[Glyph, ...]*

Per-character times, when measured (empty otherwise). Concatenated, the
glyph texts equal `text`.

#### measured *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

False when the time was interpolated inside a line rather than measured.

### muvid.lyricvid.timed_text.from_alignment_result(result, , duration=0.0, source='muvid-align')

Convert a [`muvid.align.AlignmentResult`](_autosummary/muvid.align.html.md#muvid.align.AlignmentResult) into a timed tree.

Keeps the sections and lines the aligner found — which is strictly better
than flattening to words and re-splitting on gaps, because the lyrics
document already *knows* where the lines are.

A line whose words the aligner could not match still has a `[start, end]`
(interpolated by the aligner from its neighbours); its words are spread
across that span and marked `measured=False`, so downstream can see the
difference between a measured onset and a guessed one.

* **Return type:**
  [`TimedText`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.TimedText)

```pycon
>>> from types import SimpleNamespace as NS
>>> w = NS(text='hi', start_s=0.0, end_s=0.4, token_index=0)
>>> ln = NS(line_index=0, text='hi there', start_s=0.0, end_s=1.0,
...         word_alignments=(w,))
>>> sec = NS(label='verse', lines=(ln,))
>>> tt = from_alignment_result(NS(sections=(sec,)), duration=1.0)
>>> [(x.text, x.measured) for x in tt.words()]
[('hi', True), ('there', False)]
>>> tt.sections[0].label
'verse'
```

### muvid.lyricvid.timed_text.from_alignment_store(project_root, , duration=0.0)

Read muvid’s own three-tier alignment (sections / lines / words).

muvid already declares itself the word-timing SSOT — `muvid.align` writes
a `lacing` store and `muvid.contracts` reads it — so this subgenre reads
that store rather than growing a second transcription path.

* **Return type:**
  [`TimedText`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.TimedText)

### muvid.lyricvid.timed_text.from_lyrics_and_audio(audio, , lyrics=None, aligner=None, duration=0.0)

Align `lyrics` to `audio` through muvid’s aligner registry.

Goes through [`muvid.align.align_lyrics()`](_autosummary/muvid.align.html.md#muvid.align.align_lyrics) rather than calling an ASR
directly, so a singing-grade aligner registered later is picked up here
for free. The aligner is chosen honestly by what is installed:

* `whisperx-lite` — offline, free, needs `faster-whisper`. \*\*Default
  when available.\*\* Runs on the audio itself.
* `scribe-greedy` — needs a Scribe transcript, which costs money and an
  ElevenLabs key; only reached when asked for by name, never as a silent
  fallback that spends.

With no `lyrics` at all there is nothing to align *to*; the transcript’s
own words become the text (`source='transcript'`).

`aligner` may be any registered name; unknown names raise from
`muvid.align` with the registered list.

* **Return type:**
  [`TimedText`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.TimedText)

### muvid.lyricvid.timed_text.from_subtitles(path, , duration=0.0)

Read `.srt`, `.lrc` or enhanced `.lrc` into a timed tree.

Enhanced LRC carries real per-word stamps and is used as such; plain LRC and
SRT give line times only, so their words are spread and marked
`measured=False`.

* **Return type:**
  [`TimedText`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.TimedText)

### muvid.lyricvid.timed_text.from_words(words, , duration=0.0, line_gap_s=0.9, source='words')

Build from a flat `(text, start, end)` stream, splitting lines on gaps.

The gap heuristic is deliberately simple and deliberately visible: a real
lyrics document is always better, and when one exists the other builders
use it instead of guessing.

* **Return type:**
  [`TimedText`](_autosummary/muvid.lyricvid.timed_text.html.md#muvid.lyricvid.timed_text.TimedText)


# _autosummary/muvid.lyricvid.tools.html.md

# muvid.lyricvid.tools

The SSOT verbs for the lyric-video subgenre.

Plain functions, JSON-able arguments in, JSON-able `dict` out, and
deliberately agnostic about CLI/MCP/HTTP/agent — following `ir.tools`, which
muvid’s design record already names as the reference for this shape. The CLI
(`python -m muvid.lyricvid`), the MCP tools, the shipped skill and a
production frontend all call *these*, so there is one implementation and one
place a behaviour changes.

Nothing here imports a renderer, an LLM client or numpy at module scope, so
`import muvid.lyricvid.tools` stays cheap and safe.

### Functions

| [`catalog`](_autosummary/muvid.lyricvid.tools.html.md#muvid.lyricvid.tools.catalog)()                                         | Every installed subgenre, without importing any renderer.              |
|----------------------------------------------------------------------------------------------------|------------------------------------------------------------------------|
| [`vocabulary`](_autosummary/muvid.lyricvid.tools.html.md#muvid.lyricvid.tools.vocabulary)()                                      | The closed vocabularies a treatment may draw on.                       |
| [`treatment_schema`](_autosummary/muvid.lyricvid.tools.html.md#muvid.lyricvid.tools.treatment_schema)()                                | JSON Schema for a treatment spec — also the model's output constraint. |
| [`analyze_song`](_autosummary/muvid.lyricvid.tools.html.md#muvid.lyricvid.tools.analyze_song)(audio, \*[, lyrics, subtitles, ...]) | Measure a song's words and report what a director needs to know.       |
| [`propose_treatments`](_autosummary/muvid.lyricvid.tools.html.md#muvid.lyricvid.tools.propose_treatments)(audio, \*[, lyrics, ...])      | Propose `n` treatments, ranked, each with its rationale.               |
| [`validate_treatment`](_autosummary/muvid.lyricvid.tools.html.md#muvid.lyricvid.tools.validate_treatment)(treatment)                     | Validate a treatment, and return the repaired version alongside.       |
| [`render_lyric_video`](_autosummary/muvid.lyricvid.tools.html.md#muvid.lyricvid.tools.render_lyric_video)(audio, output, \*[, ...])      | Render a lyric video.                                                  |
| [`export_captions`](_autosummary/muvid.lyricvid.tools.html.md#muvid.lyricvid.tools.export_captions)(timed_text, output_dir, ...)      | Write one caption file per language for a saved timing (muvid#145).    |

### muvid.lyricvid.tools.analyze_song(audio, , lyrics=None, subtitles=None, project=None, aligner=None, max_lines=40)

Measure a song’s words and report what a director needs to know.

Returns the section/line structure, the duration, the words-per-second the
treatment has to keep up with, and — importantly — whether the word times
were **measured** or interpolated from line times. A caller that ignores
that flag will ship a video that looks subtly out of sync.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### muvid.lyricvid.tools.catalog()

Every installed subgenre, without importing any renderer.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> c = catalog()
>>> 'lyric-video' in [s['slug'] for s in c['subgenres']]
True
```

### muvid.lyricvid.tools.export_captions(timed_text, output_dir, tracks, , offset_s=0.0, lead_s=None, min_s=None, tail_s=None, stem='captions', fmt='srt')

Write one caption file per language for a saved timing (muvid#145).

`timed_text` is a TimedText JSON file (e.g. `glyphs`’ output);
`tracks` maps a BCP-47 tag to a named transform (`"hepburn"`,
`"original"`) or a `{line text: caption}` mapping. `offset_s` shifts
every caption — by a title card put in front of the video, say. Returns
`{"tracks": {language: {"path", "n_captions"}}}`; each path goes straight
into `yb.youtube.CaptionTrack(path, language)`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### muvid.lyricvid.tools.propose_treatments(audio, , lyrics=None, subtitles=None, project=None, n=3, title='', reference_image=None, use_llm=False, model=None)

Propose `n` treatments, ranked, each with its rationale.

`use_llm=False` (the default) runs the heuristic director: no network, no
API key, no cost. `use_llm=True` asks a model, and the returned payload
then carries a `cost` block — unknown cost is reported as unknown, never
as zero.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### muvid.lyricvid.tools.render_lyric_video(audio, output, , lyrics=None, subtitles=None, project=None, timed_text=None, treatment=None, renderer='auto', title='', persona=None, aligner=None, width=1920, height=1080, fps=30, workdir=None, captions=None)

Render a lyric video. The one verb that produces a file.

`captions` (`{language: transform}`, or that as JSON) also writes one
`.srt` per language beside it — see [`export_captions()`](_autosummary/muvid.lyricvid.tools.html.md#muvid.lyricvid.tools.export_captions).

Everything else in this module exists so that a caller can decide *what* to
render before paying for it.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### muvid.lyricvid.tools.treatment_schema()

JSON Schema for a treatment spec — also the model’s output constraint.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> treatment_schema()['type']
'object'
```

### muvid.lyricvid.tools.validate_treatment(treatment)

Validate a treatment, and return the repaired version alongside.

A treatment that is *nearly* right is repaired rather than rejected, because
a mechanical substitution renders something good now where a retry costs a
round trip and may fail the same way. Every substitution is reported.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> r = validate_treatment({'scenes': [{'archetype': 'swirl'}]})
>>> r['valid'], r['repairs']
(False, ["scenes[0].archetype 'swirl' -> 'one_word_centred'"])
```

### muvid.lyricvid.tools.vocabulary()

The closed vocabularies a treatment may draw on.

One copy, read by the prompt, the JSON Schema, the UI and the docs.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> v = vocabulary()
>>> sorted(v)[:2]
['archetypes', 'cases']
```


# _autosummary/muvid.mcp.footage_tools.html.md

# muvid.mcp.footage_tools

MCP tools for the footage-aligned `music_video` genre (thorwhalen/reelee#229).

Module-level tool functions (referenced `muvid.mcp.footage_tools:<name>`) a host
aggregates via [`muvid.mcp.register_tools()`](_autosummary/muvid.mcp.html.md#muvid.mcp.register_tools). All ffmpeg + numpy only, no AI/keys.

**A transport, not an implementation.** The operations live in
[`muvid.footage.service`](_autosummary/muvid.footage.service.html.md#module-muvid.footage.service); each tool here resolves `project_id` to the caller’s
stateful [`FootageWorkspace`](_autosummary/muvid.footage.workspace.html.md#muvid.footage.workspace.FootageWorkspace) project (the caller comes
from the OAuth token), calls the operation, and turns its
[`FootageError`](_autosummary/muvid.footage.errors.html.md#muvid.footage.errors.FootageError) into a fastmcp `ToolError`. What stays here
is what only this surface does: fetching a URL (SSRF-guarded, size/time-bounded, streamed
to disk — the service takes a local file), expanding a shared folder, the per-caller
project listing, and the download claim on a render.

The tool list is derived from the operations catalogue (`muvid.mcp._footage_ops`):
operations without a hand-written tool here get a GENERATED one, `footage_<op>`, built
at import from the operation’s own signature and docstring (the named-edit operations:
`footage_set_offset`, `footage_save_edit`, `footage_set_cut` …).

Workflow: `create_project(genre='music_video')` → `set_song` → `add_footage` ×N →
`align_footage` → (`footage_timeline` to inspect) → `propose_edit(save=true)` →
`footage_set_cut` … → `footage_render` (or `assemble_music_video` in one call).
Lifecycle around it (muvid#22): `list_music_video_projects` finds a project whose id
was lost, and `remove_footage` takes a clip back out — which invalidates the
alignment, exactly as `set_song` does.

### Functions

| [`add_footage`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.add_footage)(project_id, \*, url[, name])           | Add a footage video clip from an http(s) URL (a recording of the song).                                                                                                                                                                                                 |
|-----------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`add_footage_folder`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.add_footage_folder)(project_id, \*, url[, ...])     | Add EVERY clip in a shared folder (Drive / Dropbox / OneDrive) in one call.                                                                                                                                                                                             |
| [`align_footage`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.align_footage)(project_id, \*[, keep_declared])     | Align every uploaded clip to the song by audio, and persist the result.                                                                                                                                                                                                 |
| [`assemble_music_video`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.assemble_music_video)(project_id, \*[, ...])        | Assemble the music video — auto (a selection `strategy`) or an explicit `edl`.                                                                                                                                                                                          |
| [`beat_grid`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.beat_grid)(project_id)                              | The song's beat grid — tempo and beat instants on the song timeline — WITHOUT running the scoring job.                                                                                                                                                                  |
| [`footage_beat_signals`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_beat_signals)(project_id, \*[, ...])        | Where the beat is in the song or in one video — CONTINUOUS signals, to look at, threshold and bend, not only beat instants.                                                                                                                                             |
| [`footage_clear_offset`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_clear_offset)(project_id, \*, clip_id)      | Forget where I placed this video: remove a hand-declared offset, so the next `align_footage` measures the clip by its audio instead.                                                                                                                                    |
| [`footage_delete_edit`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_delete_edit)(project_id, \*, edit_id)       | Delete a saved edit.                                                                                                                                                                                                                                                    |
| [`footage_editor_document`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_editor_document)(project_id)                | The project as lacing-native standoff annotations, for a multitrack editor.                                                                                                                                                                                             |
| [`footage_edits`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_edits)(project_id)                          | The saved edits, oldest first: each one's `edit_id`, `name`, how it was made, how many cuts it has, and `problem` — why it would not validate against the current alignment (`null` when it does).                                                                      |
| [`footage_edl_from_annotations`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_edl_from_annotations)(project_id, \*, ...)  | The DECISION tier's annotations, turned back into an `edl=` argument.                                                                                                                                                                                                   |
| [`footage_explain_edit`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_explain_edit)(project_id, \*, edit_id)      | Why each cut of an edit is where it is — the post-mortem, in plain words.                                                                                                                                                                                               |
| [`footage_filmstrip`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_filmstrip)(project_id, \*, clip_id)         | One video's filmstrip (the same record `footage_filmstrips` gives per clip, with its `clip_id` and `fps`).                                                                                                                                                              |
| [`footage_filmstrips`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_filmstrips)(project_id)                     | Every video's filmstrip — thumbnails to draw each camera's lane.                                                                                                                                                                                                        |
| [`footage_fit_to_beat`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_fit_to_beat)(project_id, \*, edit_id)       | Fit the moves to the beat: for each cut that shows a video in the part of the song the edit covers (its span) — or the cuts at `indices` — find the slip and speed that put its video's movement accents on the song's beat, and apply it where the evidence is strong. |
| [`footage_get_edit`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_get_edit)(project_id, \*, edit_id)          | One saved edit: its cut list (`edl`, every span of the song, gaps as `clip_id: null`), its name and history, and a `coverage` report.                                                                                                                                   |
| [`footage_looks`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_looks)(project_id)                          | The looks a cut can take — camera moves (punch in, slow push, slow pull, pans) and grades (vivid, black and white, posterize, cartoon) — each with its `params_schema`.                                                                                                 |
| [`footage_merge_cut`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_merge_cut)(project_id, \*, edit_id, index)  | Join cut `index` to its neighbour: the neighbour (`into` "previous" or "next") takes over its span, so the neighbour's video must cover it.                                                                                                                             |
| [`footage_peaks`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_peaks)(project_id, \*[, n])                 | The song's waveform, to draw under the timeline: `n` equal slices of the song, each the loudest moment in it (mono), scaled so the loudest slice is 1.0.                                                                                                                |
| [`footage_redo_edit`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_redo_edit)(project_id, \*, edit_id)         | Redo the change `footage_undo_edit` last took back.                                                                                                                                                                                                                     |
| [`footage_rename_edit`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_rename_edit)(project_id, \*, edit_id, name) | Give an edit a new name — what the edit picker and the renders made from it show.                                                                                                                                                                                       |
| [`footage_render`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_render)(project_id, \*, edit_id[, ...])     | Render a SAVED edit (`propose_edit(save=true)` / `footage_save_edit`) into a music video.                                                                                                                                                                               |
| [`footage_renders`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_renders)(project_id)                        | The finished videos, newest first: each one's `render_id`, speakable `ref`, the `edit_id` it was made from, its `label`, canvas, `ok`, the number of `warnings`, and `artifact_id` to play it by when the project is hosted.                                            |
| [`footage_replace_edit`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_replace_edit)(project_id, \*, edit_id, edl) | Replace a saved edit's whole cut list — the power tool for rewriting an edit at once.                                                                                                                                                                                   |
| [`footage_save_edit`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_save_edit)(project_id, \*, edl[, ...])      | Save a cut list as a new named edit.                                                                                                                                                                                                                                    |
| [`footage_set_cut`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_set_cut)(project_id, \*, edit_id, index)    | Change one cut of a saved edit (`index` is its position in `footage_get_edit`'s edl).                                                                                                                                                                                   |
| [`footage_set_has_song`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_set_has_song)(project_id, \*, clip_id, ...) | Say whether a video has the song in its own sound — overriding what listening found.                                                                                                                                                                                    |
| [`footage_set_offset`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_set_offset)(project_id, \*, clip_id, ...)   | Place one video on the song BY HAND: the song time at which the video's own first frame plays (negative = the video starts before the song does).                                                                                                                       |
| [`footage_set_span`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_set_span)(project_id, \*, edit_id, ...)     | Choose which part of the song the video covers — where it starts and ends.                                                                                                                                                                                              |
| [`footage_split_cut`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_split_cut)(project_id, \*, edit_id, at_s)   | Split the cut playing at song time `at_s` into two cuts of the same video.                                                                                                                                                                                              |
| [`footage_status`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_status)(project_id)                         | Your project's song, clips, alignment summary, and renders.                                                                                                                                                                                                             |
| [`footage_timeline`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_timeline)(project_id)                       | The coverage map: which clips cover which spans of the song (overlaps shown).                                                                                                                                                                                           |
| [`footage_undo_edit`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.footage_undo_edit)(project_id, \*, edit_id)         | Undo the last change to a saved edit (a cut changed, split, joined, the span, a whole replacement — by a person or by the assistant).                                                                                                                                   |
| [`list_music_video_projects`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.list_music_video_projects)()                        | List YOUR music_video (footage) projects, newest-modified first.                                                                                                                                                                                                        |
| [`list_strategies`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.list_strategies)()                                  | The selection strategies available for full-auto assembly.                                                                                                                                                                                                              |
| [`propose_edit`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.propose_edit)(project_id, \*[, strategy, ...])      | Propose an EDL **without rendering it** — the cheap half of assembly.                                                                                                                                                                                                   |
| [`remove_footage`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.remove_footage)(project_id, \*, clip_id)            | Remove one footage clip from the project — its stored file and its entry.                                                                                                                                                                                               |
| [`set_song`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.set_song)(project_id, \*, url)                      | Set the project's fixed clean song from an http(s) URL.                                                                                                                                                                                                                 |

### muvid.mcp.footage_tools.add_footage(project_id, , url, name='')

Add a footage video clip from an http(s) URL (a recording of the song). Free.

Accepts a **share link** as well as a direct media URL. Fetched server-side (streamed to
disk; SSRF-guarded, size/duration-capped) and asserted to be media before anything is
stored. Returns the assigned `clip_id`. Re-run `align_footage` after adding clips.

For a whole shoot in one folder, use [`add_footage_folder()`](_autosummary/muvid.mcp.footage_tools.html.md#muvid.mcp.footage_tools.add_footage_folder) — a folder link holds
many files and is refused here by name.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.add_footage_folder(project_id, , url, name_prefix='')

Add EVERY clip in a shared folder (Drive / Dropbox / OneDrive) in one call. Free.

A shoot is a folder, not a file — this is the natural unit for music-video footage. The
folder link is normalised and downloaded as a single archive server-side, then expanded
into one clip per media member.

Members that are skipped — wrong type, over the per-clip size limit, or past the project
clip cap — are NAMED in `skipped` with the reason. Nothing is silently truncated: a
coverage decision made on quietly-shortened input is worse than one made on a short list.

Returns the added clips and the skipped members. Run `align_footage` afterwards.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.align_footage(project_id, , keep_declared=True)

Align every uploaded clip to the song by audio, and persist the result. Free.

Returns each clip’s offset, a confidence in [0,1], its `support` (the fraction of
the clip that agrees on that offset, `null` when the aligner took a single
whole-clip measurement), and its coverage of the song, plus two lists:

- `low_confidence` — clips that matched weakly, for reporting;
- `unreliable` — clips whose offset the aligner will NOT vouch for. These stay in
  the project and stay addressable, and the auto path simply prefers other clips
  over them: a span another clip covers goes to that clip, and a span only an
  unreliable clip covers is left as a gap and reported in `coverage.excluded`
  (muvid#88). `assemble_music_video` still REFUSES an explicit `edl` that cuts
  to one, and still refuses an auto edit when NO clip is trustworthy, unless called
  with `allow_unreliable=true` — because a wrong offset does not fail, it renders
  a video out of sync with the song (muvid#59). Re-align, accept the smaller edit,
  or opt in deliberately;
- `no_consensus` — clips too short to be put to a vote at all (under about 4.5 s).
  **A clip in this list can be marked reliable and still be wrong**, and no other
  field will say so: its offset rests on one measurement, judged by a confidence
  score that does not rank correctness in this band — measured on the muvid#59
  shoot, the WRONG offset scored highest of three (0.834 against 0.566 and 0.621),
  and on a repeating fixture a 4.4 s clip landing 8 s out is vouched at 0.381.
  Nothing is refused on this basis, because refusing would take the correct short
  clips with it. So if a short clip looks out of sync in the render, this list is
  the first place to look — and muvid#91 is where that trade-off is being decided.

Run this after adding/removing clips and before assembling.

A clip placed by hand (`footage_set_offset`) is left as placed and named in
`kept_declared`; `keep_declared=false` forgets those first
(`footage_clear_offset`) and measures every clip.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.assemble_music_video(project_id, , strategy='', edl=None, preset='', weights=None, config=None, canvas='', allow_unreliable=False, span=None)

Assemble the music video — auto (a selection `strategy`) or an explicit `edl`. Free.

- `edl`: an explicit edit — a list of `{song_start, song_end, clip_id}` spans. Must
  be in order, non-overlapping, and each within its clip’s coverage. A span with no
  footage is an explicit gap entry (`clip_id: null`); spans of the song your entries
  do not reach (head, tail, interior holes) are gap-filled automatically and named in
  the `coverage` report.
- each `edl` entry may also carry `transition`:
  `{"duration_s": 0.4, "curve": "fade"}` — blend IN from the previous entry instead
  of hard-cutting. The blend is CENTRED on the boundary, so a beat-snapped cut stays
  on the beat: each side supplies `duration_s/2` of source beyond its own span, and
  both must actually have it (a gap side always does — it fades from black). Rejected,
  not ignored, if it is on the FIRST entry (nothing to blend from), names a curve
  outside `fade`/`fadeblack`/`fadewhite`/`dissolve`/`wipe*`/`slide*`/
  `smooth*`/`circleopen`/`circleclose`, is under 0.04 s, or does not fit. Omit
  it for a hard cut — the default, and what every entry without the key means.
- each `edl` entry may also carry `crop` / `crop_end`
  (`{"x":0,"y":0.25,"w":1,"h":0.5}`, fractions of the SOURCE frame — the
  framing decision; `crop_end` pans that window and must be the same size)
  and `look` (ONE linear ffmpeg filter chain — the grade, LUT, posterise or
  in-shot punch-in, applied to the delivery CANVAS after scaling).
  `look` is an **allowlist**: only the filters named by
  `muvid.footage.edl.LOOK_FILTERS` are accepted, and a chain naming
  anything else — a
  second container (`movie=`), a filter that writes a file
  (`metadata=…:file=`, `deshake=filename=`), a pad label, a graph
  separator, or an unclosed quote — is refused by name at `validate_edl`,
  not discovered as an ffmpeg side effect. Its output frame is also bounded:
  a `scale`/`pad`/`zoompan` size must be a plain pixel count (not
  `iw*80`, not `-1`, not `hd720`) and no more than
  `muvid.footage.edl.MAX_LOOK_SCALE` times the render canvas — frame size
  is memory, and `scale=8000:8000` costs 328 MB against 19 MB for a look
  that stays at canvas size. On those four filters only options muvid has
  measured may be set at all — `scale` w/h/s/size + `flags`, `pad`
  w/h/x/y + `color`, `crop` w/h/x/y, all of `zoompan` — because
  `pad=aspect` and `scale=force_original_aspect_ratio` move the frame
  while declaring no size a bound can read (590 MB and 941 MB measured on a
  1920x1080 canvas). Compile one with `muvid.footage.look`
  (`punch_in` / `motion` / `stylize`) rather than hand-writing it.
- an entry carrying a MOVING look (a punch-in, a pan) should also set
  `look_time_varying: true`. It changes no pixels; it puts a line in the
  reply’s `warnings` when a blended boundary restarts the move’s ramp,
  which it does because the blend is a separate seek (muvid#73). Leave it
  off — the default — for a grade, a LUT or a posterise, which never read the
  clock.
- an entry may carry `slip_s` (seconds, at most one beat either way,
  `muvid.footage.edl.SLIP_MAX_S`): the cut shows its video that much LATER
  (negative: earlier) without moving on the song — a local correction on top
  of the clip’s alignment, to put a dancer on the beat. The clip must still
  hold the slipped span.
- an entry may carry `rate` (within `1 +- muvid.footage.edl.RATE_MAX_DEV`):
  the cut plays its video that much faster (`1.03`) or slower, consuming
  `span * rate` seconds of it; the song is untouched. Every optional field
  survives verbatim in the returned `edl`.
- `strategy='weighted'` (score-driven): the beat-snapped Viterbi selector reads the
  persisted score tracks (run `score_footage` first) and the selection config —
  `preset` (“energetic”/”contemplative”) and/or `weights` (per-metric) and/or
  `config` (`lambda_switch`/`l_min_s`/`l_max_s`/`boundary_mode`). Re-weighting
  is cheap: it re-selects from the SAME scores without re-scoring.
- otherwise **full-auto**: a registered alignment-only `strategy` (see
  `list_strategies`; default `best_confidence`) builds the edit from the alignments.
- `canvas`: render-time override (“landscape”/”portrait”/”square”) — the same edit
  re-rendered in another shape, no new project needed. Default: the project’s canvas.
- `span`: `[start_s, end_s]` — render only that stretch of the song (a trimmed
  edit): the `edl` is gap-filled within it, the video is that long, and the song is
  cut to it and faded out at the end when it stops before the song does. A render
  of a trimmed edit records its `span`; pass it back with its `edl` to reproduce
  it. Default: the whole song.
- **A clip the aligner will not vouch for costs its own spans, not the whole edit**
  (muvid#88). On the auto path the strategy prefers a vouched clip wherever one
  covers the span, so an untrustworthy clip is simply not chosen while any other
  clip covers that stretch of the song. Where it was the ONLY footage, the span is
  set aside rather than cut to: it renders as a gap and is named in
  `coverage.excluded` with the clip, the span, and the confidence/support/margin
  the verdict rests on. So a five-clip shoot with one bad clip still assembles.
  The refusal remains for the two cases it was built for: an explicit `edl` naming
  an unvouched clip (you chose it), and an auto edit where NO clip is trustworthy —
  a black video reported as success is the failure this exists to prevent, so that
  still comes back as an error naming every clip.
- `allow_unreliable`: render even on clips whose alignment the aligner will not
  vouch for. **Off by default and it should stay off**: a wrong offset does not
  fail, it delivers a video out of sync with the song, so the refusal is the only
  thing standing between a bad measurement and a bad render (muvid#59). Set it when
  you have checked the offsets yourself, or when a slightly-off cut beats no cut at
  all — it also turns OFF the set-aside above, since someone who opted in wants that
  footage rendered rather than gapped. `align_footage` names the clips this
  applies to in its `unreliable` list.

The video is EXACTLY the song’s duration: each cut is trimmed at its aligned in-point,
scaled onto the canvas (padded, never stretched), gaps render black, and the CLEAN
song audio runs under it all. Returns the render + the same coverage report
`propose_edit` gives.

\*\*Read the returned `warnings` list.\*\* It is always present and usually
empty, and it is what the render PLAN found: a transition that rounded to
zero frames at this fps, or a moving look on a blended boundary whose ramp
therefore restarts (muvid#73). Neither fails the render — `ok` stays true —
so this list is the only place either one is ever said. It exists because
these findings used to be Python warnings on the server’s stderr, which a
remote caller has no access to.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.beat_grid(project_id)

The song’s beat grid — tempo and beat instants on the song timeline — WITHOUT
running the scoring job. Free.

`score_footage` computes this very grid as its first stage and then decodes every
clip through cv2 in the background, which is far too much to pay when all a caller
wants is where the beats are (thorwhalen/muvid#18 item 5) — a fixed-stride cut grid,
a check that the tempo came out right, a cut plan of its own. This is one call to
`mixing.audio.beat_grid` on the song alone (the “compute once on the master”
invariant: clips map to it through their offsets, never the other way round),
cached under the project as `scores/beat_grid.json` keyed on `song_hash` so the
second call is a file read; a project that has already been scored is served from
that run’s manifest instead. `source` says which (`computed` | `cache` |
`scores`).

Needs the `scoring` extra (`mixing[beats]`, i.e. librosa); without it the reply
is a clean error naming the install, never a traceback. Needs a song
(`set_song`); no alignment is required.

Returns `tempo_bpm`, `beats` (seconds, ascending), `n_beats`,
`song_duration` (so a caller can close the last bar) and `source`.
`downbeats` is present only when the estimator measured any — the librosa
backend has no downbeat tracker, and an empty list would read as “this song has
no downbeats”, a measurement nobody made (gate, don’t zero).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_beat_signals(project_id, , source='song', max_points=1000)

Where the beat is in the song or in one video — CONTINUOUS signals, to look at,
threshold and bend, not only beat instants.

`source` is `"song"` or a clip id. The song gets its sound (`audio_onset`: the
onset envelope the beat grid is estimated from). A video gets its own soundtrack’s
`audio_onset` when it has one, and three visual signals: `motion` (how much the
people in the picture move, the camera’s own move taken out), `motion_stops` (moves
stopping dead and turning — the movement accents) and `motion_stops_local` (the same,
place by place).

Each signal is in the media’s OWN time: sample `i` is at `t0 + i * hop_s` s of the
song, or of the clip (song time `offset + t`). Values are unnormalised, with
`min`, `max` and `p99` beside them; `None` is a sample that was not measured.
`max_points` pools each signal to at most that many samples by their maximum, so
a peak survives (0 = every sample; an editor drawing it wants that).

Measured once per media and kept (a video’s first call reads every frame and takes
tens of seconds; a second call for the same video waits for the first rather than
measuring again). Needs the `scoring` extra.

Returns `{source, kind: audio|video, duration_s, tempo_bpm, beats, signals:
{name: {name, label, domain, t0, hop_s, n, min, max, p99, values}}}` — `beats`
and `tempo_bpm` are the soundtrack’s (`[]` / `None` without one).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_clear_offset(project_id, , clip_id)

Forget where I placed this video: remove a hand-declared offset, so the next
`align_footage` measures the clip by its audio instead.

Only a DECLARED offset can be forgotten (a measured one is replaced by aligning
again); an unknown clip, or one with no declared offset, is refused. Until `align_footage`
runs again the clip has no place on the song, and footage scores made with the old
offset are dropped.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_delete_edit(project_id, , edit_id)

Delete a saved edit. Videos already rendered from it are kept (they still name
the edit they came from). An unknown `edit_id` is refused, naming the edits.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_editor_document(project_id)

The project as lacing-native standoff annotations, for a multitrack editor. Free.

One tier per clip (its `clip-alignment/v1` +, once scored, its
`clip-score-track/v1` curves) plus a `DECISION` tier holding the current default
proposal as `music-video-edl/v1` entries — everything referenced to the song by
content hash, on one shared song-time axis (thorwhalen/reelee-web#203). Needs the
`editor` extra (`lacing`); requires alignment (run `align_footage` first).

After a human edits the DECISION tier, feed its annotations back to
`assemble_music_video` via `footage_edl_from_annotations`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_edits(project_id)

The saved edits, oldest first: each one’s `edit_id`, `name`, how it was made,
how many cuts it has, and `problem` — why it would not validate against the
current alignment (`null` when it does). `unreliable` names clips it cuts to
whose offsets rendering would refuse.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_edl_from_annotations(project_id, , annotations)

The DECISION tier’s annotations, turned back into an `edl=` argument. Free.

The timeline-to-EDL half: pass `footage_editor_document`’s `DECISION` tier
(after whatever an editor did to it) and get back plain
`{song_start, song_end, clip_id}` dicts, ready for `assemble_music_video(edl=...)`
or `propose_edit` — a faithful read, not a re-selection. Annotations referencing a
song other than this project’s are refused, not read (muvid#35), so a clipboard from
another project fails saying so instead of splicing in the wrong spans.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_explain_edit(project_id, , edit_id, start_s=None, end_s=None)

Why each cut of an edit is where it is — the post-mortem, in plain words.

For every cut: where it lands (on which beat of which bar, or how far off one),
how the picture changes (a hard cut, or a fade and where that fade really
starts), what picture it shows and why that stretch (its picture changes on the
beats inside the cut; a photo’s camera move; a video synced where it was
filmed), and `flags` for the ones worth a look (`straddles_the_beat`,
`off_the_beat`, `long_hold`, `repeated_stretch`). Plus a `summary` of the
whole edit (fades vs cuts, the song’s pace and shape, the longest shot).

Every clock time in a sentence is listed in `times` (`{label, s}`) so a screen
can link it to the player. `start_s`/`end_s` keep only the cuts overlapping
that part of the song (the summary stays whole). Explains the edit as it stands
now, hand changes included. Free; reads only.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_filmstrip(project_id, , clip_id)

One video’s filmstrip (the same record `footage_filmstrips` gives per clip, with its
`clip_id` and `fps`).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_filmstrips(project_id)

Every video’s filmstrip — thumbnails to draw each camera’s lane.

Per clip: sprite sheets of `frame_w` x `frame_h` frames (`cols` x `rows` to
a sheet, left to right then down), sampled at `fps` frames per second of the
CLIP’s own time — frame `i` is the clip at `i / fps` s, which sits at song time
`offset + i / fps`. Each sheet is an `artifact_id` (when the project is hosted),
with its `first_frame` and `n_frames`. Made once per clip and kept; a clip that
has none yet takes a few seconds the first time.

Returns `{fps, clips: {clip_id: {duration_s, n_frames, frame_w, frame_h, sheets:
[{artifact_id, cols, rows, first_frame, n_frames}]}}}`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_fit_to_beat(project_id, , edit_id, indices=None, min_z=2.5, apply=True)

Fit the moves to the beat: for each cut that shows a video in the part of the song
the edit covers (its span) — or the cuts at `indices` — find the slip and speed
that put its video’s movement accents on the song’s beat, and apply it where the
evidence is strong.

Per cut, every slip within +-0.25 s (a frame at 60 fps apart) and every speed from
x0.92 to x1.08 (1 % apart) is tried; the best is scored against a null of the same
search on the video’s accents with their relation to the beat destroyed
(`muvid.footage.beat_fit`), giving a `z`. A cut changes only when `z >= min_z`
AND the fit is better than its current timing — and the video still holds the
footage it then reads. Cuts shorter than three beats, gaps and videos with nothing
measurable are left alone, each with its reason. `apply=False` reports without
changing anything. All the changes are ONE step of the edit’s history, so one
`footage_undo_edit` takes them all back.

**How much to expect.** On three phone videos of a crowd dancing, the accents locked
to the beat on one of them only; each video’s own SOUND locked on all three. So on
footage like that most cuts will be left as they are, which is the right answer when
the picture does not show the beat. It works best on a clearly visible dancer.

Needs the `scoring` extra: the first call measures each video’s movement (tens of
seconds a video; kept for next time). Returns the edit (`footage_get_edit`’s shape) plus
`fit`: `cuts` (`{index, slip_s, rate, z, applied, reason}` per cut looked at),
`fitted`, `kept` and `min_z`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_get_edit(project_id, , edit_id)

One saved edit: its cut list (`edl`, every span of the song, gaps as
`clip_id: null`), its name and history, and a `coverage` report. Cut indexes in
`footage_set_cut`/`footage_merge_cut` refer to positions in this `edl`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_looks(project_id)

The looks a cut can take — camera moves (punch in, slow push, slow pull, pans)
and grades (vivid, black and white, posterize, cartoon) — each with its
`params_schema`. Give one to `footage_set_cut` as `look={"name": ..., **params}`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_merge_cut(project_id, , edit_id, index, into='previous')

Join cut `index` to its neighbour: the neighbour (`into` “previous” or
“next”) takes over its span, so the neighbour’s video must cover it. The joined
cut keeps the neighbour’s video, framing and look. Returns the changed edit.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_peaks(project_id, , n=2000)

The song’s waveform, to draw under the timeline: `n` equal slices of the song,
each the loudest moment in it (mono), scaled so the loudest slice is 1.0.

Returns `{duration_s, n, peaks: [0..1, ...]}`; slice `i` covers song time
`i * duration_s / n` to `(i + 1) * duration_s / n`. Kept per song and `n`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_redo_edit(project_id, , edit_id)

Redo the change `footage_undo_edit` last took back. A new change after an undo
discards what could be redone. Returns the edit as it now is.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_rename_edit(project_id, , edit_id, name)

Give an edit a new name — what the edit picker and the renders made from it show.

Only the name changes; the cuts, the span and the edit’s id stay as they are, and
the rename can be undone like any other change. Returns the edit.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_render(project_id, , edit_id, canvas='', allow_unreliable=False)

Render a SAVED edit (`propose_edit(save=true)` / `footage_save_edit`) into a
music video. Free, minutes.

Same render, same refusal and same reply as `assemble_music_video` with that edit’s
cut list as `edl` — plus `edit_id`, so the video says which edit it came from.
`canvas` re-renders the same edit as “landscape”/”portrait”/”square”. Refused when
the edit cuts to a clip whose offset the aligner will not vouch for, unless
`allow_unreliable` (see `assemble_music_video`). Read the returned `warnings`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_renders(project_id)

The finished videos, newest first: each one’s `render_id`, speakable `ref`,
the `edit_id` it was made from, its `label`, canvas, `ok`, the number of
`warnings`, and `artifact_id` to play it by when the project is hosted.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_replace_edit(project_id, , edit_id, edl)

Replace a saved edit’s whole cut list — the power tool for rewriting an edit at
once. The new list is checked exactly as `footage_save_edit` checks one; on refusal the
edit is left as it was. The previous list is not kept.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_save_edit(project_id, , edl, name='', how_made='by hand', edit_id='', span=None)

Save a cut list as a new named edit.

`edl` is a list of `{song_start, song_end, clip_id}` spans (plus optional
`transition`/`crop`/`crop_end`/`look`/`look_time_varying`), in the same
form `footage_get_edit` returns and `propose_edit` produces. Holes are filled with gap
entries; the list is checked (order, overlap, every span inside its clip’s coverage)
and refused with the reason if it does not hold. `edit_id` fixes the id (an
existing one is refused — use `footage_replace_edit`). `span` (`[start_s, end_s]`)
makes the edit cover only that part of the song — its render is that long, the song
cut to it; default the whole song. Returns the saved edit.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_set_cut(project_id, , edit_id, index, clip_id=None, song_start=None, song_end=None, look=None, look_time_varying=None, slip_s=None, rate=None)

Change one cut of a saved edit (`index` is its position in `footage_get_edit`’s edl).

- `clip_id`: show another video over this span (`""` makes it a gap). The new
  video must cover the span. Its framing (`crop`) is dropped, since it was chosen
  for the old video’s frame; its `look` is kept. A video cut to the music (or a
  photo) is shown from its start; a video with the song in it, where it was filmed.
- `song_start` / `song_end`: move the cut’s boundaries. The neighbouring cut’s
  boundary moves with it, so the edit stays one continuous timeline; a move that
  would swallow a neighbour whole is refused (join them with `footage_merge_cut`).
- `look`: a NAMED look from `footage_looks` — `{"name": "slow_push", "zoom": 1.08}`,
  compiled for this cut’s length and the project’s canvas and kept on the cut as
  `look_spec` (with every parameter’s value) so it can be shown and changed —
  or, for power users,
  one raw ffmpeg filter chain (allowlisted; set `look_time_varying` for one that
  moves). `""` removes it.
- `slip_s`: show a slightly different moment of the same video over the same
  span — `0.1` reads the footage 0.1 s later — to put a dancer’s moves on the
  beat where the clip’s alignment is right overall but a little off here. At
  most one beat either way (`SLIP_MAX_S`); `0` removes it. A new video
  (`clip_id`) starts unslipped.
- `rate`: play this cut’s video a little faster or slower — `1.03` shows 3 %
  more footage over the same span, so the moves run 3 % faster (the song is never
  touched). Within `1 +- RATE_MAX_DEV`; `1` removes it. The video must hold the
  footage the cut then reads. A new video starts at speed 1.

Parameters left out are unchanged. The changed edit is checked and saved; returns it.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_set_has_song(project_id, , clip_id, has_song)

Say whether a video has the song in its own sound — overriding what listening found.

`"yes"`: it is a recording of the song (a concert, a dance); it is synced to the
song and never cut freely to the music. `"no"`: it is not (a day out, b-roll); it
is always cut to the music. `"auto"` (the default) believes the listening: a
video `align_footage` placed confidently is synced, any other is cut to the music. A
photo is always cut to the music. Like a hand-placed offset, the choice survives
listening again. Takes effect at the next `propose_edit`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_set_offset(project_id, , clip_id, offset_s)

Place one video on the song BY HAND: the song time at which the video’s own
first frame plays (negative = the video starts before the song does).

Use it when `align_footage` gets a clip wrong — on long, repetitive songs it can land a
whole chorus away (muvid#59) — or when you already know the offset. The offset is
recorded as `source: "declared"` and trusted for rendering (a person vouched for
it); how much of the song the clip covers is computed from the two durations.
`align_footage` keeps it unless told otherwise. Changing an offset makes the footage
scores stale, so they are dropped.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_set_span(project_id, , edit_id, start_s, end_s)

Choose which part of the song the video covers — where it starts and ends.

**Trimming loses nothing.** The span is a window on the edit, not a cut of it: every
cut is kept whole, and only what is RENDERED is limited to `start_s`..\`\`end_s\`\`
(the song cut to match, faded out at the end when it stops before the song does;
cuts across an edge are shortened in the render only). Widening the span again —
`start_s=0` and `end_s` = the song’s length is the whole song — brings back
exactly what was there. Returns the edit, with its `span`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_split_cut(project_id, , edit_id, at_s)

Split the cut playing at song time `at_s` into two cuts of the same video.

The two halves keep the cut’s video, framing and look; a moving framing (a pan) is
divided where it was at `at_s`. Refused on a boundary (nothing to split). Returns
the changed edit; `changed` is the index of the second half.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_status(project_id)

Your project’s song, clips, alignment summary, and renders. Free.

Also: each clip’s offset and whether it was measured or declared (`alignments`),
the saved `edits`, and `next_step` — the operation that moves the project on.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_timeline(project_id)

The coverage map: which clips cover which spans of the song (overlaps shown). Free.

The surface for choosing which parts to use before `assemble_music_video`. Built from
the persisted alignment (run `align_footage` first).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.footage_undo_edit(project_id, , edit_id)

Undo the last change to a saved edit (a cut changed, split, joined, the span, a
whole replacement — by a person or by the assistant). Returns the edit as it now
is; `footage_redo_edit` puts the change back. Up to 100 changes are kept per edit.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.list_music_video_projects()

List YOUR music_video (footage) projects, newest-modified first. Free.

The way back to a lost `project_id` (muvid#22): `list_projects` spans both
muvid genres but says nothing about a footage project’s progress, and every other
footage tool needs the id first. Each row carries where the project is in the
workflow — `has_song`, `n_clips`, `aligned`, `n_renders` — so the next call
reads off the listing without a `footage_status` per project:

- `aligned` is true only when the persisted alignment covers every current clip.
  An `add_footage` or `remove_footage` after `align_footage` makes it false
  again until you re-align.
- `n_renders` counts what `footage_status` lists under `renders`; `0` is a
  positive answer (“no cut yet”), never an omitted project.

`[]` means you have no footage projects; a visualizer project is not one and
shows up in `list_projects` instead.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.list_strategies()

The selection strategies available for full-auto assembly. Free.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.propose_edit(project_id, , strategy='', preset='', weights=None, config=None, save=False, name='', pace='')

Propose an EDL **without rendering it** — the cheap half of assembly. Free, seconds.

Selection and rendering are separate concerns, and only one of them costs an encode.
This returns the edit an `assemble_music_video` call *would* have produced, so a
caller can compare several strategies/weightings, read the coverage report, edit the
list by hand, and only then pay for a render — passing the chosen EDL straight back to
`assemble_music_video(project_id, edl=...)`.

Returns the `edl` (ready to feed back verbatim — spans the WHOLE song, with spans no
footage covers as explicit gap entries, `clip_id: null`, rendered as black), the
`strategy` actually used, and a `coverage` report naming every uncovered span of
the song and every segment that made the cut despite weak alignment. Same arguments as
`assemble_music_video`’s auto path, and the same edit it would build — including the
`coverage.excluded` recovery described there.

`save=true` also keeps it as a named edit (`name`, default “Edit N”) and returns
its `edit_id` — change it cut by cut with `footage_set_cut` /
`footage_split_cut` / `footage_merge_cut` and render it with `footage_render`.

**Footage without the song in it is cut to the music.** Only videos that are
recordings of the song are synced; every span they leave is filled from the other
videos, cut on the song’s beats with each video at the stretch whose picture changes
land on the beat (`music` in the reply). `roles` says how each clip was used;
overrule one with `footage_set_has_song`. Every video must have been listened to
(`align_footage`) or declared. `pace` (`slow`/`steady`/`driving`/
`frantic`) sets how often the cuts to the music come.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.remove_footage(project_id, , clip_id)

Remove one footage clip from the project — its stored file and its entry. Free.

Irreversible for the clip (re-add it from its URL if it was a mistake); existing
renders are untouched. Removal INVALIDATES the alignment and every persisted score
track, exactly as `set_song` does (muvid#22) — the alignment describes the clip set
it was measured on, and a removed clip’s offset must not outlive the clip. So run
`align_footage` again before `propose_edit` / `assemble_music_video`; until
then `footage_status` reports the project unaligned and this project’s
`aligned` flag in `list_music_video_projects` is false.

An unknown `clip_id` is refused, naming the clip ids the project holds, and a
refusal changes nothing on disk. Returns what was removed, the clips that remain,
and whether an alignment / score tracks were actually dropped.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.footage_tools.set_song(project_id, , url)

Set the project’s fixed clean song from an http(s) URL. Free.

Accepts a **share link** (Google Drive / Dropbox / OneDrive) as well as a direct media
URL — the link is normalised before fetching, and the downloaded bytes are checked to be
media, so a private-file sign-in page is refused with that diagnosis rather than stored.

This is the reference every uploaded clip is aligned to and whose audio the final
video uses. Replaces any previous song. Duration/size-capped.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)


# _autosummary/muvid.mcp.html.md

# muvid.mcp

muvid MCP server — the `music-visualizer` tool surface for a remote connector.

Optional subpackage (extra `muvid[mcp]`: `fastmcp` + `py2mcp` + `nw`).
`import muvid` never imports this. The connector references the tools by name via
[`TOOL_REFS`](_autosummary/muvid.mcp.html.md#muvid.mcp.TOOL_REFS); [`register_tools()`](_autosummary/muvid.mcp.html.md#muvid.mcp.register_tools) aggregates them onto a host FastMCP server
(the unified reelee AV connector, thorwhalen/muvid#3), and [`build_server()`](_autosummary/muvid.mcp.html.md#muvid.mcp.build_server)
assembles a standalone server for local (stdio) testing.

Almost every tool is **free**. The one exception is
`propose_lyric_treatments_ai` (`COSTED_TOOLS`), which calls an LLM to author a
lyric-video treatment — muvid’s first tool that spends money. It is a separate tool from
the free heuristic proposer because a host meters by tool NAME and cannot see an
argument, so a “free unless you pass a flag” tool is unmeterable by construction.
Its cost is reported as UNKNOWN rather than zero: muvid’s budget gate is conjunctive and
an unpriced call must force approval (muvid#47).

### Module Attributes

| [`TOOL_NAMES`](_autosummary/muvid.mcp.html.md#muvid.mcp.TOOL_NAMES)   | All tools this package exposes (all free).                                      |
|---------------------------------------------------------------|---------------------------------------------------------------------------------|
| [`FREE_TOOLS`](_autosummary/muvid.mcp.html.md#muvid.mcp.FREE_TOOLS)   | Alias — muvid has no costed tools.                                              |
| [`TOOL_REFS`](_autosummary/muvid.mcp.html.md#muvid.mcp.TOOL_REFS)    | Bare tool name → its `module:function` reference (tools live in three modules). |

### Functions

| [`register_tools`](_autosummary/muvid.mcp.html.md#muvid.mcp.register_tools)(server, \*[, prefix, include, ...])   | Register muvid's MCP tools onto an EXISTING FastMCP `server` — the aggregation seam for a host connector (the unified reelee connector, muvid#3).   |
|-------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|
| [`build_server`](_autosummary/muvid.mcp.html.md#muvid.mcp.build_server)(\*[, name, instructions, auth, ...])    | Assemble a standalone FastMCP server exposing muvid's tools (stdio dev/testing).                                                                    |
| [`current_email`](_autosummary/muvid.mcp.html.md#muvid.mcp.current_email)()                                      | The caller's identity for the in-flight tool call (raises if unauthenticated).                                                                      |
| [`token_email`](_autosummary/muvid.mcp.html.md#muvid.mcp.token_email)()                                        | The verified caller's email from the OAuth token (`email` claim, else `sub`).                                                                       |
| [`use_email`](_autosummary/muvid.mcp.html.md#muvid.mcp.use_email)(email)                                     | Bind the caller identity for the duration of the block (local/stdio/testing).                                                                       |
| [`data_root`](_autosummary/muvid.mcp.html.md#muvid.mcp.data_root)()                                          | The muvid data root: `$MUVID_DATA_HOME` or `~/.local/share/muvid`.                                                                                  |

### Classes

| [`VisualizerWorkspace`](_autosummary/muvid.mcp.html.md#muvid.mcp.VisualizerWorkspace)(email, root)   | A single caller's private visualizer area, addressed by `email`.   |
|-------------------------------------------------------------------------------------|--------------------------------------------------------------------|

### muvid.mcp.FREE_TOOLS *= ['list_visuals', 'list_projects', 'project_status', 'render_visualizer', 'footage_status', 'set_song', 'add_footage', 'remove_footage', 'align_footage', 'footage_set_offset', 'footage_clear_offset', 'footage_set_has_song', 'footage_timeline', 'beat_grid', 'footage_peaks', 'footage_beat_signals', 'footage_filmstrips', 'footage_filmstrip', 'list_strategies', 'propose_edit', 'footage_save_edit', 'footage_edits', 'footage_explain_edit', 'footage_get_edit', 'footage_replace_edit', 'footage_set_cut', 'footage_split_cut', 'footage_merge_cut', 'footage_fit_to_beat', 'footage_set_span', 'footage_rename_edit', 'footage_looks', 'footage_undo_edit', 'footage_redo_edit', 'footage_delete_edit', 'footage_render', 'footage_renders', 'footage_editor_document', 'add_footage_folder', 'assemble_music_video', 'list_music_video_projects', 'footage_edl_from_annotations', 'score_footage', 'footage_scores', 'footage_score_status', 'list_archetypes', 'analyze_song_lyrics', 'propose_lyric_treatments', 'validate_lyric_treatment', 'render_lyric_video', 'list_subgenres', 'render_subgenre']*

Alias — muvid has no costed tools.

### muvid.mcp.TOOL_NAMES *= ['list_visuals', 'list_projects', 'project_status', 'render_visualizer', 'footage_status', 'set_song', 'add_footage', 'remove_footage', 'align_footage', 'footage_set_offset', 'footage_clear_offset', 'footage_set_has_song', 'footage_timeline', 'beat_grid', 'footage_peaks', 'footage_beat_signals', 'footage_filmstrips', 'footage_filmstrip', 'list_strategies', 'propose_edit', 'footage_save_edit', 'footage_edits', 'footage_explain_edit', 'footage_get_edit', 'footage_replace_edit', 'footage_set_cut', 'footage_split_cut', 'footage_merge_cut', 'footage_fit_to_beat', 'footage_set_span', 'footage_rename_edit', 'footage_looks', 'footage_undo_edit', 'footage_redo_edit', 'footage_delete_edit', 'footage_render', 'footage_renders', 'footage_editor_document', 'add_footage_folder', 'assemble_music_video', 'list_music_video_projects', 'footage_edl_from_annotations', 'score_footage', 'footage_scores', 'footage_score_status', 'list_archetypes', 'analyze_song_lyrics', 'propose_lyric_treatments', 'propose_lyric_treatments_ai', 'validate_lyric_treatment', 'render_lyric_video', 'list_subgenres', 'render_subgenre']*

All tools this package exposes (all free). Bare names; a host may prefix them.

### muvid.mcp.TOOL_REFS *= {'add_footage': 'muvid.mcp.footage_tools:add_footage', 'add_footage_folder': 'muvid.mcp.footage_tools:add_footage_folder', 'align_footage': 'muvid.mcp.footage_tools:align_footage', 'analyze_song_lyrics': 'muvid.mcp.lyricvid_tools:analyze_song_lyrics', 'assemble_music_video': 'muvid.mcp.footage_tools:assemble_music_video', 'beat_grid': 'muvid.mcp.footage_tools:beat_grid', 'footage_beat_signals': 'muvid.mcp.footage_tools:footage_beat_signals', 'footage_clear_offset': 'muvid.mcp.footage_tools:footage_clear_offset', 'footage_delete_edit': 'muvid.mcp.footage_tools:footage_delete_edit', 'footage_editor_document': 'muvid.mcp.footage_tools:footage_editor_document', 'footage_edits': 'muvid.mcp.footage_tools:footage_edits', 'footage_edl_from_annotations': 'muvid.mcp.footage_tools:footage_edl_from_annotations', 'footage_explain_edit': 'muvid.mcp.footage_tools:footage_explain_edit', 'footage_filmstrip': 'muvid.mcp.footage_tools:footage_filmstrip', 'footage_filmstrips': 'muvid.mcp.footage_tools:footage_filmstrips', 'footage_fit_to_beat': 'muvid.mcp.footage_tools:footage_fit_to_beat', 'footage_get_edit': 'muvid.mcp.footage_tools:footage_get_edit', 'footage_looks': 'muvid.mcp.footage_tools:footage_looks', 'footage_merge_cut': 'muvid.mcp.footage_tools:footage_merge_cut', 'footage_peaks': 'muvid.mcp.footage_tools:footage_peaks', 'footage_redo_edit': 'muvid.mcp.footage_tools:footage_redo_edit', 'footage_rename_edit': 'muvid.mcp.footage_tools:footage_rename_edit', 'footage_render': 'muvid.mcp.footage_tools:footage_render', 'footage_renders': 'muvid.mcp.footage_tools:footage_renders', 'footage_replace_edit': 'muvid.mcp.footage_tools:footage_replace_edit', 'footage_save_edit': 'muvid.mcp.footage_tools:footage_save_edit', 'footage_score_status': 'muvid.mcp.scoring_tools:footage_score_status', 'footage_scores': 'muvid.mcp.scoring_tools:footage_scores', 'footage_set_cut': 'muvid.mcp.footage_tools:footage_set_cut', 'footage_set_has_song': 'muvid.mcp.footage_tools:footage_set_has_song', 'footage_set_offset': 'muvid.mcp.footage_tools:footage_set_offset', 'footage_set_span': 'muvid.mcp.footage_tools:footage_set_span', 'footage_split_cut': 'muvid.mcp.footage_tools:footage_split_cut', 'footage_status': 'muvid.mcp.footage_tools:footage_status', 'footage_timeline': 'muvid.mcp.footage_tools:footage_timeline', 'footage_undo_edit': 'muvid.mcp.footage_tools:footage_undo_edit', 'list_archetypes': 'muvid.mcp.lyricvid_tools:list_archetypes', 'list_music_video_projects': 'muvid.mcp.footage_tools:list_music_video_projects', 'list_projects': 'muvid.mcp.tools:list_projects', 'list_strategies': 'muvid.mcp.footage_tools:list_strategies', 'list_subgenres': 'muvid.mcp.subgenre_tools:list_subgenres', 'list_visuals': 'muvid.mcp.tools:list_visuals', 'project_status': 'muvid.mcp.tools:project_status', 'propose_edit': 'muvid.mcp.footage_tools:propose_edit', 'propose_lyric_treatments': 'muvid.mcp.lyricvid_tools:propose_lyric_treatments', 'propose_lyric_treatments_ai': 'muvid.mcp.lyricvid_tools:propose_lyric_treatments_ai', 'remove_footage': 'muvid.mcp.footage_tools:remove_footage', 'render_lyric_video': 'muvid.mcp.lyricvid_tools:render_lyric_video', 'render_subgenre': 'muvid.mcp.subgenre_tools:render_subgenre', 'render_visualizer': 'muvid.mcp.tools:render_visualizer', 'score_footage': 'muvid.mcp.scoring_tools:score_footage', 'set_song': 'muvid.mcp.footage_tools:set_song', 'validate_lyric_treatment': 'muvid.mcp.lyricvid_tools:validate_lyric_treatment'}*

Bare tool name → its `module:function` reference (tools live in three modules).

### *class* muvid.mcp.VisualizerWorkspace(email, root)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A single caller’s private visualizer area, addressed by `email`.

`email` and every `project_id` are validated as single path components, so a
caller can never escape their own subtree.

#### create_project(project_id, , title='', force=False)

Create (and return) a new visualizer bucket under this user.

* **Return type:**
  [`VisualizerProject`](_autosummary/muvid.mcp.workspace.html.md#muvid.mcp.workspace.VisualizerProject)

#### list_projects()

This user’s buckets: `[{project_id, title}]` (newest-modified first).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

#### open_project(project_id)

Open an existing visualizer bucket (raises if it doesn’t exist).

* **Return type:**
  [`VisualizerProject`](_autosummary/muvid.mcp.workspace.html.md#muvid.mcp.workspace.VisualizerProject)

### muvid.mcp.build_server(, name='muvid', instructions="muvid music-visualizer — turn a song into a 16:9, YouTube-ready visualizer video (+ thumbnail), deterministically and for free (ffmpeg only, no AI, no keys).\\n\\nWorkflow:\\n1. Create a project: create_project(genre='music-visualizer', template='<look>'). See list_visuals() for the looks (still / cqt / bars / spectrum / waves / scope) or use 'auto'.\\n2. Render: render_visualizer(project_id, audio='<https URL to the song>', cover='<https URL to the cover>', visual='<look>'). Audio/cover are direct media URLs, fetched server-side; the 'still' look needs a cover.\\n3. Inspect: project_status(project_id) lists your renders (paths + the platform-check verdict).\\n\\nAll tools are free. Renders are stored server-side in your project bucket.", auth=None, middleware=None)

Assemble a standalone FastMCP server exposing muvid’s tools (stdio dev/testing).

The hosted path aggregates onto the reelee connector via [`register_tools()`](_autosummary/muvid.mcp.html.md#muvid.mcp.register_tools);
this is the convenience builder for `muvid.mcp` on its own.

### muvid.mcp.current_email()

The caller’s identity for the in-flight tool call (raises if unauthenticated).

Resolves from the explicit [`use_email()`](_autosummary/muvid.mcp.html.md#muvid.mcp.use_email) override when present (local/stdio/
tests), else the verified OAuth token — so tools work under any host middleware, and
an unauthenticated call is failed closed.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.mcp.data_root()

The muvid data root: `$MUVID_DATA_HOME` or `~/.local/share/muvid`.

Public API (`muvid.mcp` re-exports it), so the name stays though the body moved.
A forwarder, not an alias, for the introspection reason given on the footage twin.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### muvid.mcp.register_tools(server, , prefix='', include=None, exclude=None)

Register muvid’s MCP tools onto an EXISTING FastMCP `server` — the aggregation
seam for a host connector (the unified reelee connector, muvid#3).

Mirrors `braidio.mcp.register_tools` / `falaw.bridges.mcp.register_tools`.
`prefix` namespaces the tool names (e.g. `"muvid_"`) to avoid collisions;
`include` / `exclude` (sets of bare names) select a subset. Returns the
registered (prefixed) names. The host installs identity/metering separately —
muvid’s tools resolve the caller via [`current_email()`](_autosummary/muvid.mcp.html.md#muvid.mcp.current_email), which works under any
host middleware.

### muvid.mcp.token_email()

The verified caller’s email from the OAuth token (`email` claim, else `sub`).

Lowercased, or `None` when there is no request/token context — deliberately no
fallback, so a caller is failed closed rather than handed a shared identity.

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### muvid.mcp.use_email(email)

Bind the caller identity for the duration of the block (local/stdio/testing).

### Modules

| [`footage_tools`](_autosummary/muvid.mcp.footage_tools.html.md#module-muvid.mcp.footage_tools)   | MCP tools for the footage-aligned `music_video` genre (thorwhalen/reelee#229).        |
|-------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------|
| [`identity`](_autosummary/muvid.mcp.identity.html.md#module-muvid.mcp.identity)             | Caller identity for the muvid MCP tools — resolved from the OAuth token, fail-closed. |
| [`scoring_tools`](_autosummary/muvid.mcp.scoring_tools.html.md#module-muvid.mcp.scoring_tools)   | MCP tools for the footage SCORING layer (thorwhalen/muvid#13).                        |
| [`workspace`](_autosummary/muvid.mcp.workspace.html.md#module-muvid.mcp.workspace)           | Per-user output bucket for the muvid `music-visualizer` MCP genre.                    |


# _autosummary/muvid.mcp.identity.html.md

# muvid.mcp.identity

Caller identity for the muvid MCP tools — resolved from the OAuth token, fail-closed.

A hosted connector is multi-user: every tool must place work under the *verified*
caller, never a shared/ambient identity. muvid’s tools resolve the caller via
[`current_email()`](_autosummary/muvid.mcp.identity.html.md#muvid.mcp.identity.current_email), which reads the fastmcp request’s access token — so they work
under ANY host middleware, including the shared `enlace_metering.MeteringMiddleware`
the unified reelee connector installs (thorwhalen/reelee#232), which keys its own
context var. There is **no fallback** beyond the token: an unauthenticated call is
failed closed. Mirrors `braidio.mcp.metering.token_email`/`current_email`.

### Functions

| [`current_email`](_autosummary/muvid.mcp.identity.html.md#muvid.mcp.identity.current_email)()   | The caller's identity for the in-flight tool call (raises if unauthenticated).   |
|--------------------------------------------------------------------|----------------------------------------------------------------------------------|
| [`token_email`](_autosummary/muvid.mcp.identity.html.md#muvid.mcp.identity.token_email)()     | The verified caller's email from the OAuth token (`email` claim, else `sub`).    |
| [`use_email`](_autosummary/muvid.mcp.identity.html.md#muvid.mcp.identity.use_email)(email)  | Bind the caller identity for the duration of the block (local/stdio/testing).    |

### muvid.mcp.identity.current_email()

The caller’s identity for the in-flight tool call (raises if unauthenticated).

Resolves from the explicit [`use_email()`](_autosummary/muvid.mcp.identity.html.md#muvid.mcp.identity.use_email) override when present (local/stdio/
tests), else the verified OAuth token — so tools work under any host middleware, and
an unauthenticated call is failed closed.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.mcp.identity.token_email()

The verified caller’s email from the OAuth token (`email` claim, else `sub`).

Lowercased, or `None` when there is no request/token context — deliberately no
fallback, so a caller is failed closed rather than handed a shared identity.

* **Return type:**
  [`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### muvid.mcp.identity.use_email(email)

Bind the caller identity for the duration of the block (local/stdio/testing).


# _autosummary/muvid.mcp.scoring_tools.html.md

# muvid.mcp.scoring_tools

MCP tools for the footage SCORING layer (thorwhalen/muvid#13).

A background scoring job (via `nw.jobs` — the federation’s durable/cancellable async
facade, reused rather than a second system) computes per-clip score tracks; the editor +
`assemble_music_video(strategy='weighted')` read them. All FREE (no AI/keys).

A transport over [`muvid.footage.service`](_autosummary/muvid.footage.service.html.md#module-muvid.footage.service) (`require_scorable`, `run_scoring`,
`scores`): what stays here is the connector’s own job — enqueueing on `nw.jobs` and the
bounded long-poll over it. A host runs the same `score` operation as its own job.

Key design decisions (LOCKED, see `misc/docs/footage_scoring_design.md`):

- **Scoring is keyed on INPUTS ONLY** (`song_hash` + an alignment fingerprint + the metric
  set + hop). Weights/preset are NOT here — they enter at `assemble` time, so ONE tensor is
  reused across every preset for free, and a re-align mid-flight yields a NEW job (not a dedup
  to the stale run).
- **Bounded long-poll** status so an agent needs ~1 poll, not ~15.
- **NaN never hits the wire** — masked entries serialize as `null`; the `mask` array is
  authoritative.

### Functions

| [`footage_score_status`](_autosummary/muvid.mcp.scoring_tools.html.md#muvid.mcp.scoring_tools.footage_score_status)(project_id, \*[, ...])     | The scoring job's status (bounded long-poll).                                    |
|--------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------|
| [`footage_scores`](_autosummary/muvid.mcp.scoring_tools.html.md#muvid.mcp.scoring_tools.footage_scores)(project_id, \*[, clip_id, ...])  | The persisted score tracks — for the multichannel editor + inspection.           |
| [`score_footage`](_autosummary/muvid.mcp.scoring_tools.html.md#muvid.mcp.scoring_tools.score_footage)(project_id, \*[, hop_s, metrics]) | Kick a BACKGROUND job that scores every aligned clip (quality + motion-to-beat). |

### muvid.mcp.scoring_tools.footage_score_status(project_id, , job_id='', wait_s=0)

The scoring job’s status (bounded long-poll). Free.

Pass the `job_id` from `score_footage` (or omit for the newest scoring job). With
`wait_s` > 0 this blocks up to ~\`\`wait_s\`\` seconds (capped), returning early on a
terminal state — so an agent needs ~1 poll, not many.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.scoring_tools.footage_scores(project_id, , clip_id='', metrics=None, max_points=1500)

The persisted score tracks — for the multichannel editor + inspection. Free.

- No `clip_id` → a SUMMARY (metrics, per-clip coverage %, beats, tempo, the decimated
  `selection_margin`, grid geometry) — bounded, safe as the default.
- `clip_id` → that clip’s tracks (values as `null`-masked arrays, decimated to
  `max_points` per metric), for the editor’s lanes.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.mcp.scoring_tools.score_footage(project_id, , hop_s=0.1, metrics=None)

Kick a BACKGROUND job that scores every aligned clip (quality + motion-to-beat). Free.

Returns immediately with a `job_id`; poll `footage_score_status`. Scoring extracts ALL
core metrics (re-weight later at assemble time — no re-scoring needed). Requires a song +
a run of `align_footage` first. The heavy lip-sync tier is OFF by default (opt-in,
off-prod).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)


# _autosummary/muvid.mcp.workspace.html.md

# muvid.mcp.workspace

Per-user output bucket for the muvid `music-visualizer` MCP genre.

A remote MCP connector is stateless and multi-user, so each caller must be isolated
and address work by `project_id` (never a path). The visualizer is itself stateless
— a render is a pure function of (audio, cover, visual) — so a “project” here is just a
**lightweight bucket** where a caller’s renders land, not a full nw/muvid project (no
graph, no asset store). That keeps the `music-visualizer` genre the cheapest possible
2nd genre (thorwhalen/muvid#3); a richer per-user asset library (content-addressed
uploads shared across genres) is a deliberate follow-up.

Layout (default root `~/.local/share/muvid`; override via `MUVID_DATA_HOME`):

- `{root}/visualizer/projects/{email}/{project_id}/manifest.json` — the bucket
- `{root}/visualizer/projects/{email}/{project_id}/renders/{render_id}/` — one render
  (its `.mp4`, optional `thumbnail.jpg`, and a `meta.json` sidecar)

Every **subgenre** (`lyric-video` and anything bridged by `muvid.genre_subgenres`)
gets the same kind of bucket in a directory that names it (muvid#103), so its project
ids are its own and a listing can label it:

- `{root}/subgenres/{slug}/projects/{email}/{project_id}/` — [`SubgenreWorkspace`](_autosummary/muvid.mcp.workspace.html.md#muvid.mcp.workspace.SubgenreWorkspace)

Before muvid#103 subgenre projects were created in the visualizer workspace, sharing its
id space and its `visualizer` label. Those projects are not moved: every subgenre tool
opens through [`open_subgenre_project()`](_autosummary/muvid.mcp.workspace.html.md#muvid.mcp.workspace.open_subgenre_project), which falls back to the visualizer bucket of
the same id, so an old project id keeps working exactly as before.

Per the app-data-lifecycle rule this lives in the user-data dir, **never** inside the
app/deploy tree (a deploy’s `rsync --delete` would erase it).

### Module Attributes

| [`DATA_HOME_ENV_VAR`](_autosummary/muvid.mcp.workspace.html.md#muvid.mcp.workspace.DATA_HOME_ENV_VAR)   | Env var overriding the muvid data root (where per-user visualizer buckets live).        |
|----------------------------------------------------------------------|-----------------------------------------------------------------------------------------|
| [`SUBGENRES_SEGMENT`](_autosummary/muvid.mcp.workspace.html.md#muvid.mcp.workspace.SUBGENRES_SEGMENT)   | The path segment under the data root that holds one directory per subgenre (muvid#103). |

### Functions

| [`bucket_label`](_autosummary/muvid.mcp.workspace.html.md#muvid.mcp.workspace.bucket_label)(proj)                                 | What a visualizer-drawer bucket holds: `"visualizer"`, or the one subgenre whose work is all it holds.   |
|-----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------|
| [`data_root`](_autosummary/muvid.mcp.workspace.html.md#muvid.mcp.workspace.data_root)()                                        | The muvid data root: `$MUVID_DATA_HOME` or `~/.local/share/muvid`.                                       |
| [`open_subgenre_project`](_autosummary/muvid.mcp.workspace.html.md#muvid.mcp.workspace.open_subgenre_project)(slug, email, project_id, \*) | The bucket a subgenre renders into: its own, else a visualizer bucket of that id.                        |
| [`subgenre_of_render`](_autosummary/muvid.mcp.workspace.html.md#muvid.mcp.workspace.subgenre_of_render)(meta)                           | The subgenre a render's `meta.json` says made it, if it says.                                            |
| [`subgenre_slugs_on_disk`](_autosummary/muvid.mcp.workspace.html.md#muvid.mcp.workspace.subgenre_slugs_on_disk)(\*[, root])                 | The subgenres that have a directory under the data root, sorted.                                         |

### Classes

| [`SubgenreWorkspace`](_autosummary/muvid.mcp.workspace.html.md#muvid.mcp.workspace.SubgenreWorkspace)(email, root, slug)       | A single caller's area for ONE subgenre: the visualizer's bucket, in its own directory.   |
|---------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|
| [`VisualizerProject`](_autosummary/muvid.mcp.workspace.html.md#muvid.mcp.workspace.VisualizerProject)(email, project_id, root) | One caller's visualizer bucket — a folder its renders land in.                            |
| [`VisualizerWorkspace`](_autosummary/muvid.mcp.workspace.html.md#muvid.mcp.workspace.VisualizerWorkspace)(email, root)           | A single caller's private visualizer area, addressed by `email`.                          |

### muvid.mcp.workspace.DATA_HOME_ENV_VAR *= 'MUVID_DATA_HOME'*

Env var overriding the muvid data root (where per-user visualizer buckets live).
Re-exported from [`muvid.paths`](_autosummary/muvid.paths.html.md#module-muvid.paths), the SSOT — this module used to carry its own
verbatim copy of both, beside a second copy in `muvid/footage/workspace.py`.

### muvid.mcp.workspace.SUBGENRES_SEGMENT *= 'subgenres'*

The path segment under the data root that holds one directory per subgenre (muvid#103).

### *class* muvid.mcp.workspace.SubgenreWorkspace(email, root, slug)

Bases: [`VisualizerWorkspace`](_autosummary/muvid.mcp.workspace.html.md#muvid.mcp.workspace.VisualizerWorkspace)

A single caller’s area for ONE subgenre: the visualizer’s bucket, in its own directory.

A subgenre is stateless the way the visualizer is (a “project” is where renders
land), so the bucket is the same [`VisualizerProject`](_autosummary/muvid.mcp.workspace.html.md#muvid.mcp.workspace.VisualizerProject); only the directory
differs, which gives each subgenre its own project-id space and a path that names
it (muvid#103). Built with `for_slug()`, never `for_email()`.

#### create_project(project_id, , title='', force=False)

Create a bucket, refusing to fork one this subgenre already made.

Before muvid#103 this subgenre’s projects were made in the visualizer
drawer. Creating the same id here would succeed and shadow it: the
subgenre’s tools would open the new, empty bucket, and the old renders
would drop out of their reach. So an id whose visualizer bucket holds
only this subgenre’s renders is refused, as it was before.

* **Return type:**
  [`VisualizerProject`](_autosummary/muvid.mcp.workspace.html.md#muvid.mcp.workspace.VisualizerProject)

### *class* muvid.mcp.workspace.VisualizerProject(email, project_id, root)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One caller’s visualizer bucket — a folder its renders land in.

`root` is exposed so `nw.create_genre_project`’s all-or-nothing rollback
(`nw.genres._rollback_project` removes `project.root`) reverts a half-created
bucket. Kept storage-only: no nw graph, no asset library.

#### list_renders()

This bucket’s renders (newest-first), from each render’s `meta.json`.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

#### new_render_dir(render_id)

Create + return a fresh directory for one render (traversal-checked id).

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### *class* muvid.mcp.workspace.VisualizerWorkspace(email, root)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A single caller’s private visualizer area, addressed by `email`.

`email` and every `project_id` are validated as single path components, so a
caller can never escape their own subtree.

#### create_project(project_id, , title='', force=False)

Create (and return) a new visualizer bucket under this user.

* **Return type:**
  [`VisualizerProject`](_autosummary/muvid.mcp.workspace.html.md#muvid.mcp.workspace.VisualizerProject)

#### list_projects()

This user’s buckets: `[{project_id, title}]` (newest-modified first).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)]

#### open_project(project_id)

Open an existing visualizer bucket (raises if it doesn’t exist).

* **Return type:**
  [`VisualizerProject`](_autosummary/muvid.mcp.workspace.html.md#muvid.mcp.workspace.VisualizerProject)

### muvid.mcp.workspace.bucket_label(proj)

What a visualizer-drawer bucket holds: `"visualizer"`, or the one subgenre
whose work is all it holds.

Before muvid#103 subgenre projects lived in this drawer, so the drawer cannot
label them; their renders can. Evidence is each finished render’s
`meta.json` (a render folder without one is a render that failed or is
still running, and says nothing), plus the `lyricvid/` folder the lyric
tools fetch their inputs into. A bucket with no evidence, or one that mixes
visualizer and subgenre work (allowed before and after muvid#103), stays
`"visualizer"`. Read-only: nothing is moved or rewritten.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.mcp.workspace.data_root()

The muvid data root: `$MUVID_DATA_HOME` or `~/.local/share/muvid`.

Public API (`muvid.mcp` re-exports it), so the name stays though the body moved.
A forwarder, not an alias, for the introspection reason given on the footage twin.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### muvid.mcp.workspace.open_subgenre_project(slug, email, project_id, , root=None)

The bucket a subgenre renders into: its own, else a visualizer bucket of that id.

The fallback is what keeps every project made before muvid#103 (and every
visualizer project a caller already rendered a lyric video into) working: those
live in the visualizer workspace and are read in place, never moved.

* **Return type:**
  [`VisualizerProject`](_autosummary/muvid.mcp.workspace.html.md#muvid.mcp.workspace.VisualizerProject)

### muvid.mcp.workspace.subgenre_of_render(meta)

The subgenre a render’s `meta.json` says made it, if it says.

`render_subgenre` records `subgenre` (checked first, so a choreo or montage
render, whose own meta also carries `treatment_source`, is named correctly).
`render_lyric_video` records no `subgenre`; its nested pipeline meta has
`treatment_source`, which no visualizer render carries.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

### muvid.mcp.workspace.subgenre_slugs_on_disk(, root=None)

The subgenres that have a directory under the data root, sorted.

Read from disk rather than from the installed plugins, so a project made by a
subgenre that has since been uninstalled is still listed and downloadable.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]


# _autosummary/muvid.montage.analysis.html.md

# muvid.montage.analysis

Measure the song and the pool: beat grid, bars, sections, and media strength.

Everything the planner needs is MEASURED here, once, and handed over as plain
records — the planner ([`muvid.montage.plan`](_autosummary/muvid.montage.plan.html.md#module-muvid.montage.plan)) is pure and never touches a
file. Three measurements:

**Beat grid.** `mixing.audio.beat_grid` (librosa, ISC) is the house beat
backend and is used when it is importable; `mixing` is a core dependency but
its `[beats]` extra is not, so on a bare install this module falls back to
its own small numpy estimator — spectral-flux onset strength, an
autocorrelation tempo with a log-normal prior around 120 BPM, and a comb-filter
phase search — which yields a **fixed-tempo** grid. That is honest for the
material montages are cut to and it is recorded in `Analysis.beat_source` so
nobody mistakes it for a tracked grid. Only `ImportError` is caught, and only
on the `auto` path: a `mixing` regression must surface as a traceback, not
as a quietly different grid (the `mixing` rule in the design record).

**Bars and sections.** librosa has no downbeat tracker, so downbeats are the
beat phase (0..beats_per_bar-1) with the most onset energy — a closed-form vote.
Sections, when the caller does not supply them, are derived from per-bar RMS
energy: a stretch louder than the median is a chorus, quieter is a verse, the
leading and trailing quiet runs are intro/outro. Coarse, deterministic, and
labelled `section_source="energy"` so a caller knows what it got.

**Media strength.** A photo’s strength is a closed-form function of its pixel
count and luma contrast (a 64x64 grey thumbnail decoded by ffmpeg — no Pillow).
The reuse policy reserves the strongest for the final chorus.

Module scope is stdlib-only; numpy and ffmpeg are reached inside functions.

### Functions

| [`analyze`](_autosummary/muvid.montage.analysis.html.md#muvid.montage.analysis.analyze)(audio, \*[, beats, beats_per_bar, ...])    | Measure `audio`: duration, beat grid, downbeats, bar energy, sections.    |
|-----------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------|
| [`beat_grid`](_autosummary/muvid.montage.analysis.html.md#muvid.montage.analysis.beat_grid)(audio, \*[, source, duration])           | `(beats, tempo_bpm, onset_env, onset_hop_s, source_name, note)`.          |
| [`derive_sections`](_autosummary/muvid.montage.analysis.html.md#muvid.montage.analysis.derive_sections)(bar_energy_db, bars, duration, \*) | Coarse sections from per-bar energy.                                      |
| [`downbeats_from_beats`](_autosummary/muvid.montage.analysis.html.md#muvid.montage.analysis.downbeats_from_beats)(beats, onset_at_beats, ...)   | Downbeats as the beat phase carrying the most onset energy.               |
| [`probe_media`](_autosummary/muvid.montage.analysis.html.md#muvid.montage.analysis.probe_media)(paths, \*, kind[, start_index])        | Measure each file: dimensions, duration (clips) and strength.             |
| [`sections_from_labels`](_autosummary/muvid.montage.analysis.html.md#muvid.montage.analysis.sections_from_labels)(raw, duration)                | Caller-supplied sections, clamped to the song and gap-filled with verses. |

### Classes

| [`Analysis`](_autosummary/muvid.montage.analysis.html.md#muvid.montage.analysis.Analysis)(\*, duration, tempo_bpm, beats, ...)      | What the planner knows about the song.      |
|-----------------------------------------------------------------------------------------------------|---------------------------------------------|
| [`Media`](_autosummary/muvid.montage.analysis.html.md#muvid.montage.analysis.Media)(\*, index, path, kind, width, height[, ...]) | One pool item, measured.                    |
| [`Section`](_autosummary/muvid.montage.analysis.html.md#muvid.montage.analysis.Section)(\*, label, start, end[, energy_db])        | A labelled stretch of the song, in seconds. |

### *class* muvid.montage.analysis.Analysis(\*, duration, tempo_bpm, beats, downbeats, beats_per_bar=4, sections=(), beat_source='', section_source='', bar_energy_db=(), notes=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

What the planner knows about the song.

#### *property* beat_s *: [float](https://docs.python.org/3/builtins/functions.html#float)*

Seconds per beat at the estimated tempo.

#### *classmethod* from_dict(d)

The inverse of `to_dict()` (as rounded) — so a measured song can be kept.

* **Return type:**
  [`Analysis`](_autosummary/muvid.montage.analysis.html.md#muvid.montage.analysis.Analysis)

```pycon
>>> a = Analysis(duration=4.0, tempo_bpm=120.0, beats=(0.0, 0.5), downbeats=(0.0,),
...              sections=(Section(label='verse', start=0.0, end=4.0),))
>>> Analysis.from_dict(a.to_dict()) == a
True
```

### *class* muvid.montage.analysis.Media(, index, path, kind, width, height, duration=None, strength=0.0)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One pool item, measured.

`kind` is `photo`, `clip` or `cover`; `duration` is `None` for
a still; `strength` is the closed-form score the reuse policy ranks on.

### *class* muvid.montage.analysis.Section(, label, start, end, energy_db=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A labelled stretch of the song, in seconds.

#### energy_db *: [float](https://docs.python.org/3/builtins/functions.html#float) | [None](https://docs.python.org/3/builtins/constants.html#None)*

Mean bar energy in dB (relative), when measured.

### muvid.montage.analysis.analyze(audio, , beats='auto', beats_per_bar=4, sections=None)

Measure `audio`: duration, beat grid, downbeats, bar energy, sections.

* **Return type:**
  [`Analysis`](_autosummary/muvid.montage.analysis.html.md#muvid.montage.analysis.Analysis)

### muvid.montage.analysis.beat_grid(audio, , source='auto', duration=None)

`(beats, tempo_bpm, onset_env, onset_hop_s, source_name, note)`.

`source="auto"` tries `mixing.audio.beat_grid` and falls back to the
numpy estimator only on [`ImportError`](https://docs.python.org/3/builtins/exceptions.html#ImportError) (librosa absent). Naming a
source never falls back.

### muvid.montage.analysis.derive_sections(bar_energy_db, bars, duration, , min_section_bars=4)

Coarse sections from per-bar energy. Loud = chorus, quiet = verse.

The threshold splits the bar energies in two (`_two_means_threshold()`);
interior runs shorter than `min_section_bars` (scaled down for short
songs) are absorbed into the longer neighbour; a leading/trailing quiet run
of at most `MAX_INTRO_BARS` bars is an intro/outro. A dynamically
flat song is one verse — reported as such rather than invented.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`Section`](_autosummary/muvid.montage.analysis.html.md#muvid.montage.analysis.Section), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

```pycon
>>> bars = [(i * 2.0, (i + 1) * 2.0) for i in range(8)]
>>> e = [-20, -20, -20, -20, -10, -10, -20, -20]
>>> [(s.label, s.start, s.end) for s in derive_sections(e, bars, 16.0)]
[('intro', 0.0, 8.0), ('chorus', 8.0, 12.0), ('outro', 12.0, 16.0)]
>>> [s.label for s in derive_sections([-20] * 8, bars, 16.0)]
['verse']
```

### muvid.montage.analysis.downbeats_from_beats(beats, onset_at_beats, beats_per_bar)

Downbeats as the beat phase carrying the most onset energy.

A closed-form vote over `beats_per_bar` candidate phases; ties go to
phase 0 (the first beat), which is also the answer when there is nothing
to vote with.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

```pycon
>>> downbeats_from_beats([0, .5, 1, 1.5, 2, 2.5, 3, 3.5], [1, 0, 0, 0, 1, 0, 0, 0], 4)
(0, 2)
>>> downbeats_from_beats([0, .5, 1, 1.5, 2, 2.5, 3, 3.5], [0, 0, 1, 0, 0, 0, 1, 0], 4)
(1, 3)
```

### muvid.montage.analysis.probe_media(paths, , kind, start_index=0)

Measure each file: dimensions, duration (clips) and strength.

A file with no video stream is refused with its path in the message — a
montage that silently skipped a photo would be a different montage.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`Media`](_autosummary/muvid.montage.analysis.html.md#muvid.montage.analysis.Media), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

### muvid.montage.analysis.sections_from_labels(raw, duration)

Caller-supplied sections, clamped to the song and gap-filled with verses.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`Section`](_autosummary/muvid.montage.analysis.html.md#muvid.montage.analysis.Section), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

```pycon
>>> [(s.label, s.start, s.end) for s in sections_from_labels(
...     [{'label': 'chorus', 'start': 4, 'end': 8}], 10.0)]
[('verse', 0.0, 4.0), ('chorus', 4.0, 8.0), ('verse', 8.0, 10.0)]
```


# _autosummary/muvid.montage.html.md

# muvid.montage

Montage — a beat-cut video from a pool of stills and clips (subgenre plugin).

The STANDARD kind of music video for material that has no timeline of its
own: photos and short clips cut to the song’s beat grid and section
structure — what CapCut’s “photo beat sync” templates, Animoto and Rotor sell.

> from muvid.montage import tools
> tools.render_montage(‘song.wav’, ‘out.mp4’, photos=[‘a.jpg’, ‘b.jpg’, ‘c.jpg’])

or from the command line:

```default
python -m muvid.montage render song.wav out.mp4 --photos a.jpg b.jpg c.jpg
```

Four seams, each with a default that genuinely works:

`analysis`
: the beat grid (`mixing.audio.beat_grid` where librosa is installed, a
  numpy estimator otherwise — and the plan says which), downbeats, and
  coarse sections from energy when none are supplied.

`spec`
: the treatment: a direction (accent, grade, cut feel, reuse policy) and
  scenes naming an archetype from a closed set per section.

`plan`
: the PLANNER — the core of the subgenre. Pool + grid + sections -> an edit
  list, with an explicit reuse policy so twelve photos carry a three-minute
  song. Pure and deterministic; written out as `plan.json` beside every render.

`render`
: ffmpeg in bounded stages: per-slot `zoompan`/trim parts, `xfade`
  blends, `xstack` grids, stream-copy concat, one YouTube-spec mux.

This module is a PEP 562 lazy facade, like `muvid/__init__.py`: importing it
pulls nothing heavy, and each attribute is resolved on first use.

### *class* muvid.montage.Analysis(\*, duration, tempo_bpm, beats, downbeats, beats_per_bar=4, sections=(), beat_source='', section_source='', bar_energy_db=(), notes=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

What the planner knows about the song.

#### *property* beat_s *: [float](https://docs.python.org/3/builtins/functions.html#float)*

Seconds per beat at the estimated tempo.

#### *classmethod* from_dict(d)

The inverse of `to_dict()` (as rounded) — so a measured song can be kept.

* **Return type:**
  [`Analysis`](_autosummary/muvid.montage.analysis.html.md#muvid.montage.analysis.Analysis)

```pycon
>>> a = Analysis(duration=4.0, tempo_bpm=120.0, beats=(0.0, 0.5), downbeats=(0.0,),
...              sections=(Section(label='verse', start=0.0, end=4.0),))
>>> Analysis.from_dict(a.to_dict()) == a
True
```

### *class* muvid.montage.Canvas(, width=1920, height=1080, fps=30)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Output geometry.

### *class* muvid.montage.Plan(\*, duration, tempo_bpm, beats_per_bar, beat_source, section_source, sections, media, slots, reuse=<factory>, treatment=<factory>, notes=(), plan_version='1.0')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The edit list. JSON-able, canvas-independent (windows are normalised).

#### *classmethod* from_dict(d)

Read a plan back — a hand-edited `plan.json` renders the same way.

* **Return type:**
  [`Plan`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.Plan)

### *class* muvid.montage.TreatmentSpec(\*, spec_version='1.0', title='', direction=<factory>, scenes=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A complete, plannable treatment.

```pycon
>>> s = TreatmentSpec()
>>> validate(s)
[]
>>> TreatmentSpec.from_dict(s.to_dict()) == s
True
```

#### *classmethod* from_dict(d)

Build from a plain mapping — TOTAL over what a model or a remote caller sends.

Missing keys default; wrong-shaped values are coerced toward the
field’s type rather than raised on; unknown keys are dropped. What
cannot be coerced falls to the default and `repair()` reports the
vocabulary-level substitutions afterwards.

* **Return type:**
  [`TreatmentSpec`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.TreatmentSpec)

```pycon
>>> s = TreatmentSpec.from_dict({'scenes': [None, {'applies_to': 'chorus',
...     'archetype': 'grid', 'params': 'fast'}],
...     'direction': {'palette': {'foo': 1, 'accent': '#00ff00'},
...                   'reuse': {'min_gap': '3'}}})
>>> s.scenes[1].applies_to, s.scenes[1].archetype, dict(s.scenes[1].params)
(('chorus',), 'grid', {})
>>> s.direction.palette.accent, s.direction.reuse.min_gap
('#00ff00', 3)
```

#### to_dict()

A JSON-native dict: tuples become lists, so what this emits is
exactly what `json_schema()` validates and what a file round-trips.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### muvid.montage.analyze_song(audio, , beats='auto', beats_per_bar=4, sections=None)

Measure a song: tempo, beat grid, downbeats, bar energy and sections.

The payload says WHERE the beats came from (`beat_source`) and whether
the sections were supplied or derived from energy — a caller that ignores
that will trust a fixed-tempo grid on a rubato ballad.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### muvid.montage.plan_montage(audio, , photos=(), clips=(), cover=None, treatment=None, archetype=None, strict=False, beats='auto', beats_per_bar=4, sections=None, out=None)

Plan the montage without rendering it: the edit list, as JSON.

Identical to what [`render_montage()`](_autosummary/muvid.montage.html.md#muvid.montage.render_montage) would render. `out` writes it
to a file as well.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### muvid.montage.render_montage(audio, output, , photos=(), clips=(), cover=None, treatment=None, archetype=None, strict=False, beats='auto', beats_per_bar=4, sections=None, width=1920, height=1080, fps=30, workdir=None)

Render a montage. The one verb that produces a file.

Goes through [`muvid.subgenres.render_subgenre()`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.render_subgenre) when the manifest is
registered (so the schemas are enforced), and straight to the pipeline
otherwise — the same renderer either way.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### Modules

| [`analysis`](_autosummary/muvid.montage.analysis.html.md#module-muvid.montage.analysis)   | Measure the song and the pool: beat grid, bars, sections, and media strength.                                             |
|-------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|
| [`manifest`](_autosummary/muvid.montage.manifest.html.md#module-muvid.montage.manifest)   | The montage subgenre's manifest — a beat-cut montage from a pool of stills and clips.                                     |
| [`pipeline`](_autosummary/muvid.montage.pipeline.html.md#module-muvid.montage.pipeline)   | The montage pipeline — the one path from a song and a pool to a finished video.                                           |
| [`plan`](_autosummary/muvid.montage.plan.html.md#module-muvid.montage.plan)           | The planner: a pool of media + a beat grid + sections -> an edit list of slots.                                           |
| [`render`](_autosummary/muvid.montage.render.html.md#module-muvid.montage.render)       | Render a [`Plan`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.Plan) to a YouTube-spec mp4 with ffmpeg. |
| [`spec`](_autosummary/muvid.montage.spec.html.md#module-muvid.montage.spec)           | The montage treatment spec — what a director (human or model) decides, as data.                                           |
| [`tools`](_autosummary/muvid.montage.tools.html.md#module-muvid.montage.tools)         | The SSOT verbs for the montage subgenre: analyze, plan, render.                                                           |


# _autosummary/muvid.montage.manifest.html.md

# muvid.montage.manifest

The montage subgenre’s manifest — a beat-cut montage from a pool of stills and clips.

Deliberately stdlib-only and free of any import from the rest of
[`muvid.montage`](_autosummary/muvid.montage.html.md#module-muvid.montage): this module is what a catalogue, a UI form, an MCP tool
definition or an LLM choosing among installed subgenres reads, and none of them
should pay for numpy or ffmpeg to do it. The renderer is named as a string and
imported only when a render actually runs.

The shape copies [`muvid.lyricvid.manifest`](_autosummary/muvid.lyricvid.manifest.html.md#module-muvid.lyricvid.manifest) on purpose — that file is the
reference a third-party plugin is told to look like, and a second in-tree plugin
that drifted from it would make the reference ambiguous.

### Module Attributes

| [`SLUG`](_autosummary/muvid.montage.manifest.html.md#muvid.montage.manifest.SLUG)                | Hyphen-free because it is one word; the slug is a persisted path segment and a public contract value from the day it ships, so it is chosen once.   |
|----------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|
| [`MAX_MEDIA_PER_INPUT`](_autosummary/muvid.montage.manifest.html.md#muvid.montage.manifest.MAX_MEDIA_PER_INPUT) | The largest list of files one input may carry.                                                                                                      |

### muvid.montage.manifest.MAX_MEDIA_PER_INPUT *= 64*

The largest list of files one input may carry. Stated here (and enforced in
[`muvid.montage.pipeline`](_autosummary/muvid.montage.pipeline.html.md#module-muvid.montage.pipeline), which reads `MUVID_MONTAGE_MAX_MEDIA`) so a
UI can show the bound before a caller uploads 200 photos. The runtime schema
validator does not enforce `maxItems`; the pipeline REFUSES past it.

### muvid.montage.manifest.SLUG *= 'montage'*

Hyphen-free because it is one word; the slug is a persisted path segment and
a public contract value from the day it ships, so it is chosen once.


# _autosummary/muvid.montage.pipeline.html.md

# muvid.montage.pipeline

The montage pipeline — the one path from a song and a pool to a finished video.

> audio + photos[] / clips[] (+ cover)
> : -> Analysis        beat grid, bars, sections     (muvid.montage.analysis)
>   -> TreatmentSpec   the creative decision         (muvid.montage.spec)
>   -> Plan            every cut and framing computed (muvid.montage.plan)
>   -> mp4             ffmpeg, bounded stages         (muvid.montage.render)

Each arrow is a seam with a working default, so the whole thing runs on a bare
`song.wav` and three photos with no AI, no API key and no network — and the
plan is written to `plan.json` BEFORE any frame is rendered, so a render
that fails still leaves the edit list behind for inspection.

Resource bounds are REFUSED, not clamped: a clamped request silently produces
a different video than the one asked for. The bounds read the same env-var
shape as the lyric video (`MUVID_MONTAGE_MAX_PIXELS` etc.) with the same
defaults, plus a per-input file-count bound — 64 photos is a montage, 6,400
is a denial of service.

### Module Attributes

| [`MAX_PIXELS`](_autosummary/muvid.montage.pipeline.html.md#muvid.montage.pipeline.MAX_PIXELS)   | Resource bounds, env-configurable like `MUVID_LYRICVID_*`.   |
|---------------------------------------------------------------|--------------------------------------------------------------|

### Functions

| [`build_treatment`](_autosummary/muvid.montage.pipeline.html.md#muvid.montage.pipeline.build_treatment)(params)                            | `(treatment, repair_notes, source)` from a request's params.   |
|-----------------------------------------------------------------------------------------------------|----------------------------------------------------------------|
| [`check_render_bounds`](_autosummary/muvid.montage.pipeline.html.md#muvid.montage.pipeline.check_render_bounds)(canvas, duration_s, \*[, ...]) | Refuse a render that would exceed the resource bounds.         |
| [`render`](_autosummary/muvid.montage.pipeline.html.md#muvid.montage.pipeline.render)(request)                                    | Render one montage.                                            |

### muvid.montage.pipeline.MAX_PIXELS *= 8294400*

Resource bounds, env-configurable like `MUVID_LYRICVID_*`. Same defaults:
a 4K frame, 60 fps, a 15-minute song. The media bound is this subgenre’s own.

### muvid.montage.pipeline.build_treatment(params)

`(treatment, repair_notes, source)` from a request’s params.

A supplied `treatment` is coerced and repaired; otherwise a one-scene
treatment is built from `archetype` (default `beat_cut`).

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`TreatmentSpec`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.TreatmentSpec), [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)], [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

```pycon
>>> t, notes, source = build_treatment({'archetype': 'grid'})
>>> t.scenes[0].archetype, notes, source
('grid', [], 'archetype')
>>> build_treatment({'treatment': {'scenes': [{'archetype': 'nope'}]}})[1:]
(["scenes[0].archetype 'nope' -> 'beat_cut'"], 'supplied')
```

### muvid.montage.pipeline.check_render_bounds(canvas, duration_s, , n_photos=0, n_clips=0)

Refuse a render that would exceed the resource bounds. Refuse, not clamp.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

```pycon
>>> check_render_bounds(Canvas(width=1920, height=1080, fps=30), 200.0, n_photos=12)
>>> check_render_bounds(Canvas(width=30000, height=30000, fps=30), 10.0)
Traceback (most recent call last):
...
ValueError: canvas 30000x30000 is 900000000 px/frame; the bound is 8294400 (MUVID_MONTAGE_MAX_PIXELS)
>>> check_render_bounds(Canvas(width=640, height=360, fps=24), 10.0, n_photos=65)
Traceback (most recent call last):
...
ValueError: 65 photos; the bound is 64 per input (MUVID_MONTAGE_MAX_MEDIA)
```

### muvid.montage.pipeline.render(request)

Render one montage. Satisfies [`muvid.subgenres.Renderer`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.Renderer).

`request.inputs` takes `audio` (required), `photos` and/or `clips`
(at least one non-empty) and an optional `cover`. `request.params`
takes `treatment` or `archetype`, `strict`, `beats`,
`beats_per_bar`, `sections`, `width`, `height` and `fps`.

* **Return type:**
  [`RenderResult`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.RenderResult)


# _autosummary/muvid.montage.plan.html.md

# muvid.montage.plan

The planner: a pool of media + a beat grid + sections -> an edit list of slots.

This is the core of the subgenre. Everything creative that CapCut’s “photo beat
sync” templates, Animoto and Rotor sell is a planning decision, and it lives
here as pure functions over measured data: no file is opened, no ffmpeg runs,
and the same inputs always yield the same plan.

Three decisions, each in its own place:

**Where the cuts fall** — an *archetype* (a function in `ARCHETYPE_FNS`,
registered with [`register_archetype()`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.register_archetype) the way `muvid.lyricvid.scene`
registers its layouts) walks a section’s beats and returns
[`SlotDraft`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.SlotDraft)s: a span, how many tile regions it has, which of them are
fresh, a motion cycle and a transition. Every cut is a beat, a downbeat or a
subdivision of the measured grid; the archetype only chooses the density, and
the section label and the treatment’s `cut_feel` scale it (a chorus is twice
as dense as a verse, an intro half).

**Which image fills each slot** — the reuse policy ([`assign_media()`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.assign_media)),
which is what lets twelve photos carry a three-minute song:

* \*\*never the same image twice within `min_gap` cuts\*\* (the gap shrinks to
  `pool - 1` for a small pool, so a two-photo pool alternates);
* among what is allowed, the **least-used** image wins, and ties are broken by
  a golden-ratio stride over the slot index — a closed-form permutation that
  keeps the order from reading as a loop, with no random anywhere;
* every **return uses a different crop/move variant** (the k-th use of an
  image takes the k-th entry of [`CROP_VARIANTS`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.CROP_VARIANTS); a clip’s k-th use trims
  a different stretch of it);
* the **strongest quarter of the pool is reserved for the final chorus** and
  leads it, strongest first;
* a **cover**, when given, opens and closes the montage and appears nowhere else.

**How each tile moves** — [`tile_path()`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.tile_path), a piecewise-linear window path
(normalised to the fitted frame) that the renderer compiles to `zoompan`. A
blended boundary samples the same path from both sides, so a Ken Burns move
never restarts on a crossfade (the muvid#73 lesson, honoured by construction).

The output, [`Plan`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.Plan), is JSON-able and is written next to every render as
`plan.json` — the inspectable, hand-editable artifact the subgenre contract
asks for.

### Module Attributes

| [`CROP_VARIANTS`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.CROP_VARIANTS)   | The closed set of framings a still may be shown in, as `(cx, cy, size)` of the frame fitted to the canvas (`size` is the visible fraction; the aspect always follows the canvas).   |
|------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|

### Functions

| [`assign_media`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.assign_media)(drafts, media, reuse, \*[, ...])   | Fill every region of every draft with `(media_index, use_ordinal)`.                         |
|--------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------|
| [`cut_times`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.cut_times)(section, analysis, beats_per_cut)     | Slot boundaries inside `section`: `[start, cut, cut, ..., end]`.                            |
| [`plan_montage`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.plan_montage)(analysis, media, treatment)        | Pool + measurements + treatment -> a [`Plan`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.Plan). |
| [`register_archetype`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.register_archetype)(name)                        | Register an archetype planner under `name`.                                                 |
| [`tile_path`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.tile_path)(variant, motion, length_s, \*[, ...]) | The window path for one still: piecewise linear, slot-relative seconds.                     |

### Classes

| [`Keyframe`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.Keyframe)(t, window)                               | `window` at slot-relative time `t` (seconds).                                                |
|----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------|
| [`Plan`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.Plan)(\*, duration, tempo_bpm, beats_per_bar, ...) | The edit list.                                                                               |
| [`PlanContext`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.PlanContext)(\*, analysis, direction)              | What every archetype gets: the measurements and the direction.                               |
| [`Slot`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.Slot)(\*, index, start, end, section, ...[, ...])  | One span of the montage, in song seconds.                                                    |
| [`SlotDraft`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.SlotDraft)(\*, start, end, section, archetype)     | What an archetype emits: a span with regions, before media assignment.                       |
| [`Tile`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.Tile)(\*, region, media, motion, variant, path)    | One region of a slot: which media, framed and moved how.                                     |
| [`Window`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.Window)(x, y, size)                                | A framing: top-left `(x, y)` and visible fraction `size` of the fitted frame, all in `0..1`. |

### muvid.montage.plan.CROP_VARIANTS *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[float](https://docs.python.org/3/builtins/functions.html#float), [float](https://docs.python.org/3/builtins/functions.html#float), [float](https://docs.python.org/3/builtins/functions.html#float)]]* *= {'bottom': (0.5, 0.68, 0.78), 'centre': (0.5, 0.5, 0.7), 'full': (0.5, 0.5, 1.0), 'left': (0.32, 0.5, 0.78), 'right': (0.68, 0.5, 0.78), 'top': (0.5, 0.32, 0.78)}*

The closed set of framings a still may be shown in, as `(cx, cy, size)`
of the frame fitted to the canvas (`size` is the visible fraction; the
aspect always follows the canvas). The k-th use of an image takes the k-th
variant, so a revisit is a different picture.

### *class* muvid.montage.plan.Keyframe(t, window)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

`window` at slot-relative time `t` (seconds).

### *class* muvid.montage.plan.Plan(\*, duration, tempo_bpm, beats_per_bar, beat_source, section_source, sections, media, slots, reuse=<factory>, treatment=<factory>, notes=(), plan_version='1.0')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The edit list. JSON-able, canvas-independent (windows are normalised).

#### *classmethod* from_dict(d)

Read a plan back — a hand-edited `plan.json` renders the same way.

* **Return type:**
  [`Plan`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.Plan)

### *class* muvid.montage.plan.PlanContext(, analysis, direction)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

What every archetype gets: the measurements and the direction.

### *class* muvid.montage.plan.Slot(, index, start, end, section, archetype, regions, tiles, transition='cut', transition_s=0.0)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One span of the montage, in song seconds.

#### transition *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

How this slot ARRIVES. The first slot’s is always a cut.

### *class* muvid.montage.plan.SlotDraft(, start, end, section, archetype, regions=1, fresh=(0,), motions=('none',), amplitude=0.0, transition='cut', transition_s=0.0)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

What an archetype emits: a span with regions, before media assignment.

### *class* muvid.montage.plan.Tile(, region, media, motion, variant, path, source_in=0.0, use=0)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One region of a slot: which media, framed and moved how.

#### source_in *: [float](https://docs.python.org/3/builtins/functions.html#float)*

In-point into a clip (seconds). Ignored for stills.

#### use *: [int](https://docs.python.org/3/builtins/functions.html#int)*

The ordinal of this use of the media, 0-based (drives variant/in-point).

### *class* muvid.montage.plan.Window(x, y, size)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A framing: top-left `(x, y)` and visible fraction `size` of the
fitted frame, all in `0..1`. The aspect is the canvas’s.

### muvid.montage.plan.assign_media(drafts, media, reuse, , finale_start=None)

Fill every region of every draft with `(media_index, use_ordinal)`.

Returns one `{region: (media, use)}` per draft, plus the policy’s own
account of what it did (reserved images, uses, the smallest gap it
actually produced), which goes into the plan for inspection.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)]]], [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]

```pycon
>>> from muvid.montage.analysis import Media
>>> pool = [Media(index=i, path=f'{i}.jpg', kind='photo', width=100, height=100,
...               strength=float(i)) for i in range(3)]
>>> drafts = [SlotDraft(start=i, end=i + 1, section='verse', archetype='beat_cut')
...           for i in range(7)]
>>> picks, stats = assign_media(drafts, pool, spec_mod.Reuse(min_gap=6))
>>> [p[0][0] for p in picks]      # gap shrinks to pool-1 = 2: a forced cycle
[0, 2, 1, 0, 2, 1, 0]
>>> stats['min_gap'], stats['min_observed_gap']
(2, 3)
```

### muvid.montage.plan.cut_times(section, analysis, beats_per_cut)

Slot boundaries inside `section`: `[start, cut, cut, ..., end]`.

Cuts are beats of the measured grid every `round(beats_per_cut)` beats
(anchored on the section’s first downbeat when that keeps them on the bar),
or, below one beat, evenly spaced subdivisions between consecutive beats.
A leading or trailing slot shorter than half the cut interval is folded into
its neighbour — a half-beat pickup is not a slot — and a cut within
`MIN_SLOT_S` of a boundary is dropped.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]

```pycon
>>> from muvid.montage.analysis import Analysis, Section
>>> a = Analysis(duration=8.0, tempo_bpm=120.0,
...              beats=tuple(i * 0.5 for i in range(16)),
...              downbeats=tuple(i * 2.0 for i in range(4)))
>>> cut_times(Section(label='verse', start=0.0, end=8.0), a, 4)
[0.0, 2.0, 4.0, 6.0, 8.0]
>>> cut_times(Section(label='chorus', start=1.0, end=4.0), a, 2)
[1.0, 2.0, 3.0, 4.0]
>>> cut_times(Section(label='chorus', start=0.0, end=2.0), a, 0.5)
[0.0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
>>> cut_times(Section(label='verse', start=1.5, end=6.8), a, 4)   # pickup + tail folded
[1.5, 4.0, 6.8]
```

### muvid.montage.plan.plan_montage(analysis, media, treatment)

Pool + measurements + treatment -> a [`Plan`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.Plan). Pure and deterministic.

* **Return type:**
  [`Plan`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.Plan)

### muvid.montage.plan.register_archetype(name)

Register an archetype planner under `name`.

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`Section`](_autosummary/muvid.montage.analysis.html.md#muvid.montage.analysis.Section), [`PlanContext`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.PlanContext), [`Mapping`](https://docs.python.org/3/library/typing.html#typing.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]], [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`SlotDraft`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.SlotDraft)]]], [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`Section`](_autosummary/muvid.montage.analysis.html.md#muvid.montage.analysis.Section), [`PlanContext`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.PlanContext), [`Mapping`](https://docs.python.org/3/library/typing.html#typing.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]], [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`SlotDraft`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.SlotDraft)]]]

```pycon
>>> @register_archetype('doctest-demo')
... def demo(section, ctx, params): return []
>>> 'doctest-demo' in ARCHETYPE_FNS
True
>>> del ARCHETYPE_FNS['doctest-demo']
```

### muvid.montage.plan.tile_path(variant, motion, length_s, , amplitude=0.08, punch_s=0.25)

The window path for one still: piecewise linear, slot-relative seconds.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`Keyframe`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.Keyframe), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

```pycon
>>> tile_path('full', 'none', 2.0)
(Keyframe(t=0.0, window=Window(x=0.0, y=0.0, size=1.0)),)
>>> [(k.t, k.window.size) for k in tile_path('full', 'zoom_in', 2.0, amplitude=0.1)]
[(0.0, 1.0), (2.0, 0.9)]
>>> [(k.t, k.window.size) for k in tile_path('centre', 'punch', 2.0, amplitude=0.1, punch_s=0.25)]
[(0.0, 0.63), (0.25, 0.7), (2.0, 0.7)]
```


# _autosummary/muvid.montage.render.html.md

# muvid.montage.render

Render a [`Plan`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.Plan) to a YouTube-spec mp4 with ffmpeg.

Bounded stages, the same shape as `muvid.footage.assemble`: \*\*one ffmpeg per
part\*\* (a slot’s solo span, or the blend window between two slots), a
**stream-copy concat** of the parts, and **one mux** of the song. Memory is
O(1) in the number of cuts, and a 300-slot montage never builds a 300-input
filtergraph — a chained `xfade` of that depth is the classic way to make
ffmpeg crawl.

Every part is frame-exact. Slot boundaries are rounded to frames ONCE
([`frame_layout()`](_autosummary/muvid.montage.render.html.md#muvid.montage.render.frame_layout)), a transition of `n` frames straddles its boundary
(`n//2` before, the rest after — centred on the beat, the NLE convention),
and the solo parts are shortened by exactly what the blends take, so the parts
sum to `round(duration * fps)` by construction rather than by luck.

A still is turned into motion by `zoompan=d=N` over ONE decoded frame — the
image is decoded once per part, not once per output frame — with the window
path compiled to expressions in `on` (the output frame counter) plus a frame
offset. That offset is what lets a blend part sample the SAME Ken Burns path
the solo part was on: the motion never restarts on a crossfade. Stills are
pre-fitted to `OVERSAMPLE` times the tile so `zoompan` always
downsamples, which is what avoids its well-known sub-pixel jitter.

`muvid.montage` does not use `looks.compile_motion` for this, although
`looks` is the house motion compiler: its fragment keys on `in_time` and
is built for video sources at `d=1`; over one still frame expanded by
`d=N` the input time never advances. The expression builder here is the
`on`-keyed twin of it, and it is a pure function with a doctest.

All ffmpeg goes through [`muvid.visualize.ffmpeg`](_autosummary/muvid.visualize.ffmpeg.html.md#module-muvid.visualize.ffmpeg); the encode arguments
are the visualizer’s own so a montage is the same H.264/AAC/no-edit-list file
the rest of muvid ships.

### Functions

| [`frame_layout`](_autosummary/muvid.montage.render.html.md#muvid.montage.render.frame_layout)(starts, duration, ...)               | Slot boundaries in frames, and the transition length into each slot.   |
|----------------------------------------------------------------------------------------------------|------------------------------------------------------------------------|
| [`grade_filter`](_autosummary/muvid.montage.render.html.md#muvid.montage.render.grade_filter)(grade[, accent, strength])           | The ffmpeg fragment for a grade, or `""` for none.                     |
| [`render_plan`](_autosummary/muvid.montage.render.html.md#muvid.montage.render.render_plan)(plan, \*, canvas, audio, output, ...) | Render `plan` over `audio` to `output`.                                |
| [`zoompan_exprs`](_autosummary/muvid.montage.render.html.md#muvid.montage.render.zoompan_exprs)(path, \*, frame_offset, fps)        | The `z`/`x`/`y` expressions for `zoompan` over a still.                |

### Classes

| [`Canvas`](_autosummary/muvid.montage.render.html.md#muvid.montage.render.Canvas)(\*[, width, height, fps])              | Output geometry.                         |
|------------------------------------------------------------------------------------------------|------------------------------------------|
| [`Part`](_autosummary/muvid.montage.render.html.md#muvid.montage.render.Part)(\*, kind, slot, offset, n_frames[, ...]) | One ffmpeg invocation's worth of frames. |

### *class* muvid.montage.render.Canvas(, width=1920, height=1080, fps=30)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Output geometry.

### *class* muvid.montage.render.Part(, kind, slot, offset, n_frames, prev=None, prev_offset=0, curve='fade')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One ffmpeg invocation’s worth of frames.

`offset` is the first frame of this part relative to `slot`’s start
(negative on the incoming side of a blend). A `blend` also names the
outgoing `prev` slot and its offset.

### muvid.montage.render.frame_layout(starts, duration, transitions_s, fps)

Slot boundaries in frames, and the transition length into each slot.

`starts[i]` is slot i’s start; `transitions_s[i]` the crossfade INTO
slot i (0 for a cut; slot 0’s is always 0). A transition is clamped to
half the shorter neighbour and dropped below `MIN_TRANSITION_FRAMES`.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`int`](https://docs.python.org/3/builtins/functions.html#int)], [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`int`](https://docs.python.org/3/builtins/functions.html#int)]]

```pycon
>>> frame_layout([0.0, 1.0, 2.0], 3.0, [0.0, 0.5, 0.05], 24)
([0, 24, 48, 72], [0, 12, 0])
>>> frame_layout([0.0, 0.1], 0.2, [0.0, 0.1], 24)   # 2-frame slots: no room
([0, 2, 5], [0, 0])
```

### muvid.montage.render.grade_filter(grade, accent='#e0533d', , strength=0.35)

The ffmpeg fragment for a grade, or `""` for none.

`tint` mixes `strength` of `luma * accent` into the frame — a closed
form over the accent colour, never a caller-supplied filter string.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> grade_filter('none')
''
>>> grade_filter('mono')
'hue=s=0'
>>> grade_filter('tint', '#ff0000', strength=0.5)
'colorchannelmixer=rr=0.6495:rg=0.2935:rb=0.057:gr=0:gg=0.5:gb=0:br=0:bg=0:bb=0.5'
```

### muvid.montage.render.render_plan(plan, , canvas, audio, output, workdir, grade='none', palette=None, crf=18, preset='fast', keep_parts=False)

Render `plan` over `audio` to `output`. Writes exactly `output`.

* **Parameters:**
  * **plan** ([`Plan`](_autosummary/muvid.montage.plan.html.md#muvid.montage.plan.Plan)) – The edit list. Its media paths are opened as-is.
  * **canvas** ([`Canvas`](_autosummary/muvid.montage.render.html.md#muvid.montage.render.Canvas)) – Output size and frame rate. Even dimensions only.
  * **audio** ([`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – The song; the video is exactly as long as it.
  * **output** ([`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – The mp4 to write.
  * **workdir** ([`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – Where the parts go (`workdir/parts`, removed after a
    successful mux unless `keep_parts`).
  * **grade** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – One of [`muvid.montage.spec.GRADES`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.GRADES).
  * **palette** ([`Palette`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.Palette) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Supplies the tint accent and the grid pad colour.
  * **crf** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – libx264 knobs, per part.
  * **preset** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – libx264 knobs, per part.
  * **keep_parts** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – Leave the intermediate parts on disk.
* **Return type:**
  [`RenderResult`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.RenderResult)
* **Returns:**
  A `RenderResult` whose `meta` carries the frame accounting,
  the parts count, and the verifier’s findings.

### muvid.montage.render.zoompan_exprs(path, , frame_offset, fps)

The `z`/`x`/`y` expressions for `zoompan` over a still.

`on` is the output frame counter; `frame_offset` shifts it so a part
that starts mid-slot (or, negative, before the slot’s first frame on the
incoming side of a blend) samples the slot’s own path.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

```pycon
>>> from muvid.montage.plan import Keyframe, Window
>>> path = (Keyframe(0.0, Window(0.0, 0.0, 1.0)), Keyframe(2.0, Window(0.1, 0.1, 0.8)))
>>> e = zoompan_exprs(path, frame_offset=12, fps=24)
>>> e['z']
'1/(1+(-0.2)*min(max((((on+12)/24)-0)/2,0),1))'
>>> e['x']
'iw*(0+(0.1)*min(max((((on+12)/24)-0)/2,0),1))'
>>> zoompan_exprs(path[:1], frame_offset=0, fps=24)
{'z': '1/(1)', 'x': 'iw*(0)', 'y': 'ih*(0)'}
```


# _autosummary/muvid.montage.spec.html.md

# muvid.montage.spec

The montage treatment spec — what a director (human or model) decides, as data.

Two layers, the same split as [`muvid.lyricvid.spec`](_autosummary/muvid.lyricvid.spec.html.md#module-muvid.lyricvid.spec) and for the same
reason: the half a model is good at is separated from the half it is not.

`direction`
: WHAT and WHY — a mood, the accent colour and grade, how the cutting should
  *feel* (a multiplier over the archetype’s own pacing), and the reuse policy
  that decides how a small pool carries a long song.

`scenes`
: HOW — an **archetype** from a closed set applied to named sections, plus a
  small closed set of parameters. Never a cut time, never a crop rectangle.

Three rules follow:

* **No timings from the model.** Every cut sits on a beat, a bar or a
  subdivision of the song’s measured grid; the spec names a density, Python
  applies it ([`muvid.montage.plan`](_autosummary/muvid.montage.plan.html.md#module-muvid.montage.plan)).
* **No geometry from the model.** Crops and moves come from a closed set of
  variants, chosen by the planner’s reuse policy so a revisited photo shows a
  different framing. A model that emitted rectangles would emit wrong ones.
* **Closed vocabularies everywhere**, so an invalid spec can be *projected*
  onto the valid space ([`repair()`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.repair)) rather than bounced back for a retry.

This module is stdlib-only and import-safe on purpose: it is on the listing
path of the plugin surface.

### Module Attributes

| [`ARCHETYPES`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.ARCHETYPES)       | How the pool is cut to the song.                                       |
|-------------------------------------------------------------------|------------------------------------------------------------------------|
| [`ARCHETYPE_PARAMS`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.ARCHETYPE_PARAMS) | The parameters each archetype accepts, as a small JSON Schema per key. |
| [`CUT_FEELS`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.CUT_FEELS)        | How the cutting should feel.                                           |
| [`CUT_FEEL_FACTORS`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.CUT_FEEL_FACTORS) | Multiplier on beats-per-cut per cut feel.                              |
| [`GRADES`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.GRADES)           | A colour grade applied to every frame.                                 |
| [`MOTIONS`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.MOTIONS)          | How a still moves within a slot.                                       |
| [`TRANSITIONS`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.TRANSITIONS)      | How a slot arrives.                                                    |
| [`SECTION_LABELS`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.SECTION_LABELS)   | Section labels the planner knows how to pace.                          |

### Functions

| [`coerce`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.coerce)(obj)            | Take whatever a caller or a model produced and return a plannable spec.               |
|-------------------------------------------------------------------------|---------------------------------------------------------------------------------------|
| [`json_schema`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.json_schema)()          | The JSON Schema for a [`TreatmentSpec`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.TreatmentSpec). |
| [`one_scene`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.one_scene)([archetype]) | The treatment a caller gets from `archetype=` alone.                                  |
| [`repair`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.repair)(spec)           | Project `spec` onto the valid space.                                                  |
| [`validate`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.validate)(spec)         | Return a list of human-readable problems.                                             |
| [`vocabulary`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.vocabulary)()           | Every closed vocabulary, for prompts and UI.                                          |

### Classes

| [`Direction`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.Direction)(\*[, mood, palette, cut_feel, ...])   | The song-level creative decision.                        |
|--------------------------------------------------------------------------------------------------|----------------------------------------------------------|
| [`Palette`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.Palette)(\*[, bg, accent])                       | Colours, as `#rrggbb`.                                   |
| [`Reuse`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.Reuse)(\*[, min_gap, reserve_for_finale])        | The reuse POLICY — how a small pool carries a long song. |
| [`Scene`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.Scene)(\*[, applies_to, archetype, params])      | One archetype, applied to part of the song.              |
| [`TreatmentSpec`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.TreatmentSpec)(\*[, spec_version, title, ...])   | A complete, plannable treatment.                         |

### muvid.montage.spec.ARCHETYPES *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [str](https://docs.python.org/3/builtins/stdtypes.html#str)]* *= {'ballad_dissolve': 'Cut every 2-4 bars on a downbeat, one-beat crossfades, a slow Ken Burns drift on each still. For slow songs and quiet sections.', 'beat_cut': "Hard cuts on the grid — every beat or half-bar in a chorus, every bar in a verse — with a punch-zoom on each cut. The 'photo beat sync' look.", 'grid': 'A 2x2 grid of tiles; one tile swaps on every beat. Dense and busy; wants a pool of eight or more.', 'stop_motion': 'Stills held for a beat subdivision with no motion at all — a flip-book. Mechanical, playful.'}*

How the pool is cut to the song. The planner owns the timing and geometry;
the spec only names the family and its knobs.

### muvid.montage.spec.ARCHETYPE_PARAMS *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [Any](https://docs.python.org/3/library/typing.html#typing.Any)]]]* *= {'ballad_dissolve': {'bars_per_cut': {'default': 4, 'description': 'Bars between cuts in a verse; a chorus halves it.', 'maximum': 16, 'minimum': 1, 'type': 'number'}, 'drift': {'default': 0.08, 'description': 'Ken Burns amplitude as a fraction of the frame.', 'maximum': 0.3, 'minimum': 0, 'type': 'number'}, 'fade_beats': {'default': 1, 'description': 'Crossfade length in beats. 0 is a hard cut.', 'maximum': 4, 'minimum': 0, 'type': 'number'}}, 'beat_cut': {'beats_per_cut': {'default': 4, 'description': 'Beats between cuts in a verse; a chorus halves it.', 'maximum': 16, 'minimum': 1, 'type': 'number'}, 'punch': {'default': 0.12, 'description': 'Punch-zoom amount on each cut (0 disables).', 'maximum': 0.3, 'minimum': 0, 'type': 'number'}}, 'grid': {'beats_per_swap': {'default': 2, 'description': 'Beats between tile swaps in a verse; a chorus halves it.', 'maximum': 16, 'minimum': 1, 'type': 'number'}}, 'stop_motion': {'subdivision': {'default': 2, 'description': 'Holds per beat (2 = eighth notes). A chorus doubles it, up to 4.', 'maximum': 4, 'minimum': 1, 'type': 'integer'}}}*

The parameters each archetype accepts, as a small JSON Schema per key. A key
outside this table is a validation error and is dropped by [`repair()`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.repair);
a value outside its range is clamped. Closed, so a plugin UI can render it.

### muvid.montage.spec.CUT_FEELS *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [str](https://docs.python.org/3/builtins/stdtypes.html#str)]* *= {'driving': 'Twice as many cuts. Pushes forward.', 'frantic': "Four times as many cuts, floored at the archetype's minimum.", 'slow': "Half as many cuts as the archetype's default. Contemplative.", 'steady': "The archetype's own pacing."}*

How the cutting should feel. A MULTIPLIER over each archetype’s own pacing,
so the same treatment reads “slow” on a ballad and “driving” on a banger
without the model naming a number of beats.

### muvid.montage.spec.CUT_FEEL_FACTORS *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [float](https://docs.python.org/3/builtins/functions.html#float)]* *= {'driving': 0.5, 'frantic': 0.25, 'slow': 2.0, 'steady': 1.0}*

Multiplier on beats-per-cut per cut feel. Smaller is more cuts.

### *class* muvid.montage.spec.Direction(\*, mood='', palette=<factory>, cut_feel='steady', grade='none', reuse=<factory>, rationale='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The song-level creative decision.

### muvid.montage.spec.GRADES *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [str](https://docs.python.org/3/builtins/stdtypes.html#str)]* *= {'mono': 'Desaturate to black and white.', 'none': 'The pool as shot.', 'tint': "Blend the palette's accent colour into the frame (a duotone-ish wash)."}*

A colour grade applied to every frame. Closed because the grade is
EXECUTABLE ffmpeg; the palette’s accent parameterises it, never a filter string.

### muvid.montage.spec.MOTIONS *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [str](https://docs.python.org/3/builtins/stdtypes.html#str)]* *= {'none': 'Held still.', 'pan_left': 'A slow drift leftward.', 'pan_right': 'A slow drift rightward.', 'punch': 'Arrives zoomed in and relaxes to rest within half a beat.', 'zoom_in': 'A slow push in.', 'zoom_out': 'A slow pull out.'}*

How a still moves within a slot. Assigned by the ARCHETYPE, never by the
spec — listed here so the plan’s vocabulary is closed and documented.

### *class* muvid.montage.spec.Palette(, bg='#000000', accent='#e0533d')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Colours, as `#rrggbb`. `accent` parameterises the `tint` grade;
`bg` is the letterbox/pad colour when a tile does not fill its region.

### *class* muvid.montage.spec.Reuse(, min_gap=6, reserve_for_finale=True)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The reuse POLICY — how a small pool carries a long song.

`min_gap` is how many cuts must pass before an image may return (it
shrinks automatically to `pool - 1` for a small pool). Every return uses
a different crop/move variant. `reserve_for_finale` holds the strongest
quarter of the pool back for the last chorus.

### muvid.montage.spec.SECTION_LABELS *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [str](https://docs.python.org/3/builtins/stdtypes.html#str)]* *= {'bridge': 'Chorus density.', 'chorus': 'Dense cuts; the last chorus gets the strongest images.', 'intro': 'Sparse cuts.', 'outro': 'Sparse cuts.', 'verse': "The archetype's verse pacing."}*

Section labels the planner knows how to pace. Others are paced as a verse.

### *class* muvid.montage.spec.Scene(\*, applies_to=('\*', ), archetype='beat_cut', params=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One archetype, applied to part of the song.

`applies_to` names sections by label, or `"*"` for the whole song. A
named scene wins its section; sections no scene names fall back to the
first `"*"` scene, so a one-scene spec is valid and complete.

### muvid.montage.spec.TRANSITIONS *: [dict](https://docs.python.org/3/builtins/stdtypes.html#dict)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [str](https://docs.python.org/3/builtins/stdtypes.html#str)]* *= {'cut': 'A hard cut.', 'dissolve': 'A noisy dissolve.', 'fade': 'A crossfade.', 'fadeblack': 'Dip to black.'}*

How a slot arrives. A curated subset of ffmpeg’s `xfade` transitions, the
same posture as `muvid.footage.edl.TRANSITION_CURVES`: a name outside it is
refused here rather than discovered as an ffmpeg error three stages later.

### *class* muvid.montage.spec.TreatmentSpec(\*, spec_version='1.0', title='', direction=<factory>, scenes=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A complete, plannable treatment.

```pycon
>>> s = TreatmentSpec()
>>> validate(s)
[]
>>> TreatmentSpec.from_dict(s.to_dict()) == s
True
```

#### *classmethod* from_dict(d)

Build from a plain mapping — TOTAL over what a model or a remote caller sends.

Missing keys default; wrong-shaped values are coerced toward the
field’s type rather than raised on; unknown keys are dropped. What
cannot be coerced falls to the default and [`repair()`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.repair) reports the
vocabulary-level substitutions afterwards.

* **Return type:**
  [`TreatmentSpec`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.TreatmentSpec)

```pycon
>>> s = TreatmentSpec.from_dict({'scenes': [None, {'applies_to': 'chorus',
...     'archetype': 'grid', 'params': 'fast'}],
...     'direction': {'palette': {'foo': 1, 'accent': '#00ff00'},
...                   'reuse': {'min_gap': '3'}}})
>>> s.scenes[1].applies_to, s.scenes[1].archetype, dict(s.scenes[1].params)
(('chorus',), 'grid', {})
>>> s.direction.palette.accent, s.direction.reuse.min_gap
('#00ff00', 3)
```

#### to_dict()

A JSON-native dict: tuples become lists, so what this emits is
exactly what [`json_schema()`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.json_schema) validates and what a file round-trips.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### muvid.montage.spec.coerce(obj)

Take whatever a caller or a model produced and return a plannable spec.

Accepts a [`TreatmentSpec`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.TreatmentSpec), a mapping, or a JSON string — and repairs
it. This is the one entry point production code should use.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`TreatmentSpec`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.TreatmentSpec), [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]

```pycon
>>> spec, notes = coerce('{"scenes": [{"archetype": "grid"}]}')
>>> spec.scenes[0].archetype, notes
('grid', [])
```

### muvid.montage.spec.json_schema()

The JSON Schema for a [`TreatmentSpec`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.TreatmentSpec).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> s = json_schema()
>>> s['properties']['scenes']['items']['properties']['archetype']['enum'][0]
'ballad_dissolve'
```

### muvid.montage.spec.one_scene(archetype='beat_cut', \*\*direction)

The treatment a caller gets from `archetype=` alone.

* **Return type:**
  [`TreatmentSpec`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.TreatmentSpec)

```pycon
>>> one_scene('grid', cut_feel='driving').scenes[0].archetype
'grid'
```

### muvid.montage.spec.repair(spec)

Project `spec` onto the valid space. Returns `(spec, notes)`.

A model that names a grade that does not exist has made a small, mechanical
mistake; projecting it onto the default renders something good now, where a
retry costs a round trip. Every substitution is reported.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`TreatmentSpec`](_autosummary/muvid.montage.spec.html.md#muvid.montage.spec.TreatmentSpec), [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]

```pycon
>>> fixed, notes = repair(TreatmentSpec(scenes=(Scene(archetype='swirl'),)))
>>> fixed.scenes[0].archetype
'beat_cut'
>>> notes
["scenes[0].archetype 'swirl' -> 'beat_cut'"]
>>> fixed, notes = repair(TreatmentSpec(scenes=(
...     Scene(archetype='beat_cut', params={'punch': 9, 'nope': 1}),)))
>>> dict(fixed.scenes[0].params), notes
({'punch': 0.3}, ['scenes[0].params.punch 9 -> 0.3', "scenes[0].params dropped unknown ['nope']"])
```

### muvid.montage.spec.validate(spec)

Return a list of human-readable problems. Empty means plannable.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

```pycon
>>> validate(TreatmentSpec(scenes=(Scene(archetype='nope'),)))
["scenes[0].archetype 'nope' is not one of the known archetypes"]
>>> validate(TreatmentSpec(scenes=(Scene(archetype='grid', params={'punch': 1}),)))
["scenes[0].params 'punch' is not a parameter of 'grid' (allowed: ['beats_per_swap'])"]
```

### muvid.montage.spec.vocabulary()

Every closed vocabulary, for prompts and UI. One copy, several readers.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]


# _autosummary/muvid.montage.tools.html.md

# muvid.montage.tools

The SSOT verbs for the montage subgenre: analyze, plan, render.

Plain functions, JSON-able arguments in, JSON-able `dict` out, and
deliberately agnostic about CLI/MCP/HTTP/agent — the same shape as
[`muvid.lyricvid.tools`](_autosummary/muvid.lyricvid.tools.html.md#module-muvid.lyricvid.tools). The CLI (`python -m muvid.montage`), the generic
subgenre MCP transport and a future frontend all call *these*, so there is one
implementation and one place a behaviour changes.

`plan` exists separately from `render` because the plan IS the creative
product: a caller can look at the edit list (which photo where, how the
choruses are paced, what got reserved for the finale) before paying for a
render, and `render` computes the identical plan — it is deterministic.

Nothing here imports numpy or ffmpeg at module scope.

### Functions

| [`analyze_song`](_autosummary/muvid.montage.tools.html.md#muvid.montage.tools.analyze_song)(audio, \*[, beats, ...])            | Measure a song: tempo, beat grid, downbeats, bar energy and sections.   |
|---------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| [`plan_montage`](_autosummary/muvid.montage.tools.html.md#muvid.montage.tools.plan_montage)(audio, \*[, photos, clips, ...])    | Plan the montage without rendering it: the edit list, as JSON.          |
| [`render_montage`](_autosummary/muvid.montage.tools.html.md#muvid.montage.tools.render_montage)(audio, output, \*[, photos, ...]) | Render a montage.                                                       |
| [`treatment_schema`](_autosummary/muvid.montage.tools.html.md#muvid.montage.tools.treatment_schema)()                               | JSON Schema for a treatment spec — also a model's output constraint.    |
| [`validate_treatment`](_autosummary/muvid.montage.tools.html.md#muvid.montage.tools.validate_treatment)(treatment)                    | Validate a treatment, and return the repaired version alongside.        |
| [`vocabulary`](_autosummary/muvid.montage.tools.html.md#muvid.montage.tools.vocabulary)()                                     | The closed vocabularies a treatment may draw on.                        |

### muvid.montage.tools.analyze_song(audio, , beats='auto', beats_per_bar=4, sections=None)

Measure a song: tempo, beat grid, downbeats, bar energy and sections.

The payload says WHERE the beats came from (`beat_source`) and whether
the sections were supplied or derived from energy — a caller that ignores
that will trust a fixed-tempo grid on a rubato ballad.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### muvid.montage.tools.plan_montage(audio, , photos=(), clips=(), cover=None, treatment=None, archetype=None, strict=False, beats='auto', beats_per_bar=4, sections=None, out=None)

Plan the montage without rendering it: the edit list, as JSON.

Identical to what [`render_montage()`](_autosummary/muvid.montage.tools.html.md#muvid.montage.tools.render_montage) would render. `out` writes it
to a file as well.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### muvid.montage.tools.render_montage(audio, output, , photos=(), clips=(), cover=None, treatment=None, archetype=None, strict=False, beats='auto', beats_per_bar=4, sections=None, width=1920, height=1080, fps=30, workdir=None)

Render a montage. The one verb that produces a file.

Goes through [`muvid.subgenres.render_subgenre()`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.render_subgenre) when the manifest is
registered (so the schemas are enforced), and straight to the pipeline
otherwise — the same renderer either way.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### muvid.montage.tools.treatment_schema()

JSON Schema for a treatment spec — also a model’s output constraint.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> treatment_schema()['type']
'object'
```

### muvid.montage.tools.validate_treatment(treatment)

Validate a treatment, and return the repaired version alongside.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> r = validate_treatment({'scenes': [{'archetype': 'swirl'}]})
>>> r['valid'], r['repairs']
(False, ["scenes[0].archetype 'swirl' -> 'beat_cut'"])
```

### muvid.montage.tools.vocabulary()

The closed vocabularies a treatment may draw on.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> sorted(vocabulary())[:3]
['archetype_params', 'archetypes', 'cut_feels']
```


# _autosummary/muvid.paths.html.md

# muvid.paths

The ONE place muvid decides where its data lives.

Every muvid part that persists anything answers *where* from here: the footage genre’s
stateful projects (`muvid/footage/workspace.py`), the visualizer’s per-user render
buckets (`muvid/mcp/workspace.py`), and — new with this module — the local, single-user
project folders the part-3 pipeline and the part-4 subgenres work in.

Default root `~/.local/share/muvid`, overridable with `$MUVID_DATA_HOME`. Per the
app-data-lifecycle rule this is the user-data dir and **never** the app/deploy tree: a
deploy’s `rsync --delete` treats anything it did not build as drift and erases it.

**Why this module exists at all, rather than a third copy of a four-line function.**
`data_home` and [`safe_component()`](_autosummary/muvid.paths.html.md#muvid.paths.safe_component) were duplicated verbatim in the two workspace
modules, and the part-3 surfaces had *no* copy — `root` is a required positional on
every part-3 CLI verb \*\*except `serve``**, and on
:class:`muvid.project.MusicVideoProject`, so nothing computed a default and the only
written-down answer lived in prose: ``.claude/skills/muvid/SKILL.md` and the `README`
both said `~/muvid/<song-stem>`. That was followed literally, which put 36 MB of a real
project — two generated song takes, three rendered videos, the poem sources — in
`~/muvid/il-pleut`: an app-named directory under `$HOME` that no deploy, backup or
tool owns. A default stated in prose drifts from the code and cannot be tested; one
computed by a function can be both. So the fix is not a better sentence, it is
[`project_root()`](_autosummary/muvid.paths.html.md#muvid.paths.project_root) plus a `muvid project-root` verb that the skill and the README
*call* instead of a path they *state*.

**\`\`serve\`\` is the exception, and it is the one that actually wrote.** `serve(root=".")`
defaults to the cwd, which is a fine convention for “the project I am standing in” — but
the UI has no create-a-project action and used to `mkdir -p` its way into whatever
directory it was pointed at, so `muvid serve` in any folder plus one save in the browser
scaffolded `script/` and `.muvid/` there. The fix is in `muvid/ui/app.py`, which now
refuses a root that is not a project, rather than here: the default is *right*, and it was
the absent precondition that was wrong. Do not restate the old “required on every verb”
claim — it was in this docstring, in `facade` and in `CLAUDE.md`, and it was false in
exactly the place it mattered.

A person choosing to keep their own working files in `~/Downloads` is a different thing
and not this module’s business — the rule is about where a *tool* writes by default.

### Module Attributes

| [`DATA_HOME_ENV_VAR`](_autosummary/muvid.paths.html.md#muvid.paths.DATA_HOME_ENV_VAR)   | Env var overriding the muvid data root.                                            |
|----------------------------------------------------------------------|------------------------------------------------------------------------------------|
| [`PROJECTS_KIND`](_autosummary/muvid.paths.html.md#muvid.paths.PROJECTS_KIND)       | The `{kind}` subfolder local (single-user, non-connector) project folders live in. |

### Functions

| [`data_home`](_autosummary/muvid.paths.html.md#muvid.paths.data_home)()                      | The muvid data root: `$MUVID_DATA_HOME` or `~/.local/share/muvid`.             |
|-----------------------------------------------------------------------------------|--------------------------------------------------------------------------------|
| [`project_root`](_autosummary/muvid.paths.html.md#muvid.paths.project_root)(name, \*[, kind])   | Where a local muvid project named `name` belongs: `{data_home}/{kind}/{name}`. |
| [`safe_component`](_autosummary/muvid.paths.html.md#muvid.paths.safe_component)(value, \*, label) | A single, traversal-safe path component (no `/`, `\`, `..`, or empties).       |

### muvid.paths.DATA_HOME_ENV_VAR *= 'MUVID_DATA_HOME'*

Env var overriding the muvid data root. ONE knob for the root, never one per kind —
N env vars where one belongs is the anti-pattern the app-data-lifecycle rule names.

### muvid.paths.PROJECTS_KIND *= 'projects'*

The `{kind}` subfolder local (single-user, non-connector) project folders live in.
Data never goes at the root itself: leaving the root open is what let the footage
genre add `music_video/` and the visualizer `visualizer/` without a migration.

**Deliberately not the part-3 genre slug issue #4 will pick** (recommended there:
`music-video-ai`), and deliberately kept despite reading like
`music_video/projects/{email}/` one level down. Two reasons. The connector layout is
per-email and per-`project_id` — `{root}/{slug}/projects/{email}/{id}` — while this
is a local, single-user, per-*name* folder the CLI creates; they are different shapes,
so #4 landing does not imply these folders move, and naming this after a slug that is
still only *recommended* would pre-commit to it. The apparent collision is
disambiguated by depth: `projects/` at depth 1 is local, `projects/` under a genre
slug is the connector’s. If #4 decides otherwise, this is a one-word change plus a
migration of whatever exists by then — which is why it is a named constant.

### muvid.paths.data_home()

The muvid data root: `$MUVID_DATA_HOME` or `~/.local/share/muvid`.

The override is `expanduser`-ed and **required to be absolute**, because this path
is now printed by `muvid project-root` for a caller to interpolate. Without the
first, `MUVID_DATA_HOME=~/store` yields a *literal* `~` directory, so the printed
string and what [`muvid.project.MusicVideoProject`](_autosummary/muvid.project.html.md#muvid.project.MusicVideoProject) resolves it to are two
different places. Without the second, a relative override roots the whole data store
at the cwd — which, run from an app directory, is the very failure this module exists
to prevent, reached through its own knob. A misconfigured root is refused loudly
rather than read as something plausible.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### muvid.paths.project_root(name, , kind='projects')

Where a local muvid project named `name` belongs: `{data_home}/{kind}/{name}`.

Returns the path without creating it — `muvid init` (or a subgenre’s own renderer)
owns creation, and a resolver that made directories could not be used to *ask* where
a project would go.

`kind` is the seam for a surface that wants its own subtree rather than sharing
`projects/`; the connector-facing genres already use their own (`music_video/`,
`visualizer/`) through the workspace classes that own those layouts.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### muvid.paths.safe_component(value, , label)

A single, traversal-safe path component (no `/`, `\`, `..`, or empties).

Every caller-supplied name reaching the data root goes through this. On the footage
MCP surface the names arrive from a remote OAuth caller, so there it is a trust
boundary rather than a tidiness check; [`project_root()`](_autosummary/muvid.paths.html.md#muvid.paths.project_root) is CLI-only and not on
that surface. Behaviour is unchanged from the two copies this replaced — notably
there is no length cap and no unicode normalisation, both pre-existing.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)


# _autosummary/muvid.project.html.md

# muvid.project

Project facade — folder layout, persistence, and a small `dol`-backed mall.

A `MusicVideoProject` is just a directory with a `project.json`. Everything
else (lyrics, characters, environments, shots) lives in a predictable
sub-folder so external tools (`lookbook`, `lacing`, `mixing`, `an`)
can address slices of it directly.

The mall is a `MutableMapping` view over the same folders: useful when
called from a notebook or agent that wants to write a single character card
without learning the full schema.

### Classes

| [`MusicVideoProject`](_autosummary/muvid.project.html.md#muvid.project.MusicVideoProject)(root)   | Filesystem-backed music video project.   |
|----------------------------------------------------------------------------|------------------------------------------|

### *class* muvid.project.MusicVideoProject(root)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Filesystem-backed music video project.

All write methods touch the disk immediately; readers always re-read
the SSOT (no in-memory cache) so external edits are picked up.

#### *classmethod* init(root, , title='', song_path=None, copy_song=True, exist_ok=False)

Create a fresh project directory.

If `song_path` is given, the audio is copied (or moved if
`copy_song=False`) into `song/`, probed for duration, and
registered in `project.json`.

* **Return type:**
  [`MusicVideoProject`](_autosummary/muvid.project.html.md#muvid.project.MusicVideoProject)

#### log_decision(kind, \*\*payload)

Append a one-line JSON entry to `.muvid/decisions.jsonl`.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

#### set_song(source, , copy=True)

Register an audio file as this project’s song.

The file is copied (or moved) to `song/`, probed for duration
with ffprobe, and recorded in `project.json`.

* **Return type:**
  [`SongInfo`](_autosummary/muvid.schema.html.md#muvid.schema.SongInfo)

#### update_spec(\*\*changes)

Read, replace, write. Returns the new spec.

* **Return type:**
  [`ProjectSpec`](_autosummary/muvid.schema.html.md#muvid.schema.ProjectSpec)


# _autosummary/muvid.renderers.animation.html.md

# muvid.renderers.animation

Render strategy: animation — handoff to the `an` package.

We synthesize a minimal `an` scene for this shot’s interval: each
lyric line becomes a dialogue beat for the singing character; the shot’s
environment becomes the entity backdrop.

Lipsync alignment: muvid already owns the SSOT for word timings (the
`lacing` alignment store written by `muvid align`). We build a
`an.audio.WordTimingsLipSync` from those timings and pass it
into `an.orchestrate` so `an` does NOT re-transcribe the same
audio with whisper. Falls back to `an`’s default lipsync provider
when no alignment store exists yet (e.g. user skipped `muvid align`).

Camera: muvid and `an` do not share a camera vocabulary and must not
pretend to. `muvid.schema.ShotSpec.camera` is free prose a director
writes into the script (`**camera**: slow push-in`); `an`’s
`camera.move` is a closed set of named moves, and a name outside it is a
hard refusal at both validate and compile — deliberately, because a camera
move that silently no-ops is the failure it exists to prevent. So the prose
is TRANSLATED here, at the boundary, and never passed through
(muvid#44: this module emitted `move: static`, which `an` has never
implemented, so every animation render failed validate and fell back).

Failure handling: an engine that never ran is not an engine that ran and
refused, and muvid#46 was filed because this module collapsed the two. `an`
states a refusal as *data* (`OrchestratorReport.success is False`), so the old
handling was one `if` that discarded `report.error`, `report.validation`
and `report.verifications` and returned a still image under the shot’s own
filename. The output was wrong rather than absent (a freeze frame reads as a
creative choice), the provenance line recorded the REQUESTED strategy so the
affected shots could not be found afterwards, and `still` can reach
`falaw.generate_image` — a silent degradation that bills, under a gate that
`cost.py` had already told the shot was free. Now: a missing `an` raises
`RendererUnavailable` and the DISPATCHER
degrades and journals it, because the dispatcher is where the provenance line is
written; an `an` that refuses raises
`AnimationRenderError` carrying every finding.

That closed set is not fixed, and muvid declares no `an` floor — `an` is
in no extra, the import below is soft, and a user may have any version.
`hold`/`push_in`/`pull_out`/`zoom_in`/`zoom_out` have always been
there; the four TRANSLATING moves (`pan_left`, `pan_right`, `tilt_up`,
`tilt_down`) arrived with an#109 and shipped in `an` 0.1.65 — on 0.1.64
and below, emitting one of them is muvid#44 again. So
[`an_camera_move()`](_autosummary/muvid.renderers.animation.html.md#muvid.renderers.animation.an_camera_move) checks the move against the vocabulary the INSTALLED
`an` reports (`an.ir.camera.CAMERA_MOVES`) and degrades to `hold` with
a warning rather than emitting a move that will be refused. That is the floor,
enforced at the only place that can see which `an` is actually there.

### Module Attributes

| [`DFLT_AN_CAMERA_MOVE`](_autosummary/muvid.renderers.animation.html.md#muvid.renderers.animation.DFLT_AN_CAMERA_MOVE)    | `an`'s spelling of "the camera does not move", and what a direction this table cannot name resolves to.                     |
|-------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|
| [`AN_CAMERA_MOVE_PHRASES`](_autosummary/muvid.renderers.animation.html.md#muvid.renderers.animation.AN_CAMERA_MOVE_PHRASES) | muvid prose -> `an` move name — the WHOLE recognised vocabulary, including the several ways a director spells "don't move". |

### Functions

| [`an_camera_move`](_autosummary/muvid.renderers.animation.html.md#muvid.renderers.animation.an_camera_move)(direction, \*[, known_moves])   | Translate a muvid camera direction into an `an` `camera.move` name.   |
|-------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------|
| [`render_animation`](_autosummary/muvid.renderers.animation.html.md#muvid.renderers.animation.render_animation)(ctx, \*[, quality])           | Synthesize a tiny `an` scene for this shot and orchestrate it.        |

### muvid.renderers.animation.AN_CAMERA_MOVE_PHRASES *: [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [str](https://docs.python.org/3/builtins/stdtypes.html#str)], ...]* *= (('push in', 'push_in'), ('push into', 'push_in'), ('dolly in', 'push_in'), ('zoom in', 'zoom_in'), ('pull out', 'pull_out'), ('pull back', 'pull_out'), ('dolly out', 'pull_out'), ('zoom out', 'zoom_out'), ('pan left', 'pan_left'), ('pan right', 'pan_right'), ('tilt up', 'tilt_up'), ('tilt down', 'tilt_down'), ('crane up', 'tilt_up'), ('crane down', 'tilt_down'), ('static', 'hold'), ('hold', 'hold'), ('locked', 'hold'), ('locked off', 'hold'), ('lock off', 'hold'), ('no movement', 'hold'))*

muvid prose -> `an` move name — the WHOLE recognised vocabulary, including
the several ways a director spells “don’t move”. A direction that matches
nothing here is not silently translated: [`an_camera_move()`](_autosummary/muvid.renderers.animation.html.md#muvid.renderers.animation.an_camera_move) warns, so a
dropped direction is visible in the run rather than only in the render.

Matching is by WORD, never by substring, and the move that occurs EARLIEST in
the direction wins — the director’s first-written move, not this table’s
declaration order. Ties (two phrases starting at the same word) go to the
longer phrase, so `push into` beats `push in` on specificity rather than
on which line happens to come first. Declaration order is the last
tie-break and decides nothing today.

The values are `an`’s vocabulary and nothing else. They are pinned in CI
against `tests.test_animation_camera`’s recorded snapshot of
`an.ir.camera.CAMERA_MOVES`, and — on a machine that has `an` — against
the live set. A hand-copied vocabulary is exactly how `static` survived
here after `an` tightened the rule.

`an` distinguishes a push (1.0->1.25) from a zoom (1.0->1.5), so the prose
does too: a “push” is the gentler move, “zoom” the emphatic one.

### muvid.renderers.animation.DFLT_AN_CAMERA_MOVE *= 'hold'*

`an`’s spelling of “the camera does not move”, and what a direction this
table cannot name resolves to. Not a guess dressed as a move: an
uninterpretable direction is a reason to leave the camera alone, not to
invent a push-in the director did not ask for. It is also what this module
effectively meant by the invalid `static` it used to emit.

### muvid.renderers.animation.an_camera_move(direction, , known_moves=None)

Translate a muvid camera direction into an `an` `camera.move` name.

Never returns a name the `an` in front of it cannot honour. Two
independent ways that can happen, and each one WARNS rather than passing
silently — a dropped camera direction is the failure muvid#44 was:

- the direction names no move this table knows (“handheld, drifting”), or
- it names one the *installed* `an` does not implement (`pan_left` is
  an#109, released in `an` 0.1.65; on 0.1.60 it is a hard refusal at
  validate and at compile).

`known_moves` is the vocabulary to check against; `None` means “ask the
installed `an`”, and when `an` is absent nothing is narrowed.

```pycon
>>> an_camera_move("slow push-in")
'push_in'
>>> an_camera_move("static")
'hold'
>>> an_camera_move("")
'hold'
>>> an_camera_move("Pan Left across the room")
'pan_left'
```

The director’s first-written move wins, not this table’s order, and a
negated move is refused rather than obeyed:

```pycon
>>> an_camera_move("pan left, then push in")
'pan_left'
>>> an_camera_move("static, no push-in")
'hold'
```

An `an` that cannot do the move gets the no-op instead of a refusal:

```pycon
>>> an_camera_move("pan left", known_moves={"hold", "push_in"})
'hold'
```

A direction naming nothing is not silently dropped:

```pycon
>>> import warnings
>>> with warnings.catch_warnings(record=True) as caught:
...     warnings.simplefilter("always")
...     an_camera_move("handheld, drifting")
'hold'
>>> "handheld, drifting" in str(caught[0].message)
True
```

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.renderers.animation.render_animation(ctx, , quality='balanced')

Synthesize a tiny `an` scene for this shot and orchestrate it.

Raises `RendererUnavailable` when `an`
is not installed — the dispatcher answers that by rendering a `still` and
journalling that it did. Raises
`AnimationRenderError` when `an` IS
installed and refuses the scene, because that is a bug in what muvid
synthesized and a still image is a wrong answer, not a lesser one (muvid#46).

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)


# _autosummary/muvid.renderers.html.md

# muvid.renderers

Render dispatch — turn a single ShotSpec into `shots/<id>/output.mp4`.

Each render strategy is a small function `render_<strategy>(project,
shot, *, audio_slice_path, ctx) -> Path`. The dispatcher resolves
shared dependencies (audio slice, lyric lines that fall in the shot,
character anchor image, environment anchor image) once and passes them
in.

Caching: each shot output’s name is content-derived. If
`shots/<id>/output.mp4` exists and the recorded `shot.json` hash
matches the current ShotSpec, we skip.

### Functions

| [`billed_video_seconds`](_autosummary/muvid.renderers.html.md#muvid.renderers.billed_video_seconds)(duration_s)                  | The seconds a video-generation call is BILLED for a shot of this length.                                         |
|----------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------|
| `render_all`(project, \*[, quality, force])                                                        |                                                                                                                  |
| [`render_shot`](_autosummary/muvid.renderers.html.md#muvid.renderers.render_shot)(project, shot_id, \*[, quality, ...]) | Render a single shot.                                                                                            |
| [`shot_is_rendered`](_autosummary/muvid.renderers.html.md#muvid.renderers.shot_is_rendered)(project, shot, global_style, \*) | Whether [`render_shot()`](_autosummary/muvid.renderers.html.md#muvid.renderers.render_shot) would return the cached output untouched. |

### Classes

| [`RenderContext`](_autosummary/muvid.renderers.html.md#muvid.renderers.RenderContext)(\*, project, shot, shot_dir, ...)   | Shared resolved inputs for rendering a shot.   |
|----------------------------------------------------------------------------------------------------|------------------------------------------------|

### *class* muvid.renderers.RenderContext(, project, shot, shot_dir, audio_slice_path, character_image_paths, environment_image_path, lyric_lines, global_style='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Shared resolved inputs for rendering a shot.

### muvid.renderers.billed_video_seconds(duration_s)

The seconds a video-generation call is BILLED for a shot of this length.

The fal video models take an integer duration with a floor of one second —
`max(1, round())` is the exact expression both video renderers send — so
an estimate must price THIS number, never the raw float: a 0.4 s shot is
billed one second, and a zero-duration shot priced at $0.00 with
`has_unknown_costs` false is the muvid#52 defect one level down (the
estimate paraphrasing the renderer’s arithmetic instead of calling it).

* **Return type:**
  [`int`](https://docs.python.org/3/builtins/functions.html#int)

### muvid.renderers.render_shot(project, shot_id, , quality='balanced', force=False)

Render a single shot. Returns the path to the produced mp4.

Skipped (returns the existing path) if a previously-rendered output
matches the current shot definition’s hash, unless `force=True` —
the [`shot_is_rendered()`](_autosummary/muvid.renderers.html.md#muvid.renderers.shot_is_rendered) predicate, which the cost estimate shares.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### muvid.renderers.shot_is_rendered(project, shot, global_style, , force=False)

Whether [`render_shot()`](_autosummary/muvid.renderers.html.md#muvid.renderers.render_shot) would return the cached output untouched.

THE definition of “already rendered” — there is exactly one, and both the
renderer and `muvid.cost.estimate_render_cost` call it. The estimate used
to apply a weaker proxy (`output.mp4` exists), so an edited-but-rendered
shot (file present, hash stale) and a `--force` run were both priced at
$0.00 and then billed (muvid#52) — the same defect family as “unknown reads
as free” (muvid#47), with “pending reads as done” as the variant. Two
predicates that must agree is the shape that produced it; this function is
the agreement.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

### Modules

| [`animation`](_autosummary/muvid.renderers.animation.html.md#module-muvid.renderers.animation)           | Render strategy: animation — handoff to the `an` package.           |
|-------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------|
| [`image_to_video`](_autosummary/muvid.renderers.image_to_video.html.md#module-muvid.renderers.image_to_video) | Render strategy: image_to_video.                                    |
| [`lipsync`](_autosummary/muvid.renderers.lipsync.html.md#module-muvid.renderers.lipsync)               | Render strategy: lipsync.                                           |
| [`still`](_autosummary/muvid.renderers.still.html.md#module-muvid.renderers.still)                   | Render strategy: still — a single image held for the shot duration. |
| [`text_to_video`](_autosummary/muvid.renderers.text_to_video.html.md#module-muvid.renderers.text_to_video)   | Render strategy: text_to_video.                                     |


# _autosummary/muvid.renderers.image_to_video.html.md

# muvid.renderers.image_to_video

Render strategy: image_to_video.

Workflow:

1. If we have an environment anchor image, use it as the i2v seed.
   Else, generate a fresh storyboard still via `falaw.generate_image`.
2. Call `falaw.image_to_video(image, prompt, extra={duration: ...})`.
3. Trim/pad to the shot’s exact duration.

### Functions

| `render_image_to_video`(ctx, \*[, quality])   |    |
|-----------------------------------------------|----|


# _autosummary/muvid.renderers.lipsync.html.md

# muvid.renderers.lipsync

Render strategy: lipsync.

For shots where a character is “singing on screen”. We need a still
image of the character (the curated anchor) plus the audio slice. Calls
`falaw.animate_face` (image+audio → talking video).

If multiple characters are present, we pick the first one and warn —
multi-character lipsync isn’t in v0.

### Functions

| `render_lipsync`(ctx, \*[, quality])   |    |
|----------------------------------------|----|


# _autosummary/muvid.renderers.still.html.md

# muvid.renderers.still

Render strategy: still — a single image held for the shot duration.

Cheapest possible render. Uses ffmpeg to loop the image and mux the
shot’s audio slice onto it. If we have an environment anchor, we use
it; otherwise we generate a still via `falaw.generate_image`.

### Functions

| `render_still`(ctx, \*[, quality])   |    |
|--------------------------------------|----|


# _autosummary/muvid.renderers.text_to_video.html.md

# muvid.renderers.text_to_video

Render strategy: text_to_video. Pure prompt → video clip.

### Functions

| `render_text_to_video`(ctx, \*[, quality])   |    |
|----------------------------------------------|----|


# _autosummary/muvid.schema.html.md

# muvid.schema

Schema for an muvid project — the SSOT data shapes.

A music video project is a folder. `project.json` at the root holds the
plan: song metadata, named characters/environments, song sections, and
shots with start/end times and a chosen render strategy. Every other
file in the project (lyrics, alignment, character cards, storyboards,
shot videos) is a derived artifact that points back to entries here.

Schemas are dataclasses (frozen=True) so equality/hash are structural
and edits create new instances. `to_dict` / `from_dict` are
mechanical (asdict / kwargs); `schema_version` lets us migrate later.

### Module Attributes

| [`RenderStrategy`](_autosummary/muvid.schema.html.md#muvid.schema.RenderStrategy)   | Render strategies a single shot can use.   |
|-------------------------------------------------------------------|--------------------------------------------|

### Classes

| [`CharacterRef`](_autosummary/muvid.schema.html.md#muvid.schema.CharacterRef)(\*, name[, description])             | Pointer to a character folder under `characters/<name>/`.      |
|----------------------------------------------------------------------------------------------------|----------------------------------------------------------------|
| [`EnvironmentRef`](_autosummary/muvid.schema.html.md#muvid.schema.EnvironmentRef)(\*, name[, description])           | Pointer to an environment folder under `environments/<name>/`. |
| [`ProjectSpec`](_autosummary/muvid.schema.html.md#muvid.schema.ProjectSpec)(\*[, schema_version, title, ...])     | The top-level project SSOT, persisted as `project.json`.       |
| [`SectionSpec`](_autosummary/muvid.schema.html.md#muvid.schema.SectionSpec)(\*, id, start_s, end_s[, label, ...]) | A non-overlapping span of the song with a label.               |
| [`ShotSpec`](_autosummary/muvid.schema.html.md#muvid.schema.ShotSpec)(\*, id, start_s, end_s[, ...])           | A timeline-locked visual unit of the music video.              |
| [`SongInfo`](_autosummary/muvid.schema.html.md#muvid.schema.SongInfo)(\*, audio_path, duration_s[, ...])       | Metadata for the master audio file.                            |

### *class* muvid.schema.CharacterRef(, name, description='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Pointer to a character folder under `characters/<name>/`.

The folder contains the canonical card.json + curated reference
images. We only carry the name + a quick description here so the
project SSOT stays small.

### *class* muvid.schema.EnvironmentRef(, name, description='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Pointer to an environment folder under `environments/<name>/`.

### *class* muvid.schema.ProjectSpec(, schema_version=1, title='', song=None, characters=(), environments=(), sections=(), shots=(), global_style='', notes='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The top-level project SSOT, persisted as `project.json`.

### muvid.schema.RenderStrategy

Render strategies a single shot can use. The `render` subpackage
dispatches on this string.

alias of [`Literal`](https://docs.python.org/3/library/typing.html#typing.Literal)[‘lipsync’, ‘image_to_video’, ‘text_to_video’, ‘animation’, ‘still’]

### *class* muvid.schema.SectionSpec(, id, start_s, end_s, label='', energy='', mood='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A non-overlapping span of the song with a label.

`label` is free-form (“intro”, “verse”, “chorus”, “bridge”,
“outro”) so users can use whatever taxonomy fits their song.

### *class* muvid.schema.ShotSpec(, id, start_s, end_s, section_id='', render_strategy='image_to_video', environment='', characters=(), description='', camera='', framing='medium', notes='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A timeline-locked visual unit of the music video.

`[start_s, end_s)` is half-open. Shots within a project are
sorted by `start_s` and should be non-overlapping (the validator
warns otherwise — overlap can be intentional for transitions but
isn’t supported by the basic compositor).

### *class* muvid.schema.SongInfo(, audio_path, duration_s, sample_rate=0, bitrate=0, bpm=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Metadata for the master audio file.


# _autosummary/muvid.script.html.md

# muvid.script

Script (screenplay) markdown ↔ ShotSpec list.

The script is a human-friendly markdown anchored to the song timeline.
Each `###` header introduces a shot with a header line of the form:

```default
### <id> | <start>-<end> | <strategy>
```

Then optional `**key**: value` lines (`env`, `chars`, `camera`,
`framing`) followed by a free-form prose description block. Sections
are introduced by `## [<label>] <start> → <end>` headers but are
optional (the project-level sections list is the SSOT for those).

The parse is intentionally lenient — we ignore anything we don’t
understand — so the agent can write the file and the user can edit it
without learning a strict grammar.

### Functions

| [`parse_and_apply`](_autosummary/muvid.script.html.md#muvid.script.parse_and_apply)(project, \*[, path])   | Parse `script/script.md` and upsert any sections/shots it defines.   |
|-----------------------------------------------------------------------------------------|----------------------------------------------------------------------|
| [`parse_script`](_autosummary/muvid.script.html.md#muvid.script.parse_script)(md)                       | Parse a script markdown string into (sections, shots).               |
| [`render_script`](_autosummary/muvid.script.html.md#muvid.script.render_script)(sections, shots)         | Inverse of `parse_script`.                                           |
| [`write_script`](_autosummary/muvid.script.html.md#muvid.script.write_script)(project)                  | Render the project's current sections+shots to `script/script.md`.   |

### muvid.script.parse_and_apply(project, , path=None)

Parse `script/script.md` and upsert any sections/shots it defines.

Existing sections/shots not present in the script are left alone.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

### muvid.script.parse_script(md)

Parse a script markdown string into (sections, shots).

Sections are returned only when explicitly headed with `## [label]`;
otherwise it’s an empty list and you should rely on the project’s
section list separately.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`SectionSpec`](_autosummary/muvid.schema.html.md#muvid.schema.SectionSpec)], [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`ShotSpec`](_autosummary/muvid.schema.html.md#muvid.schema.ShotSpec)]]

### muvid.script.render_script(sections, shots)

Inverse of `parse_script`. Writes the canonical markdown form.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.script.write_script(project)

Render the project’s current sections+shots to `script/script.md`.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)


# _autosummary/muvid.subgenres.html.md

# muvid.subgenres

muvid subgenres — the plugin surface for *another kind of video*.

muvid’s three parts each know how to make one thing. A **subgenre** is how a
fourth kind gets added — by muvid, or by anyone else — without editing muvid.

> from muvid.subgenres import Subgenre, register_subgenre

> MY_THING = Subgenre(
> : slug=”ransom-note”,
>   title=”Ransom note”,
>   description=”Lyrics cut from magazines, one word per beat.”,
>   render=”my_package.render:render”,       # a STRING, imported lazily
>   inputs={“type”: “object”, “required”: [“audio”],
>   <br/>
>   > “properties”: {“audio”: {“type”: “string”, “format”: “path”}}},

> )

Ship it by pointing an entry point at the manifest:

```default
[project.entry-points."muvid.subgenres.v1"]
ransom-note = "my_package.subgenre:MY_THING"
```

Two things make this worth having over a plain dict:

**The entry point’s value is a manifest, not a renderer.** Listing every
installed subgenre imports no rendering code, so a UI or an LLM can choose among
twelve of them in microseconds and a plugin whose heavy dependency is missing
degrades to a recorded error instead of taking the catalogue down.

\*\*It composes with `nw` rather than competing with it.\*\* `nw.Genre` is the
cross-package catalogue a host connector reads; a muvid subgenre is one more
`nw.Template` underneath muvid’s own genre, carrying `params={"subgenre":
slug}`. So a plugin author depends on `muvid` and nothing else, and the
connector picks the plugin up for free.

Where each piece lives:

* `muvid.subgenres._manifest` — the [`Subgenre`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.Subgenre) data, stdlib only.
* `muvid.subgenres._registry` — registration, discovery, resolution.
* `muvid.subgenres._contract` — the [`Renderer`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.Renderer) protocol.
* [`muvid.subgenres.testing`](_autosummary/muvid.subgenres.testing.html.md#module-muvid.subgenres.testing) — a conformance kit for plugin authors.

This module imports nothing heavier than the standard library, so
`import muvid.subgenres` is safe on any import-safe path.

### Functions

| [`get_subgenre`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.get_subgenre)(slug, \*[, refresh])           | The manifest for `slug`.                                                        |
|----------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------|
| [`iter_subgenres`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.iter_subgenres)(\*[, refresh])               | Iterate manifests in slug order.                                                |
| [`list_subgenres`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.list_subgenres)(\*[, refresh])               | Every available subgenre slug, sorted.                                          |
| [`register_subgenre`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.register_subgenre)(subgenre)                 | Register a subgenre in this process.                                            |
| [`render_subgenre`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.render_subgenre)(slug, \*, inputs[, params]) | Render `slug` — the one call that imports plugin code.                          |
| [`resolve_renderer`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.resolve_renderer)(slug)                      | Import and return the renderer for `slug`.                                      |
| [`subgenre_catalog`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.subgenre_catalog)(\*[, refresh])             | JSON-able catalogue: what a CLI, an HTTP route, an MCP tool or an agent serves. |
| [`unregister_subgenre`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.unregister_subgenre)(slug)                   | Remove an in-process registration.                                              |

### Classes

| [`Example`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.Example)(\*, description[, params, inputs, ...])   | One invocation worth showing to a human or an agent.   |
|----------------------------------------------------------------------------------------------------|--------------------------------------------------------|
| [`RenderRequest`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.RenderRequest)(\*, subgenre, inputs[, params])     | Everything a subgenre renderer gets.                   |
| [`RenderResult`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.RenderResult)(\*, output[, duration_s, ...])       | What a renderer gives back.                            |
| [`Renderer`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.Renderer)(\*args, \*\*kwargs)                      | `(RenderRequest) -> RenderResult`, and nothing else.   |
| [`Subgenre`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.Subgenre)(\*, slug, title, description, render)    | A declared kind of video, and how to render one.       |

### *class* muvid.subgenres.Example(\*, description, params=<factory>, inputs=<factory>, preview=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One invocation worth showing to a human or an agent.

Agents pick better from examples than from prose — the Remotion registry’s
finding, and the reason this is a first-class field rather than something
buried in `description`.

#### inputs *: [Mapping](https://docs.python.org/3/library/typing.html#typing.Mapping)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [Any](https://docs.python.org/3/library/typing.html#typing.Any)]*

Inputs for this example, when they can be stated without a local path.
The conformance kit uses the first Example’s inputs for its trial render.

#### preview *: [str](https://docs.python.org/3/builtins/stdtypes.html#str) | [None](https://docs.python.org/3/builtins/constants.html#None)*

Optional path, relative to the plugin package, of a short sample render.

### *class* muvid.subgenres.RenderRequest(\*, subgenre, inputs, params=<factory>, workdir, output)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Everything a subgenre renderer gets.

* **Parameters:**
  * **subgenre** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – the slug being rendered, so one function can serve several.
  * **inputs** ([`Mapping`](https://docs.python.org/3/library/typing.html#typing.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]) – the caller’s files and primitives, validated against the
    manifest’s `inputs` schema before it gets here.
  * **params** ([`Mapping`](https://docs.python.org/3/library/typing.html#typing.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]) – the styling/treatment knobs, validated against
    `params_schema`.
  * **workdir** ([`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – a directory the renderer may write intermediates into. It
    exists, and it belongs to this render — nothing else writes there.
  * **output** ([`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – where the finished artifact must be written.

### *class* muvid.subgenres.RenderResult(\*, output, duration_s=None, artifacts=<factory>, meta=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

What a renderer gives back.

* **Parameters:**
  * **output** ([`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – the finished artifact. Must be the request’s `output`.
  * **duration_s** ([`float`](https://docs.python.org/3/builtins/functions.html#float) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – length of the produced media, when it has one.
  * **artifacts** ([`Mapping`](https://docs.python.org/3/library/typing.html#typing.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)]) – named side products worth keeping — a `.ass` subtitle
    file, a thumbnail, the resolved treatment spec. These are how a
    subgenre stays inspectable instead of producing an opaque file.
  * **meta** ([`Mapping`](https://docs.python.org/3/library/typing.html#typing.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]) – anything else worth recording; must be JSON-able.

### *class* muvid.subgenres.Renderer(\*args, \*\*kwargs)

Bases: [`Protocol`](https://docs.python.org/3/library/typing.html#typing.Protocol)

`(RenderRequest) -> RenderResult`, and nothing else.

### *class* muvid.subgenres.Subgenre(\*, slug, title, description, render, inputs=<factory>, params_schema=<factory>, produces='video/mp4', examples=(), intake_kinds=(), cost_profile=None, provider=None, api_versions=())

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A declared kind of video, and how to render one.

Everything here is JSON-able except `render`, which is a *reference*
to a callable rather than the callable itself. Construct one at module
scope in a stdlib-only module and point an entry point at it.

* **Parameters:**
  * **slug** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – stable id; also a path segment and a public contract value, so
    choose it once. Lowercase, hyphen-separated.
  * **inputs** ([`Mapping`](https://docs.python.org/3/library/typing.html#typing.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]) – JSON Schema for the *files and primitives* the renderer
    needs. One schema serves the UI form, CLI validation and the MCP tool
    definition, which is why it is a schema and not prose.
  * **params_schema** ([`Mapping`](https://docs.python.org/3/library/typing.html#typing.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]) – JSON Schema for the styling/treatment knobs.
  * **render** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – `"package.module:function"`. Imported lazily, once, at
    render time. The function must satisfy [`Renderer`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.Renderer).
  * **api_versions** ([`Sequence`](https://docs.python.org/3/library/typing.html#typing.Sequence)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]) – the manifest API versions this plugin is written
    against. Must include the running `API_VERSION`.

#### api_versions *: [Sequence](https://docs.python.org/3/library/typing.html#typing.Sequence)[[str](https://docs.python.org/3/builtins/stdtypes.html#str)]*

a default bound at muvid’s import time
would make a plugin that never stated a version “declare” whichever
version happens to be running — vacuous for the careless, and it left
the careful plugin that pinned “1” refused on a v2 host while the one
that pinned nothing sailed through. A plugin states what it was written
against; the host decides.

* **Type:**
  REQUIRED. No default on purpose

#### cost_profile *: [str](https://docs.python.org/3/builtins/stdtypes.html#str) | [None](https://docs.python.org/3/builtins/constants.html#None)*

`None` means genuinely free. Anything else names a cost estimator, and
an unknown cost must force approval rather than encode as zero — see
muvid’s conjunctive budget gate.

#### intake_kinds *: [Sequence](https://docs.python.org/3/library/typing.html#typing.Sequence)[[str](https://docs.python.org/3/builtins/stdtypes.html#str)]*

Free-form tags a host may use to route “what are you making?” answers.

#### provider *: [str](https://docs.python.org/3/builtins/stdtypes.html#str) | [None](https://docs.python.org/3/builtins/constants.html#None)*

Which distribution provided this, filled in by the loader for
entry-point plugins. `None` for in-process registrations.

#### to_dict()

JSON-able form — what a catalogue, a UI or an agent actually reads.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### muvid.subgenres.get_subgenre(slug, , refresh=False)

The manifest for `slug`. Imports no renderer.

* **Return type:**
  [`Subgenre`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.Subgenre)

### muvid.subgenres.iter_subgenres(, refresh=False)

Iterate manifests in slug order.

* **Return type:**
  [`Iterator`](https://docs.python.org/3/library/typing.html#typing.Iterator)[[`Subgenre`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.Subgenre)]

### muvid.subgenres.list_subgenres(, refresh=False)

Every available subgenre slug, sorted. Imports no renderer.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### muvid.subgenres.register_subgenre(subgenre)

Register a subgenre in this process. Returns it, so it can be used inline.

Raises [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError) on a slug collision, matching `nw.genres`’
`on_conflict="error"`: a slug is a persisted path segment and a public
contract value, so silently replacing one is never the kind thing to do.

* **Return type:**
  [`Subgenre`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.Subgenre)

```pycon
>>> from muvid.subgenres import Subgenre, register_subgenre, list_subgenres
>>> sg = register_subgenre(Subgenre(
...     slug='demo-doctest', title='Demo', description='.',
...     render='muvid.subgenres.testing:echo_renderer', api_versions=("1",)))
>>> 'demo-doctest' in list_subgenres()
True
>>> unregister_subgenre('demo-doctest')
```

### muvid.subgenres.render_subgenre(slug, , inputs, params=None, workdir, output)

Render `slug` — the one call that imports plugin code.

Deliberately takes paths and primitives rather than a
[`MusicVideoProject`](_autosummary/muvid.project.html.md#muvid.project.MusicVideoProject): a plugin coupled to muvid’s
project schema would break on every schema change, and could not be driven
from a CLI, an HTTP request or an MCP tool without muvid building the
project first. A plugin that needs more asks muvid for it through a narrow
accessor.

* **Return type:**
  [`RenderResult`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.RenderResult)

### muvid.subgenres.resolve_renderer(slug)

Import and return the renderer for `slug`.

This is the only function in the module that imports plugin code, and it is
called at render time, not at listing time.

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### muvid.subgenres.subgenre_catalog(, refresh=False)

JSON-able catalogue: what a CLI, an HTTP route, an MCP tool or an agent serves.

Imports no renderer, so this stays cheap however many plugins are installed.
`load_errors` is part of the payload on purpose — a broken plugin should be
visible to whoever is choosing, not silently absent.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### muvid.subgenres.unregister_subgenre(slug)

Remove an in-process registration. Mostly for tests and doctests.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

### Modules

| [`testing`](_autosummary/muvid.subgenres.testing.html.md#module-muvid.subgenres.testing)   | A conformance kit for subgenre plugin authors.   |
|-------------------------------------------------------------------------------------------|--------------------------------------------------|


# _autosummary/muvid.subgenres.testing.html.md

# muvid.subgenres.testing

A conformance kit for subgenre plugin authors.

Ship a contract and people will implement it slightly wrong; ship a contract
*and a test that proves conformance* and they won’t. pytest does this with
`pytester`, Datasette with `datasette.utils.testing` — this is the small
version.

In your plugin’s test suite:

```default
from muvid.subgenres.testing import check_subgenre_conformance
from my_package.subgenre import MY_THING

def test_conformance(tmp_path):
    report = check_subgenre_conformance(MY_THING, workdir=tmp_path)
    assert report.ok, report.summary()
```

Pass `render=False` to check only the manifest — useful in a fast unit suite
where you don’t want to pay for a real render.

The module also provides [`echo_renderer()`](_autosummary/muvid.subgenres.testing.html.md#muvid.subgenres.testing.echo_renderer), a real (if trivial) renderer
used by muvid’s own tests and by this module’s doctests.

### Functions

| [`check_subgenre_conformance`](_autosummary/muvid.subgenres.testing.html.md#muvid.subgenres.testing.check_subgenre_conformance)(subgenre, \*, workdir)   | Check a manifest against the contract, and optionally run one render.   |
|------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| [`echo_renderer`](_autosummary/muvid.subgenres.testing.html.md#muvid.subgenres.testing.echo_renderer)(request)                              | A renderer that writes its request as JSON.                             |

### Classes

| [`ConformanceReport`](_autosummary/muvid.subgenres.testing.html.md#muvid.subgenres.testing.ConformanceReport)(slug[, passed, failures, ...])   | What [`check_subgenre_conformance()`](_autosummary/muvid.subgenres.testing.html.md#muvid.subgenres.testing.check_subgenre_conformance) found.   |
|-----------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------|

### *class* muvid.subgenres.testing.ConformanceReport(slug, passed=<factory>, failures=<factory>, skipped=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

What [`check_subgenre_conformance()`](_autosummary/muvid.subgenres.testing.html.md#muvid.subgenres.testing.check_subgenre_conformance) found.

### muvid.subgenres.testing.check_subgenre_conformance(subgenre, , workdir, render=True, inputs=None, params=None)

Check a manifest against the contract, and optionally run one render.

* **Return type:**
  [`ConformanceReport`](_autosummary/muvid.subgenres.testing.html.md#muvid.subgenres.testing.ConformanceReport)

```pycon
>>> import tempfile
>>> sg = Subgenre(slug='conformance-demo', title='Demo',
...               description='A demo.',
...               render='muvid.subgenres.testing:echo_renderer', api_versions=("1",))
>>> with tempfile.TemporaryDirectory() as d:
...     rep = check_subgenre_conformance(sg, workdir=d)
>>> rep.ok
True
```

### muvid.subgenres.testing.echo_renderer(request)

A renderer that writes its request as JSON. Not a stub — it is a real,
total implementation of the contract, which is what makes it usable as the
fixture every conformance check runs against.

* **Return type:**
  [`RenderResult`](_autosummary/muvid.subgenres.html.md#muvid.subgenres.RenderResult)


# _autosummary/muvid.ui.html.md

# muvid.ui

Minimal local web UI for an muvid project.

Single-page UI backed by a thin FastAPI wrapper around the facade.

### Modules


# _autosummary/muvid.visualize.canvas.html.md

# muvid.visualize.canvas

Lay a cover image out on a 16:9 canvas, and derive a thumbnail from it.

Cover art is usually square (or, worse, portrait) while video platforms are
16:9. Letting the platform pillarbox the art leaves black bars; instead we fill
the frame with a blurred, darkened copy of the cover and place the sharp cover
centred on top. It reads as intentional, and it is the same treatment whether
the result becomes a still video, the ground truth for a Ken Burns pan, or the
upload thumbnail — one [`CoverLayout`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.CoverLayout), one filtergraph, three uses.

Every filter chain here is built as a *string* rather than executed, so the
same chains compose into the bigger filtergraph that [`muvid.visualize.video`](_autosummary/muvid.visualize.video.html.md#module-muvid.visualize.video)
assembles for audio-reactive visuals.

### Module Attributes

| [`DEFAULT_SIZE`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.DEFAULT_SIZE)   | 1080p is YouTube's sweet spot for a static music video.             |
|-----------------------------------------------------------------|---------------------------------------------------------------------|
| [`THUMBNAIL_SIZE`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.THUMBNAIL_SIZE) | YouTube rejects thumbnails over 2 MiB, and wants at least 1280x720. |

### Functions

| [`background_chain`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.background_chain)(size, layout, \*, src, out)     | Filter chain turning cover stream `src` into a full-frame background.     |
|---------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------|
| [`brightness_saturation_lut`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.brightness_saturation_lut)(\*[, brightness, ...]) | `lutyuv` y/u/v expressions reproducing `eq`'s brightness + saturation.    |
| [`canvas_image`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.canvas_image)(image, \*[, saveas, size, ...])     | Render the composed canvas (background + centred cover + title) as a PNG. |
| [`compose_chain`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.compose_chain)(size, layout, \*, src, out[, ...]) | The whole cover-on-canvas filtergraph: background, centred cover, title.  |
| [`cover_box`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.cover_box)(size, layout)                          | The bounding box the sharp cover is fitted into, for `size`/`layout`.     |
| [`cover_chain`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.cover_chain)(size, layout, \*, src, out)          | Filter chain scaling cover stream `src` to the centred sharp cover.       |
| [`default_font`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.default_font)()                                   | Path to a usable TrueType font, or `None` if none was found.              |
| [`dim_saturation_lut`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.dim_saturation_lut)(\*[, dim, saturation])        | `lutyuv` y/u/v expressions that DARKEN luma and desaturate chroma.        |
| [`escape_filter_value`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.escape_filter_value)(value)                       | Escape `value` for use as a filter option inside an ffmpeg filtergraph.   |
| [`lut_filter`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.lut_filter)(exprs, \*[, label])                   | A `lutyuv` filter from either LUT builder's expressions.                  |
| [`overlay_chain`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.overlay_chain)(\*, background, cover, out[, ...]) | Filter chain centring the `cover` stream over the `background` stream.    |
| [`thumbnail_image`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.thumbnail_image)(image, \*[, saveas, size, ...])  | Render `image` as a 16:9 JPEG thumbnail that YouTube will accept.         |
| [`title_chain`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.title_chain)(title, size[, style])                | Filter chain burning `title` into the bottom of stream `src`.             |

### Classes

| [`CoverLayout`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.CoverLayout)([background, blur_sigma, dim, ...])   | How a cover image is placed on the canvas.         |
|----------------------------------------------------------------------------------------------------|----------------------------------------------------|
| [`TitleStyle`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.TitleStyle)([size_fraction, color, font, ...])     | How a burnt-in title is drawn (ffmpeg `drawtext`). |

### *class* muvid.visualize.canvas.CoverLayout(background='blur', blur_sigma=30.0, dim=0.65, saturation=0.8, cover_fraction=0.92, cover_alpha=1.0, background_color='black')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

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`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.CoverLayout.background_color)).

#### blur_sigma

Gaussian blur strength for the `"blur"` background.

#### dim

How much to darken the background, 0 (unchanged) to 1 (black).
Multiplicative — the background’s luma is *scaled* by `1 - dim`
about black, so a shadow gets darker rather than being deleted. See
[`dim_saturation_lut()`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.dim_saturation_lut); the constant is not comparable with the
additive offset that preceded it (muvid#70).

#### saturation

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

#### 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.

#### 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.

#### background_color

Fill colour when `background="color"`.

#### dim *: [float](https://docs.python.org/3/builtins/functions.html#float)* *= 0.65*

the multiplicative dim that lands the plate’s mean
DISPLAY luma where the additive `0.25` left it, pooled over four
photographs (muvid#70). The two forms are not comparable at the same
nominal value — see [`dim_saturation_lut()`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.dim_saturation_lut).

* **Type:**
  Measured, not chosen

### muvid.visualize.canvas.DEFAULT_SIZE *= (1920, 1080)*

1080p is YouTube’s sweet spot for a static music video.

* **Type:**
  Default 16
* **Type:**
  9 canvas

### muvid.visualize.canvas.THUMBNAIL_SIZE *= (1280, 720)*

YouTube rejects thumbnails over 2 MiB, and wants at least 1280x720.

### *class* muvid.visualize.canvas.TitleStyle(size_fraction=0.045, color='white', font=None, margin_fraction=0.06, box=True, box_color='black@0.45')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

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

#### size_fraction

Font size as a fraction of canvas height.

#### color

Text colour.

#### font

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

#### margin_fraction

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

#### box

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

#### box_color

Colour (with alpha) of that plate.

### muvid.visualize.canvas.background_chain(size, layout, , src, out)

Filter chain turning cover stream `src` into a full-frame background.

The darken/desaturate step is `lutyuv`, not `eq`: `eq` is GPL-only
(muvid#69). Its luma half is no longer `eq`’s arithmetic either — a dim has
to scale, not subtract, or the plate’s shadows are deleted rather than
darkened. See [`dim_saturation_lut()`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.dim_saturation_lut).

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.visualize.canvas.brightness_saturation_lut(, brightness=0.0, saturation=1.0)

`lutyuv` y/u/v expressions reproducing `eq`’s brightness + saturation.

`eq` is compiled into ffmpeg only under `--enable-gpl`, so every chain that
reached for it made muvid require a GPL build for what is arithmetic on three
planes. `lutyuv` is LGPL and expresses the same two knobs exactly as `vf_eq`
defines them: brightness is an ADDITIVE offset of `brightness * 255` on luma,
saturation a scaling of chroma about `_CHROMA_PIVOT_SCALE`.

Returns a `{component: expression}` mapping rather than a filter string
because the two callers need different shapes — one composes a filtergraph,
the other emits one `sendcmd` command per component — and a second copy of
this arithmetic is exactly how the two would drift apart.

* **Parameters:**
  * **brightness** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – Additive luma offset, -1 to 1, in `eq`’s units (a fraction
    of full scale). `0` is a no-op.
  * **saturation** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – Chroma scaling about neutral. `1` is a no-op.
* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### Examples

```pycon
>>> lut = brightness_saturation_lut(brightness=-0.25, saturation=0.8)
>>> lut["y"]
'clip(val-63.75,0,if(eq(minval,0),maxval,maxval*255/235))'
>>> lut["u"] == lut["v"]
True
>>> brightness_saturation_lut()["y"], brightness_saturation_lut()["u"]
('clip(val+0,0,if(eq(minval,0),maxval,maxval*255/235))', 'clip(val*1+(if(eq(minval,0),maxval,maxval*255/240))*0,0,if(eq(minval,0),maxval,maxval*255/240))')
```

### muvid.visualize.canvas.canvas_image(image, , saveas=None, size=(1920, 1080), layout=None, title=None, title_style=None)

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** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – The cover art.
  * **saveas** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Output PNG path (default: `<image-stem>.canvas.png`).
  * **size** ([`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)]) – Canvas size.
  * **layout** ([`CoverLayout`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.CoverLayout) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Placement/treatment of the cover (a default one when omitted).
  * **title** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Burn this title into the canvas (omit for no title).
  * **title_style** ([`TitleStyle`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.TitleStyle) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – How to draw that title.
* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)
* **Returns:**
  Path to the rendered PNG.

### muvid.visualize.canvas.compose_chain(size, layout, , src, out, title=None, title_style=None)

The whole cover-on-canvas filtergraph: background, centred cover, title.

`src` is a single cover-image stream; it is split so the same image feeds
both the blurred background and the sharp foreground.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.visualize.canvas.cover_box(size, layout)

The bounding box the sharp cover is fitted into, for `size`/`layout`.

Scales with *both* frame dimensions, so a cover fitted into it with
`force_original_aspect_ratio=decrease` grows until it meets whichever edge
comes first — filling the frame up to `cover_fraction`, minus padding.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)]

### muvid.visualize.canvas.cover_chain(size, layout, , src, out)

Filter chain scaling cover stream `src` to the centred sharp cover.

When `layout.cover_alpha < 1` the scaled cover is made semi-transparent
(`colorchannelmixer=aa=…`, over an `rgba` copy so an opaque source gains
an alpha channel), so a following [`overlay_chain()`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.overlay_chain) lets the background
show through it.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.visualize.canvas.default_font()

Path to a usable TrueType font, or `None` if none was found.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

### muvid.visualize.canvas.dim_saturation_lut(, dim=0.0, saturation=1.0)

`lutyuv` y/u/v expressions that DARKEN luma and desaturate chroma.

The sibling of [`brightness_saturation_lut()`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.brightness_saturation_lut), and deliberately not the same
arithmetic. `eq`’s brightness — which this replaces at the one site that wanted
to *darken* rather than to *shift* — is an additive offset, and subtracting a
constant does not dim a picture: it slides the histogram down and clamps
everything below the offset to the floor. The plate’s shadows did not get darker,
they were deleted, and at the reactive constants that took 57–99% of the plate to
display black (muvid#70).

So luma is *scaled* by `1 - dim` about `_DIM_PIVOT`, which preserves the
order of every pair of pixels — a shadow stays darker than what is next to it
instead of joining it at black. Chroma is untouched by the change and still goes
through `_chroma_expr()`, so the desaturation half remains `eq`’s.

`dim` therefore means something different from the additive offset it replaces,
and the constants that ship were re-measured rather than converted: see
[`CoverLayout`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.CoverLayout) and [`muvid.visualize.visuals`](_autosummary/muvid.visualize.visuals.html.md#module-muvid.visualize.visuals).

* **Parameters:**
  * **dim** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – How much to darken, 0 (unchanged) to 1 (black).
  * **saturation** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – Chroma scaling about neutral. `1` is a no-op.
* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### Examples

```pycon
>>> lut = dim_saturation_lut(dim=0.65, saturation=0.8)
>>> lut["y"]
'clip((val-minval)*0.35+minval,0,if(eq(minval,0),maxval,maxval*255/235))'
>>> lut["u"] == lut["v"] == brightness_saturation_lut(saturation=0.8)["u"]
True
>>> dim_saturation_lut()["y"]  # 0 is a no-op, and reads as one
'clip((val-minval)*1+minval,0,if(eq(minval,0),maxval,maxval*255/235))'
```

### muvid.visualize.canvas.escape_filter_value(value)

Escape `value` for use as a filter option inside an ffmpeg filtergraph.

ffmpeg unescapes such a value **twice**, so escaping it once is not enough:

1. The *filtergraph* parser reads the whole graph first, splitting it on
   `_GRAPH_LEVEL_SPECIALS` and consuming one layer of quoting.
2. Each surviving per-filter argument string is only then split into options
   on `_OPTION_LEVEL_SPECIALS`, consuming a second layer.

Escaping therefore runs in the mirror order — option level first, then graph
level over that result — so that each ffmpeg pass peels off exactly one
layer. A literal `'` comes out as `\\\'` and a literal `:` as `\\:`.

Escaping only once is why a title like `"Song: Part 1"` used to become a
filtergraph syntax error, and why a workdir whose name contained `'` or
`:` broke every `sendcmd=f=<path>` render.

Note that `%` is deliberately *not* escaped: neither parser treats it as
special, so `\%` was simply unescaped back to `%` and escaping it never
did anything. drawtext’s `%{...}` text expansion is a third level that
applies to that one filter’s `text` option only, and is out of scope for a
general-purpose filtergraph escaper.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.visualize.canvas.lut_filter(exprs, , label='')

A `lutyuv` filter from either LUT builder’s expressions.

The expressions contain `,`, which the *filtergraph* parser reads as “next
filter”, so every one goes through [`escape_filter_value()`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.escape_filter_value) — the same
escaper, and the same reason, as a `sendcmd` script path.

* **Parameters:**
  * **exprs** ([`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]) – `{component: expression}`.
  * **label** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – Optional `@label` so `sendcmd` can address this filter.
* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### Examples

```pycon
>>> lut_filter({"y": "clip(val+0,0,255)"}, label="flash")
'lutyuv@flash=y=clip(val+0\\,0\\,255)'
```

### muvid.visualize.canvas.overlay_chain(, background, cover, out, shortest=False)

Filter chain centring the `cover` stream over the `background` stream.

* **Parameters:**
  * **background** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – Label of the background video stream.
  * **cover** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – Label of the (already scaled) cover stream.
  * **out** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – Label to emit.
  * **shortest** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – End the overlay when the shortest input ends — required when a
    finite, audio-driven background is overlaid with an endlessly
    looping still cover, or the render would never terminate.
* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.visualize.canvas.thumbnail_image(image, , saveas=None, size=(1280, 720), layout=None, title=None, title_style=None, max_bytes=2097152)

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** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – The cover art.
  * **saveas** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Output JPEG path (default: `<image-stem>.thumb.jpg`).
  * **size** ([`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)]) – Thumbnail size (YouTube wants >= 1280x720, 16:9).
  * **layout** ([`CoverLayout`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.CoverLayout) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Placement/treatment of the cover.
  * **title** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Burn this title into the thumbnail (omit for none).
  * **title_style** ([`TitleStyle`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.TitleStyle) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – How to draw that title.
  * **max_bytes** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – Hard size ceiling.
* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)
* **Returns:**
  Path to the rendered JPEG.

### muvid.visualize.canvas.title_chain(title, size, style=None, , src, out)

Filter chain burning `title` into the bottom of stream `src`.

* **Raises:**
  [**FfmpegError**](_autosummary/muvid.visualize.html.md#muvid.visualize.FfmpegError) – This ffmpeg has no `drawtext`, or no font was found.
* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)


# _autosummary/muvid.visualize.ffmpeg.html.md

# muvid.visualize.ffmpeg

Low-level ffmpeg/ffprobe primitives shared across [`muvid.visualize`](_autosummary/muvid.visualize.html.md#module-muvid.visualize).

Thin, dependency-free wrappers around the `ffmpeg`/`ffprobe` binaries that
`muvid.visualize` assumes on the PATH: running a command with a readable
error, probing duration and streams, checking that an optional filter was
compiled in, decoding raw PCM for an analysis pass, and measuring loudness
(EBU R128) for two-pass normalization.

Nothing here knows about music or a destination platform — it is the shared
substrate under [`muvid.visualize.canvas`](_autosummary/muvid.visualize.canvas.html.md#module-muvid.visualize.canvas), [`muvid.visualize.visuals`](_autosummary/muvid.visualize.visuals.html.md#module-muvid.visualize.visuals),
[`muvid.visualize.reactive`](_autosummary/muvid.visualize.reactive.html.md#module-muvid.visualize.reactive), and [`muvid.visualize.video`](_autosummary/muvid.visualize.video.html.md#module-muvid.visualize.video).

### Module Attributes

| [`FFMPEG_TIMEOUT_ENV_VAR`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.FFMPEG_TIMEOUT_ENV_VAR)   | Env var bounding a single ffmpeg invocation's wall-clock (seconds).                                                                                                  |
|---------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`DEFAULT_LOUDNESS_I`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.DEFAULT_LOUDNESS_I)       | Loudness targets, in the units EBU R128 / ffmpeg `loudnorm` uses.                                                                                                    |
| [`PCM_SAMPLE_FORMAT`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.PCM_SAMPLE_FORMAT)        | little-endian 32-bit float, which is exactly `numpy.float32`'s memory layout, so an analysis pass can read the bytes straight into an array with no conversion step. |

### Functions

| [`decode_pcm`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.decode_pcm)(audio, \*, sample_rate[, channels])   | Decode `audio` to raw [`PCM_SAMPLE_FORMAT`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.PCM_SAMPLE_FORMAT) samples on stdout.    |
|---------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------|
| [`has_filter`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.has_filter)(name)                                 | Whether this ffmpeg build has the `name` filter compiled in.                                                   |
| [`measure_loudness`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.measure_loudness)(audio[, target])                | Analyse `audio` (loudnorm pass 1) and return `target` with the result.                                         |
| [`media_duration`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.media_duration)(media)                            | Duration of `media` in seconds.                                                                                |
| [`probe`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.probe)(media)                                     | Return `ffprobe`'s `format` + `streams` JSON for `media`.                                                      |
| [`require_ffmpeg`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.require_ffmpeg)(\*tools)                          | Raise a helpful [`FfmpegError`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.FfmpegError) if any of `tools` is not on PATH. |
| [`require_filter`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.require_filter)(name, \*, needed_for)             | Raise unless this ffmpeg build has the `name` filter.                                                          |
| [`run_ffmpeg`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.run_ffmpeg)(args, \*[, overwrite])                | Run `ffmpeg` with `args`, raising a readable error on failure.                                                 |

### Classes

| [`Loudness`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.Loudness)([integrated, true_peak, lra, measured])   | An EBU R128 loudness target, plus the measurement of a specific track.   |
|-----------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------|

### Exceptions

| [`FfmpegError`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.FfmpegError)   | An ffmpeg/ffprobe invocation failed, or a needed tool/filter is absent.   |
|----------------------------------------------------------------|---------------------------------------------------------------------------|

### muvid.visualize.ffmpeg.DEFAULT_LOUDNESS_I *= -14.0*

Loudness targets, in the units EBU R128 / ffmpeg `loudnorm` uses.
The defaults match what YouTube normalizes playback to, so a track mastered
here is neither turned down nor left quiet relative to the rest of a playlist.

### muvid.visualize.ffmpeg.FFMPEG_TIMEOUT_ENV_VAR *= 'MUVID_FFMPEG_TIMEOUT_S'*

Env var bounding a single ffmpeg invocation’s wall-clock (seconds). Unset =
no timeout (the historical behaviour). A long-running host (the reelee MCP
connector renders synchronously over HTTP) sets this so an oversized or
adversarial input can’t pin a worker indefinitely — a belt-and-braces bound
on top of the caller’s own input-duration cap.

### *exception* muvid.visualize.ffmpeg.FfmpegError

Bases: [`RuntimeError`](https://docs.python.org/3/builtins/exceptions.html#RuntimeError)

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

### *class* muvid.visualize.ffmpeg.Loudness(integrated=-14.0, true_peak=-1.0, lra=11.0, measured=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

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

`measured` is the `loudnorm` analysis pass output (`None` until
[`measure_loudness()`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.measure_loudness) has run). Carrying both lets [`filter_spec()`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.Loudness.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()

The `loudnorm` filter string for this target.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.visualize.ffmpeg.PCM_SAMPLE_FORMAT *= 'f32le'*

little-endian 32-bit float, which is
exactly `numpy.float32`’s memory layout, so an analysis pass can read the
bytes straight into an array with no conversion step.

* **Type:**
  Sample format the raw-PCM decode emits

### muvid.visualize.ffmpeg.decode_pcm(audio, , sample_rate, channels=1)

Decode `audio` to raw [`PCM_SAMPLE_FORMAT`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.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** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – The media file to decode.
  * **sample_rate** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – Resample to this rate. Analysis rarely needs full quality,
    and a low rate keeps a long track’s decode cheap.
  * **channels** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – Downmix to this many channels (1 = mono).
* **Return type:**
  [`bytes`](https://docs.python.org/3/builtins/stdtypes.html#bytes)
* **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()`](_autosummary/muvid.visualize.reactive.html.md#muvid.visualize.reactive.flash_filter)).

### muvid.visualize.ffmpeg.has_filter(name)

Whether this ffmpeg build has the `name` filter compiled in.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

### muvid.visualize.ffmpeg.measure_loudness(audio, target=None)

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** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – The audio (or video) file to measure.
  * **target** ([`Loudness`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.Loudness) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – The loudness target; a default one is used when omitted.
* **Return type:**
  [`Loudness`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.Loudness)
* **Returns:**
  A new [`Loudness`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.Loudness) with `measured` populated.

### muvid.visualize.ffmpeg.media_duration(media)

Duration of `media` in seconds.

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

* **Raises:**
  [**FfmpegError**](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.FfmpegError) – The duration could not be determined.
* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

### muvid.visualize.ffmpeg.probe(media)

Return `ffprobe`’s `format` + `streams` JSON for `media`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.visualize.ffmpeg.require_ffmpeg(\*tools)

Raise a helpful [`FfmpegError`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.FfmpegError) if any of `tools` is not on PATH.

* **Parameters:**
  **\*tools** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – Binaries to require (defaults to `ffmpeg` and `ffprobe`).
* **Raises:**
  [**FfmpegError**](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.FfmpegError) – With per-platform install instructions.
* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

### muvid.visualize.ffmpeg.require_filter(name, , needed_for)

Raise unless this ffmpeg build has the `name` filter.

Filters like `drawtext` (libfreetype) and `showcqt` (libfftw/avfilter
extras) are build-time options, so a working ffmpeg is not enough — the
specific filter has to be there.

* **Raises:**
  [**FfmpegError**](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.FfmpegError) – Naming the filter, the feature that needs it, and the fix.
* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

### muvid.visualize.ffmpeg.run_ffmpeg(args, , overwrite=True)

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

* **Parameters:**
  * **args** ([`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]) – Arguments after the global flags (inputs, filters, output).
  * **overwrite** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – Pass `-y` (overwrite the output without prompting).
* **Return type:**
  [`CompletedProcess`](https://docs.python.org/3/library/subprocess.html#subprocess.CompletedProcess)
* **Returns:**
  The completed process.
* **Raises:**
  [**FfmpegError**](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.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.


# _autosummary/muvid.visualize.html.md

# 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`](_autosummary/muvid.facade.html.md#module-muvid.facade), [`muvid.renderers`](_autosummary/muvid.renderers.html.md#module-muvid.renderers)) is a separate
concern and this subpackage stands on its own.

The one call most people need:

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

Everything else is a knob on that: [`list_visuals()`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.list_visuals)
names the built-in looks, [`register_visual()`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.register_visual) adds
your own, [`CoverLayout`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.CoverLayout) controls how the cover
sits on the canvas, and [`thumbnail_image()`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.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()`](_autosummary/muvid.visualize.reactive.html.md#muvid.visualize.reactive.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).

### Functions

| [`failures`](_autosummary/muvid.visualize.html.md#muvid.visualize.failures)(checks)                                 | Just the checks that failed.                                                                                   |
|---------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------|
| [`report`](_autosummary/muvid.visualize.html.md#muvid.visualize.report)(checks)                                   | Render `checks` as an aligned, readable block.                                                                 |
| [`verify_video`](_autosummary/muvid.visualize.html.md#muvid.visualize.verify_video)(video, \*[, audio, thumbnail, ...]) | Check `video` against YouTube's expectations; return one result per check.                                     |
| [`decode_pcm`](_autosummary/muvid.visualize.html.md#muvid.visualize.decode_pcm)(audio, \*, sample_rate[, channels])   | Decode `audio` to raw `PCM_SAMPLE_FORMAT` samples on stdout.                                                   |
| [`has_filter`](_autosummary/muvid.visualize.html.md#muvid.visualize.has_filter)(name)                                 | Whether this ffmpeg build has the `name` filter compiled in.                                                   |
| [`measure_loudness`](_autosummary/muvid.visualize.html.md#muvid.visualize.measure_loudness)(audio[, target])                | Analyse `audio` (loudnorm pass 1) and return `target` with the result.                                         |
| [`media_duration`](_autosummary/muvid.visualize.html.md#muvid.visualize.media_duration)(media)                            | Duration of `media` in seconds.                                                                                |
| [`probe`](_autosummary/muvid.visualize.html.md#muvid.visualize.probe)(media)                                     | Return `ffprobe`'s `format` + `streams` JSON for `media`.                                                      |
| [`require_ffmpeg`](_autosummary/muvid.visualize.html.md#muvid.visualize.require_ffmpeg)(\*tools)                          | Raise a helpful [`FfmpegError`](_autosummary/muvid.visualize.html.md#muvid.visualize.FfmpegError) if any of `tools` is not on PATH. |
| [`run_ffmpeg`](_autosummary/muvid.visualize.html.md#muvid.visualize.run_ffmpeg)(args, \*[, overwrite])                | Run `ffmpeg` with `args`, raising a readable error on failure.                                                 |
| [`canvas_image`](_autosummary/muvid.visualize.html.md#muvid.visualize.canvas_image)(image, \*[, saveas, size, ...])     | Render the composed canvas (background + centred cover + title) as a PNG.                                      |
| [`thumbnail_image`](_autosummary/muvid.visualize.html.md#muvid.visualize.thumbnail_image)(image, \*[, saveas, size, ...])  | Render `image` as a 16:9 JPEG thumbnail that YouTube will accept.                                              |
| [`flash_filter`](_autosummary/muvid.visualize.html.md#muvid.visualize.flash_filter)(audio, \*, fps, duration, workdir)  | A filter fragment that makes the stream it follows pulse with the beat.                                        |
| [`onset_envelope`](_autosummary/muvid.visualize.html.md#muvid.visualize.onset_envelope)(audio, \*, fps[, duration, ...])  | Per-video-frame onset strength in `[0, 1]`, with phosphor-style decay.                                         |
| [`list_visuals`](_autosummary/muvid.visualize.html.md#muvid.visualize.list_visuals)()                                   | The names of every registered visual strategy.                                                                 |
| [`register_visual`](_autosummary/muvid.visualize.html.md#muvid.visualize.register_visual)(name)                            | Register a visual strategy under `name` (the open-closed seam).                                                |
| [`resolve_visual`](_autosummary/muvid.visualize.html.md#muvid.visualize.resolve_visual)(visual, ctx)                      | Turn `visual` (a name, or any callable) into a [`VisualPlan`](_autosummary/muvid.visualize.html.md#muvid.visualize.VisualPlan).    |
| [`render_audio_video`](_autosummary/muvid.visualize.html.md#muvid.visualize.render_audio_video)(audio[, image, visual, ...])  | Render `audio` into a video, using `visual` for the picture.                                                   |

### Classes

| [`Check`](_autosummary/muvid.visualize.html.md#muvid.visualize.Check)(name, ok, detail)                          | One verification result.                                               |
|---------------------------------------------------------------------------------------------------|------------------------------------------------------------------------|
| [`Loudness`](_autosummary/muvid.visualize.html.md#muvid.visualize.Loudness)([integrated, true_peak, lra, measured]) | An EBU R128 loudness target, plus the measurement of a specific track. |
| [`CoverLayout`](_autosummary/muvid.visualize.html.md#muvid.visualize.CoverLayout)([background, blur_sigma, dim, ...])  | How a cover image is placed on the canvas.                             |
| [`TitleStyle`](_autosummary/muvid.visualize.html.md#muvid.visualize.TitleStyle)([size_fraction, color, font, ...])    | How a burnt-in title is drawn (ffmpeg `drawtext`).                     |
| [`VisualContext`](_autosummary/muvid.visualize.html.md#muvid.visualize.VisualContext)(audio, image, duration, size, fps) | Everything a visual strategy needs to know about the render.           |
| [`VisualPlan`](_autosummary/muvid.visualize.html.md#muvid.visualize.VisualPlan)([inputs, filters, video, ...])        | The ffmpeg fragments that render one strategy's video stream.          |
| [`RenderResult`](_autosummary/muvid.visualize.html.md#muvid.visualize.RenderResult)(path, duration, size, fps, visual)  | A rendered video and what is worth knowing about it.                   |

### Exceptions

| [`FfmpegError`](_autosummary/muvid.visualize.html.md#muvid.visualize.FfmpegError)   | An ffmpeg/ffprobe invocation failed, or a needed tool/filter is absent.   |
|----------------------------------------------------------------|---------------------------------------------------------------------------|

### *class* muvid.visualize.Check(name, ok, detail)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One verification result.

### *class* muvid.visualize.CoverLayout(background='blur', blur_sigma=30.0, dim=0.65, saturation=0.8, cover_fraction=0.92, cover_alpha=1.0, background_color='black')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

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`](_autosummary/muvid.visualize.html.md#muvid.visualize.CoverLayout.background_color)).

#### blur_sigma

Gaussian blur strength for the `"blur"` background.

#### dim

How much to darken the background, 0 (unchanged) to 1 (black).
Multiplicative — the background’s luma is *scaled* by `1 - dim`
about black, so a shadow gets darker rather than being deleted. See
`dim_saturation_lut()`; the constant is not comparable with the
additive offset that preceded it (muvid#70).

#### saturation

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

#### 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.

#### 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.

#### background_color

Fill colour when `background="color"`.

#### dim *: [float](https://docs.python.org/3/builtins/functions.html#float)* *= 0.65*

the multiplicative dim that lands the plate’s mean
DISPLAY luma where the additive `0.25` left it, pooled over four
photographs (muvid#70). The two forms are not comparable at the same
nominal value — see `dim_saturation_lut()`.

* **Type:**
  Measured, not chosen

### *exception* muvid.visualize.FfmpegError

Bases: [`RuntimeError`](https://docs.python.org/3/builtins/exceptions.html#RuntimeError)

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

### *class* muvid.visualize.Loudness(integrated=-14.0, true_peak=-1.0, lra=11.0, measured=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

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

`measured` is the `loudnorm` analysis pass output (`None` until
[`measure_loudness()`](_autosummary/muvid.visualize.html.md#muvid.visualize.measure_loudness) has run). Carrying both lets [`filter_spec()`](_autosummary/muvid.visualize.html.md#muvid.visualize.Loudness.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()

The `loudnorm` filter string for this target.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### *class* muvid.visualize.RenderResult(path, duration, size, fps, visual, loudness=None, canvas=None, extras=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A rendered video and what is worth knowing about it.

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

#### path

The rendered mp4.

#### duration

Its duration in seconds.

#### size

Frame size.

#### fps

Frame rate.

#### visual

The strategy that produced it.

#### loudness

The applied loudness target and measurement, if normalized.

#### canvas

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

### *class* muvid.visualize.TitleStyle(size_fraction=0.045, color='white', font=None, margin_fraction=0.06, box=True, box_color='black@0.45')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

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

#### size_fraction

Font size as a fraction of canvas height.

#### color

Text colour.

#### font

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

#### margin_fraction

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

#### box

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

#### box_color

Colour (with alpha) of that plate.

### *class* muvid.visualize.VisualContext(audio, image, duration, size, fps, layout=<factory>, title=None, title_style=None, workdir=<factory>, options=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Everything a visual strategy needs to know about the render.

#### audio

The audio file (ffmpeg input 0).

#### image

The cover art, if the caller supplied one.

#### duration

Audio duration in seconds.

#### size

Canvas size (width, height).

#### fps

Output frame rate.

#### layout

How the cover sits on the canvas.

#### title

Title to burn in, if any.

#### title_style

How to draw that title.

#### workdir

A directory the strategy may write intermediate files into.

#### options

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

#### require_image(visual)

The cover image, or a [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError) naming what to do instead.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### *class* muvid.visualize.VisualPlan(inputs=<factory>, filters=<factory>, video='vbg', uses_audio=False, has_cover=False, has_title=False, still=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

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.

#### filters

`filter_complex` chains, joined with `;` by the renderer.

#### video

Label of the video stream the chains emit.

#### uses_audio

The plan consumes the `[aviz]` audio copy.

#### has_cover

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

#### has_title

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

#### 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`.

### muvid.visualize.canvas_image(image, , saveas=None, size=(1920, 1080), layout=None, title=None, title_style=None)

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** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – The cover art.
  * **saveas** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Output PNG path (default: `<image-stem>.canvas.png`).
  * **size** ([`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)]) – Canvas size.
  * **layout** ([`CoverLayout`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.CoverLayout) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Placement/treatment of the cover (a default one when omitted).
  * **title** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Burn this title into the canvas (omit for no title).
  * **title_style** ([`TitleStyle`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.TitleStyle) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – How to draw that title.
* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)
* **Returns:**
  Path to the rendered PNG.

### muvid.visualize.decode_pcm(audio, , sample_rate, channels=1)

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** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – The media file to decode.
  * **sample_rate** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – Resample to this rate. Analysis rarely needs full quality,
    and a low rate keeps a long track’s decode cheap.
  * **channels** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – Downmix to this many channels (1 = mono).
* **Return type:**
  [`bytes`](https://docs.python.org/3/builtins/stdtypes.html#bytes)
* **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()`](_autosummary/muvid.visualize.reactive.html.md#muvid.visualize.reactive.flash_filter)).

### muvid.visualize.failures(checks)

Just the checks that failed.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Check`](_autosummary/muvid.visualize.verify.html.md#muvid.visualize.verify.Check)]

### muvid.visualize.flash_filter(audio, , fps, duration, workdir, label='flash', brightness=0.25, saturation=0.8, decay=0.5)

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=…,lutyuv@<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 `lutyuv` starts as an identity table (brightness 0, saturation 1); the
script drives it. It is a LUT rather than an `eq` because `eq` exists only
in a GPL-configured ffmpeg (muvid#69) — see
[`brightness_saturation_lut()`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.brightness_saturation_lut).

* **Parameters:**
  * **audio** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – The track whose beats drive the flash.
  * **fps** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – The render’s frame rate (one command per component per frame).
  * **duration** ([`float`](https://docs.python.org/3/builtins/functions.html#float) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Clamp the flash to this many seconds (`None` = whole track).
  * **workdir** ([`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – Directory to write the `sendcmd` script into.
  * **label** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – `sendcmd` label for this flash’s `lutyuv`.
  * **brightness** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – Peak brightness boost on a beat.
  * **saturation** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – Peak saturation boost on a beat.
  * **decay** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – Per-frame afterglow of a pulse.
* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.visualize.has_filter(name)

Whether this ffmpeg build has the `name` filter compiled in.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

### muvid.visualize.list_visuals()

The names of every registered visual strategy.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### muvid.visualize.measure_loudness(audio, target=None)

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** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – The audio (or video) file to measure.
  * **target** ([`Loudness`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.Loudness) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – The loudness target; a default one is used when omitted.
* **Return type:**
  [`Loudness`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.Loudness)
* **Returns:**
  A new [`Loudness`](_autosummary/muvid.visualize.html.md#muvid.visualize.Loudness) with `measured` populated.

### muvid.visualize.media_duration(media)

Duration of `media` in seconds.

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

* **Raises:**
  [**FfmpegError**](_autosummary/muvid.visualize.html.md#muvid.visualize.FfmpegError) – The duration could not be determined.
* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

### muvid.visualize.onset_envelope(audio, , fps, duration=None, sr=22050, decay=0.5)

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** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – The track to analyse.
  * **fps** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – Video frame rate — one envelope value per frame.
  * **duration** ([`float`](https://docs.python.org/3/builtins/functions.html#float) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Clamp the envelope to this many seconds (defaults to the whole
    track).
  * **sr** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – Analysis sample rate.
  * **decay** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – Per-frame persistence of a pulse, 0 (no trail) to <1 (longer
    afterglow).
* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]
* **Returns:**
  One value per frame. Empty if the audio could not be decoded.

### muvid.visualize.probe(media)

Return `ffprobe`’s `format` + `streams` JSON for `media`.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### muvid.visualize.register_visual(name)

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

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`VisualContext`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualContext)], [`VisualPlan`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualPlan) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]], [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`VisualContext`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualContext)], [`VisualPlan`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualPlan) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]

### Examples

```pycon
>>> @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, image=None, , visual='auto', saveas=None, size=(1920, 1080), fps=24, title=None, layout=None, title_style=None, normalize=False, loudness=None, crf=18, preset='medium', audio_bitrate='384k', gop_seconds=2.0, options=None, workdir=None)

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** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – The song (`.wav` is preferred when you have it — YouTube
    re-encodes regardless, so give it the cleanest input).
  * **image** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Cover art. Used for the picture, and composed onto a 16:9 canvas.
  * **visual** (`Union`[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`VisualContext`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualContext)], [`VisualPlan`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualPlan) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]) – A registered strategy name (`"still"`, `"ken_burns"`,
    `"cqt"`, `"bars"`, `"spectrum"`, `"waves"`, `"scope"`),
    `"auto"`, or any callable (see [`muvid.visualize.visuals`](_autosummary/muvid.visualize.visuals.html.md#module-muvid.visualize.visuals)).
  * **saveas** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Output path (default: `<audio-stem>.mp4`).
  * **size** ([`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)]) – Canvas size; the default is 1080p.
  * **fps** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – Frame rate.
  * **title** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Burn this title into the frame.
  * **layout** ([`CoverLayout`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.CoverLayout) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – How the cover sits on the canvas.
  * **title_style** ([`TitleStyle`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.TitleStyle) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – How the title is drawn.
  * **normalize** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – Loudness-normalize the audio (two-pass EBU R128).
  * **loudness** ([`Loudness`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.Loudness) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – The loudness target; a YouTube-appropriate default is used
    when omitted.
  * **gop_seconds** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – Encoder knobs.
  * **options** ([`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Strategy-specific options, passed to the visual.
  * **workdir** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Where intermediates go (a temporary directory by default).
* **Return type:**
  [`RenderResult`](_autosummary/muvid.visualize.video.html.md#muvid.visualize.video.RenderResult)
* **Returns:**
  A [`RenderResult`](_autosummary/muvid.visualize.html.md#muvid.visualize.RenderResult).
* **Raises:**
  [**ValueError**](https://docs.python.org/3/builtins/exceptions.html#ValueError) – `size` has an odd dimension — H.264 at yuv420p (the only
      pixel format every player decodes) cannot encode one.

### muvid.visualize.report(checks)

Render `checks` as an aligned, readable block.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.visualize.require_ffmpeg(\*tools)

Raise a helpful [`FfmpegError`](_autosummary/muvid.visualize.html.md#muvid.visualize.FfmpegError) if any of `tools` is not on PATH.

* **Parameters:**
  **\*tools** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – Binaries to require (defaults to `ffmpeg` and `ffprobe`).
* **Raises:**
  [**FfmpegError**](_autosummary/muvid.visualize.html.md#muvid.visualize.FfmpegError) – With per-platform install instructions.
* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

### muvid.visualize.resolve_visual(visual, ctx)

Turn `visual` (a name, or any callable) into a [`VisualPlan`](_autosummary/muvid.visualize.html.md#muvid.visualize.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`](_autosummary/muvid.visualize.html.md#muvid.visualize.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**](https://docs.python.org/3/builtins/exceptions.html#ValueError) – `visual` names a strategy that is not registered.
* **Return type:**
  [`VisualPlan`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualPlan)

### muvid.visualize.run_ffmpeg(args, , overwrite=True)

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

* **Parameters:**
  * **args** ([`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]) – Arguments after the global flags (inputs, filters, output).
  * **overwrite** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – Pass `-y` (overwrite the output without prompting).
* **Return type:**
  [`CompletedProcess`](https://docs.python.org/3/library/subprocess.html#subprocess.CompletedProcess)
* **Returns:**
  The completed process.
* **Raises:**
  [**FfmpegError**](_autosummary/muvid.visualize.html.md#muvid.visualize.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, , saveas=None, size=(1280, 720), layout=None, title=None, title_style=None, max_bytes=2097152)

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** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – The cover art.
  * **saveas** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Output JPEG path (default: `<image-stem>.thumb.jpg`).
  * **size** ([`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)]) – Thumbnail size (YouTube wants >= 1280x720, 16:9).
  * **layout** ([`CoverLayout`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.CoverLayout) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Placement/treatment of the cover.
  * **title** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Burn this title into the thumbnail (omit for none).
  * **title_style** ([`TitleStyle`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.TitleStyle) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – How to draw that title.
  * **max_bytes** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – Hard size ceiling.
* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)
* **Returns:**
  Path to the rendered JPEG.

### muvid.visualize.verify_video(video, , audio=None, thumbnail=None, loudness=None, check_loudness=False, duration_tolerance=0.5, expected_canvas=None, expected_duration=None)

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

* **Parameters:**
  * **video** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – The rendered mp4.
  * **audio** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – The source song — enables the duration-match check, which is the
    one that catches a mis-built filtergraph.
  * **thumbnail** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – The thumbnail to check against YouTube’s limits.
  * **loudness** ([`Loudness`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.Loudness) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – The target the video was normalized to.
  * **check_loudness** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – Actually measure the output’s loudness. This decodes the
    whole track, so it is off by default.
  * **duration_tolerance** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – Allowed audio/video duration difference, in seconds.
  * **expected_canvas** ([`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)] | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – 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.
  * **expected_duration** ([`float`](https://docs.python.org/3/builtins/functions.html#float) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – The length the render was ASKED for, when that is not
    the whole of `audio` — a trimmed edit renders only part of the song.
    Arms the duration check on its own, and wins over `audio`’s length.
* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Check`](_autosummary/muvid.visualize.verify.html.md#muvid.visualize.verify.Check)]
* **Returns:**
  A list of [`Check`](_autosummary/muvid.visualize.html.md#muvid.visualize.Check). Falsy checks are the problems; [`report()`](_autosummary/muvid.visualize.html.md#muvid.visualize.report)
  renders them, and [`failures()`](_autosummary/muvid.visualize.html.md#muvid.visualize.failures) filters them.

### Modules

| [`canvas`](_autosummary/muvid.visualize.canvas.html.md#module-muvid.visualize.canvas)     | Lay a cover image out on a 16:9 canvas, and derive a thumbnail from it.                                             |
|-------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------|
| [`ffmpeg`](_autosummary/muvid.visualize.ffmpeg.html.md#module-muvid.visualize.ffmpeg)     | Low-level ffmpeg/ffprobe primitives shared across [`muvid.visualize`](_autosummary/muvid.visualize.html.md#module-muvid.visualize). |
| [`reactive`](_autosummary/muvid.visualize.reactive.html.md#module-muvid.visualize.reactive) | Precomputed audio-reactivity: pulse a video filter in time with the music.                                          |
| [`verify`](_autosummary/muvid.visualize.verify.html.md#module-muvid.visualize.verify)     | Check a rendered music video against what YouTube actually wants.                                                   |
| [`video`](_autosummary/muvid.visualize.video.html.md#module-muvid.visualize.video)       | Render an audio file into a video: pick a visual, mux, normalize, encode.                                           |
| [`visuals`](_autosummary/muvid.visualize.visuals.html.md#module-muvid.visualize.visuals)   | Visual strategies: how the *picture* of an audio-driven video is produced.                                          |


# _autosummary/muvid.visualize.reactive.html.md

# muvid.visualize.reactive

Precomputed audio-reactivity: pulse a video filter in time with the music.

Because `muvid` renders from an audio *file*, not a live stream, the whole
loudness envelope is knowable up front. This module turns that envelope into an
ffmpeg `sendcmd` script that rewrites a named `lutyuv`’s lookup table frame
by frame — a beat-reactive “flash” baked deterministically into the render. No
realtime, and no dependency beyond `numpy` and the ffmpeg
[`muvid.visualize`](_autosummary/muvid.visualize.html.md#module-muvid.visualize) already requires.

It is a general seam, not spectrum-specific: any visual can attach a flash to a
named filter in its chain (see [`flash_filter()`](_autosummary/muvid.visualize.reactive.html.md#muvid.visualize.reactive.flash_filter)). The vectorscope reacts to
the music through its own amplitude; the spectrogram uses this.

The whole chain degrades to *nothing* rather than to an error: a track that will
not decode, or an ffmpeg build without [`FLASH_FILTERS`](_autosummary/muvid.visualize.reactive.html.md#muvid.visualize.reactive.FLASH_FILTERS), yields an empty
fragment, so a visual can append it unconditionally.

### Module Attributes

| [`ENVELOPE_SR`](_autosummary/muvid.visualize.reactive.html.md#muvid.visualize.reactive.ENVELOPE_SR)         | Sample rate the envelope is measured at.                                                                                                      |
|----------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------|
| [`FLASH_DECAY`](_autosummary/muvid.visualize.reactive.html.md#muvid.visualize.reactive.FLASH_DECAY)         | Per-frame persistence of a pulse, 0 (no trail) to <1 (longer afterglow), so a beat flashes and fades rather than blinking for a single frame. |
| [`FLASH_BRIGHTNESS`](_autosummary/muvid.visualize.reactive.html.md#muvid.visualize.reactive.FLASH_BRIGHTNESS)    | Peak brightness boost at a full-strength pulse, in `eq`'s units (an additive offset as a fraction of full scale, -1..1).                      |
| [`FLASH_SATURATION`](_autosummary/muvid.visualize.reactive.html.md#muvid.visualize.reactive.FLASH_SATURATION)    | Peak saturation boost at a full-strength pulse, *added to* 1.0.                                                                               |
| [`DEFAULT_FLASH_LABEL`](_autosummary/muvid.visualize.reactive.html.md#muvid.visualize.reactive.DEFAULT_FLASH_LABEL) | Default `sendcmd` label for the pulsing lookup table.                                                                                         |
| [`FLASH_FILTERS`](_autosummary/muvid.visualize.reactive.html.md#muvid.visualize.reactive.FLASH_FILTERS)       | The ffmpeg filters a flash chain is built from.                                                                                               |
| [`FLASH_COMPONENTS`](_autosummary/muvid.visualize.reactive.html.md#muvid.visualize.reactive.FLASH_COMPONENTS)    | one per `lutyuv` component.                                                                                                                   |

### Functions

| [`flash_filter`](_autosummary/muvid.visualize.reactive.html.md#muvid.visualize.reactive.flash_filter)(audio, \*, fps, duration, workdir)   | A filter fragment that makes the stream it follows pulse with the beat.   |
|----------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------|
| [`onset_envelope`](_autosummary/muvid.visualize.reactive.html.md#muvid.visualize.reactive.onset_envelope)(audio, \*, fps[, duration, ...])   | Per-video-frame onset strength in `[0, 1]`, with phosphor-style decay.    |

### muvid.visualize.reactive.DEFAULT_FLASH_LABEL *= 'flash'*

Default `sendcmd` label for the pulsing lookup table. Distinct per flash, so
one filtergraph can carry several without their commands crossing.

The label is NOT the `sendcmd` target, and reading it as one is how muvid#72
came to report the flash as inert. `avfilter_graph_send_command` matches three
things and a bare label is none of them, so the target is the *instance* name —
the whole `lutyuv@flash` spelling, which [`flash_filter()`](_autosummary/muvid.visualize.reactive.html.md#muvid.visualize.reactive.flash_filter) derives from the
filter it emits rather than composing a second time. Measured on ffmpeg 9.0.1 and
6.1.6, driving a grey source with one full-strength pulse on frame 5 in a graph
that also carries `background_chain`’s own unlabelled `lutyuv` — and in BOTH
graph orders, because the type name’s answer depends on the order:

> target          plate first                  flash first (muvid’s own order)
> : plate      flash             plate      flash

> [lutyuv@flash](mailto:lutyuv@flash)    unmoved    126 -> 189        unmoved    126 -> 189  <- shipped
> lutyuv          33 -> 67   unmoved           unmoved    126 -> 189
> flash           unmoved    unmoved           unmoved    unmoved
> all             ffmpeg SIGSEGVs on both builds

`sendcmd` dispatches with `AVFILTER_CMD_FLAG_ONE`, so the type name reaches
only the FIRST `lutyuv` the parser created and stops. Which one that is comes
down to where the chains sit in the composed string, and in muvid’s real graph
the flash is FIRST — `_reactive_plan` emits `[aviz]…lutyuv@flash…[_viz]`
before `background_chain` (measured on the composed spectrum graph: char 279
against char 493). So the type name would happen to work today, silently, and
break on any reordering; muvid#72’s prediction that it would flash the plate
instead is what happens in the other order. The instance name is the address
that does not depend on the accident — measured identical in both orders above.
`all` hands `y <lut expression>` to every commandable filter in the graph,
including the `crop` in `background_chain`, whose `y` is a geometry
expression; it fails to parse and the process dies (exit 139, both builds).

### muvid.visualize.reactive.ENVELOPE_SR *= 22050*

Sample rate the envelope is measured at. Low is fine — we only need a
per-frame loudness curve, not audio quality.

### muvid.visualize.reactive.FLASH_BRIGHTNESS *= 0.25*

Peak brightness boost at a full-strength pulse, in `eq`’s units (an additive
offset as a fraction of full scale, -1..1). At rest the filter is a no-op; this
is how far a beat pushes it.

### muvid.visualize.reactive.FLASH_COMPONENTS *= ('y', 'u', 'v')*

one per `lutyuv` component.
`eq` took two (`brightness`, `saturation`); a LUT is addressed per plane,
so chroma costs two commands carrying the same expression.

* **Type:**
  The `sendcmd` commands one flash frame sends

### muvid.visualize.reactive.FLASH_DECAY *= 0.5*

Per-frame persistence of a pulse, 0 (no trail) to <1 (longer afterglow), so a
beat flashes and fades rather than blinking for a single frame.

### muvid.visualize.reactive.FLASH_FILTERS *= ('sendcmd', 'lutyuv')*

The ffmpeg filters a flash chain is built from. Both are core filters, but a
stripped build can omit either — and the flash is a garnish, so a build that
cannot do it should render the visual *without* the flash rather than fail.

This tuple is the *probe*, so it has to name the filters the chain really
contains. It said `eq` while the point of muvid#69 was to stop needing `eq`
(GPL-only): left stale, an LGPL build — the very build this change exists to
serve — would have failed the probe and silently rendered with no flash at all.

### muvid.visualize.reactive.FLASH_SATURATION *= 0.8*

Peak saturation boost at a full-strength pulse, *added to* 1.0.

### muvid.visualize.reactive.flash_filter(audio, , fps, duration, workdir, label='flash', brightness=0.25, saturation=0.8, decay=0.5)

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=…,lutyuv@<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`](_autosummary/muvid.visualize.reactive.html.md#muvid.visualize.reactive.FLASH_FILTERS), so a caller can
append it unconditionally and still render.

The `lutyuv` starts as an identity table (brightness 0, saturation 1); the
script drives it. It is a LUT rather than an `eq` because `eq` exists only
in a GPL-configured ffmpeg (muvid#69) — see
[`brightness_saturation_lut()`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.brightness_saturation_lut).

* **Parameters:**
  * **audio** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – The track whose beats drive the flash.
  * **fps** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – The render’s frame rate (one command per component per frame).
  * **duration** ([`float`](https://docs.python.org/3/builtins/functions.html#float) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Clamp the flash to this many seconds (`None` = whole track).
  * **workdir** ([`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – Directory to write the `sendcmd` script into.
  * **label** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – `sendcmd` label for this flash’s `lutyuv`.
  * **brightness** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – Peak brightness boost on a beat.
  * **saturation** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – Peak saturation boost on a beat.
  * **decay** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – Per-frame afterglow of a pulse.
* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.visualize.reactive.onset_envelope(audio, , fps, duration=None, sr=22050, decay=0.5)

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** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – The track to analyse.
  * **fps** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – Video frame rate — one envelope value per frame.
  * **duration** ([`float`](https://docs.python.org/3/builtins/functions.html#float) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Clamp the envelope to this many seconds (defaults to the whole
    track).
  * **sr** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – Analysis sample rate.
  * **decay** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – Per-frame persistence of a pulse, 0 (no trail) to <1 (longer
    afterglow).
* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]
* **Returns:**
  One value per frame. Empty if the audio could not be decoded.


# _autosummary/muvid.visualize.verify.html.md

# muvid.visualize.verify

Check a rendered music video against what YouTube actually wants.

Rendering can succeed and still produce something wrong: a video a few seconds
longer than the song, a pixel format half the world cannot decode, a thumbnail
over the upload limit, a track 6 LU quieter than the rest of the album. Those
failures are silent — the file plays fine locally — so they are worth asserting
rather than eyeballing.

```pycon
>>> from muvid.visualize import verify_video, report
>>> checks = verify_video("song.mp4", audio="song.wav")
>>> print(report(checks))
✓ container      h264 / aac
✓ pixel format   yuv420p
...
```

This is the machine-checkable half of the quality checklist; the `music2video`
skill carries the half that needs judgement.

### Module Attributes

| [`DURATION_TOLERANCE`](_autosummary/muvid.visualize.verify.html.md#muvid.visualize.verify.DURATION_TOLERANCE)   | How far the video may run past (or short of) the audio, in seconds.   |
|-----------------------------------------------------------------------|-----------------------------------------------------------------------|
| [`LOUDNESS_TOLERANCE`](_autosummary/muvid.visualize.verify.html.md#muvid.visualize.verify.LOUDNESS_TOLERANCE)   | How far the integrated loudness may sit from the target, in LU.       |

### Functions

| [`failures`](_autosummary/muvid.visualize.verify.html.md#muvid.visualize.verify.failures)(checks)                                 | Just the checks that failed.                                               |
|---------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------|
| [`report`](_autosummary/muvid.visualize.verify.html.md#muvid.visualize.verify.report)(checks)                                   | Render `checks` as an aligned, readable block.                             |
| [`verify_video`](_autosummary/muvid.visualize.verify.html.md#muvid.visualize.verify.verify_video)(video, \*[, audio, thumbnail, ...]) | Check `video` against YouTube's expectations; return one result per check. |

### Classes

| [`Check`](_autosummary/muvid.visualize.verify.html.md#muvid.visualize.verify.Check)(name, ok, detail)   | One verification result.   |
|----------------------------------------------------------------------------|----------------------------|

### *class* muvid.visualize.verify.Check(name, ok, detail)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One verification result.

### muvid.visualize.verify.DURATION_TOLERANCE *= 0.5*

How far the video may run past (or short of) the audio, in seconds. A still
video is cut on a GOP boundary, so an exact match is not achievable.

### muvid.visualize.verify.LOUDNESS_TOLERANCE *= 1.5*

How far the integrated loudness may sit from the target, in LU. Encoding to
AAC moves it slightly, so demanding an exact hit would fail every time.

### muvid.visualize.verify.failures(checks)

Just the checks that failed.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Check`](_autosummary/muvid.visualize.verify.html.md#muvid.visualize.verify.Check)]

### muvid.visualize.verify.report(checks)

Render `checks` as an aligned, readable block.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### muvid.visualize.verify.verify_video(video, , audio=None, thumbnail=None, loudness=None, check_loudness=False, duration_tolerance=0.5, expected_canvas=None, expected_duration=None)

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

* **Parameters:**
  * **video** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – The rendered mp4.
  * **audio** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – The source song — enables the duration-match check, which is the
    one that catches a mis-built filtergraph.
  * **thumbnail** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – The thumbnail to check against YouTube’s limits.
  * **loudness** ([`Loudness`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.Loudness) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – The target the video was normalized to.
  * **check_loudness** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – Actually measure the output’s loudness. This decodes the
    whole track, so it is off by default.
  * **duration_tolerance** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – Allowed audio/video duration difference, in seconds.
  * **expected_canvas** ([`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)] | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – 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.
  * **expected_duration** ([`float`](https://docs.python.org/3/builtins/functions.html#float) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – The length the render was ASKED for, when that is not
    the whole of `audio` — a trimmed edit renders only part of the song.
    Arms the duration check on its own, and wins over `audio`’s length.
* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Check`](_autosummary/muvid.visualize.verify.html.md#muvid.visualize.verify.Check)]
* **Returns:**
  A list of [`Check`](_autosummary/muvid.visualize.verify.html.md#muvid.visualize.verify.Check). Falsy checks are the problems; [`report()`](_autosummary/muvid.visualize.verify.html.md#muvid.visualize.verify.report)
  renders them, and [`failures()`](_autosummary/muvid.visualize.verify.html.md#muvid.visualize.verify.failures) filters them.


# _autosummary/muvid.visualize.video.html.md

# muvid.visualize.video

Render an audio file into a video: pick a visual, mux, normalize, encode.

This is the assembler. A [`visuals`](_autosummary/muvid.visualize.visuals.html.md#module-muvid.visualize.visuals) strategy says what the
frames look like; everything platform-facing lives here — the 16:9 canvas, the
H.264/AAC encode YouTube prefers, EBU R128 loudness normalization, and the
guarantee that the video ends exactly when the song does.

```pycon
>>> from muvid.visualize import render_audio_video
>>> result = render_audio_video("song.wav", image="cover.png")
>>> result.path, result.duration
(PosixPath('song.mp4'), 154.92)
```

### Module Attributes

| [`DEFAULT_CRF`](_autosummary/muvid.visualize.video.html.md#muvid.visualize.video.DEFAULT_CRF)           | visually lossless enough for a source YouTube re-encodes.                                                                              |
|------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------|
| [`DEFAULT_AUDIO_BITRATE`](_autosummary/muvid.visualize.video.html.md#muvid.visualize.video.DEFAULT_AUDIO_BITRATE) | YouTube's recommended audio for stereo uploads.                                                                                        |
| [`AUDIO_SAMPLE_RATE`](_autosummary/muvid.visualize.video.html.md#muvid.visualize.video.AUDIO_SAMPLE_RATE)     | `loudnorm` upsamples to 192 kHz internally for true-peak detection, and leaks that rate into the output unless the rate is fixed here. |
| [`DEFAULT_GOP_SECONDS`](_autosummary/muvid.visualize.video.html.md#muvid.visualize.video.DEFAULT_GOP_SECONDS)   | Seconds between keyframes.                                                                                                             |

### Functions

| [`render_audio_video`](_autosummary/muvid.visualize.video.html.md#muvid.visualize.video.render_audio_video)(audio[, image, visual, ...])   | Render `audio` into a video, using `visual` for the picture.   |
|----------------------------------------------------------------------------------------------------|----------------------------------------------------------------|

### Classes

| [`RenderResult`](_autosummary/muvid.visualize.video.html.md#muvid.visualize.video.RenderResult)(path, duration, size, fps, visual)   | A rendered video and what is worth knowing about it.   |
|----------------------------------------------------------------------------------------------------|--------------------------------------------------------|

### muvid.visualize.video.AUDIO_SAMPLE_RATE *= 48000*

`loudnorm` upsamples to 192 kHz internally for true-peak
detection, and leaks that rate into the output unless the rate is fixed here.

* **Type:**
  Pinned deliberately

### muvid.visualize.video.DEFAULT_AUDIO_BITRATE *= '384k'*

YouTube’s recommended audio for stereo uploads.

### muvid.visualize.video.DEFAULT_CRF *= 18*

visually lossless enough for a source YouTube re-encodes.

* **Type:**
  Constant-rate-factor

### muvid.visualize.video.DEFAULT_GOP_SECONDS *= 2.0*

Seconds between keyframes. YouTube nominally asks for a GOP of half the frame
rate (a keyframe every 0.5 s); for the static and near-static pictures this
module produces, that multiplies the intra-frames — and the upload size — for
no gain, since YouTube re-encodes anyway. Two seconds is the usual compromise.
Pass `gop_seconds=0.5` for strict compliance.

### *class* muvid.visualize.video.RenderResult(path, duration, size, fps, visual, loudness=None, canvas=None, extras=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A rendered video and what is worth knowing about it.

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

#### path

The rendered mp4.

#### duration

Its duration in seconds.

#### size

Frame size.

#### fps

Frame rate.

#### visual

The strategy that produced it.

#### loudness

The applied loudness target and measurement, if normalized.

#### canvas

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

### muvid.visualize.video.render_audio_video(audio, image=None, , visual='auto', saveas=None, size=(1920, 1080), fps=24, title=None, layout=None, title_style=None, normalize=False, loudness=None, crf=18, preset='medium', audio_bitrate='384k', gop_seconds=2.0, options=None, workdir=None)

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** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)) – The song (`.wav` is preferred when you have it — YouTube
    re-encodes regardless, so give it the cleanest input).
  * **image** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Cover art. Used for the picture, and composed onto a 16:9 canvas.
  * **visual** (`Union`[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`VisualContext`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualContext)], [`VisualPlan`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualPlan) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]) – A registered strategy name (`"still"`, `"ken_burns"`,
    `"cqt"`, `"bars"`, `"spectrum"`, `"waves"`, `"scope"`),
    `"auto"`, or any callable (see [`muvid.visualize.visuals`](_autosummary/muvid.visualize.visuals.html.md#module-muvid.visualize.visuals)).
  * **saveas** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Output path (default: `<audio-stem>.mp4`).
  * **size** ([`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)]) – Canvas size; the default is 1080p.
  * **fps** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – Frame rate.
  * **title** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Burn this title into the frame.
  * **layout** ([`CoverLayout`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.CoverLayout) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – How the cover sits on the canvas.
  * **title_style** ([`TitleStyle`](_autosummary/muvid.visualize.canvas.html.md#muvid.visualize.canvas.TitleStyle) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – How the title is drawn.
  * **normalize** ([`bool`](https://docs.python.org/3/builtins/functions.html#bool)) – Loudness-normalize the audio (two-pass EBU R128).
  * **loudness** ([`Loudness`](_autosummary/muvid.visualize.ffmpeg.html.md#muvid.visualize.ffmpeg.Loudness) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – The loudness target; a YouTube-appropriate default is used
    when omitted.
  * **gop_seconds** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – Encoder knobs.
  * **options** ([`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Strategy-specific options, passed to the visual.
  * **workdir** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – Where intermediates go (a temporary directory by default).
* **Return type:**
  [`RenderResult`](_autosummary/muvid.visualize.video.html.md#muvid.visualize.video.RenderResult)
* **Returns:**
  A [`RenderResult`](_autosummary/muvid.visualize.video.html.md#muvid.visualize.video.RenderResult).
* **Raises:**
  [**ValueError**](https://docs.python.org/3/builtins/exceptions.html#ValueError) – `size` has an odd dimension — H.264 at yuv420p (the only
      pixel format every player decodes) cannot encode one.


# _autosummary/muvid.visualize.visuals.html.md

# muvid.visualize.visuals

Visual strategies: how the *picture* of an audio-driven video is produced.

A strategy is a function from a [`VisualContext`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualContext) (the audio, the optional
cover, the duration, the canvas) to a [`VisualPlan`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualPlan) (the ffmpeg inputs
and filter chains that yield one video stream). [`muvid.visualize.video`](_autosummary/muvid.visualize.video.html.md#module-muvid.visualize.video) owns the
muxing, encoding, and loudness; a strategy only says what the frames look like.

That split is what keeps this open-closed: the built-ins are registered by name
(`"still"`, `"ken_burns"`, `"cqt"`, …), and anything else you can express
as a callable — a librosa/matplotlib animation, a projectM render, a shader —
plugs in through the same seam, either by returning a [`VisualPlan`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualPlan) or by
returning the path of a silent video it rendered itself.

```pycon
>>> sorted(list_visuals())
['bars', 'cqt', 'ken_burns', 'scope', 'spectrum', 'still', 'waves']
```

Conventions a strategy must honour:

- **ffmpeg input 0 is always the audio.** Inputs a plan adds are numbered from
  1, in the order they appear in [`VisualPlan.inputs`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualPlan.inputs).
- To react to the audio, set `uses_audio=True` and consume the `[aviz]`
  label — a dedicated copy of the audio, split off so the output track stays
  untouched.
- Emit exactly one video stream, labelled [`VisualPlan.video`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualPlan.video).

### Module Attributes

| [`REACTIVE_COVER_FRACTION`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.REACTIVE_COVER_FRACTION)   | Over a reactive background the cover fills nearly the whole frame (with a little padding), and is made slightly transparent so the visualizer plays on through it rather than being hidden behind an opaque card.    |
|----------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`SCOPE_COVER_ALPHA`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.SCOPE_COVER_ALPHA)         | The vectorscope fills the whole frame, so it keeps the same big cover as the other visuals but a touch more transparent, letting the line-work read both through the cover and in the space around it.               |
| [`SCOPE_BG_DIM`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.SCOPE_BG_DIM)              | The vectorscope's plate is darker still, so its sparse line-work has the least competition of any visual.                                                                                                            |
| [`DEFAULT_TINT`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.DEFAULT_TINT)              | Default accent for the line/bar visualizers, applied as a *tint*.                                                                                                                                                    |
| [`REACTIVE_BG_SATURATION`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.REACTIVE_BG_SATURATION)    | Reactive backgrounds are pushed dark and near-neutral so the teal accent reads (a bright, saturated blurred cover both tints everything its own colour and, under the screen blend, washes the accent out to white). |
| [`Visual`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.Visual)                    | context in, plan out.                                                                                                                                                                                                |

### Functions

| [`bars_visual`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.bars_visual)(ctx)            | Frequency bars (the classic EQ look), via ffmpeg's `showfreqs`.                                             |
|------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------|
| [`cqt_visual`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.cqt_visual)(ctx)             | Constant-Q transform bars — pitch-aligned, the most *musical* reactive look.                                |
| [`ken_burns_visual`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.ken_burns_visual)(ctx)       | A slow pan/zoom across the cover, lasting exactly as long as the song.                                      |
| [`list_visuals`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.list_visuals)()              | The names of every registered visual strategy.                                                              |
| [`register_visual`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.register_visual)(name)       | Register a visual strategy under `name` (the open-closed seam).                                             |
| [`resolve_visual`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.resolve_visual)(visual, ctx) | Turn `visual` (a name, or any callable) into a [`VisualPlan`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualPlan). |
| [`scope_visual`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.scope_visual)(ctx)           | The stereo Lissajous figure, via ffmpeg's `avectorscope`.                                                   |
| [`spectrum_visual`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.spectrum_visual)(ctx)        | A scrolling spectrogram, via ffmpeg's `showspectrum`.                                                       |
| [`still_visual`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.still_visual)(ctx)           | The cover, composed on a 16:9 canvas, held for the whole song.                                              |
| [`waves_visual`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.waves_visual)(ctx)           | The waveform, via ffmpeg's `showwaves`.                                                                     |

### Classes

| [`VisualContext`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualContext)(audio, image, duration, size, fps)   | Everything a visual strategy needs to know about the render.   |
|-----------------------------------------------------------------------------------------------------|----------------------------------------------------------------|
| [`VisualPlan`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualPlan)([inputs, filters, video, ...])          | The ffmpeg fragments that render one strategy's video stream.  |

### muvid.visualize.visuals.DEFAULT_TINT *= 'colorchannelmixer=rr=0.16:gg=0.80:bb=0.85'*

Default accent for the line/bar visualizers, applied as a *tint*. Several
ffmpeg visualizers ignore their `colors` option (`showfreqs` draws white
whatever you ask for), so rather than fight each filter we render them white
and recolour the whole visualization here — one knob, identical accent across
every method, so a whole album’s videos read as one release. It is a
`colorchannelmixer` mapping white (r=g=b) to a light teal; override per call
with the `tint` option (an empty string leaves the visualizer’s own colour).

### muvid.visualize.visuals.REACTIVE_BG_SATURATION *= 0.18*

Reactive backgrounds are pushed dark and near-neutral so the teal accent reads
(a bright, saturated blurred cover both tints everything its own colour and,
under the screen blend, washes the accent out to white). The sharp centred
cover still carries the artwork’s colour; only the surround is muted.

The dim is MULTIPLICATIVE (muvid#70), so this value is not comparable with the
additive `0.5` it replaces — it is the constant that lands the plate’s mean
display luma where the offset left it, measured over four photographs (as PNGs;
on JPEG covers the OLD chain landed somewhere else, because an additive offset
is range-dependent and a scaling is not — see `canvas._DIM_PIVOT`).

The washout this comment warns about was then measured rather than assumed: under
`screen`, out = P + V(1 - P), so the accent’s contribution is what a brighter
plate would eat. Isolated by rendering the real composed graph twice, once with
the visualizer branch removed — which under `screen` leaves the plate alone — and
differencing: over six covers (three photographs, each as PNG and as JPEG) the
accent’s frame-mean added luma moves by at most 0.10/255. State the instrument
with any number here: the accent is sparse, so a frame mean is small and a
mean over the visualizer’s own pixels is not, and only the ratio is portable.
It still reads.

### muvid.visualize.visuals.REACTIVE_COVER_FRACTION *= 0.95*

Over a reactive background the cover fills nearly the whole frame (with a
little padding), and is made slightly transparent so the visualizer plays on
through it rather than being hidden behind an opaque card.

### muvid.visualize.visuals.SCOPE_BG_DIM *= 0.965*

The vectorscope’s plate is darker still, so its sparse line-work has the least
competition of any visual. Named rather than spelled at the call site for the
same reason `spectrum_visual`’s is: muvid#70 named three constants to retune and
there were four sites, the fourth being a bare literal nothing pointed at.
Multiplicative like the other two, and measured the same way — the value that
lands the plate where the additive `0.55` left it.

### muvid.visualize.visuals.SCOPE_COVER_ALPHA *= 0.72*

The vectorscope fills the whole frame, so it keeps the same big cover as the
other visuals but a touch more transparent, letting the line-work read both
through the cover and in the space around it.

### muvid.visualize.visuals.Visual

context in, plan out. Returning a path to an already-rendered
silent video is also accepted (see [`resolve_visual()`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.resolve_visual)).

* **Type:**
  A strategy

alias of `Callable`[[[`VisualContext`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualContext)], `VisualPlan | Path | str`]

### *class* muvid.visualize.visuals.VisualContext(audio, image, duration, size, fps, layout=<factory>, title=None, title_style=None, workdir=<factory>, options=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Everything a visual strategy needs to know about the render.

#### audio

The audio file (ffmpeg input 0).

#### image

The cover art, if the caller supplied one.

#### duration

Audio duration in seconds.

#### size

Canvas size (width, height).

#### fps

Output frame rate.

#### layout

How the cover sits on the canvas.

#### title

Title to burn in, if any.

#### title_style

How to draw that title.

#### workdir

A directory the strategy may write intermediate files into.

#### options

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

#### require_image(visual)

The cover image, or a [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError) naming what to do instead.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### *class* muvid.visualize.visuals.VisualPlan(inputs=<factory>, filters=<factory>, video='vbg', uses_audio=False, has_cover=False, has_title=False, still=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

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.

#### filters

`filter_complex` chains, joined with `;` by the renderer.

#### video

Label of the video stream the chains emit.

#### uses_audio

The plan consumes the `[aviz]` audio copy.

#### has_cover

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

#### has_title

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

#### 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`.

### muvid.visualize.visuals.bars_visual(ctx)

Frequency bars (the classic EQ look), via ffmpeg’s `showfreqs`.

Rendered white; colour comes from the accent `tint` (see [`DEFAULT_TINT`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.DEFAULT_TINT)).

* **Return type:**
  [`VisualPlan`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualPlan)

### muvid.visualize.visuals.cqt_visual(ctx)

Constant-Q transform bars — pitch-aligned, the most *musical* reactive look.

Rendered white; colour comes from the accent `tint` (recolour it with the
`tint` option — *not* a per-filter colour, which the tint would multiply).

`showcqt` can draw two panes: the bargraph, and beneath it a *sonogram*
that keeps every past frame and scrolls it downward, so the music leaves a
trail of where it has been. The sonogram is off by default (bars only, the
cleanest read). Give `sono_fraction` a value in (0, 1) to hand that share
of the frame’s height to the trail — the bargraph keeps the rest.

* **Return type:**
  [`VisualPlan`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualPlan)

Options:
: `sono_fraction`: share of the height given to the scrolling sonogram,
  : 0 (default, bars only) to just under 1.
  <br/>
  `sono_v` / `bar_v`: sonogram and bargraph volume (sensitivity).
  `sono_g` / `bar_g`: sonogram and bargraph gamma (contrast).
  Plus the shared background/cover keys of `_reactive_plan()`.

### Examples

```pycon
>>> ctx = VisualContext(Path("a.wav"), None, 10.0, (1920, 1080), 24)
>>> "sono_h=0" in cqt_visual(ctx).filters[0]  # bars only, by default
True
>>> trail = replace(ctx, options={"sono_fraction": 0.6})
>>> f = cqt_visual(trail).filters[0]
>>> "bar_h=432" in f and "sono_h=648" in f  # 40% bars / 60% trail
True
```

### muvid.visualize.visuals.ken_burns_visual(ctx)

A slow pan/zoom across the cover, lasting exactly as long as the song.

Renders through the `burns` package (via `mixing.video`). By default it
pans across the *composed canvas* rather than the raw cover, so a square or
portrait image still fills a 16:9 frame instead of being letterboxed by the
pan.

Frames are rendered in Python (Pillow), so this is by far the slowest
visual — budget several times the song’s duration. The ffmpeg-native
visuals are an order of magnitude faster.

* **Return type:**
  [`VisualPlan`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualPlan)

Options:
: `source`: `"canvas"` (default) or `"image"` — what to pan across.

### muvid.visualize.visuals.list_visuals()

The names of every registered visual strategy.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### muvid.visualize.visuals.register_visual(name)

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

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`VisualContext`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualContext)], [`VisualPlan`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualPlan) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]], [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`VisualContext`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualContext)], [`VisualPlan`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualPlan) | [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) | [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]]

### Examples

```pycon
>>> @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.visuals.resolve_visual(visual, ctx)

Turn `visual` (a name, or any callable) into a [`VisualPlan`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.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`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.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**](https://docs.python.org/3/builtins/exceptions.html#ValueError) – `visual` names a strategy that is not registered.
* **Return type:**
  [`VisualPlan`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualPlan)

### muvid.visualize.visuals.scope_visual(ctx)

The stereo Lissajous figure, via ffmpeg’s `avectorscope`.

Tuned for drama *and* dynamics. Anti-aliased lines, `mirror=xy` to fill all
four quadrants symmetrically, and a generous `zoom` so the figure spills
into the space around the cover. Crucially the amplitude scale is **linear**,
not `sqrt`: a compressive scale keeps a steady mix pinned wide the whole
time (a constant scribble), whereas linear lets the figure *breathe* — wide
and full when the music swells, small and calm when it settles. The cover
stays the same big size as the other visuals, a touch more transparent so
the line-work reads through it too.

Options: `zoom`, `scale` (`lin`/`sqrt`/`cbrt`/`log`), `mirror`
(`none`/`x`/`y`/`xy`), `draw`, plus the shared background/cover keys.

* **Return type:**
  [`VisualPlan`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualPlan)

### muvid.visualize.visuals.spectrum_visual(ctx)

A scrolling spectrogram, via ffmpeg’s `showspectrum`.

A `log` *frequency* axis spreads out the low-mid range where music lives
(a linear axis crams it into the bottom edge), lifted gain makes the detail
read, and `overlap` quickens the right-to-left scroll. Rendered over a
darker background so it stands out. The default `green` colormap is tinted
to the teal accent; pass a `color` to use ffmpeg’s colormap instead.

The whole spectrogram also **pulses with the beat**: a precomputed onset
envelope (see [`muvid.visualize.reactive`](_autosummary/muvid.visualize.reactive.html.md#module-muvid.visualize.reactive)) drives a `sendcmd`-controlled
brightness/saturation flash, so attacks in the music read as flashes at the
live leading edge instead of the display feeling merely synced to playback.
The flash is appended last, after the recolour, so it modulates the colours
the frame actually shows.

Options: `color` (colormap — the teal tint applies only to the default),
`gain`, `saturation`, `overlap` (scroll speed, 0–1), `flash` (bool,
default on), `flash_brightness`, `flash_saturation`, plus the shared
background/cover keys.

* **Return type:**
  [`VisualPlan`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualPlan)

### muvid.visualize.visuals.still_visual(ctx)

The cover, composed on a 16:9 canvas, held for the whole song.

* **Return type:**
  [`VisualPlan`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualPlan)

### muvid.visualize.visuals.waves_visual(ctx)

The waveform, via ffmpeg’s `showwaves`.

Rendered white; colour comes from the accent `tint`. `options={"mode":
...}` sets the waveform *shape* (`cline`/`line`/`p2p`/`point`).

* **Return type:**
  [`VisualPlan`](_autosummary/muvid.visualize.visuals.html.md#muvid.visualize.visuals.VisualPlan)


# about-this-build.html.md

<!-- generated by epythet -->

# About this build

This documentation was built on **2026-10-14 12:29 UTC** from commit <a href="https://github.com/reeleehq/muvid/commit/f2479a9bf8ae91a7edcbc016d1fd4f6a5486a262"><code>f2479a9</code></a> on branch <code>main</code>, for **muvid 0.0.94** (from <code>pyproject.toml</code>).

#### NOTE
Nothing suggests a mismatch: the tree was clean at the commit above, and the documented version is the one on PyPI.

## Source

|                     |                                                                                                                                                       |
|---------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| Commit              | <a href="https://github.com/reeleehq/muvid/commit/f2479a9bf8ae91a7edcbc016d1fd4f6a5486a262"><code>f2479a9bf8ae91a7edcbc016d1fd4f6a5486a262</code></a> |
| Branch              | <code>main</code>                                                                                                                                     |
| Tags at this commit | <code>0.0.94</code>                                                                                                                                   |
| Working tree        | clean                                                                                                                                                 |
| Remote              | <code>https://github.com/reeleehq/muvid</code>                                                                                                        |

## Continuous integration

|              |                                                                                            |
|--------------|--------------------------------------------------------------------------------------------|
| Repository   | <code>reeleehq/muvid</code>                                                                |
| Run          | <a href="https://github.com/reeleehq/muvid/actions/runs/38563205257">38563205257</a>       |
| Ref          | <code>refs/heads/main</code>                                                               |
| Event commit | <code>b640c3255019cbb7b9ac837856d8981ac0cf93dd</code> (in the history of the built commit) |

## Tools

|          |         |
|----------|---------|
| epythet  | 0.2.17  |
| Sphinx   | 9.1.0   |
| docutils | 0.22.4  |
| Python   | 3.12.15 |

## Configuration as resolved

|               |                                                                   |
|---------------|-------------------------------------------------------------------|
| theme         | <code>auto</code> (Sphinx theme <code>sphinxawesome_theme</code>) |
| accent        | <code>#0e6a27</code>                                              |
| api_generator | <code>autosummary</code>                                          |
| ignore        | <code>tests/</code>, <code>scrap/</code>, <code>examples/</code>  |
| agent_outputs | <code>true</code>                                                 |
| aggregates    | <code>md</code>                                                   |
| ai_artifacts  | <code>true</code>                                                 |

## Package on PyPI

Latest release: <a href="https://pypi.org/project/muvid/0.0.94/">0.0.94</a>, the same as the documented version.

## Reproduce

```bash
git clone https://github.com/reeleehq/muvid && cd muvid
git checkout f2479a9bf8ae91a7edcbc016d1fd4f6a5486a262
pip install "epythet==0.2.17"
epythet quickstart . --ignore tests/ scrap/ examples/
```

The same data, for machines: <a href="build_info.json"><code>build_info.json</code></a> (schema version 1).


# ai-agents.html.md

<!-- generated by epythet -->

# For AI agents

`muvid` ships artifacts for coding agents alongside its code. This page lists
them, says where each lives in the repository, and points at the
machine-readable copies of this documentation.

## Skills

Skills are folders holding a `SKILL.md` (the [Agent Skills](https://agentskills.io) format): a description that tells an agent when to use it and a body with the procedure. Install one into your agent with `gh skill` (any host: `--agent claude-code`, `copilot`, `cursor`, `codex`, `gemini`), or use the copy bundled in the wheel.

### `muvid-lyric-video`

Make a lyric video — a typographic music video where the words appear in time with the singing — from a song, using muvid. Use when the user has an audio file and wants the lyrics animated, a karaoke video, kinetic typography, a “words on screen” music video, or wants a poem/lyric laid out in a shape that fills in as it is sung. Covers CONCRETE POETRY and CALLIGRAMS — a poem whose shape on the page is the point (Apollinaire’s “Il pleut”, shaped text, word art, ASCII-art-style layouts) — via the `calligram` and `shape_fill` archetypes. Also use to inspect or hand-edit an existing treatment, to choose between treatment options, or when a lyric video came out badly and you need to work out why (usually: the word timings were interpolated rather than measured). Triggers on “lyric video”, “karaoke video”, “animate these lyrics”, “words in time with the music”, “kinetic typography”, “put the lyrics on screen”, “calligram”, “concrete poem”, “concrete poetry”, “shaped text”, “word art”, “make a video of this poem”. If the user has a poem but NO audio, muvid cannot make the song — generate it first with `arioso` (see “You need a song first” below), then come back here.

```bash
gh skill install thorwhalen/muvid muvid-lyric-video --agent claude-code
```

Source: [`muvid/data/skills/muvid-lyric-video`](https://github.com/thorwhalen/muvid/tree/HEAD/muvid/data/skills/muvid-lyric-video) (bundled with the pip package).

### `muvid-montage`

Cut a person’s own videos and photos to a song — the “memories” kind of music video, where the footage does NOT contain the song (a day out, a trip, a party filmed in clips, a folder of photos) and the audio is the song itself. Use when someone has a song plus footage and photos and wants them “put to music”, “cut to the beat”, “made into a memories video”, a slideshow to music, a montage, a recap; when a music video came out mostly black or oddly placed because the clips have no song in their sound; or when deciding whether a clip should be synced to the song or cut to the music. Covers the studio flow, the Python and MCP calls, how each clip’s role is decided (and overridden), photos with Ken Burns moves, the pace knob, how the picture is aligned to the beat, and the research behind it (methods, libraries and licences, apps and market, UX).

```bash
gh skill install thorwhalen/muvid muvid-montage --agent claude-code
```

Source: [`muvid/data/skills/muvid-montage`](https://github.com/thorwhalen/muvid/tree/HEAD/muvid/data/skills/muvid-montage) (bundled with the pip package).

### `muvid`

Use when the user wants to make a music video from a song. Triggers on “make a music video”, “turn this song into a video”, “/muvid”, or any work inside a muvid project folder (look for project.json with muvid schema_version). Drives muvid’s NARRATIVE, shot-by-shot pipeline: transcribing the lyrics off the audio, aligning them, casting characters, establishing environments, writing the script, rendering shots. Also the router to muvid’s one-command surfaces — song in, video out, no project folder: lyric video, karaoke, kinetic typography, concrete poem, CALLIGRAM, audio visualizer, and assembling N phone recordings of one gig. If the user has no audio file, muvid cannot make one — see “muvid needs a song” below.

Source: [`.claude/skills/muvid`](https://github.com/thorwhalen/muvid/tree/HEAD/.claude/skills/muvid).

### `muvid-choose-footage-segments`

Use when choosing WHICH uploaded footage clip to show over each span of a fixed song in muvid’s `music_video` genre — the scoring + selection layer on top of audio alignment. Triggers on “which clip should be on-air”, “score footage segments”, “auto-choose footage”, “pick the best take”, “footage selection strategy”, “multichannel editor view”, “lip-sync score”, “motion-to-beat / dance-match score”, or building the auto composer / human-editor timeline for aligned footage. ALSO covers what happens to the pixels once a clip is chosen — the per-cut crop window and the `look` seam onto the `looks` package: “punch in”, “in-shot zoom”, “push-in”, “Ken Burns on footage”, “colour grade a cut”, “apply a LUT”, “stylize the edit”. The concrete per-metric scoring recipes live in `muvid-score-footage`; this skill owns the score MENU, the song-time data model, the two-consumer principle, the selection strategy, and the crop/look spatial half.

Source: [`.claude/skills/muvid-choose-footage-segments`](https://github.com/thorwhalen/muvid/tree/HEAD/.claude/skills/muvid-choose-footage-segments).

### `muvid-score-footage`

Concrete recipes for computing per-clip score curves for muvid’s `music_video` footage selection — the lip-sync score, the motion-to-beat / dance-match score, and the cheap CPU quality tier — each resolved onto the shared song-time grid. Use when IMPLEMENTING a footage score. Triggers on “compute lip-sync score”, “SyncNet / LSE-C”, “motion beat alignment / BAS”, “optical flow motion envelope”, “sharpness / blur / shake / exposure score”, “face framing score”, “beat grid on the master”. The overall menu + selection + data model is `muvid-choose-footage-segments`.

Source: [`.claude/skills/muvid-score-footage`](https://github.com/thorwhalen/muvid/tree/HEAD/.claude/skills/muvid-score-footage).

### `muvid-visualize`

Turn a song (+ optional cover) into a video with muvid.visualize — the lightweight, ffmpeg-only half of muvid: a still cover, a Ken Burns pan, or an audio-reactive visualizer (cqt/spectrum/waves/bars/scope) on a 16:9 canvas, loudness-normalized, plus a matching thumbnail. Use when the user wants to render an audio-visualizer or cover video from audio (no AI, no upload), pick or tune a visual, add a custom visualizer, or verify a rendered video. For *publishing* the result to YouTube, see `yb`’s `music2video` skill (it ships at `yb/data/skills/music2video/`, so it may not appear in an enabled-skill list).

Source: [`.claude/skills/muvid-visualize`](https://github.com/thorwhalen/muvid/tree/HEAD/.claude/skills/muvid-visualize).

The bundled skills are also on disk after `pip install muvid`, under the package’s `data/skills/` directory; link them into an agent without network access with `skill link-skills <that directory>`.

## Instruction files

Files agents read before working in this repository.

- [`.claude/CLAUDE.md`](https://github.com/thorwhalen/muvid/tree/HEAD/.claude/CLAUDE.md): read by Claude Code

## Machine-readable documentation

This site publishes the same documentation in forms that fit an agent’s context window:

- [`llms.txt`](https://thorwhalen.github.io/muvid/llms.txt): an index of every page with a one-line description ([llms.txt](https://llmstxt.org) format)
- [`muvid.md`](https://thorwhalen.github.io/muvid/muvid.md): the whole documentation as one Markdown file
- `<page>.html.md`: a rendered Markdown twin of every page, advertised from each page’s `<head>` with `<link rel="alternate" type="text/markdown">`
- [`objects.inv`](https://thorwhalen.github.io/muvid/objects.inv): the Sphinx inventory: a symbol-to-URL index (`sphobjinv convert plain objects.inv -`)


# api.html.md

# API reference

| [`muvid`](_autosummary/muvid.html.md#module-muvid)   | muvid — tools to make music videos.   |
|-----------------------------------------------------------------------|---------------------------------------|


