renoise-kit 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rn/assemble.py ADDED
@@ -0,0 +1,602 @@
1
+ """rn assemble: render an EDL to a finished MP4, frame-exact. rn finish: the same output standard for any video.
2
+
3
+ Pipeline (spec/mcp/03-media-kit.md §4-5):
4
+ 1. every main-track clip -> its own intermediate at the output size/fps with exactly `frames` frames, plus a WAV
5
+ of exactly the same length (a slot engine: each piece is checkable on its own);
6
+ 2. each transition is rendered from the two slot ends it joins, then the pieces are read back in order through the concat
7
+ demuxer (one decoder at a time, so memory does not grow with the clip count), overlay tracks are stacked, subtitles
8
+ burned and the video encoded;
9
+ 3. audio is mixed separately (clip audio with matching crossfades + audio items + optional ducking), gained to the
10
+ LUFS target and limited until the ENCODED true peak fits; then both streams are copied into the MP4.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import dataclasses
15
+ import hashlib
16
+ import json
17
+ import math
18
+ import os
19
+ import shutil
20
+ import tempfile
21
+ from fractions import Fraction
22
+ from pathlib import Path
23
+
24
+ from . import __version__
25
+ from . import audio as audio_mod
26
+ from .edl import Clip, Edit, cache_dir, load, source_offsets
27
+ from .ff import RnError, ff_color, has_filter, probe, run, source_matrix, to_bt709
28
+
29
+ SR = audio_mod.SAMPLE_RATE
30
+ # Lossless slots: the only lossy step is the final encode.
31
+ INTERMEDIATE = ['-c:v', 'libx264', '-preset', 'ultrafast', '-qp', '0', '-pix_fmt', 'yuv420p', '-an']
32
+
33
+
34
+ def _fit(clip: Clip, width: int, height: int, background: str) -> str:
35
+ # Every source is converted to BT.709 here, once; RGB images and solids would otherwise get swscale's BT.601.
36
+ scale = f'scale={width}:{height}:{to_bt709(clip.matrix)}'
37
+ if clip.fit == 'cover':
38
+ return f'{scale}:force_original_aspect_ratio=increase:flags=lanczos,crop={width}:{height}'
39
+ if clip.fit == 'contain':
40
+ return (f'{scale}:force_original_aspect_ratio=decrease:flags=lanczos,'
41
+ f'pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:color={ff_color(background)}')
42
+ return f'{scale}:flags=lanczos'
43
+
44
+
45
+ REMAP_SEGMENTS = 64 # a speed ramp's time map as at most this many straight pieces (well under a frame of error)
46
+
47
+
48
+ def _remap(points: list, frames: int, fps: Fraction) -> str:
49
+ """setpts expression in T (source seconds from the clip's in) -> output seconds: the inverse of the ramp's integral,
50
+ piecewise linear through its value at every few output frames."""
51
+ offsets = source_offsets(points, frames, fps)
52
+ step = max(1, math.ceil(frames / REMAP_SEGMENTS))
53
+ marks = [(offsets[n], n / float(fps)) for n in [*range(0, frames, step), frames]]
54
+ tau_end, t_end = marks[-1]
55
+ expr = f'({t_end:.6f}+(T-{tau_end:.6f})/{points[-1][1]:.6f})' # past the last mark: the final speed
56
+ for (tau0, t0), (tau1, t1) in reversed(list(zip(marks, marks[1:]))):
57
+ if tau1 > tau0:
58
+ expr = f'if(lt(T,{tau1:.6f}),{t0:.6f}+(T-{tau0:.6f})*{(t1 - t0) / (tau1 - tau0):.6f},{expr})'
59
+ return expr
60
+
61
+
62
+ def _timing(clip: Clip, fps: Fraction) -> str:
63
+ """Source range -> output frame grid. Starts half a source frame early so float rounding never drops the first frame."""
64
+ start = max(0.0, clip.start_s - 0.5 / float(clip.src_fps)) if clip.src_fps else clip.start_s
65
+ chain = f'setpts=PTS-STARTPTS,trim=start={start:.6f},setpts=PTS-STARTPTS'
66
+ ramp = (clip.keys or {}).get('speed')
67
+ if ramp:
68
+ chain += f",setpts='{_remap(ramp, clip.frames, fps)}'/TB"
69
+ elif clip.speed != 1:
70
+ chain += f',setpts=PTS/{clip.speed:.6f}'
71
+ # Slow motion blends neighbouring frames instead of repeating them (a visible stutter at 0.3-1x).
72
+ slow = min(v for _, v, _ in ramp) < 1 if ramp else clip.speed < 1
73
+ chain += f',framerate=fps={fps}' if slow else f',fps={fps}'
74
+ return chain
75
+
76
+
77
+ _EASE = {
78
+ 'linear': 'U', 'hold': '0', 'in': 'U*U', 'out': '(1-(1-U)*(1-U))', 'in3': 'U*U*U', 'out3': '(1-pow(1-U,3))',
79
+ 'inout': 'if(lt(U,0.5),2*U*U,1-2*(1-U)*(1-U))', 'inout3': 'if(lt(U,0.5),4*U*U*U,1-4*pow(1-U,3))',
80
+ }
81
+
82
+
83
+ def _curve(points: list[tuple[float, float, str]] | None, var: str, default: float) -> str:
84
+ """Keyframes -> an ffmpeg expression in `var` (seconds): held before the first key and after the last."""
85
+ if not points:
86
+ return repr(float(default))
87
+ expr = repr(float(points[-1][1]))
88
+ for (t0, v0, _), (t1, v1, ease) in reversed(list(zip(points, points[1:]))):
89
+ u = f'clip(({var}-{t0})/{t1 - t0},0,1)'
90
+ segment = f'({v0}+({v1 - v0})*({_EASE[ease].replace("U", u)}))'
91
+ expr = f'if(lt({var},{t1}),{segment},{expr})'
92
+ return f'if(lt({var},{points[0][0]}),{points[0][1]},{expr})'
93
+
94
+
95
+ def _perspective(keys: dict, fps: Fraction, *, centre: tuple[str, str], scale_default: float = 1.0) -> str:
96
+ """Scale (about `centre`), rotate (degrees) and move via perspective's source corners: smooth, sub-pixel, alpha kept.
97
+ `centre` are expressions in t for where the layer's centre lands; the layer's own centre is (W/2, H/2) of its input
98
+ for the main track, (cx0, cy0) passed in through the same centre pair for overlays."""
99
+ # perspective's `on` counts output frames from 1 (measured on ffmpeg 9), so frame 0 is on-1 = 0.
100
+ t = f'((on-1)/{float(fps)})'
101
+ scale = _curve(keys.get('scale'), t, scale_default)
102
+ angle = f'(({_curve(keys.get("rotate"), t, 0)})*PI/180)'
103
+ cx, cy = centre
104
+ corners = []
105
+ for k, (ox, oy) in enumerate((('0', '0'), ('W', '0'), ('0', 'H'), ('W', 'H'))):
106
+ dx, dy = f'(({ox})-({cx}))/({scale})', f'(({oy})-({cy}))/({scale})'
107
+ corners.append(f"x{k}='{{px}}+{dx}*cos({angle})+{dy}*sin({angle})':y{k}='{{py}}-{dx}*sin({angle})+{dy}*cos({angle})'")
108
+ return ':'.join(corners)
109
+
110
+
111
+ def _video_input(clip: Clip, fps: Fraction, size: tuple[int, int]) -> list[str]:
112
+ if clip.kind == 'color':
113
+ # Generated as RGB so the BT.709 conversion downstream gets the colour right.
114
+ return ['-f', 'lavfi', '-i', f'color=c={ff_color(clip.color or "black")}:s={size[0]}x{size[1]}:r={fps},format=rgb24']
115
+ if clip.kind == 'image':
116
+ return ['-loop', '1', '-framerate', str(fps), '-i', str(clip.source)]
117
+ if clip.kind == 'sequence':
118
+ return ['-framerate', str(clip.src_fps), '-start_number', str(clip.seq_start), '-i', str(clip.source)]
119
+ return [*(['-c:v', clip.decoder] if clip.decoder else []), '-i', str(clip.source)]
120
+
121
+
122
+ def _render_slot(clip: Clip, edit: Edit, out: Path) -> None:
123
+ chain = _timing(clip, edit.fps) + ',' if clip.kind in ('video', 'sequence') else ''
124
+ chain += _fit(clip, edit.width, edit.height, edit.background)
125
+ if clip.keys:
126
+ # Main-track move: the plate scales/rotates about the frame centre and shifts by x/y (fractions of the frame).
127
+ t = f'((on-1)/{float(edit.fps)})'
128
+ cx = f'(W/2+({_curve(clip.keys.get("x"), t, 0)})*W)'
129
+ cy = f'(H/2+({_curve(clip.keys.get("y"), t, 0)})*H)'
130
+ chain += ',perspective=' + _perspective(clip.keys, edit.fps, centre=(cx, cy)).replace('{px}', 'W/2').replace('{py}', 'H/2') \
131
+ + ':eval=frame:interpolation=cubic'
132
+ if clip.vf:
133
+ chain += f',{clip.vf}'
134
+ chain += f',setsar=1,format=yuv420p,{audio_mod.BT709}'
135
+ # Hold the last frame if the source runs short, then cut to the exact frame count.
136
+ chain += f',tpad=stop_mode=clone:stop={clip.frames},trim=end_frame={clip.frames},setpts=N/({edit.fps}*TB)'
137
+ run(['ffmpeg', *_video_input(clip, edit.fps, (edit.width, edit.height)), '-filter_complex', f'[0:v]{chain}[v]', '-map', '[v]',
138
+ *INTERMEDIATE, '-r', str(edit.fps), '-y', str(out)])
139
+ frames = probe(out)['video']['frames']
140
+ if frames != clip.frames:
141
+ raise RnError(f'{clip.where}: slot rendered {frames} frames, expected {clip.frames}')
142
+
143
+
144
+ def _atempo(speed: float) -> str:
145
+ factors = []
146
+ while speed > 2.0:
147
+ factors.append(2.0)
148
+ speed /= 2.0
149
+ while speed < 0.5:
150
+ factors.append(0.5)
151
+ speed /= 0.5
152
+ factors.append(speed)
153
+ return ','.join(f'atempo={f:.6f}' for f in factors if abs(f - 1) > 1e-9)
154
+
155
+
156
+ def _gain(keys: dict | None, af: str | None) -> str:
157
+ """keys.gain_db (dB over t, the stream's own seconds) and a raw "af" chain, as filters to append."""
158
+ chain = ''
159
+ if keys and 'gain_db' in keys:
160
+ chain += f",volume=volume='pow(10,({_curve(keys['gain_db'], 't', 0)})/20)':eval=frame"
161
+ return chain + (f',{af}' if af else '')
162
+
163
+
164
+ def _render_slot_audio(clip: Clip, edit: Edit, out: Path, fades: tuple[float, float] = (0.0, 0.0)) -> None:
165
+ """The clip's own sound, exactly as long as its slot; `fades` (in, out) soften hard cuts (mix.cut_fade)."""
166
+ samples = round(clip.frames / edit.fps * SR)
167
+ if clip.audio and clip.has_audio:
168
+ chain = f'asetpts=PTS-STARTPTS,atrim=start={clip.start_s:.6f},asetpts=PTS-STARTPTS'
169
+ tempo = _atempo(clip.speed) if clip.speed != 1 else ''
170
+ chain += (',' + tempo if tempo else '') + f',aresample={SR},aformat=sample_fmts=flt:channel_layouts=stereo'
171
+ if clip.gain_db:
172
+ chain += f',volume={clip.gain_db}dB'
173
+ chain += _gain(clip.keys, clip.af)
174
+ chain += f',apad,atrim=end_sample={samples}'
175
+ if fades[0]:
176
+ chain += f',afade=t=in:d={fades[0]:.4f}'
177
+ if fades[1]:
178
+ chain += f',afade=t=out:st={max(0.0, samples / SR - fades[1]):.6f}:d={fades[1]:.4f}'
179
+ run(['ffmpeg', '-i', str(clip.source), '-filter_complex', f'[0:a]{chain}[a]', '-map', '[a]', '-c:a', 'pcm_f32le', '-y', str(out)])
180
+ else:
181
+ run(['ffmpeg', '-f', 'lavfi', '-i', f'anullsrc=r={SR}:cl=stereo', '-af', f'atrim=end_sample={samples}',
182
+ '-c:a', 'pcm_f32le', '-y', str(out)])
183
+
184
+
185
+ def _join(edit: Edit, slots: list[Path], work: Path) -> Path:
186
+ """The main track as a concat-demuxer list: slot bodies and pre-rendered transitions, in timeline order.
187
+
188
+ Opening every slot in one graph keeps a decoder and its frame queues alive per clip: 61 1080p clips ran an 8 GB
189
+ sandbox out of memory. A transition only needs the last frames of one slot and the first of the next."""
190
+ grid = _grid(edit)
191
+ pieces: list[Path] = []
192
+ for i, (clip, slot) in enumerate(zip(edit.base, slots)):
193
+ head = clip.transition_frames
194
+ tail = edit.base[i + 1].transition_frames if i + 1 < len(edit.base) else 0
195
+ if head:
196
+ previous = edit.base[i - 1]
197
+ piece = work / f'trans{i:03d}.mkv'
198
+ duration = head / edit.fps
199
+ run(['ffmpeg', '-i', str(slots[i - 1]), '-i', str(slot), '-filter_complex',
200
+ f'[0:v]trim=start_frame={previous.frames - head},{grid}[a];[1:v]trim=end_frame={head},{grid}[b];'
201
+ f'[a][b]xfade=transition={clip.transition}:duration={float(duration):.6f}:offset=0,{grid}[v]',
202
+ '-map', '[v]', *INTERMEDIATE, '-r', str(edit.fps), '-y', str(piece)])
203
+ pieces.append(piece)
204
+ if clip.frames - head - tail <= 0:
205
+ continue
206
+ if not head and not tail:
207
+ pieces.append(slot)
208
+ continue
209
+ piece = work / f'body{i:03d}.mkv'
210
+ run(['ffmpeg', '-i', str(slot), '-filter_complex', f'[0:v]trim=start_frame={head}:end_frame={clip.frames - tail},{grid}[v]',
211
+ '-map', '[v]', *INTERMEDIATE, '-r', str(edit.fps), '-y', str(piece)])
212
+ pieces.append(piece)
213
+ listing = work / 'main.txt'
214
+ listing.write_text(''.join(f"file '{piece.resolve()}'\n" for piece in pieces))
215
+ return listing
216
+
217
+
218
+ def _grid(edit: Edit) -> str:
219
+ # xfade and concat need one shared timebase: 1/fps everywhere, pts = frame number. Slots already hold exactly
220
+ # `frames` frames; renumber them instead of resampling (fps would drop one on ms timestamps).
221
+ return f'settb={edit.fps.denominator}/{edit.fps.numerator},setpts=N,fps={edit.fps}'
222
+
223
+
224
+ def _overlay_size(clip: Clip, edit: Edit) -> tuple[int, int]:
225
+ """Pixel size an overlay is drawn at (its scale before keys)."""
226
+ if clip.kind == 'color':
227
+ return clip.w or edit.width, clip.h or edit.height
228
+ src_w, src_h = clip.src_size or (edit.width, edit.height)
229
+ if clip.w and clip.h:
230
+ return clip.w, clip.h
231
+ if clip.w:
232
+ return clip.w, round(src_h * clip.w / src_w / 2) * 2
233
+ if clip.h:
234
+ return round(src_w * clip.h / src_h / 2) * 2, clip.h
235
+ return src_w, src_h
236
+
237
+
238
+ MARGIN = 4 # transparent px around a moving overlay's canvas
239
+
240
+
241
+ def _moves(clip: Clip) -> bool:
242
+ return bool(clip.keys) and any(k in clip.keys for k in ('scale', 'rotate', 'x', 'y'))
243
+
244
+
245
+ def _overlay_chain(clip: Clip, edit: Edit, index: int) -> str:
246
+ parts = []
247
+ if clip.kind in ('video', 'sequence'):
248
+ parts.append(_timing(clip, edit.fps))
249
+ size = f'{clip.w or -2}:{clip.h or -2}' if (clip.w or clip.h) and clip.kind != 'color' else 'iw:ih'
250
+ parts.append(f'scale={size}:flags=lanczos:{to_bt709(clip.matrix)}')
251
+ if clip.vf:
252
+ parts.append(clip.vf)
253
+ parts.append(f'format=yuva420p,{audio_mod.BT709}')
254
+ # Integer frame pts on the output grid (as _join): a float seconds offset truncates one frame early.
255
+ parts.append(f'tpad=stop_mode=clone:stop={clip.frames},trim=end_frame={clip.frames},'
256
+ f'settb={edit.fps.denominator}/{edit.fps.numerator},setpts=N')
257
+ seconds = clip.frames / edit.fps
258
+ if clip.opacity < 1:
259
+ parts.append(f'colorchannelmixer=aa={clip.opacity:.4f}')
260
+ if clip.fade_in:
261
+ parts.append(f'fade=t=in:st=0:d={clip.fade_in:.4f}:alpha=1')
262
+ if clip.fade_out:
263
+ parts.append(f'fade=t=out:st={max(0.0, float(seconds) - clip.fade_out):.4f}:d={clip.fade_out:.4f}:alpha=1')
264
+ if clip.keys and 'opacity' in clip.keys:
265
+ # T is seconds from the overlay's first frame here (pts = frame number, timebase 1/fps).
266
+ parts.append(f"geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='alpha(X,Y)*({_curve(clip.keys['opacity'], 'T', 1)})'")
267
+ if _moves(clip):
268
+ # Put the layer on a transparent canvas the size of the frame, then move it there. perspective repeats the
269
+ # canvas edge for anything it samples outside, so the canvas keeps a transparent margin on every side and is
270
+ # overlaid at (-MARGIN, -MARGIN).
271
+ ow, oh = _overlay_size(clip, edit)
272
+ cw, ch = max(edit.width, ow) + 2 * MARGIN, max(edit.height, oh) + 2 * MARGIN
273
+ t = f'((on-1)/{float(edit.fps)})'
274
+ cx = f'({_curve(clip.keys.get("x"), t, clip.x)}+{ow / 2 + MARGIN})'
275
+ cy = f'({_curve(clip.keys.get("y"), t, clip.y)}+{oh / 2 + MARGIN})'
276
+ parts.append(f'pad={cw}:{ch}:{MARGIN}:{MARGIN}:color=black@0')
277
+ parts.append('perspective=' + _perspective(clip.keys, edit.fps, centre=(cx, cy))
278
+ .replace('{px}', str(ow / 2 + MARGIN)).replace('{py}', str(oh / 2 + MARGIN)) + ':eval=frame:interpolation=cubic')
279
+ parts.append(f'setpts=PTS+{clip.timeline_start}')
280
+ return f'[{index}:v]' + ','.join(parts) + f'[o{index}]'
281
+
282
+
283
+ def _render_video(edit: Edit, slots: list[Path], work: Path, out: Path, span: tuple[int, int]) -> None:
284
+ inputs = ['-f', 'concat', '-safe', '0', '-i', str(_join(edit, slots, work))]
285
+ lines, current = [f'[0:v]{_grid(edit)}[base]'], 'base'
286
+ def add(clip: Clip) -> int:
287
+ index = sum(1 for arg in inputs if arg == '-i')
288
+ # A solid has no size of its own: missing dimensions fill the frame.
289
+ inputs.extend(_video_input(clip, edit.fps, (clip.w or edit.width, clip.h or edit.height)))
290
+ lines.append(_overlay_chain(clip, edit, index))
291
+ return index
292
+
293
+ def canvas(color: str, fmt: str) -> str:
294
+ """A full-frame stream lasting the whole timeline, to lay a layer or a matte on."""
295
+ index = sum(1 for arg in inputs if arg == '-i')
296
+ inputs.extend(['-f', 'lavfi', '-i', f'color=c={color}:s={edit.width}x{edit.height}:r={edit.fps}:'
297
+ f'd={float(edit.total_frames / edit.fps):.6f},format={fmt}'])
298
+ return f'{index}:v'
299
+
300
+ for clip in edit.overlays:
301
+ index = add(clip)
302
+ x, y = (-MARGIN, -MARGIN) if _moves(clip) else (clip.x, clip.y)
303
+ if not (clip.mask or clip.blend):
304
+ lines.append(f'[{current}][o{index}]overlay=x={x}:y={y}:eof_action=pass:format=auto[ov{index}]')
305
+ current = f'ov{index}'
306
+ continue
307
+ # Masks and blend modes work on the layer spread over the whole frame and timeline (alpha kept).
308
+ lines.append(f'[{canvas("black@0", "yuva420p")}][o{index}]overlay=x={x}:y={y}:eof_action=pass:format=auto[layer{index}]')
309
+ layer = f'layer{index}'
310
+ if clip.mask:
311
+ matte = add(clip.mask)
312
+ pick = 'alphaextract' if clip.mask_mode == 'alpha' else 'format=gray'
313
+ # Inverted before it is laid on white: where the matte has no frames the layer stays unmasked.
314
+ lines.append(f'[o{matte}]{pick}{",negate" if clip.mask_invert else ""}[mg{index}]')
315
+ lines.append(f'[{canvas("white", "gray")}][mg{index}]overlay=eof_action=pass:format=auto,format=gray[mk{index}]')
316
+ lines.append(f'[{layer}]split[lc{index}][la{index}];[la{index}]alphaextract[a{index}];'
317
+ f'[a{index}][mk{index}]blend=all_mode=multiply[am{index}];[lc{index}][am{index}]alphamerge[lm{index}]')
318
+ layer = f'lm{index}'
319
+ if clip.blend and clip.blend != 'normal':
320
+ # Blend modes are RGB arithmetic: convert explicitly (BT.709 limited) both ways, never by swscale defaults.
321
+ rgb = 'scale=in_color_matrix=bt709:in_range=tv'
322
+ lines.append(f'[{current}]{rgb},format=gbrp,split[b1_{index}][b2_{index}]')
323
+ lines.append(f'[{layer}]{rgb},format=gbrap,split[l1_{index}][l2_{index}];[l1_{index}]format=gbrp[lr{index}];'
324
+ f'[l2_{index}]alphaextract[lx{index}]')
325
+ lines.append(f'[b1_{index}][lr{index}]blend=all_mode={clip.blend}[bl{index}];[bl{index}][lx{index}]alphamerge[bx{index}]')
326
+ lines.append(f'[b2_{index}][bx{index}]overlay=format=gbrp,scale=out_color_matrix=bt709:out_range=tv,format=yuv420p,'
327
+ f'{audio_mod.BT709}[ov{index}]')
328
+ else:
329
+ lines.append(f'[{current}][{layer}]overlay=format=auto[ov{index}]')
330
+ current = f'ov{index}'
331
+ tail = f'[{current}]'
332
+ if edit.subtitles:
333
+ shutil.copyfile(edit.subtitles, work / f'subs{edit.subtitles.suffix.lower()}')
334
+ style = f":force_style='{edit.subtitle_style}'" if edit.subtitle_style else ''
335
+ lines.append(f"{tail}subtitles=filename=subs{edit.subtitles.suffix.lower()}{style}[sub]")
336
+ tail = '[sub]'
337
+ lines.append(f'{tail}format=yuv420p,{audio_mod.BT709},trim=start_frame={span[0]}:end_frame={span[1]},setpts=N/({edit.fps}*TB)[out]')
338
+ run(['ffmpeg', *inputs, '-filter_complex', ';'.join(lines), '-map', '[out]', *audio_mod.VIDEO_ENCODE, *audio_mod.CFR_VUI,
339
+ '-crf', str(edit.crf), '-preset', edit.preset, '-r', str(edit.fps), '-an', '-y', str(out.resolve())],
340
+ cwd=work)
341
+
342
+
343
+ def _render_audio(edit: Edit, slot_audio: list[Path], work: Path) -> Path | None:
344
+ has_clip_audio = any(c.audio and c.has_audio for c in edit.base)
345
+ if not has_clip_audio and not edit.audio:
346
+ return None
347
+ total_samples = round(edit.total_frames / edit.fps * SR)
348
+ inputs, lines = [], []
349
+ for path in slot_audio:
350
+ inputs += ['-i', str(path)]
351
+ current = '0:a'
352
+ for i, clip in enumerate(edit.base[1:], start=1):
353
+ label = f'b{i}'
354
+ if clip.transition_frames:
355
+ lines.append(f'[{current}][{i}:a]acrossfade=d={float(clip.transition_frames / edit.fps):.6f}:c1=tri:c2=tri[{label}]')
356
+ else:
357
+ lines.append(f'[{current}][{i}:a]concat=n=2:v=0:a=1[{label}]')
358
+ current = label
359
+ lines.append(f'[{current}]apad,atrim=end_sample={total_samples}[base]')
360
+ roles: dict[str, list[str]] = {'music': [], 'voice': [], 'sfx': []}
361
+ for item in edit.audio:
362
+ index = sum(1 for arg in inputs if arg == '-i')
363
+ inputs += ['-i', str(item.source)]
364
+ chain = f'asetpts=PTS-STARTPTS,atrim=start={item.start_s:.6f}'
365
+ if item.duration:
366
+ chain += f':duration={item.duration:.6f}'
367
+ chain += f',asetpts=PTS-STARTPTS,aresample={SR},aformat=sample_fmts=flt:channel_layouts=stereo'
368
+ if item.gain_db:
369
+ chain += f',volume={item.gain_db}dB'
370
+ chain += _gain(item.keys, item.af)
371
+ if item.fade_in:
372
+ chain += f',afade=t=in:st=0:d={item.fade_in:.4f}'
373
+ if item.fade_out:
374
+ length = item.duration or (probe(item.source)['audio']['duration'] or 0) - item.start_s
375
+ available = min(length, edit.duration - item.at)
376
+ chain += f',afade=t=out:st={max(0.0, available - item.fade_out):.4f}:d={item.fade_out:.4f}'
377
+ if item.at:
378
+ chain += f',adelay=delays={round(item.at * 1000)}:all=1'
379
+ label = f'x{index}'
380
+ lines.append(f'[{index}:a]{chain}[{label}]')
381
+ roles[item.role].append(label)
382
+
383
+ def bus(labels: list[str], name: str) -> str | None:
384
+ if not labels:
385
+ return None
386
+ if len(labels) == 1:
387
+ return labels[0]
388
+ lines.append(''.join(f'[{label}]' for label in labels) + f'amix=inputs={len(labels)}:normalize=0:duration=longest[{name}]')
389
+ return name
390
+
391
+ voice = bus(roles['voice'], 'voice')
392
+ music = bus(roles['music'], 'music')
393
+ sfx = bus(roles['sfx'], 'sfx')
394
+ if edit.duck_db and voice and music:
395
+ # ponytail: sidechain compression approximates duck_db; a gain envelope from voice activity is exact.
396
+ ratio = min(20.0, 1 + edit.duck_db / 2)
397
+ # The key is padded with silence: sidechaincompress ends when either input does, which would cut the
398
+ # music off at the last word of the voice.
399
+ lines.append(f'[{voice}]asplit=2[voice_mix][voice_raw];[voice_raw]apad[voice_key]')
400
+ lines.append(f'[{music}][voice_key]sidechaincompress=threshold=0.02:ratio={ratio:.2f}:attack=30:release=400[ducked]')
401
+ voice, music = 'voice_mix', 'ducked'
402
+ stems = ['base'] + [s for s in (voice, music, sfx) if s]
403
+ if len(stems) > 1:
404
+ lines.append(''.join(f'[{s}]' for s in stems) + f'amix=inputs={len(stems)}:normalize=0:duration=first[mixed]')
405
+ final = 'mixed'
406
+ else:
407
+ final = 'base'
408
+ lines.append(f'[{final}]apad,atrim=end_sample={total_samples}[aout]')
409
+ mix = work / 'mix.wav'
410
+ run(['ffmpeg', *inputs, '-filter_complex', ';'.join(lines), '-map', '[aout]', '-c:a', 'pcm_f32le', '-ar', str(SR), '-y', str(mix)])
411
+ return mix
412
+
413
+
414
+ PREVIEW_SCALE = 0.5
415
+
416
+
417
+ def _preview(edit: Edit, scale: float) -> None:
418
+ """A draft: smaller frame, fast encode. Overlay pixel positions and sizes scale with the frame; main-track keys are
419
+ fractions of the frame and libass scales subtitles by their PlayRes, so the draft is the same edit, smaller.
420
+ ponytail: renders the whole timeline; a time range needs anchors, transitions and subtitles shifted, add if drafts get slow."""
421
+ edit.width, edit.height = (max(2, round(v * scale / 2) * 2) for v in (edit.width, edit.height))
422
+ edit.crf, edit.preset = 30, 'ultrafast'
423
+ for clip in edit.overlays:
424
+ if clip.kind != 'color' and not (clip.w or clip.h):
425
+ clip.w = (clip.src_size or (probe(clip.source)['video']['width'],))[0]
426
+ clip.x, clip.y = round(clip.x * scale), round(clip.y * scale)
427
+ clip.w, clip.h = (max(1, round(v * scale)) if v else v for v in (clip.w, clip.h))
428
+ for prop in ('x', 'y'):
429
+ if clip.keys and prop in clip.keys:
430
+ clip.keys[prop] = [(t, v * scale, ease) for t, v, ease in clip.keys[prop]]
431
+ if clip.mask:
432
+ clip.mask.w, clip.mask.h = edit.width, edit.height
433
+
434
+
435
+ def _files(source: Path | None, sequence: bool = False) -> list:
436
+ """What a render depends on besides its parameters: the source files' size and mtime (a sequence: its folder's)."""
437
+ if source is None:
438
+ return []
439
+ files = sorted(f for f in source.parent.iterdir() if f.is_file()) if sequence else [source]
440
+ return [(f.name, f.stat().st_size, f.stat().st_mtime_ns) for f in files]
441
+
442
+
443
+ def _slot_key(clip: Clip, edit: Edit, part: str) -> str:
444
+ # Where a clip sits and how it is named do not change its slot; moving or renaming a clip reuses the render.
445
+ params = {k: v for k, v in dataclasses.asdict(clip).items()
446
+ if k not in ('where', 'timeline_start', 'id', 'meta', 'transition', 'transition_frames')}
447
+ material = [__version__, part, edit.width, edit.height, str(edit.fps), edit.background, params,
448
+ _files(clip.source, clip.kind == 'sequence')]
449
+ return hashlib.sha256(json.dumps(material, default=str, sort_keys=True).encode()).hexdigest()[:24]
450
+
451
+
452
+ def _cached(cache: Path | None, key: str, suffix: str, work: Path, render) -> Path:
453
+ """render(path) once per key. ponytail: the cache only grows; delete .rn-cache (or RN_CACHE) to reclaim space."""
454
+ if cache is None:
455
+ target = work / f'{key}{suffix}'
456
+ render(target)
457
+ return target
458
+ target = cache / f'{key}{suffix}'
459
+ if not target.exists():
460
+ cache.mkdir(parents=True, exist_ok=True)
461
+ partial = cache / f'{key}.{os.getpid()}.part{suffix}'
462
+ render(partial)
463
+ partial.replace(target)
464
+ return target
465
+
466
+
467
+ def _fingerprint(path: Path, placeholders: bool) -> str:
468
+ """A nested edit's render depends on its text and, recursively, on every file it reads."""
469
+ edit = load(path, placeholders=placeholders)
470
+ parts = [__version__, path.read_text(), placeholders]
471
+ for clip in edit.base + edit.overlays:
472
+ parts.append(_fingerprint(clip.source, placeholders) if clip.kind == 'edit' else _files(clip.source, clip.kind == 'sequence'))
473
+ parts += [_files(item.source) for item in edit.audio] + [_files(edit.subtitles)]
474
+ return hashlib.sha256(json.dumps(parts, default=str).encode()).hexdigest()[:24]
475
+
476
+
477
+ def _render_nested(edit: Edit, cache: Path | None, work: Path, placeholders: bool) -> None:
478
+ """Nested edits render first (to the cache when on) and then play as ordinary video sources."""
479
+ for clip in edit.base + edit.overlays:
480
+ if clip.kind == 'edit':
481
+ key = 'edit-' + _fingerprint(clip.source, placeholders)
482
+ clip.source = _cached(cache, key, '.mp4', work, lambda target, source=clip.source: assemble_command(
483
+ str(source), str(target), dry_run=False, keep=False, cache=cache is not None, placeholders=placeholders))
484
+ clip.kind = 'video'
485
+
486
+
487
+ def _span(edit: Edit, time_range: str | None) -> tuple[int, int]:
488
+ """--range A-B (seconds) -> output frames [a, b). Clips wholly outside it render as cheap background slots and
489
+ overlays there are dropped; everything is still laid out on the full timeline, so anchors and transitions hold."""
490
+ if not time_range:
491
+ return 0, edit.total_frames
492
+ try:
493
+ a, b = (float(part) for part in time_range.split('-'))
494
+ except ValueError:
495
+ raise RnError(f'--range "{time_range}": seconds as A-B, e.g. 3.5-6') from None
496
+ span = (max(0, round(a * edit.fps)), min(edit.total_frames, round(b * edit.fps)))
497
+ if span[0] >= span[1]:
498
+ raise RnError(f'--range {time_range}: empty inside the timeline (0-{edit.duration:.3f}s)')
499
+ for i, clip in enumerate(edit.base):
500
+ if clip.timeline_start + clip.frames <= span[0] or clip.timeline_start >= span[1]:
501
+ edit.base[i] = dataclasses.replace(clip, kind='color', color=edit.background, source=None, keys=None, audio=False,
502
+ has_audio=False, src_fps=None, matrix='bt709', decoder=None)
503
+ edit.overlays = [c for c in edit.overlays if c.timeline_start < span[1] and c.timeline_start + c.frames > span[0]]
504
+ return span
505
+
506
+
507
+ def assemble_command(edl_path: str, out: str, *, dry_run: bool, keep: bool, preview: bool = False, cache: bool = True,
508
+ placeholders: bool = False, time_range: str | None = None) -> dict:
509
+ edit = load(edl_path, placeholders=placeholders)
510
+ if edit.subtitles and not has_filter('subtitles'):
511
+ raise RnError('this ffmpeg has no libass, so it cannot burn subtitles; on macOS: brew install ffmpeg-full, then put '
512
+ '"$(brew --prefix ffmpeg-full)/bin" first on PATH')
513
+ if preview:
514
+ _preview(edit, PREVIEW_SCALE)
515
+ if dry_run:
516
+ return edit.summary()
517
+ out_path = Path(out)
518
+ store = cache_dir(edit.path.parent) / 'slots' if cache else None
519
+ span = _span(edit, time_range)
520
+ with tempfile.TemporaryDirectory(prefix='rn-assemble-') as tmp:
521
+ work = Path(tmp)
522
+ _render_nested(edit, store, work, placeholders)
523
+ slots, slot_audio = [], []
524
+ for i, clip in enumerate(edit.base):
525
+ slots.append(_cached(store, _slot_key(clip, edit, 'video'), '.mkv', work, lambda target, c=clip: _render_slot(c, edit, target)))
526
+ # A hard cut's own sound fades on both sides; a transition already crossfades it.
527
+ fades = (edit.cut_fade if i and not clip.transition_frames else 0.0,
528
+ edit.cut_fade if i + 1 < len(edit.base) and not edit.base[i + 1].transition_frames else 0.0)
529
+ slot_audio.append(_cached(store, _slot_key(clip, edit, f'audio{fades}'), '.wav', work,
530
+ lambda target, c=clip, f=fades: _render_slot_audio(c, edit, target, f)))
531
+ video = work / 'video.mp4'
532
+ _render_video(edit, slots, work, video, span)
533
+ frames = probe(video, exact=True)['video']['frames']
534
+ if frames != span[1] - span[0]:
535
+ raise RnError(f'rendered {frames} frames, expected {span[1] - span[0]}')
536
+ mix = _render_audio(edit, slot_audio, work)
537
+ loudness = None
538
+ m4a = None
539
+ if mix:
540
+ m4a = work / 'audio.m4a'
541
+ excerpt = mix
542
+ if span != (0, edit.total_frames):
543
+ # A range keeps the full mix's level: the gain is measured on the whole mix, applied to the excerpt.
544
+ excerpt = work / 'excerpt.wav'
545
+ run(['ffmpeg', '-i', str(mix), '-af', f'atrim=start_sample={round(span[0] / edit.fps * SR)}:'
546
+ f'end_sample={round(span[1] / edit.fps * SR)},asetpts=PTS-STARTPTS', '-c:a', 'pcm_f32le', '-y', str(excerpt)])
547
+ loudness = audio_mod.finish_audio(excerpt, m4a, lufs=edit.lufs, true_peak=edit.true_peak, reference=mix)
548
+ audio_mod.mux(video, m4a, out_path, frames=span[1] - span[0])
549
+ if keep:
550
+ kept = out_path.with_suffix('.parts')
551
+ shutil.copytree(work, kept, dirs_exist_ok=True)
552
+ summary = edit.summary()
553
+ summary['path'] = str(out_path)
554
+ summary['audio'] = loudness
555
+ summary['preview'] = preview
556
+ if span != (0, edit.total_frames):
557
+ summary['range'] = {'start_frame': span[0], 'end_frame': span[1]}
558
+ summary['output'].update(frames=span[1] - span[0], duration=round(float((span[1] - span[0]) / edit.fps), 4))
559
+ return summary
560
+
561
+
562
+ def finish_command(source: str, out: str, *, lufs: float | None, true_peak: float, crf: int, preset: str, copy_video: bool,
563
+ height: int | None = None, audio: str | None = None, audio_offset: float = 0.0) -> dict:
564
+ """Any video -> the delivery standard: H.264 High yuv420p bt709, moov first, LUFS target, encoded TP <= limit.
565
+ `audio`: take the sound from this file instead (e.g. a renderer's separate WAV mix); `audio_offset`: seconds to move
566
+ the sound later (negative: earlier, cutting its head), e.g. to undo an encoder delay a renderer left in."""
567
+ info = probe(source, exact=True)
568
+ sound = audio or source
569
+ if audio and not probe(audio)['audio']:
570
+ raise RnError(f'--audio {audio}: no audio stream')
571
+ video = info['video']
572
+ if not video or video['still']:
573
+ raise RnError(f'{source}: not a video')
574
+ frames = video['frames']
575
+ out_path = Path(out)
576
+ with tempfile.TemporaryDirectory(prefix='rn-finish-') as tmp:
577
+ work = Path(tmp)
578
+ encoded = work / 'video.mp4'
579
+ compliant = video['codec'] == 'h264' and video['pix_fmt'] == 'yuv420p' and video['sar'] == '1:1'
580
+ if height and copy_video:
581
+ raise RnError('--height re-encodes the picture; drop --copy-video')
582
+ if copy_video and compliant:
583
+ run(['ffmpeg', '-i', source, '-map', '0:v:0', '-c:v', 'copy', '-an', '-y', str(encoded)])
584
+ else:
585
+ size = f'-2:{height // 2 * 2}' if height else 'trunc(iw/2)*2:trunc(ih/2)*2' # --height keeps the aspect, even width
586
+ even = f'scale={size}:flags=lanczos:{to_bt709(source_matrix(video))},setsar=1,format=yuv420p,{audio_mod.BT709}'
587
+ run(['ffmpeg', *(['-c:v', video['alpha_decoder']] if video.get('alpha_decoder') else []), '-i', source, '-map', '0:v:0', '-vf', even, *audio_mod.VIDEO_ENCODE,
588
+ *(audio_mod.CFR_VUI if video['cfr'] else []), '-crf', str(crf), '-preset', preset,
589
+ '-fps_mode', 'passthrough', '-an', '-y', str(encoded)])
590
+ loudness = None
591
+ m4a = None
592
+ if audio or info['audio']:
593
+ wav = work / 'mix.wav'
594
+ seconds = float(frames / Fraction(video['fps']))
595
+ shift = (f'atrim=start={-audio_offset:.6f},asetpts=PTS-STARTPTS,' if audio_offset < 0 else
596
+ f'adelay=delays={round(audio_offset * 1000)}:all=1,' if audio_offset > 0 else '')
597
+ run(['ffmpeg', '-i', sound, '-map', '0:a:0', '-af', f'{shift}aresample={SR},apad,atrim=end={seconds:.6f}', '-ac', '2',
598
+ '-c:a', 'pcm_f32le', '-y', str(wav)])
599
+ m4a = work / 'audio.m4a'
600
+ loudness = audio_mod.finish_audio(wav, m4a, lufs=lufs, true_peak=true_peak)
601
+ audio_mod.mux(encoded, m4a, out_path, frames=frames)
602
+ return {'path': str(out_path), 'frames': frames, 'video_copied': bool(copy_video and compliant), 'audio': loudness}