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/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)