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:
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 constantfps. A gap entry (clip_path == ""— a span of the song with no footage) renders as black from acolorsource, 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 equalsround(song_duration*fps)by construction, whatever the cut count, source rates, or a clip whose audio outlives its video.A boundary carrying a
Transitioncontributes a fourth kind of part: a two-inputxfade, 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: thepre/postterms cancel against the transition length, so the total isround(song_duration*fps)with or without transitions.Concat by stream copy (concat demuxer) — no re-encode, no filtergraph.
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) 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 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) 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 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(); 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 output frame rate for the assembled video. |
|
9 1080p) when the caller/genre gives none. |
Functions
|
Render |
Exceptions
A render-plan finding the caller should see — not an error, not silence. |
- exception muvid.footage.assemble.AssemblyWarning[source]¶
Bases:
RuntimeWarningA render-plan finding the caller should see — not an error, not silence.
A
RuntimeWarningsubclass, so every existingpytest.warns(RuntimeWarning)and every stderr reader keeps working. It exists so the reply half can pick these out:warnings.warnreaches a developer’s stderr and nothing else, andassemble_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 — soassemble_music_video()takes anon_notesink and the tool returns what it collects.A distinct class rather than a stderr filter or a
catch_warningsblock:catch_warningsmutates 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)[source]¶
Render
cuts(a validated, contiguous, gap-filled EDL) intoout_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 byfill_gaps, is the whole song. Returnsout_path.- Parameters:
on_note – optional
str -> Nonesink 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 asAssemblyWarningas well; this is the additional path, and the only one a remote caller can see.assemble_music_videois 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 becausecatch_warningsmutates process-global state that concurrent renders would share.fade_out_s (
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 trimmedspan), 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
FootageCancelledis raised — so a cancelled render ends within one cut’s encode rather than minutes later.
- Return type: