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() the way muvid.lyricvid.scene
registers its layouts) walks a section’s beats and returns
SlotDrafts: 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()),
which is what lets twelve photos carry a three-minute song:
**never the same image twice within
min_gapcuts** (the gap shrinks topool - 1for 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; 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(), 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, 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
The closed set of framings a still may be shown in, as |
Functions
|
Fill every region of every draft with |
|
Slot boundaries inside |
|
Pool + measurements + treatment -> a |
|
Register an archetype planner under |
|
The window path for one still: piecewise linear, slot-relative seconds. |
Classes
|
|
|
The edit list. |
|
What every archetype gets: the measurements and the direction. |
|
One span of the montage, in song seconds. |
|
What an archetype emits: a span with regions, before media assignment. |
|
One region of a slot: which media, framed and moved how. |
|
A framing: top-left |
- muvid.montage.plan.CROP_VARIANTS: dict[str, tuple[float, float, 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 (sizeis 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)[source]¶
Bases:
objectwindowat slot-relative timet(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')[source]¶
Bases:
objectThe edit list. JSON-able, canvas-independent (windows are normalised).
- class muvid.montage.plan.PlanContext(*, analysis, direction)[source]¶
Bases:
objectWhat 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)[source]¶
Bases:
objectOne span of the montage, in song seconds.
- class muvid.montage.plan.SlotDraft(*, start, end, section, archetype, regions=1, fresh=(0,), motions=('none',), amplitude=0.0, transition='cut', transition_s=0.0)[source]¶
Bases:
objectWhat 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)[source]¶
Bases:
objectOne region of a slot: which media, framed and moved how.
- class muvid.montage.plan.Window(x, y, size)[source]¶
Bases:
objectA framing: top-left
(x, y)and visible fractionsizeof the fitted frame, all in0..1. The aspect is the canvas’s.
- muvid.montage.plan.assign_media(drafts, media, reuse, *, finale_start=None)[source]¶
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.>>> 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)[source]¶
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 withinMIN_SLOT_Sof a boundary is dropped.>>> 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)[source]¶
Pool + measurements + treatment -> a
Plan. Pure and deterministic.- Return type:
- muvid.montage.plan.register_archetype(name)[source]¶
Register an archetype planner under
name.- Return type:
Callable[[Callable[[Section,PlanContext,Mapping[str,Any]],list[SlotDraft]]],Callable[[Section,PlanContext,Mapping[str,Any]],list[SlotDraft]]]
>>> @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)[source]¶
The window path for one still: piecewise linear, slot-relative seconds.
>>> 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)]