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(). Deterministic repair is cheaper, faster and more predictable than a retry, and it means a slightly-wrong model output still renders.

Module Attributes

ARCHETYPES

How words are placed on screen.

MOTIONS

How a single word arrives.

PERSISTENCE

What a word does when it is no longer current.

QUANTIZE

What the animation clock is quantised to.

SHAPE_KINDS

Where a shape outline may come from.

INLINE_SHAPE_KINDS

The shape kinds that carry no reference to anything outside the spec.

Functions

coerce(obj)

Take whatever a caller or a model produced and return a renderable spec.

json_schema()

The JSON Schema for a TreatmentSpec.

repair(spec)

Project spec onto the valid space.

validate(spec)

Return a list of human-readable problems.

vocabulary()

Every closed vocabulary, for prompts and UI.

Classes

Direction(*[, mood, palette, typography, ...])

The song-level creative decision.

Palette(*[, bg, fg, accent, dim])

Colours, as #rrggbb.

Scene(*[, applies_to, archetype, motion, ...])

One treatment, applied to part of the song.

ShapeRef(*[, kind, value])

Where a shape_fill / concrete_page outline comes from.

Timing(*[, quantize_to, cut_style, ...])

The quantisation POLICY.

TreatmentSpec(*[, spec_version, title, ...])

A complete, renderable treatment.

Typography(*[, family, weight, case, ...])

Type choices.

muvid.lyricvid.spec.ARCHETYPES: dict[str, 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='')[source]

Bases: object

The song-level creative decision. The half a model is actually good at.

muvid.lyricvid.spec.INLINE_SHAPE_KINDS: frozenset[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[str, 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[str, 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')[source]

Bases: object

Colours, as #rrggbb. dim is the un-sung state where one exists.

muvid.lyricvid.spec.QUANTIZE: dict[str, 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[str, 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>)[source]

Bases: 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')[source]

Bases: 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)[source]

Bases: object

The quantisation POLICY. Never actual times.

attack_s: float

Seconds a word takes to arrive. Small, or it stops reading as on-the-beat.

lead_s: 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>)[source]

Bases: object

A complete, renderable treatment.

>>> s = TreatmentSpec()
>>> validate(s)
[]
>>> TreatmentSpec.from_dict(s.to_dict()) == s
True
classmethod from_dict(d)[source]

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

>>> 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()[source]

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[str, Any]

class muvid.lyricvid.spec.Typography(*, family='DejaVu Sans', weight=700, case='as_written', tracking=0.0, max_line_chars=28)[source]

Bases: 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)[source]

Take whatever a caller or a model produced and return a renderable spec.

Accepts a TreatmentSpec, a mapping, or a JSON string — and repairs it. This is the one entry point production code should use.

Return type:

tuple[TreatmentSpec, list[str]]

muvid.lyricvid.spec.json_schema()[source]

The JSON Schema for a TreatmentSpec.

Return type:

dict[str, Any]

>>> s = json_schema()
>>> s['properties']['scenes']['items']['properties']['archetype']['enum'][0]
'one_word_centred'
muvid.lyricvid.spec.repair(spec)[source]

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[TreatmentSpec, list[str]]

>>> 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)[source]

Return a list of human-readable problems. Empty means renderable.

Return type:

list[str]

>>> validate(TreatmentSpec(scenes=(Scene(archetype='nope'),)))
["scenes[0].archetype 'nope' is not one of the known archetypes"]
muvid.lyricvid.spec.vocabulary()[source]

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

Return type:

dict[str, dict[str, str]]