muvid.montage.render

Render a 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()), 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; 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(starts, duration, ...)

Slot boundaries in frames, and the transition length into each slot.

grade_filter(grade[, accent, strength])

The ffmpeg fragment for a grade, or "" for none.

render_plan(plan, *, canvas, audio, output, ...)

Render plan over audio to output.

zoompan_exprs(path, *, frame_offset, fps)

The z/x/y expressions for zoompan over a still.

Classes

Canvas(*[, width, height, fps])

Output geometry.

Part(*, kind, slot, offset, n_frames[, ...])

One ffmpeg invocation's worth of frames.

class muvid.montage.render.Canvas(*, width=1920, height=1080, fps=30)[source]

Bases: object

Output geometry.

class muvid.montage.render.Part(*, kind, slot, offset, n_frames, prev=None, prev_offset=0, curve='fade')[source]

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

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

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

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

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

Render plan over audio to output. Writes exactly output.

Parameters:
  • plan (Plan) – The edit list. Its media paths are opened as-is.

  • canvas (Canvas) – Output size and frame rate. Even dimensions only.

  • audio (Path | str) – The song; the video is exactly as long as it.

  • output (Path | str) – The mp4 to write.

  • workdir (Path | str) – Where the parts go (workdir/parts, removed after a successful mux unless keep_parts).

  • grade (str) – One of muvid.montage.spec.GRADES.

  • palette (Palette | None) – Supplies the tint accent and the grid pad colour.

  • crf (int) – libx264 knobs, per part.

  • preset (str) – libx264 knobs, per part.

  • keep_parts (bool) – Leave the intermediate parts on disk.

Return type:

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

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

>>> 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)'}