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() 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
randomhere 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 trickmuvid.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 (
Noneat 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:
rasterise the shape to a boolean mask on a canvas-shaped pixel grid (
shape_mask());take a chamfer distance transform of that mask — how deep inside the outline each pixel is;
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”;
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’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
names them): 'named' resolves a built-in outline from the
NAMED_SHAPES registry (circle, heart, star, apple,
square — add one with 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
The three sources a shape can come from. |
Functions
|
Pack |
|
|
|
Rasterise a shape to a boolean mask over the whole canvas. |
|
Register a named outline, muvid's house registry idiom. |
Every registered outline name, sorted. |
|
|
The outline function for |
Classes
|
Where one word goes, in |
- class muvid.lyricvid.shape.Placement(*, text, x, y, size, rotated=False)[source]¶
Bases:
objectWhere one word goes, in
muvid.lyricvid.scene.Cue’s coordinates.Keyword-only and frozen, like the
Cueit 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) – the word, exactly as it should be drawn.y (
float) – centre of the text, normalised to the canvas (0..1, top-left).size (
float) – cap height as a fraction of canvas height.rotated (
bool) – the word is set at 90 degrees (bottom-up). Only ever True when the caller asked forallow_rotation;Cuecannot express a rotation, soshape_filldoes not ask for one.
- muvid.lyricvid.shape.SHAPE_KINDS: dict[str, Callable[[...], 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.ShapeRefis: the extension seam for a new outline isregister_shape(), not a fourth kind.
- muvid.lyricvid.shape.bounding_box(placement, *, aspect=1.7777777777777777)[source]¶
(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.
>>> 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()[source]¶
Every registered outline name, sorted.
>>> 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)[source]¶
Pack
textsinside a shape, in the order given.- Parameters:
texts (
Sequence[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) –'named','svg_path'or'mask_image'— seeSHAPE_KINDSandmuvid.lyricvid.spec.ShapeRef.shape_value (
str) – the outline’s name, path data, or image path.aspect (
float) – canvas width / height.base_size (
float) – cap height as a fraction of canvas height, before salience.allow_rotation (
bool) – also try each word turned 90 degrees.muvid.lyricvid.scene.Cuecannot express a rotation, soshape_fillleaves this off; a renderer that can should turn it on.shrink_steps (
int) – how many progressively smaller retries a word gets before it is dropped.mask_options (
Optional[Mapping[str,Any]]) – forwarded to the kind’s builder (thresholdandinvertfor'mask_image',curve_samplesfor'svg_path').
- Return type:
- Returns:
one entry per input, in the same order.
Noneis “did not fit” — dropping a word beats overlapping two.
>>> words = 'apple yum juicy crunchy red yellow green delicious'.split() >>> placed = pack_words_into_shape(words, shape_value='apple') >>> len(placed) == len(words) TrueSame input, same picture — every time, forever:
>>> placed == pack_words_into_shape(words, shape_value='apple') TrueEvery placed word is wholly inside the outline:
>>> 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:
>>> 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:]) FalseAn SVG outline works the same way (
M/L/H/V/C/S/Q/T/Z; arcs are refused):>>> 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)[source]¶
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; seepolygon_shape()for the latter.>>> @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)[source]¶
The outline function for
name, or a ValueError naming the options.>>> 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)[source]¶
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 (lessmargin), which is what keeps a circle round on a 16:9 frame.- Return type:
>>> 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)