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.
- renoise_kit-0.5.0.dist-info/METADATA +307 -0
- renoise_kit-0.5.0.dist-info/RECORD +33 -0
- renoise_kit-0.5.0.dist-info/WHEEL +5 -0
- renoise_kit-0.5.0.dist-info/entry_points.txt +2 -0
- renoise_kit-0.5.0.dist-info/licenses/LICENSE +21 -0
- renoise_kit-0.5.0.dist-info/top_level.txt +1 -0
- rn/__init__.py +2 -0
- rn/__main__.py +5 -0
- rn/align.py +92 -0
- rn/assemble.py +602 -0
- rn/assets.py +125 -0
- rn/audio.py +79 -0
- rn/captions.py +343 -0
- rn/capture.py +97 -0
- rn/cli.py +557 -0
- rn/compare.py +115 -0
- rn/cuts.py +81 -0
- rn/editops.py +252 -0
- rn/edl.py +898 -0
- rn/export.py +200 -0
- rn/faces.py +135 -0
- rn/ff.py +183 -0
- rn/fonts.py +87 -0
- rn/frames.py +205 -0
- rn/fx/__init__.py +223 -0
- rn/fx/fx.js +150 -0
- rn/listen.py +199 -0
- rn/matte.py +58 -0
- rn/qa.py +212 -0
- rn/ref.py +74 -0
- rn/ref_guide.md +245 -0
- rn/signal.py +190 -0
- rn/text.py +112 -0
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}
|