an.assemble#
Film assembly: rendered shots → one film, with transitions and a sound layer.
Every shot renders in isolation (an.render); this module decides how the
shots meet and what is heard over them. It runs only when a scene asks for it
— a non-cut Transition or any
SoundCue — so a scene with neither takes the old
_ffmpeg_concat path, byte for byte (needs_assembly()).
Where each part happens, and why there.
Picture: transitions are composed in the FRAME STAGE, on the per-shot PNGs, in exact integer arithmetic, and encoded by the same
an.media.mp4.mux_framesevery shot uses. Composing in ffmpeg (xfade) would decode already-encoded shots and re-encode them — a second generation of x264 loss on every frame of the film, not just the transition — and would retire the render pipeline’s “ffmpeg never touches a frame” clause.The picture is a stream-copy concat of SEGMENTS (an#260), each encoded once from PNGs: a shot no transition touches is its own mp4’s video stream, copied; a shot a transition touches contributes the encoded span between its windows (its body) plus the PNGs inside them; each run of composed frames is encoded on its own. So a reused shot needs its mp4 (and, at a transition, its body and window PNGs, a few dozen frames) — never every frame it has (
shot_windows(),ShotParts). The film’s frameiis ati / fpsand decodes to exactly what its segment decodes to (measured, an#260;MIN_SEGMENT_FRAMESis why no segment is shorter than three).Sound: the film’s audio is rebuilt from SOURCES — every dialogue line’s cached WAV and every cue’s asset, placed in film time — in one ffmpeg mix, then muxed onto the picture with
-c:v copy. Mixing onto the shots’ already-encoded AAC would be a second audio generation for the dialogue.
The timeline is frame-exact. Shot i occupies frame_count(duration,
fps) frames (the renderer’s own rule) starting at film frame
FilmTimeline.starts [i]; audio is placed at start / fps plus
its shot-local time, so a line stays on the frames it was lip-synced to
whatever the transitions do.
>>> from an.ir.schema import Shot, Transition
>>> tl = film_timeline(
... [Shot(id="a", duration=2.0),
... Shot(id="b", duration=2.0, transition=Transition(kind="dissolve", duration=0.5))],
... fps=10,
... )
>>> tl.starts, tl.total_frames # b starts 5 frames early: the film is 0.5 s shorter
((0, 15), 35)
Module Attributes
The fewest frames a segment of a MULTI-segment picture may have. |
Functions
|
Assemble rendered shots into |
|
The linear gain a ducked cue plays at, at film time |
|
Seconds the delivered film runs: the shots' durations, minus each dissolve's overlap. |
|
Lay |
|
True when the scene asks for anything beyond hard cuts and shot audio. |
|
The film's picture as segments, in film order. |
|
A rendered shot's |
|
Each shot's |
|
Every reason these shots' transitions cannot be assembled, as |
|
Every frame of the film as a PNG, in |
Classes
|
Where each shot's frames land in the film, and what blends them. |
|
One independently encoded run of the film's picture, film frames |
|
What a film takes from a shot its transitions touch: the PNGs inside its |
|
Which of one shot's |
Exceptions
The shots cannot be assembled as the scene asks. |
- exception an.assemble.AssemblyError[source]#
Bases:
RuntimeErrorThe shots cannot be assembled as the scene asks. Carries the fix.
- class an.assemble.FilmTimeline(fps, frames, starts, dissolve_in, fade_in, fade_out, fade_in_color, fade_out_color, total_frames, closing=False, wipe_in=())[source]#
Bases:
objectWhere each shot’s frames land in the film, and what blends them.
dissolve_in[i]— frames shotioverlaps the previous shot by.fade_in[i]— frames at shoti’s head that fade up from a colour.fade_out[i]— frames at shoti’s tail that fade to a colour (the NEXT shot’s fade colour, or the film’s closing fade’s on the last shot).closing— the last shot’sfade_outis the film’s closing fade (Meta.closing_transition, an#389), so its final frame IS the colour: no next shot reaches it instead.
- an.assemble.MIN_SEGMENT_FRAMES: int = 3#
The fewest frames a segment of a MULTI-segment picture may have. Measured on ffmpeg 9.0.1 / libx264 with the pinned argv (an#260): a stream of three or more frames carries a two-frame B-pyramid decode delay (its first DTS is two frames before its first PTS) whatever its content, and a stream of one or two frames carries none. The concat demuxer offsets every file alike, so a delay-free segment between two delayed ones leaves the DTS going backwards; the muxer patches that with one-tick packets, and a constant-rate decode of the film then shows a frame twice.
shot_windows()widens every short run instead, so each segment of a multi-segment picture has the delay.
- class an.assemble.Segment(kind, shot, start, stop)[source]#
Bases:
objectOne independently encoded run of the film’s picture, film frames
[start, stop): a whole shot’s own stream ("shot"), a shot’s encoded body ("body"), or a run of PNGs composed here ("frames").
- class an.assemble.ShotParts(window, frames, body=None)[source]#
Bases:
objectWhat a film takes from a shot its transitions touch: the PNGs inside its
ShotWindow(frames: shot-local index -> path) and its body, encoded once (body;Nonewhen the window covers the shot).
- class an.assemble.ShotWindow(frames, head=0, tail=0)[source]#
Bases:
objectWhich of one shot’s
framesits film needs as PNGs: the firstheadand the lasttail.Between them is the shot’s body, which the film takes as one encoded span. A shot with an empty window (
whole) is taken as its own mp4’s video stream, so it needs no frame at all.>>> w = ShotWindow(frames=10, head=0, tail=4) >>> w.whole, w.body, w.png_indices (False, (0, 6), (6, 7, 8, 9))
- an.assemble.assemble_film(scene, shot_results, output, *, fps, mall, work_dir, pix_fmt=None, parts=None)[source]#
Assemble rendered shots into
output: the picture as a concat of segments (transitions composed in), then the mix.shot_resultsare the renderers’RenderResult`s, in timeline order. A shot no transition touches contributes its mp4 alone. A shot one touches needs its :class:`ShotPartsfor itsshot_windows()window: pass them inparts(a reused shot’s come from the shot cache), or the shot’sframe_manifestmust hold its frames, from which they are built.Why not one mux of every frame, as before an#260: the picture depended on every frame of every shot, so a film with a dissolve or a music bed could reuse no shot without caching all of its PNGs — hundreds of MB per 1080p shot. The segment concat puts frame
iati / fpsexactly as the one mux did (measured, an#260), and each frame decodes to exactly what its own segment decodes to.- Return type:
- an.assemble.duck_gain(t, spans, *, duck_db, attack, release)[source]#
The linear gain a ducked cue plays at, at film time
t— the spec the ffmpeg expressions (_duck_expressions()) are written from.Full level away from dialogue;
duck_dbdown while a line plays; a linear ramp overattackseconds BEFORE each line (so its first syllable is already clear) andreleaseseconds after.- Return type:
>>> spans = [(1.0, 2.0)] >>> [round(duck_gain(t, spans, duck_db=-20, attack=0.5, release=0.5), 3) ... for t in (0.0, 0.75, 1.5, 2.25, 3.0)] [1.0, 0.55, 0.1, 0.55, 1.0]
- an.assemble.film_duration(scene, *, fps=None)[source]#
Seconds the delivered film runs: the shots’ durations, minus each dissolve’s overlap. Exactly
sum(durations)for a scene without one, so every existing document’s arithmetic is unchanged.- Return type:
>>> from an.ir.schema import SceneIR, Shot, Transition >>> film_duration(SceneIR(timeline=[Shot(id="a", duration=2.0), ... Shot(id="b", duration=2.0, transition=Transition(kind="dissolve", duration=0.5))])) 3.5
- an.assemble.film_timeline(shots, *, fps, closing=None)[source]#
Lay
shotsend to end, overlapping each dissolve, the last fading out byclosing(the film’s closing fade, an#389). RaisesAssemblyErroron anytransition_problems().- Return type:
- an.assemble.needs_assembly(scene, *, fps=None)[source]#
True when the scene asks for anything beyond hard cuts and shot audio.
Decided on FRAMES at the render’s rate (
fps, default the scene’s): a transition that rounds to zero frames asks for nothing, and must not cost a scene its byte-identical concat.- Return type:
>>> from an.ir.schema import Meta, SceneIR, Shot, Transition >>> needs_assembly(SceneIR(timeline=[Shot(id="a"), Shot(id="b")])) False >>> needs_assembly(SceneIR(timeline=[ ... Shot(id="a"), Shot(id="b", transition=Transition(kind="fade", duration=0.0))])) False
- an.assemble.picture_segments(timeline, windows)[source]#
The film’s picture as segments, in film order.
A film frame is part of a shot’s body when exactly one shot shows it and that frame is outside the shot’s window; every other frame (a blend, a fade, or a frame a window was widened over) is composed from PNGs.
>>> from an.ir.schema import Shot, Transition >>> tl = film_timeline([Shot(id="a", duration=1.0), Shot(id="b", duration=1.0, ... transition=Transition(kind="dissolve", duration=0.4))], fps=10) >>> [(s.kind, s.shot, s.start, s.stop) for s in picture_segments(tl, shot_windows(tl))] [('body', 0, 0, 6), ('frames', None, 6, 10), ('body', 1, 10, 16)]
- an.assemble.shot_parts(frames, window, *, fps, work_dir, pix_fmt=None)[source]#
A rendered shot’s
ShotPartsforwindow, from its frames.The body is encoded by
an.media.mp4.mux_frames— the shot mux’s own encoder and argv — from the body’s PNGs, renumbered from zero inwork_dir. The window’s PNGs are referenced where they are.- Return type:
- an.assemble.shot_windows(timeline, *, min_segment_frames=3)[source]#
Each shot’s
ShotWindow: the frames its transitions touch, widened until every segment of the picture hasmin_segment_frames.A pure function of the timeline, so the render loop, the shot cache and the garbage collector all agree on what a shot’s film needs from it. A picture of one segment has no minimum (there is nothing to concatenate).
- Return type:
>>> from an.ir.schema import Shot, Transition >>> d = Transition(kind="dissolve", duration=0.1) # one frame at 10 fps >>> tl = film_timeline([Shot(id="a", duration=1.0), Shot(id="b", duration=1.0, ... transition=d)], fps=10) >>> [(w.head, w.tail) for w in shot_windows(tl)] # the 1-frame run, widened to 3 [(0, 1), (3, 0)]
- an.assemble.transition_problems(shots, fps, *, closing=None)[source]#
Every reason these shots’ transitions cannot be assembled, as
(shot index, message). The ONE listan validatereports andfilm_timeline()raises on, so the two cannot disagree.>>> from an.ir.schema import Shot, Transition >>> transition_problems([Shot(id="a", transition=Transition(kind="dissolve"))], fps=30) [(0, "shot 'a' is the first shot, so a dissolve has nothing to dissolve from; use a fade (from a colour) or a cut")]
- an.assemble.write_film_frames(timeline, frame_of, out_dir, *, pattern=None)[source]#
Every frame of the film as a PNG, in
out_dir— what the delivered picture shows, frame for frame, BEFORE it is encoded.The film itself is a concat of segments (an#260) and never holds this sequence on disk; a measurement that needs the composed picture — the bench’s reference for an assembled scene (an#279) — builds it here, with the same per-frame composition (
_compose_frame()) the segments use.frame_of(i, j)is the PNG of framejof shoti.