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/edl.py
ADDED
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
"""The edit decision list (edit.json): parsed into a frame-exact timeline. Contract: SPEC.md §4."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from fractions import Fraction
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .ff import HEX_COLOR, RnError, probe, source_matrix
|
|
16
|
+
|
|
17
|
+
# ffmpeg xfade transitions (7.x); 'fade' and 'dissolve' cover most edits.
|
|
18
|
+
XFADE = {
|
|
19
|
+
'fade', 'fadeblack', 'fadewhite', 'fadegrays', 'dissolve', 'pixelize', 'distance', 'radial', 'circleopen', 'circleclose',
|
|
20
|
+
'circlecrop', 'rectcrop', 'wipeleft', 'wiperight', 'wipeup', 'wipedown', 'wipetl', 'wipetr', 'wipebl', 'wipebr',
|
|
21
|
+
'slideleft', 'slideright', 'slideup', 'slidedown', 'smoothleft', 'smoothright', 'smoothup', 'smoothdown',
|
|
22
|
+
'horzopen', 'horzclose', 'vertopen', 'vertclose', 'diagtl', 'diagtr', 'diagbl', 'diagbr', 'hlslice', 'hrslice',
|
|
23
|
+
'vuslice', 'vdslice', 'hblur', 'squeezeh', 'squeezev', 'zoomin', 'coverleft', 'coverright', 'coverup', 'coverdown',
|
|
24
|
+
'revealleft', 'revealright', 'revealup', 'revealdown',
|
|
25
|
+
}
|
|
26
|
+
FITS = {'cover', 'contain', 'stretch'}
|
|
27
|
+
EASES = {'linear', 'in', 'out', 'inout', 'in3', 'out3', 'inout3', 'hold'}
|
|
28
|
+
KEYS_MAIN = {'scale', 'x', 'y', 'rotate', 'speed', 'gain_db'}
|
|
29
|
+
KEYS_OVERLAY = {'scale', 'x', 'y', 'rotate', 'opacity', 'speed'}
|
|
30
|
+
KEYS_AUDIO = {'gain_db'}
|
|
31
|
+
# ffmpeg blend modes, computed in RGB; "add" is accepted for "addition".
|
|
32
|
+
BLENDS = {'normal', 'addition', 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'difference', 'exclusion', 'softlight',
|
|
33
|
+
'hardlight', 'colordodge', 'colorburn', 'linearlight', 'vividlight', 'pinlight', 'average', 'negation', 'phoenix'}
|
|
34
|
+
_GRAPH_BREAKERS = re.compile(r'[;\[\]]')
|
|
35
|
+
# The same curves as assemble's ffmpeg expressions (assemble._EASE), for what rn computes itself (speed ramps).
|
|
36
|
+
EASE_FN = {
|
|
37
|
+
'linear': lambda u: u, 'hold': lambda u: 0.0, 'in': lambda u: u * u, 'out': lambda u: 1 - (1 - u) ** 2,
|
|
38
|
+
'in3': lambda u: u ** 3, 'out3': lambda u: 1 - (1 - u) ** 3,
|
|
39
|
+
'inout': lambda u: 2 * u * u if u < 0.5 else 1 - 2 * (1 - u) ** 2,
|
|
40
|
+
'inout3': lambda u: 4 * u ** 3 if u < 0.5 else 1 - 4 * (1 - u) ** 3,
|
|
41
|
+
}
|
|
42
|
+
SPEED_STEPS = 8 # integration sub-steps per output frame
|
|
43
|
+
_SEQ = re.compile(r'%0?(\d*)d')
|
|
44
|
+
ROLES = {'music', 'voice', 'sfx'}
|
|
45
|
+
ID = re.compile(r'^[A-Za-z0-9][A-Za-z0-9_.-]*$')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def curve_value(points: list[tuple[float, float, str]], t: float) -> float:
|
|
49
|
+
"""Keyframes at time t: held before the first and after the last, eased into each key."""
|
|
50
|
+
if t <= points[0][0]:
|
|
51
|
+
return points[0][1]
|
|
52
|
+
for (t0, v0, _), (t1, v1, ease) in zip(points, points[1:]):
|
|
53
|
+
if t < t1:
|
|
54
|
+
return v0 + (v1 - v0) * EASE_FN[ease]((t - t0) / (t1 - t0))
|
|
55
|
+
return points[-1][1]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def source_offsets(points: list[tuple[float, float, str]], frames: int, fps: Fraction) -> list[float]:
|
|
59
|
+
"""Seconds of source consumed before each output frame n = 0..frames of a speed curve (output time on its x axis)."""
|
|
60
|
+
dt = 1 / (float(fps) * SPEED_STEPS)
|
|
61
|
+
offsets, tau, t = [0.0], 0.0, 0.0
|
|
62
|
+
for _ in range(frames):
|
|
63
|
+
for _ in range(SPEED_STEPS):
|
|
64
|
+
tau += curve_value(points, t + dt / 2) * dt
|
|
65
|
+
t += dt
|
|
66
|
+
offsets.append(tau)
|
|
67
|
+
return offsets
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def frames_for_span(points: list[tuple[float, float, str]], span: float, fps: Fraction) -> int:
|
|
71
|
+
"""Output frames a speed curve needs to consume `span` seconds of source (the nearest whole frame)."""
|
|
72
|
+
dt = 1 / (float(fps) * SPEED_STEPS)
|
|
73
|
+
previous = tau = t = 0.0
|
|
74
|
+
frames = 0
|
|
75
|
+
while tau < span and frames < 1_000_000:
|
|
76
|
+
previous = tau
|
|
77
|
+
for _ in range(SPEED_STEPS):
|
|
78
|
+
tau += curve_value(points, t + dt / 2) * dt
|
|
79
|
+
t += dt
|
|
80
|
+
frames += 1
|
|
81
|
+
return frames - 1 if span - previous < tau - span else frames
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class Clip:
|
|
86
|
+
where: str
|
|
87
|
+
kind: str # video | sequence | image | color | edit (a nested EDL, rendered before the slots)
|
|
88
|
+
source: Path | None
|
|
89
|
+
color: str | None
|
|
90
|
+
start_s: float # position inside the source, seconds from its first frame
|
|
91
|
+
speed: float
|
|
92
|
+
frames: int # frames on the output timeline
|
|
93
|
+
fit: str
|
|
94
|
+
audio: bool
|
|
95
|
+
gain_db: float
|
|
96
|
+
has_audio: bool = False
|
|
97
|
+
src_fps: Fraction | None = None
|
|
98
|
+
matrix: str = 'bt709' # YUV matrix to decode the source with (ff.source_matrix)
|
|
99
|
+
decoder: str | None = None # forced decoder, e.g. libvpx-vp9 to keep WebM alpha
|
|
100
|
+
short_by: int = 0 # timeline frames the source cannot cover (filled by holding its last frame)
|
|
101
|
+
transition: str | None = None
|
|
102
|
+
transition_frames: int = 0
|
|
103
|
+
timeline_start: int = 0
|
|
104
|
+
# overlays only
|
|
105
|
+
x: int = 0
|
|
106
|
+
y: int = 0
|
|
107
|
+
w: int | None = None
|
|
108
|
+
h: int | None = None
|
|
109
|
+
opacity: float = 1.0
|
|
110
|
+
fade_in: float = 0.0
|
|
111
|
+
fade_out: float = 0.0
|
|
112
|
+
# keyframed transform, {prop: [(t, value, ease)]}, t in seconds from the clip's first frame
|
|
113
|
+
keys: dict | None = None
|
|
114
|
+
seq_start: int = 0 # sequences: first file number
|
|
115
|
+
src_size: tuple[int, int] | None = None # sequences and images: pixel size
|
|
116
|
+
id: str | None = None
|
|
117
|
+
meta: object = None # kept as given, never interpreted
|
|
118
|
+
vf: str | None = None # raw ffmpeg video filters, applied in the clip's own chain
|
|
119
|
+
af: str | None = None # raw ffmpeg audio filters (main-track clips' own sound)
|
|
120
|
+
blend: str | None = None # overlays: blend mode against everything below
|
|
121
|
+
mask: 'Clip | None' = None # overlays: a full-frame matte timed on the timeline
|
|
122
|
+
mask_mode: str = 'luma' # luma | alpha
|
|
123
|
+
mask_invert: bool = False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class AudioItem:
|
|
128
|
+
where: str
|
|
129
|
+
source: Path
|
|
130
|
+
role: str
|
|
131
|
+
at: float
|
|
132
|
+
start_s: float
|
|
133
|
+
duration: float | None
|
|
134
|
+
gain_db: float
|
|
135
|
+
fade_in: float
|
|
136
|
+
fade_out: float
|
|
137
|
+
id: str | None = None
|
|
138
|
+
meta: object = None
|
|
139
|
+
af: str | None = None
|
|
140
|
+
keys: dict | None = None # gain_db over time, t from the item's first sample on the timeline
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class Edit:
|
|
145
|
+
path: Path
|
|
146
|
+
width: int
|
|
147
|
+
height: int
|
|
148
|
+
fps: Fraction
|
|
149
|
+
background: str
|
|
150
|
+
crf: int
|
|
151
|
+
preset: str
|
|
152
|
+
base: list[Clip]
|
|
153
|
+
overlays: list[Clip]
|
|
154
|
+
audio: list[AudioItem]
|
|
155
|
+
subtitles: Path | None
|
|
156
|
+
subtitle_style: str | None
|
|
157
|
+
lufs: float | None
|
|
158
|
+
true_peak: float
|
|
159
|
+
duck_db: float
|
|
160
|
+
total_frames: int = 0
|
|
161
|
+
cut_fade: float = 0.0 # seconds of fade on each side of a hard cut in the main track's own sound
|
|
162
|
+
accept: list[dict] = field(default_factory=list) # qa warnings judged intended: {"check", "at"?, "note"?}
|
|
163
|
+
warnings: list[str] = field(default_factory=list)
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def duration(self) -> float:
|
|
167
|
+
return float(self.total_frames / self.fps)
|
|
168
|
+
|
|
169
|
+
def summary(self) -> dict:
|
|
170
|
+
return {
|
|
171
|
+
'output': {'width': self.width, 'height': self.height, 'fps': str(self.fps), 'frames': self.total_frames,
|
|
172
|
+
'duration': round(self.duration, 4)},
|
|
173
|
+
'base': [{'where': c.where, 'id': c.id, 'start_frame': c.timeline_start, 'frames': c.frames,
|
|
174
|
+
'transition_frames': c.transition_frames} for c in self.base],
|
|
175
|
+
'overlays': [{'where': c.where, 'id': c.id, 'start_frame': c.timeline_start, 'frames': c.frames} for c in self.overlays],
|
|
176
|
+
'audio': [{'where': a.where, 'id': a.id, 'role': a.role, 'at': a.at} for a in self.audio],
|
|
177
|
+
'warnings': self.warnings,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _num(value, where: str, *, minimum: float | None = None, positive: bool = False) -> float:
|
|
182
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
183
|
+
raise RnError(f'{where}: expected a number of seconds, got {json.dumps(value)}')
|
|
184
|
+
if positive and value <= 0:
|
|
185
|
+
raise RnError(f'{where}: must be > 0')
|
|
186
|
+
if minimum is not None and value < minimum:
|
|
187
|
+
raise RnError(f'{where}: must be >= {minimum}')
|
|
188
|
+
return float(value)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _position(value, where: str, source_fps: Fraction | None, source: Path | None = None, *, end: bool = False) -> float:
|
|
192
|
+
"""Seconds, or {"frame": n} / {"time": s} / {"shot": n, "offset": s} inside a source. A shot is one found by
|
|
193
|
+
`rn cuts` with its defaults; as "in" it means the shot's first frame, as "out" its end."""
|
|
194
|
+
if isinstance(value, dict):
|
|
195
|
+
if 'shot' in value:
|
|
196
|
+
if source is None or source_fps is None:
|
|
197
|
+
raise RnError(f'{where}: shot positions need a video source')
|
|
198
|
+
shot = value['shot']
|
|
199
|
+
if not isinstance(shot, int) or shot < 0:
|
|
200
|
+
raise RnError(f'{where}.shot: expected a non-negative integer (shot numbers from rn cuts)')
|
|
201
|
+
from .cuts import shot_span
|
|
202
|
+
start, stop = shot_span(source, shot)
|
|
203
|
+
return max(0.0, (stop if end else start) + _num(value.get('offset', 0), f'{where}.offset'))
|
|
204
|
+
if 'frame' in value:
|
|
205
|
+
if source_fps is None:
|
|
206
|
+
raise RnError(f'{where}: frame positions need a video source')
|
|
207
|
+
frame = value['frame']
|
|
208
|
+
if not isinstance(frame, int) or frame < 0:
|
|
209
|
+
raise RnError(f'{where}.frame: expected a non-negative integer')
|
|
210
|
+
return float(frame / source_fps)
|
|
211
|
+
if 'time' in value:
|
|
212
|
+
return _num(value['time'], f'{where}.time', minimum=0)
|
|
213
|
+
raise RnError(f'{where}: expected seconds, {{"frame": n}} or {{"time": s}}')
|
|
214
|
+
return _num(value, where, minimum=0)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _frames(seconds: float, fps: Fraction) -> int:
|
|
218
|
+
return max(0, round(seconds * fps))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _resolve(edit_dir: Path, value, where: str) -> Path:
|
|
222
|
+
if not isinstance(value, str) or not value:
|
|
223
|
+
raise RnError(f'{where}: expected a file path')
|
|
224
|
+
path = (edit_dir / value).expanduser()
|
|
225
|
+
if not path.exists():
|
|
226
|
+
raise RnError(f'{where}: file not found: {path}')
|
|
227
|
+
return path
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@dataclass
|
|
231
|
+
class Timeline:
|
|
232
|
+
"""What output-timeline anchors resolve against: the laid-out main track, named markers and listen results."""
|
|
233
|
+
fps: Fraction
|
|
234
|
+
edit_dir: Path
|
|
235
|
+
base: list[Clip]
|
|
236
|
+
markers: dict[str, float]
|
|
237
|
+
by_id: dict[str, Clip] = field(default_factory=dict) # main-track clips and the overlays parsed so far
|
|
238
|
+
ends: dict[str, Clip] = field(default_factory=dict) # a "ranges" clip's id -> its last piece (for clip_end)
|
|
239
|
+
_listen: dict[str, dict] = field(default_factory=dict)
|
|
240
|
+
|
|
241
|
+
def listen(self, value, where: str) -> dict:
|
|
242
|
+
path = _resolve(self.edit_dir, value, where)
|
|
243
|
+
if str(path) not in self._listen:
|
|
244
|
+
try:
|
|
245
|
+
self._listen[str(path)] = json.loads(path.read_text())
|
|
246
|
+
except json.JSONDecodeError as error:
|
|
247
|
+
raise RnError(f'{where}: {path.name} is not JSON ({error}); write it with rn listen --json') from None
|
|
248
|
+
return self._listen[str(path)]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _anchor(value, where: str, timeline: Timeline) -> float:
|
|
252
|
+
"""Seconds on the output timeline: a number, or an anchor plus "offset" seconds:
|
|
253
|
+
{"clip": i} / {"clip_end": i} (main-track clip i as laid out, transitions included; or a clip id, main track or an
|
|
254
|
+
earlier overlay), {"marker": "name"},
|
|
255
|
+
{"frame": n}, {"beat": n, "listen": "music.json"}, {"word": n, "listen": "vo.json", "edge": "start"|"end"}.
|
|
256
|
+
Beat and word times are relative to the analysed file: add "offset" when that audio is placed later."""
|
|
257
|
+
if not isinstance(value, dict):
|
|
258
|
+
return _num(value, where, minimum=0)
|
|
259
|
+
offset = _num(value.get('offset', 0), f'{where}.offset')
|
|
260
|
+
kinds = [k for k in ('clip', 'clip_end', 'marker', 'frame', 'time', 'beat', 'word') if k in value]
|
|
261
|
+
if len(kinds) != 1:
|
|
262
|
+
raise RnError(f'{where}: one of clip, clip_end, marker, frame, beat, word (plus optional offset)')
|
|
263
|
+
kind = kinds[0]
|
|
264
|
+
index = value[kind]
|
|
265
|
+
if kind in ('clip', 'clip_end'):
|
|
266
|
+
if isinstance(index, str):
|
|
267
|
+
clip = timeline.by_id.get(index)
|
|
268
|
+
if clip is None:
|
|
269
|
+
raise RnError(f'{where}.{kind}: no clip with id "{index}" on the main track or in an earlier overlay')
|
|
270
|
+
elif not isinstance(index, int) or not 0 <= index < len(timeline.base):
|
|
271
|
+
raise RnError(f'{where}.{kind}: main-track clip index 0..{len(timeline.base) - 1}, or a clip id')
|
|
272
|
+
else:
|
|
273
|
+
clip = timeline.base[index]
|
|
274
|
+
if kind == 'clip_end' and isinstance(index, str):
|
|
275
|
+
clip = timeline.ends.get(index, clip)
|
|
276
|
+
frame = clip.timeline_start + (clip.frames if kind == 'clip_end' else 0)
|
|
277
|
+
seconds = float(frame / timeline.fps)
|
|
278
|
+
elif kind == 'marker':
|
|
279
|
+
if index not in timeline.markers:
|
|
280
|
+
raise RnError(f'{where}.marker: "{index}" is not in the top-level "markers" ({sorted(timeline.markers) or "none defined"})')
|
|
281
|
+
seconds = timeline.markers[index]
|
|
282
|
+
elif kind == 'frame':
|
|
283
|
+
if not isinstance(index, int) or index < 0:
|
|
284
|
+
raise RnError(f'{where}.frame: a non-negative integer')
|
|
285
|
+
seconds = float(index / timeline.fps)
|
|
286
|
+
elif kind == 'time':
|
|
287
|
+
seconds = _num(index, f'{where}.time', minimum=0)
|
|
288
|
+
else:
|
|
289
|
+
data = timeline.listen(value.get('listen'), f'{where}.listen')
|
|
290
|
+
items = (data.get('beats') or {}).get('times') if kind == 'beat' else data.get('words')
|
|
291
|
+
if not items:
|
|
292
|
+
raise RnError(f'{where}: {value.get("listen")} has no {kind}s (run rn listen{" --transcribe" if kind == "word" else ""} --json)')
|
|
293
|
+
if not isinstance(index, int) or not 0 <= index < len(items):
|
|
294
|
+
raise RnError(f'{where}.{kind}: index 0..{len(items) - 1}')
|
|
295
|
+
if kind == 'beat':
|
|
296
|
+
seconds = float(items[index])
|
|
297
|
+
else:
|
|
298
|
+
edge = value.get('edge', 'start')
|
|
299
|
+
if edge not in ('start', 'end'):
|
|
300
|
+
raise RnError(f'{where}.edge: "start" or "end"')
|
|
301
|
+
seconds = float(items[index][edge])
|
|
302
|
+
return max(0.0, seconds + offset)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _keys(raw, where: str, allowed: set[str]) -> dict | None:
|
|
306
|
+
"""{"scale": [[t, v], [t, v, "out"], ...], ...}: t seconds from the clip's first frame, values held before the
|
|
307
|
+
first and after the last key; the ease names the curve into that key (linear, in, out, inout, in3, out3, inout3, hold)."""
|
|
308
|
+
if raw is None:
|
|
309
|
+
return None
|
|
310
|
+
if not isinstance(raw, dict) or not raw:
|
|
311
|
+
raise RnError(f'{where}: {{"scale": [[0, 1], [0.5, 1.1, "out"]], ...}}')
|
|
312
|
+
keys = {}
|
|
313
|
+
for prop, points in raw.items():
|
|
314
|
+
if prop not in allowed:
|
|
315
|
+
raise RnError(f'{where}.{prop}: keyable here: {sorted(allowed)}')
|
|
316
|
+
if not isinstance(points, list) or not points:
|
|
317
|
+
raise RnError(f'{where}.{prop}: a list of [t, value] or [t, value, ease]')
|
|
318
|
+
parsed, last = [], -1.0
|
|
319
|
+
for k, point in enumerate(points):
|
|
320
|
+
if not isinstance(point, list) or len(point) not in (2, 3):
|
|
321
|
+
raise RnError(f'{where}.{prop}[{k}]: [t, value] or [t, value, ease]')
|
|
322
|
+
t = _num(point[0], f'{where}.{prop}[{k}] time', minimum=0)
|
|
323
|
+
if t <= last:
|
|
324
|
+
raise RnError(f'{where}.{prop}[{k}]: times must increase')
|
|
325
|
+
value = _num(point[1], f'{where}.{prop}[{k}] value')
|
|
326
|
+
ease = point[2] if len(point) == 3 else 'linear'
|
|
327
|
+
if ease not in EASES:
|
|
328
|
+
raise RnError(f'{where}.{prop}[{k}]: ease one of {sorted(EASES)}')
|
|
329
|
+
if prop == 'scale' and value <= 0:
|
|
330
|
+
raise RnError(f'{where}.scale: must be > 0')
|
|
331
|
+
if prop == 'opacity' and not 0 <= value <= 1:
|
|
332
|
+
raise RnError(f'{where}.opacity: between 0 and 1')
|
|
333
|
+
if prop == 'speed' and not 0.1 <= value <= 8:
|
|
334
|
+
raise RnError(f'{where}.speed: between 0.1 and 8')
|
|
335
|
+
if prop == 'gain_db' and not -60 <= value <= 24:
|
|
336
|
+
raise RnError(f'{where}.gain_db: between -60 and 24 dB')
|
|
337
|
+
parsed.append((t, value, ease))
|
|
338
|
+
last = t
|
|
339
|
+
keys[prop] = parsed
|
|
340
|
+
return keys
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _sequence(edit_dir: Path, pattern, where: str) -> tuple[Path, int, int, tuple[int, int]]:
|
|
344
|
+
"""A numbered image sequence (printf pattern like "title/%05d.png") -> (pattern path, first number, count, size)."""
|
|
345
|
+
if not isinstance(pattern, str) or not _SEQ.search(pattern):
|
|
346
|
+
raise RnError(f'{where}: a numbered pattern like "title/%05d.png"')
|
|
347
|
+
path = (edit_dir / pattern).expanduser()
|
|
348
|
+
width = _SEQ.search(path.name)
|
|
349
|
+
if not width:
|
|
350
|
+
raise RnError(f'{where}: the frame number goes in the file name, e.g. "title/%05d.png"')
|
|
351
|
+
digits = width.group(1)
|
|
352
|
+
head, tail = path.name[:width.start()], path.name[width.end():]
|
|
353
|
+
number = re.compile(re.escape(head) + (r'(\d{%s})' % int(digits) if digits else r'(\d+)') + re.escape(tail) + '$')
|
|
354
|
+
numbers = sorted(int(m.group(1)) for f in (path.parent.iterdir() if path.parent.is_dir() else []) if (m := number.match(f.name)))
|
|
355
|
+
if not numbers:
|
|
356
|
+
raise RnError(f'{where}: no files match {path}')
|
|
357
|
+
start = numbers[0]
|
|
358
|
+
count = next((k for k, n in enumerate(numbers) if n != start + k), len(numbers))
|
|
359
|
+
if count < len(numbers):
|
|
360
|
+
raise RnError(f'{where}: numbering has a gap after {head}{start + count - 1}{tail}; a sequence must be contiguous')
|
|
361
|
+
from PIL import Image
|
|
362
|
+
first = path.parent / f'{head}{start:0{int(digits) if digits else 1}d}{tail}'
|
|
363
|
+
with Image.open(first) as image:
|
|
364
|
+
size = image.size
|
|
365
|
+
return path, start, count, size
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def cache_dir(edit_dir: Path) -> Path:
|
|
369
|
+
"""Renders rn can reuse: slots, nested edits, placeholder cards. RN_CACHE overrides <edit dir>/.rn-cache."""
|
|
370
|
+
return Path(os.environ.get('RN_CACHE') or edit_dir / '.rn-cache')
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _filters(raw: dict, key: str, where: str) -> str | None:
|
|
374
|
+
"""A raw ffmpeg filter chain ("eq=saturation=1.2,unsharp"): any filter, but one linear chain, so it cannot reach
|
|
375
|
+
into the rest of the graph."""
|
|
376
|
+
value = raw.get(key)
|
|
377
|
+
if value is None:
|
|
378
|
+
return None
|
|
379
|
+
if not isinstance(value, str) or not value.strip() or _GRAPH_BREAKERS.search(value):
|
|
380
|
+
raise RnError(f'{where}.{key}: one ffmpeg filter chain like "eq=saturation=1.2,unsharp" (no ; [ ])')
|
|
381
|
+
return value
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _ident(raw: dict, where: str) -> tuple[str | None, object]:
|
|
385
|
+
ident = raw.get('id')
|
|
386
|
+
if ident is not None and (not isinstance(ident, str) or not ID.match(ident)):
|
|
387
|
+
raise RnError(f'{where}.id: letters, digits, "_", "-" and "." (starting with a letter or digit)')
|
|
388
|
+
return ident, raw.get('meta')
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
_SANS_CJK = ('Noto Sans CJK', 'NotoSansCJK', 'PingFang', 'Source Han Sans', 'SourceHanSans', 'Hiragino Sans', 'Heiti', 'WenQuanYi')
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _font(size: int):
|
|
395
|
+
"""A face that covers Chinese when fontconfig lists one (placeholder text is often a prompt), else Pillow's default.
|
|
396
|
+
fc-match ':lang=zh' is not enough: it falls back to any font; fc-list only lists fonts that cover the language."""
|
|
397
|
+
from PIL import ImageFont
|
|
398
|
+
if shutil.which('fc-list'):
|
|
399
|
+
files = subprocess.run(['fc-list', ':lang=zh-cn', '--format', '%{file}\n'], capture_output=True, text=True).stdout.split('\n')
|
|
400
|
+
files = [f for f in files if Path(f).suffix.lower() in ('.ttf', '.otf', '.ttc')]
|
|
401
|
+
for path in sorted(files, key=lambda f: not any(name in f for name in _SANS_CJK)):
|
|
402
|
+
try:
|
|
403
|
+
return ImageFont.truetype(path, size)
|
|
404
|
+
except OSError:
|
|
405
|
+
continue
|
|
406
|
+
return ImageFont.load_default(size=size)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _wrap(draw, text: str, font, width: int) -> list[str]:
|
|
410
|
+
"""Greedy by measured width; breaks at spaces when a line has one, anywhere otherwise (CJK has no spaces)."""
|
|
411
|
+
lines, line = [], ''
|
|
412
|
+
for char in text:
|
|
413
|
+
if draw.textlength(line + char, font=font) <= width:
|
|
414
|
+
line += char
|
|
415
|
+
continue
|
|
416
|
+
cut = line.rfind(' ')
|
|
417
|
+
if cut > 0 and not char.isspace():
|
|
418
|
+
lines.append(line[:cut])
|
|
419
|
+
line = line[cut + 1:] + char
|
|
420
|
+
else:
|
|
421
|
+
lines.append(line)
|
|
422
|
+
line = char.lstrip()
|
|
423
|
+
return lines + ([line] if line else [])
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _card(edit_dir: Path, size: tuple[int, int], lines: list[str]) -> Path:
|
|
427
|
+
"""A placeholder still: dark slate, the clip's name, its meta label or prompt, and what is missing."""
|
|
428
|
+
from PIL import Image, ImageDraw
|
|
429
|
+
digest = hashlib.sha1(json.dumps([size, lines], ensure_ascii=False).encode()).hexdigest()[:16]
|
|
430
|
+
path = cache_dir(edit_dir) / 'placeholders' / f'{digest}.png'
|
|
431
|
+
if path.exists():
|
|
432
|
+
return path
|
|
433
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
434
|
+
width, height = size
|
|
435
|
+
image = Image.new('RGB', size, (38, 40, 46))
|
|
436
|
+
draw = ImageDraw.Draw(image)
|
|
437
|
+
unit = max(14, min(width, height) // 18)
|
|
438
|
+
big, small = _font(unit * 2), _font(unit)
|
|
439
|
+
y = height // 6
|
|
440
|
+
draw.text((width // 12, y), lines[0], fill=(255, 214, 90), font=big)
|
|
441
|
+
y += unit * 3
|
|
442
|
+
for line in lines[1:]:
|
|
443
|
+
for wrapped in _wrap(draw, line, small, width * 5 // 6)[:8]:
|
|
444
|
+
draw.text((width // 12, y), wrapped, fill=(230, 230, 230), font=small)
|
|
445
|
+
y += int(unit * 1.4)
|
|
446
|
+
y += unit // 2
|
|
447
|
+
image.save(path)
|
|
448
|
+
return path
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _placeholder(raw: dict, where: str, edit_dir: Path, size: tuple[int, int], warnings: list[str]) -> dict | None:
|
|
452
|
+
"""With placeholders on, a clip whose source is missing becomes a card for the same time span."""
|
|
453
|
+
key = next(k for k in ('src', 'frames', 'image', 'color') if k in raw)
|
|
454
|
+
value = raw[key]
|
|
455
|
+
if key == 'color' or not isinstance(value, str):
|
|
456
|
+
return None
|
|
457
|
+
path = (edit_dir / value).expanduser()
|
|
458
|
+
if (path.parent.is_dir() and any(path.parent.iterdir())) if key == 'frames' else path.exists():
|
|
459
|
+
return None
|
|
460
|
+
if 'duration' in raw:
|
|
461
|
+
seconds = raw['duration']
|
|
462
|
+
elif isinstance(raw.get('out'), (int, float)) and isinstance(raw.get('in', 0), (int, float)):
|
|
463
|
+
seconds = (raw['out'] - raw.get('in', 0)) / raw.get('speed', 1)
|
|
464
|
+
else:
|
|
465
|
+
raise RnError(f'{where}: {value} is missing; a placeholder needs "duration" or numeric "in"/"out"')
|
|
466
|
+
meta = raw.get('meta') if isinstance(raw.get('meta'), dict) else {}
|
|
467
|
+
label = meta.get('label') or meta.get('prompt') or ''
|
|
468
|
+
card = _card(edit_dir, size, [raw.get('id') or Path(value).name, *([str(label)] if label else []), f'missing: {value}'])
|
|
469
|
+
warnings.append(f'{where}: {value} is missing; placeholder card for {seconds}s')
|
|
470
|
+
keep = {k: v for k, v in raw.items() if k not in ('src', 'frames', 'image', 'fps', 'in', 'out', 'speed', 'audio', 'gain_db', 'duration', 'until')}
|
|
471
|
+
return {**keep, 'image': str(card), 'duration': seconds, 'fit': raw.get('fit', 'contain')}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _clip(raw: dict, where: str, edit_dir: Path, fps: Fraction, *, overlay: bool, warnings: list[str],
|
|
475
|
+
timeline: Timeline | None = None, size: tuple[int, int] = (1920, 1080), placeholders: bool = False,
|
|
476
|
+
stack: tuple[Path, ...] = ()) -> Clip:
|
|
477
|
+
if not isinstance(raw, dict):
|
|
478
|
+
raise RnError(f'{where}: expected an object')
|
|
479
|
+
kinds = [k for k in ('src', 'frames', 'image', 'color') if k in raw]
|
|
480
|
+
if len(kinds) != 1:
|
|
481
|
+
raise RnError(f'{where}: give exactly one of "src" (video or a nested edit .json), "frames" (image sequence), "image" or "color"')
|
|
482
|
+
if placeholders:
|
|
483
|
+
raw = _placeholder(raw, where, edit_dir, size, warnings) or raw
|
|
484
|
+
at_s = 0.0
|
|
485
|
+
if overlay:
|
|
486
|
+
assert timeline is not None
|
|
487
|
+
at_s = _anchor(raw.get('at', 0), f'{where}.at', timeline)
|
|
488
|
+
if 'until' in raw:
|
|
489
|
+
if 'duration' in raw or 'out' in raw:
|
|
490
|
+
raise RnError(f'{where}: give "until" or "duration"/"out", not both')
|
|
491
|
+
until = _anchor(raw['until'], f'{where}.until', timeline)
|
|
492
|
+
if until <= at_s:
|
|
493
|
+
raise RnError(f'{where}.until: {until:.3f}s is not after "at" ({at_s:.3f}s)')
|
|
494
|
+
raw = {**raw, 'duration': until - at_s}
|
|
495
|
+
elif 'until' in raw:
|
|
496
|
+
raise RnError(f'{where}.until: main-track clips play back to back; "until" is for overlays')
|
|
497
|
+
fit = raw.get('fit', 'cover')
|
|
498
|
+
if fit not in FITS:
|
|
499
|
+
raise RnError(f'{where}.fit: one of {sorted(FITS)}')
|
|
500
|
+
speed = _num(raw.get('speed', 1.0), f'{where}.speed', positive=True)
|
|
501
|
+
if not 0.1 <= speed <= 8:
|
|
502
|
+
raise RnError(f'{where}.speed: keep between 0.1 and 8')
|
|
503
|
+
gain = _num(raw.get('gain_db', 0.0), f'{where}.gain_db')
|
|
504
|
+
clip = Clip(where=where, kind='video', source=None, color=None, start_s=0.0, speed=speed, frames=0, fit=fit,
|
|
505
|
+
audio=bool(raw.get('audio', not overlay)), gain_db=gain)
|
|
506
|
+
clip.id, clip.meta = _ident(raw, where)
|
|
507
|
+
clip.vf, clip.af = _filters(raw, 'vf', where), _filters(raw, 'af', where)
|
|
508
|
+
if overlay and clip.af:
|
|
509
|
+
raise RnError(f'{where}.af: overlays are silent; filter their sound in "audio"')
|
|
510
|
+
clip.keys = _keys(raw.get('keys'), f'{where}.keys', KEYS_OVERLAY if overlay else KEYS_MAIN)
|
|
511
|
+
ramp = (clip.keys or {}).get('speed')
|
|
512
|
+
if ramp and 'speed' in raw:
|
|
513
|
+
raise RnError(f'{where}: give "speed" or keys.speed, not both')
|
|
514
|
+
if ramp and not ('src' in raw or 'frames' in raw):
|
|
515
|
+
raise RnError(f'{where}.keys.speed: only video, sequences and nested edits play at a speed')
|
|
516
|
+
if 'pivot' in raw and 'until' in raw:
|
|
517
|
+
raise RnError(f'{where}: "pivot" places the clip by a point inside it; it cannot also end "until" an anchor')
|
|
518
|
+
|
|
519
|
+
if 'src' in raw or 'frames' in raw:
|
|
520
|
+
if 'src' in raw and str(raw['src']).lower().endswith('.json'):
|
|
521
|
+
# A nested edit: laid out and checked now, rendered (and cached) by assemble before the slots.
|
|
522
|
+
clip.source = _resolve(edit_dir, raw['src'], f'{where}.src').resolve()
|
|
523
|
+
if clip.source in stack:
|
|
524
|
+
raise RnError(f'{where}.src: {clip.source.name} includes itself (through {" -> ".join(p.name for p in stack)})')
|
|
525
|
+
sub = load(clip.source, placeholders=placeholders, _stack=stack)
|
|
526
|
+
clip.kind = 'edit'
|
|
527
|
+
src_fps = sub.fps
|
|
528
|
+
clip.src_size = (sub.width, sub.height)
|
|
529
|
+
src_len = sub.duration
|
|
530
|
+
clip.has_audio = bool(sub.audio or any(c.audio and c.has_audio for c in sub.base))
|
|
531
|
+
elif 'src' in raw:
|
|
532
|
+
clip.source = _resolve(edit_dir, raw['src'], f'{where}.src')
|
|
533
|
+
info = probe(clip.source)
|
|
534
|
+
video = info['video']
|
|
535
|
+
if not video or video['still']:
|
|
536
|
+
raise RnError(f'{where}.src: {clip.source.name} has no video; use "image" for stills, "frames" for a numbered sequence')
|
|
537
|
+
src_fps = Fraction(video['fps'])
|
|
538
|
+
clip.matrix = source_matrix(video)
|
|
539
|
+
clip.decoder = video.get('alpha_decoder')
|
|
540
|
+
clip.src_size = (video['width'], video['height'])
|
|
541
|
+
src_len = float(video['frames'] / src_fps) if video['frames'] else (video['duration'] or info['duration'] or 0)
|
|
542
|
+
clip.has_audio = info['audio'] is not None
|
|
543
|
+
else:
|
|
544
|
+
clip.kind = 'sequence'
|
|
545
|
+
clip.source, clip.seq_start, count, clip.src_size = _sequence(edit_dir, raw['frames'], f'{where}.frames')
|
|
546
|
+
try:
|
|
547
|
+
src_fps = Fraction(str(raw.get('fps', fps)))
|
|
548
|
+
except (ValueError, ZeroDivisionError):
|
|
549
|
+
raise RnError(f'{where}.fps: a number or "30000/1001"') from None
|
|
550
|
+
if src_fps <= 0:
|
|
551
|
+
raise RnError(f'{where}.fps: must be > 0')
|
|
552
|
+
src_len = float(count / src_fps)
|
|
553
|
+
clip.audio = False
|
|
554
|
+
if 'audio' in raw or 'gain_db' in raw:
|
|
555
|
+
raise RnError(f'{where}: image sequences are silent; add their sound to "audio"')
|
|
556
|
+
clip.src_fps = src_fps
|
|
557
|
+
clip.start_s = _position(raw.get('in', 0), f'{where}.in', src_fps, clip.source if clip.kind == 'video' else None)
|
|
558
|
+
if clip.start_s >= src_len:
|
|
559
|
+
raise RnError(f'{where}.in: {clip.start_s:.3f}s is past the end of {clip.source.name} ({src_len:.3f}s)')
|
|
560
|
+
if 'duration' in raw and 'out' in raw:
|
|
561
|
+
raise RnError(f'{where}: give "out" or "duration", not both')
|
|
562
|
+
if 'duration' in raw:
|
|
563
|
+
clip.frames = _frames(_num(raw['duration'], f'{where}.duration', positive=True), fps)
|
|
564
|
+
else:
|
|
565
|
+
end = _position(raw['out'], f'{where}.out', src_fps, clip.source if clip.kind == 'video' else None, end=True) if 'out' in raw else src_len
|
|
566
|
+
if end <= clip.start_s:
|
|
567
|
+
raise RnError(f'{where}.out: must be after "in"')
|
|
568
|
+
span = min(end, src_len) - clip.start_s
|
|
569
|
+
clip.frames = frames_for_span(ramp, span, fps) if ramp else _frames(span / speed, fps)
|
|
570
|
+
if end > src_len + 0.5 / float(src_fps):
|
|
571
|
+
warnings.append(f'{where}: out {end:.3f}s is past the end of {clip.source.name} ({src_len:.3f}s); clamped')
|
|
572
|
+
if ramp:
|
|
573
|
+
available = src_len - clip.start_s
|
|
574
|
+
covered = sum(1 for tau in source_offsets(ramp, clip.frames, fps)[:-1] if tau < available)
|
|
575
|
+
if clip.audio and clip.has_audio:
|
|
576
|
+
warnings.append(f'{where}: a speed ramp drops the clip\'s own sound; place audio in "audio"')
|
|
577
|
+
clip.audio = False
|
|
578
|
+
else:
|
|
579
|
+
covered = _frames((src_len - clip.start_s) / speed, fps)
|
|
580
|
+
if covered < clip.frames:
|
|
581
|
+
clip.short_by = clip.frames - covered
|
|
582
|
+
warnings.append(f'{where}: {clip.source.name} covers {covered} of {clip.frames} frames; holding its last frame')
|
|
583
|
+
else:
|
|
584
|
+
if 'image' in raw:
|
|
585
|
+
clip.kind = 'image'
|
|
586
|
+
clip.source = _resolve(edit_dir, raw['image'], f'{where}.image')
|
|
587
|
+
image = probe(clip.source)['video']
|
|
588
|
+
if not image:
|
|
589
|
+
raise RnError(f'{where}.image: {clip.source.name} is not an image')
|
|
590
|
+
clip.matrix = source_matrix(image)
|
|
591
|
+
clip.src_size = (image['width'], image['height'])
|
|
592
|
+
else:
|
|
593
|
+
clip.kind = 'color'
|
|
594
|
+
clip.color = raw['color']
|
|
595
|
+
if not isinstance(clip.color, str) or not (HEX_COLOR.match(clip.color) or clip.color.isalpha()):
|
|
596
|
+
raise RnError(f'{where}.color: expected "#rrggbb" or a color name')
|
|
597
|
+
clip.audio = False
|
|
598
|
+
clip.frames = _frames(_num(raw.get('duration'), f'{where}.duration', positive=True), fps)
|
|
599
|
+
if clip.frames < 1:
|
|
600
|
+
raise RnError(f'{where}: shorter than one frame at {fps} fps')
|
|
601
|
+
|
|
602
|
+
if 'pivot' in raw:
|
|
603
|
+
# The pivot (source seconds; for stills and colours, seconds into the clip) lands on "at".
|
|
604
|
+
if not overlay:
|
|
605
|
+
raise RnError(f'{where}.pivot: main-track clips play back to back; pivot positions overlays and audio')
|
|
606
|
+
if ramp:
|
|
607
|
+
raise RnError(f'{where}.pivot: not with a speed ramp')
|
|
608
|
+
inside = _num(raw['pivot'], f'{where}.pivot', minimum=0) - (clip.start_s if clip.kind in ('video', 'sequence', 'edit') else 0)
|
|
609
|
+
if inside < 0:
|
|
610
|
+
raise RnError(f'{where}.pivot: before the clip\'s "in"')
|
|
611
|
+
at_s -= inside / speed
|
|
612
|
+
if at_s < 0: # the part before the timeline start is dropped
|
|
613
|
+
dropped = _frames(-at_s, fps)
|
|
614
|
+
if dropped >= clip.frames:
|
|
615
|
+
raise RnError(f'{where}.pivot: puts the whole clip before 0 s')
|
|
616
|
+
clip.start_s += dropped / float(fps) * speed if clip.kind in ('video', 'sequence', 'edit') else 0
|
|
617
|
+
clip.frames -= dropped
|
|
618
|
+
at_s = 0.0
|
|
619
|
+
if overlay:
|
|
620
|
+
clip.timeline_start = _frames(at_s, fps)
|
|
621
|
+
clip.x = int(_num(raw.get('x', 0), f'{where}.x'))
|
|
622
|
+
clip.y = int(_num(raw.get('y', 0), f'{where}.y'))
|
|
623
|
+
clip.w = int(_num(raw['w'], f'{where}.w', positive=True)) if 'w' in raw else None
|
|
624
|
+
clip.h = int(_num(raw['h'], f'{where}.h', positive=True)) if 'h' in raw else None
|
|
625
|
+
clip.opacity = _num(raw.get('opacity', 1.0), f'{where}.opacity', minimum=0)
|
|
626
|
+
if clip.opacity > 1:
|
|
627
|
+
raise RnError(f'{where}.opacity: between 0 and 1')
|
|
628
|
+
clip.fade_in = _num(raw.get('fade_in', 0), f'{where}.fade_in', minimum=0)
|
|
629
|
+
clip.fade_out = _num(raw.get('fade_out', 0), f'{where}.fade_out', minimum=0)
|
|
630
|
+
if 'transition' in raw:
|
|
631
|
+
raise RnError(f'{where}.transition: transitions are for the first (main) track; use fade_in / fade_out on overlays')
|
|
632
|
+
if 'audio' in raw or 'gain_db' in raw:
|
|
633
|
+
raise RnError(f'{where}: overlay clips are silent; add their sound to "audio" with "at" (role "sfx")')
|
|
634
|
+
blend = raw.get('blend')
|
|
635
|
+
if blend is not None:
|
|
636
|
+
blend = {'add': 'addition'}.get(blend, blend)
|
|
637
|
+
if blend not in BLENDS:
|
|
638
|
+
raise RnError(f'{where}.blend: one of {sorted(BLENDS | {"add"})}')
|
|
639
|
+
clip.blend = blend
|
|
640
|
+
if 'mask' in raw:
|
|
641
|
+
clip.mask, clip.mask_mode, clip.mask_invert = _mask(raw['mask'], where, clip, edit_dir, fps, warnings, timeline, size)
|
|
642
|
+
else:
|
|
643
|
+
for key in ('at', 'x', 'y', 'w', 'h', 'opacity', 'fade_in', 'fade_out', 'blend', 'mask'):
|
|
644
|
+
if key in raw:
|
|
645
|
+
raise RnError(f'{where}.{key}: main-track clips play back to back; put positioned clips on another track')
|
|
646
|
+
transition = raw.get('transition')
|
|
647
|
+
if transition is not None:
|
|
648
|
+
if not isinstance(transition, dict) or transition.get('type') not in XFADE:
|
|
649
|
+
raise RnError(f'{where}.transition: {{"type": one of xfade names (fade, dissolve, wipeleft, slideup, ...), "duration": s}}')
|
|
650
|
+
clip.transition = transition['type']
|
|
651
|
+
clip.transition_frames = _frames(_num(transition.get('duration', 0.5), f'{where}.transition.duration', positive=True), fps)
|
|
652
|
+
if clip.keys and 'gain_db' in clip.keys and not (clip.audio and clip.has_audio):
|
|
653
|
+
raise RnError(f'{where}.keys.gain_db: the clip plays no sound of its own')
|
|
654
|
+
if clip.keys and overlay and ('scale' in clip.keys or 'rotate' in clip.keys) and clip.kind == 'color' and not (clip.w and clip.h):
|
|
655
|
+
raise RnError(f'{where}.keys: give a colour overlay "w" and "h" to scale or rotate it')
|
|
656
|
+
unknown = set(raw) - {'src', 'frames', 'fps', 'image', 'color', 'in', 'out', 'duration', 'until', 'speed', 'fit', 'audio', 'gain_db',
|
|
657
|
+
'at', 'x', 'y', 'w', 'h', 'opacity', 'fade_in', 'fade_out', 'transition', 'keys', 'note', 'id', 'meta',
|
|
658
|
+
'pivot', 'vf', 'af', 'blend', 'mask'}
|
|
659
|
+
if unknown:
|
|
660
|
+
raise RnError(f'{where}: unknown field(s) {sorted(unknown)}')
|
|
661
|
+
return clip
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _mask(raw, where: str, layer: Clip, edit_dir: Path, fps: Fraction, warnings: list[str], timeline: Timeline,
|
|
665
|
+
size: tuple[int, int]) -> tuple[Clip, str, bool]:
|
|
666
|
+
"""A matte over the whole frame (stretched to it) that multiplies an overlay's alpha: "luma" white = keep, or the
|
|
667
|
+
matte's own "alpha". Its time defaults to the overlay's; outside its span the overlay is not masked."""
|
|
668
|
+
where = f'{where}.mask'
|
|
669
|
+
if not isinstance(raw, dict):
|
|
670
|
+
raise RnError(f'{where}: {{"frames": "matte/%05d.png", "mode": "luma"|"alpha", "invert": false}} (any clip source)')
|
|
671
|
+
mode, invert = raw.get('mode', 'luma'), raw.get('invert', False)
|
|
672
|
+
if mode not in ('luma', 'alpha') or not isinstance(invert, bool):
|
|
673
|
+
raise RnError(f'{where}: "mode" luma or alpha, "invert" true or false')
|
|
674
|
+
placed = {'x', 'y', 'w', 'h', 'blend', 'mask', 'opacity'} & set(raw)
|
|
675
|
+
if placed:
|
|
676
|
+
raise RnError(f'{where}: a mask covers the whole frame; {sorted(placed)} do not apply')
|
|
677
|
+
matte = {k: v for k, v in raw.items() if k not in ('mode', 'invert')}
|
|
678
|
+
matte.setdefault('at', float(layer.timeline_start / fps))
|
|
679
|
+
if not {'duration', 'out', 'until'} & set(matte):
|
|
680
|
+
matte['duration'] = float(layer.frames / fps)
|
|
681
|
+
matte.setdefault('fit', 'stretch')
|
|
682
|
+
clip = _clip(matte, where, edit_dir, fps, overlay=True, warnings=warnings, timeline=timeline, size=size)
|
|
683
|
+
clip.w, clip.h = size
|
|
684
|
+
return clip, mode, invert
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _audio(raw: dict, where: str, edit_dir: Path, timeline: Timeline) -> AudioItem:
|
|
688
|
+
if not isinstance(raw, dict):
|
|
689
|
+
raise RnError(f'{where}: expected an object')
|
|
690
|
+
source = _resolve(edit_dir, raw.get('src'), f'{where}.src')
|
|
691
|
+
info = probe(source)
|
|
692
|
+
if not info['audio']:
|
|
693
|
+
raise RnError(f'{where}.src: {source.name} has no audio stream')
|
|
694
|
+
role = raw.get('role', 'sfx')
|
|
695
|
+
if role not in ROLES:
|
|
696
|
+
raise RnError(f'{where}.role: one of {sorted(ROLES)}')
|
|
697
|
+
start = _num(raw.get('in', 0), f'{where}.in', minimum=0)
|
|
698
|
+
duration = None
|
|
699
|
+
if 'duration' in raw and 'out' in raw:
|
|
700
|
+
raise RnError(f'{where}: give "out" or "duration", not both')
|
|
701
|
+
if 'duration' in raw:
|
|
702
|
+
duration = _num(raw['duration'], f'{where}.duration', positive=True)
|
|
703
|
+
elif 'out' in raw:
|
|
704
|
+
duration = _num(raw['out'], f'{where}.out', positive=True) - start
|
|
705
|
+
if duration <= 0:
|
|
706
|
+
raise RnError(f'{where}.out: must be after "in"')
|
|
707
|
+
unknown = set(raw) - {'src', 'role', 'at', 'in', 'out', 'duration', 'gain_db', 'fade_in', 'fade_out', 'note', 'id', 'meta', 'pivot',
|
|
708
|
+
'af', 'keys'}
|
|
709
|
+
if unknown:
|
|
710
|
+
raise RnError(f'{where}: unknown field(s) {sorted(unknown)}')
|
|
711
|
+
ident, meta = _ident(raw, where)
|
|
712
|
+
at = _anchor(raw.get('at', 0), f'{where}.at', timeline)
|
|
713
|
+
if 'pivot' in raw:
|
|
714
|
+
# The pivot (seconds in the source: a hit, a downbeat, the drop) lands on "at"; what falls before 0 s is cut.
|
|
715
|
+
inside = _num(raw['pivot'], f'{where}.pivot', minimum=0) - start
|
|
716
|
+
if inside < 0:
|
|
717
|
+
raise RnError(f'{where}.pivot: before "in"')
|
|
718
|
+
at -= inside
|
|
719
|
+
if at < 0:
|
|
720
|
+
start -= at
|
|
721
|
+
if duration is not None:
|
|
722
|
+
duration += at
|
|
723
|
+
if duration <= 0:
|
|
724
|
+
raise RnError(f'{where}.pivot: puts the whole item before 0 s')
|
|
725
|
+
at = 0.0
|
|
726
|
+
return AudioItem(where=where, source=source, role=role, at=at, start_s=start,
|
|
727
|
+
duration=duration, gain_db=_num(raw.get('gain_db', 0), f'{where}.gain_db'),
|
|
728
|
+
fade_in=_num(raw.get('fade_in', 0), f'{where}.fade_in', minimum=0),
|
|
729
|
+
fade_out=_num(raw.get('fade_out', 0), f'{where}.fade_out', minimum=0), id=ident, meta=meta,
|
|
730
|
+
af=_filters(raw, 'af', where), keys=_keys(raw.get('keys'), f'{where}.keys', KEYS_AUDIO))
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _expand_ranges(clips: list) -> tuple[list[tuple[str, dict]], dict[str, tuple[int, int]]]:
|
|
734
|
+
"""A main-track clip with "ranges": [[in, out], ...] plays those spans of one source back to back: one clip per span.
|
|
735
|
+
Its id names the first span (later ones get <id>.1, <id>.2...); {"clip_end": id} is the end of the last span.
|
|
736
|
+
Index anchors count the spans."""
|
|
737
|
+
pieces, groups = [], {}
|
|
738
|
+
for i, raw in enumerate(clips):
|
|
739
|
+
where = f'tracks[0].clips[{i}]'
|
|
740
|
+
if not (isinstance(raw, dict) and 'ranges' in raw):
|
|
741
|
+
pieces.append((where, raw))
|
|
742
|
+
continue
|
|
743
|
+
spans = raw['ranges']
|
|
744
|
+
if not isinstance(spans, list) or not spans or not all(isinstance(x, list) and len(x) == 2 for x in spans):
|
|
745
|
+
raise RnError(f'{where}.ranges: [[in, out], ...] with at least one span')
|
|
746
|
+
clash = {'in', 'out', 'duration', 'keys'} & set(raw)
|
|
747
|
+
if clash:
|
|
748
|
+
raise RnError(f'{where}: "ranges" replaces {sorted(clash)}')
|
|
749
|
+
first = len(pieces)
|
|
750
|
+
for k, (start, end) in enumerate(spans):
|
|
751
|
+
piece = {key: value for key, value in raw.items() if key not in ('ranges', 'transition', 'id')}
|
|
752
|
+
piece.update({'in': start, 'out': end})
|
|
753
|
+
if k == 0 and 'transition' in raw:
|
|
754
|
+
piece['transition'] = raw['transition']
|
|
755
|
+
if raw.get('id'):
|
|
756
|
+
piece['id'] = raw['id'] if k == 0 else f'{raw["id"]}.{k}'
|
|
757
|
+
pieces.append((f'{where}.ranges[{k}]', piece))
|
|
758
|
+
if raw.get('id'):
|
|
759
|
+
groups[raw['id']] = (first, len(pieces) - 1)
|
|
760
|
+
return pieces, groups
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def load(path: str | Path, *, placeholders: bool = False, _stack: tuple[Path, ...] = ()) -> Edit:
|
|
764
|
+
"""Parses and lays out an EDL. placeholders: a missing source becomes a card for its time span instead of an error."""
|
|
765
|
+
path = Path(path).resolve()
|
|
766
|
+
stack = (*_stack, path)
|
|
767
|
+
try:
|
|
768
|
+
raw = json.loads(path.read_text())
|
|
769
|
+
except FileNotFoundError:
|
|
770
|
+
raise RnError(f'no such file: {path}') from None
|
|
771
|
+
except json.JSONDecodeError as error:
|
|
772
|
+
raise RnError(f'{path}: invalid JSON: {error}') from None
|
|
773
|
+
edit_dir = path.parent
|
|
774
|
+
output = raw.get('output') or {}
|
|
775
|
+
width = int(_num(output.get('width'), 'output.width', positive=True))
|
|
776
|
+
height = int(_num(output.get('height'), 'output.height', positive=True))
|
|
777
|
+
if width % 2 or height % 2:
|
|
778
|
+
raise RnError('output.width / output.height: must be even for yuv420p')
|
|
779
|
+
try:
|
|
780
|
+
fps = Fraction(str(output.get('fps', 30)))
|
|
781
|
+
except (ValueError, ZeroDivisionError):
|
|
782
|
+
raise RnError('output.fps: a number or "30000/1001"') from None
|
|
783
|
+
if not 1 <= fps <= 120:
|
|
784
|
+
raise RnError('output.fps: between 1 and 120')
|
|
785
|
+
background = output.get('background', '#000000')
|
|
786
|
+
if not isinstance(background, str) or not (HEX_COLOR.match(background) or background.isalpha()):
|
|
787
|
+
raise RnError('output.background: "#rrggbb" or a color name')
|
|
788
|
+
|
|
789
|
+
tracks = raw.get('tracks')
|
|
790
|
+
if not isinstance(tracks, list) or not tracks or not isinstance(tracks[0], dict) or not tracks[0].get('clips'):
|
|
791
|
+
raise RnError('tracks: need at least one track whose "clips" list is the main track')
|
|
792
|
+
warnings: list[str] = []
|
|
793
|
+
context = {'size': (width, height), 'placeholders': placeholders, 'stack': stack}
|
|
794
|
+
pieces, groups = _expand_ranges(tracks[0]['clips'])
|
|
795
|
+
base = [_clip(c, where, edit_dir, fps, overlay=False, warnings=warnings, **context) for where, c in pieces]
|
|
796
|
+
if base[0].transition:
|
|
797
|
+
raise RnError('tracks[0].clips[0].transition: the first clip has nothing to transition from')
|
|
798
|
+
position = 0
|
|
799
|
+
for i, clip in enumerate(base):
|
|
800
|
+
if clip.transition_frames:
|
|
801
|
+
limit = min(base[i - 1].frames, clip.frames) - 1
|
|
802
|
+
if clip.transition_frames > limit:
|
|
803
|
+
raise RnError(f'{clip.where}.transition.duration: {clip.transition_frames} frames is longer than the clips it joins allow ({limit})')
|
|
804
|
+
if clip.transition_frames + base[i - 1].transition_frames > base[i - 1].frames:
|
|
805
|
+
raise RnError(f'{clip.where}.transition.duration: overlaps the transition into the previous clip ({base[i - 1].where}); '
|
|
806
|
+
'shorten one of them or lengthen that clip')
|
|
807
|
+
position -= clip.transition_frames
|
|
808
|
+
clip.timeline_start = position
|
|
809
|
+
position += clip.frames
|
|
810
|
+
total = position
|
|
811
|
+
markers_raw = raw.get('markers') or {}
|
|
812
|
+
if not isinstance(markers_raw, dict):
|
|
813
|
+
raise RnError('markers: {"name": seconds, ...} on the output timeline')
|
|
814
|
+
markers = {}
|
|
815
|
+
for name, value in markers_raw.items():
|
|
816
|
+
markers[name] = _position(value, f'markers.{name}', fps)
|
|
817
|
+
timeline = Timeline(fps=fps, edit_dir=edit_dir, base=base, markers=markers)
|
|
818
|
+
seen: set[str] = set()
|
|
819
|
+
|
|
820
|
+
def register(item, clip: Clip | None = None) -> None:
|
|
821
|
+
if item.id is None:
|
|
822
|
+
return
|
|
823
|
+
if item.id in seen:
|
|
824
|
+
raise RnError(f'{item.where}.id: "{item.id}" is used twice; ids must be unique in the edit')
|
|
825
|
+
seen.add(item.id)
|
|
826
|
+
if clip is not None:
|
|
827
|
+
timeline.by_id[item.id] = clip
|
|
828
|
+
|
|
829
|
+
for clip in base:
|
|
830
|
+
register(clip, clip)
|
|
831
|
+
for ident, (first, last) in groups.items():
|
|
832
|
+
timeline.ends[ident] = base[last]
|
|
833
|
+
overlays = []
|
|
834
|
+
for t, track in enumerate(tracks[1:], start=1):
|
|
835
|
+
if not isinstance(track, dict) or not isinstance(track.get('clips'), list):
|
|
836
|
+
raise RnError(f'tracks[{t}]: expected {{"clips": [...]}}')
|
|
837
|
+
for i, c in enumerate(track['clips']):
|
|
838
|
+
clip = _clip(c, f'tracks[{t}].clips[{i}]', edit_dir, fps, overlay=True, warnings=warnings, timeline=timeline, **context)
|
|
839
|
+
if clip.timeline_start >= total:
|
|
840
|
+
raise RnError(f'{clip.where}.at: starts after the main track ends ({float(total / fps):.3f}s)')
|
|
841
|
+
if clip.timeline_start + clip.frames > total:
|
|
842
|
+
warnings.append(f'{clip.where}: runs past the end of the main track; cut at {float(total / fps):.3f}s')
|
|
843
|
+
clip.frames = total - clip.timeline_start
|
|
844
|
+
overlays.append(clip)
|
|
845
|
+
register(clip, clip)
|
|
846
|
+
unknown = set(track) - {'clips', 'id', 'meta', 'note'}
|
|
847
|
+
if unknown:
|
|
848
|
+
raise RnError(f'tracks[{t}]: unknown field(s) {sorted(unknown)}')
|
|
849
|
+
audio = [_audio(a, f'audio[{i}]', edit_dir, timeline) for i, a in enumerate(raw.get('audio') or [])]
|
|
850
|
+
for item in audio:
|
|
851
|
+
register(item)
|
|
852
|
+
|
|
853
|
+
subtitles = style = None
|
|
854
|
+
if raw.get('subtitles'):
|
|
855
|
+
sub = raw['subtitles']
|
|
856
|
+
if not isinstance(sub, dict):
|
|
857
|
+
raise RnError('subtitles: {"file": "captions.ass", "force_style": "..."}')
|
|
858
|
+
subtitles = _resolve(edit_dir, sub.get('file'), 'subtitles.file')
|
|
859
|
+
if subtitles.suffix.lower() not in ('.ass', '.ssa', '.srt'):
|
|
860
|
+
raise RnError('subtitles.file: .ass or .srt')
|
|
861
|
+
style = sub.get('force_style')
|
|
862
|
+
if style is not None and (not isinstance(style, str) or "'" in style or '\\' in style):
|
|
863
|
+
raise RnError('subtitles.force_style: ASS overrides like "Fontname=Noto Sans CJK SC,Fontsize=48" (no quotes or backslashes)')
|
|
864
|
+
mix = raw.get('mix') or {}
|
|
865
|
+
lufs = mix.get('lufs', -14.0)
|
|
866
|
+
if lufs is not None:
|
|
867
|
+
lufs = _num(lufs, 'mix.lufs')
|
|
868
|
+
if not -40 <= lufs <= -5:
|
|
869
|
+
raise RnError('mix.lufs: between -40 and -5 (or null to keep levels as mixed)')
|
|
870
|
+
true_peak = _num(mix.get('true_peak', -1.0), 'mix.true_peak')
|
|
871
|
+
if not -9 <= true_peak <= 0:
|
|
872
|
+
raise RnError('mix.true_peak: between -9 and 0 dBTP')
|
|
873
|
+
duck = _num(mix.get('duck_db', 0), 'mix.duck_db', minimum=0)
|
|
874
|
+
cut_fade = _num(mix.get('cut_fade', 0), 'mix.cut_fade', minimum=0)
|
|
875
|
+
if cut_fade > 0.25:
|
|
876
|
+
raise RnError('mix.cut_fade: up to 0.25 s (it removes clicks at hard cuts; longer fades are audio items)')
|
|
877
|
+
crf = int(_num(output.get('crf', 18), 'output.crf', minimum=0))
|
|
878
|
+
preset = output.get('preset', 'medium')
|
|
879
|
+
accept = raw.get('accept') or []
|
|
880
|
+
if not isinstance(accept, list) or not all(isinstance(a, dict) and isinstance(a.get('check'), str) for a in accept):
|
|
881
|
+
raise RnError('accept: [{"check": "dead_air", "at": 1.5, "note": "the pause before the drop"}, ...] ("at" optional)')
|
|
882
|
+
for a in accept:
|
|
883
|
+
if 'at' in a:
|
|
884
|
+
_num(a['at'], 'accept[].at', minimum=0)
|
|
885
|
+
unknown = set(raw) - {'output', 'tracks', 'audio', 'subtitles', 'mix', 'markers', 'note', 'version', 'accept'}
|
|
886
|
+
if unknown:
|
|
887
|
+
raise RnError(f'unknown top-level field(s) {sorted(unknown)}')
|
|
888
|
+
return Edit(path=path, width=width, height=height, fps=fps, background=background, crf=crf, preset=preset, base=base,
|
|
889
|
+
overlays=overlays, audio=audio, subtitles=subtitles, subtitle_style=style, lufs=lufs, true_peak=true_peak,
|
|
890
|
+
duck_db=duck, total_frames=total, warnings=warnings, cut_fade=cut_fade, accept=accept)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def seconds(frames: int, fps: Fraction) -> float:
|
|
894
|
+
return float(Fraction(frames) / fps)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def ceil_frames(seconds_value: float, fps: Fraction) -> int:
|
|
898
|
+
return math.ceil(seconds_value * fps - 1e-9)
|