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/compare.py ADDED
@@ -0,0 +1,115 @@
1
+ """rn compare: two videos side by side at the same times (a cut against its reference, or two versions of a cut).
2
+ Measurement only: it shows where they differ in picture and cut timing, never which is better."""
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from .ff import RnError, probe, run, source_fps
10
+ from .frames import _compose, _save_under, _timecode, extract
11
+
12
+ PER_PAGE = 6 # times per page: A on the top row, B underneath
13
+ CUT_TOLERANCE = 0.25 # seconds: a cut in B this close to one in A counts as the same cut
14
+
15
+
16
+ def _video(path: str) -> dict:
17
+ info = probe(path)
18
+ if not info['video'] or info['video']['still']:
19
+ raise RnError(f'{path}: not a video')
20
+ return info
21
+
22
+
23
+ def match_cuts(a: list[float], b: list[float], tolerance: float = CUT_TOLERANCE) -> dict:
24
+ """Pairs each cut in A with the nearest unused cut in B within `tolerance`; offset = B - A in seconds."""
25
+ unused = list(b)
26
+ pairs = []
27
+ for t in a:
28
+ near = min(unused, key=lambda u: abs(u - t), default=None)
29
+ if near is not None and abs(near - t) <= tolerance:
30
+ unused.remove(near)
31
+ pairs.append({'a': t, 'b': near, 'offset': round(near - t, 4)})
32
+ else:
33
+ pairs.append({'a': t, 'b': None, 'offset': None})
34
+ matched = [p['offset'] for p in pairs if p['offset'] is not None]
35
+ return {'pairs': pairs, 'only_b': unused, 'matched': len(matched),
36
+ 'mean_abs_offset': round(sum(abs(o) for o in matched) / len(matched), 4) if matched else None}
37
+
38
+
39
+ def compare_command(path_a: str, path_b: str, *, out: str | None, count: int | None, seconds: list[float] | None, width: int,
40
+ max_kb: int, cuts: bool, video: str | None) -> dict:
41
+ from PIL import Image, ImageOps
42
+
43
+ info_a, info_b = _video(path_a), _video(path_b)
44
+ fps_a, fps_b = source_fps(info_a), source_fps(info_b)
45
+ frames_a, frames_b = info_a['video']['frames'], info_b['video']['frames']
46
+ length = max(float(frames_a / fps_a), float(frames_b / fps_b))
47
+ if seconds:
48
+ times = sorted({t for t in seconds if 0 <= t < length})
49
+ else:
50
+ n = max(1, count or 12)
51
+ times = [round(k * length / n, 3) for k in range(n)]
52
+ if not times:
53
+ raise RnError(f'no times inside the longer video ({length:.2f}s)')
54
+ index_a = {t: math.floor(t * float(fps_a) + 1e-6) for t in times}
55
+ index_b = {t: math.floor(t * float(fps_b) + 1e-6) for t in times}
56
+
57
+ base = Path(out) if out else Path('sheets') / f'compare-{Path(path_a).stem}-{Path(path_b).stem}.jpg'
58
+ base.parent.mkdir(parents=True, exist_ok=True)
59
+ pages = []
60
+ with tempfile.TemporaryDirectory() as tmp:
61
+ (Path(tmp) / 'a').mkdir()
62
+ (Path(tmp) / 'b').mkdir()
63
+ images = {}
64
+ for side, path, indices, frames in (('a', path_a, index_a, frames_a), ('b', path_b, index_b, frames_b)):
65
+ wanted = [i for i in indices.values() if i < frames]
66
+ if wanted: # the shorter video may have no frame at any of the chosen times
67
+ images.update({(side, i): Image.open(p).convert('RGB') for i, p in extract(path, wanted, Path(tmp) / side, width=width)})
68
+ tile_h = max(image.height for image in images.values())
69
+ blank = Image.new('RGB', (width, tile_h))
70
+ page_count = math.ceil(len(times) / PER_PAGE)
71
+ for page in range(page_count):
72
+ part = times[page * PER_PAGE:(page + 1) * PER_PAGE]
73
+ row_a = [ImageOps.pad(images[('a', index_a[t])], (width, tile_h)) if ('a', index_a[t]) in images else blank for t in part]
74
+ row_b = [ImageOps.pad(images[('b', index_b[t])], (width, tile_h)) if ('b', index_b[t]) in images else blank for t in part]
75
+ labels = [f'A #{index_a[t]} {_timecode(index_a[t], fps_a)}' for t in part] + \
76
+ [f'B #{index_b[t]} {_timecode(index_b[t], fps_b)}' for t in part]
77
+ target = base if page_count == 1 else base.with_name(f'{base.stem}-p{page + 1}{base.suffix or ".jpg"}')
78
+ title = f'top A {Path(path_a).name} ({fps_a}fps {frames_a}f) bottom B {Path(path_b).name} ({fps_b}fps {frames_b}f)'
79
+ size = _save_under(_compose([], fps_a, len(part), title, row_a + row_b, labels), target, max_kb)
80
+ pages.append({'path': str(target), 'times': part, 'bytes': size})
81
+
82
+ result: dict = {'a': {'path': path_a, 'fps': str(fps_a), 'frames': frames_a, 'duration': round(float(frames_a / fps_a), 4)},
83
+ 'b': {'path': path_b, 'fps': str(fps_b), 'frames': frames_b, 'duration': round(float(frames_b / fps_b), 4)},
84
+ 'pages': pages}
85
+ if cuts:
86
+ from .cuts import detect
87
+ cuts_a = [c['time'] for c in detect(str(Path(path_a).resolve()))['cuts']]
88
+ cuts_b = [c['time'] for c in detect(str(Path(path_b).resolve()))['cuts']]
89
+ result['cuts'] = {'a': cuts_a, 'b': cuts_b, **match_cuts(cuts_a, cuts_b)}
90
+ if video:
91
+ # Same height side by side, A's sound; the shorter one holds its last frame.
92
+ height = min(720, info_a['video']['height']) // 2 * 2
93
+ run(['ffmpeg', '-i', path_a, '-i', path_b, '-filter_complex',
94
+ f'[0:v]scale=-2:{height},setsar=1[a];[1:v]scale=-2:{height},setsar=1[b];[a][b]hstack=inputs=2[v]',
95
+ '-map', '[v]', *(['-map', '0:a:0', '-c:a', 'aac'] if info_a['audio'] else []), '-c:v', 'libx264', '-preset', 'veryfast',
96
+ '-crf', '23', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-y', video])
97
+ result['video'] = video
98
+ return result
99
+
100
+
101
+ def summary_lines(result: dict) -> list[str]:
102
+ a, b = result['a'], result['b']
103
+ lines = [f'A {a["path"]} {a["fps"]}fps {a["frames"]}f {a["duration"]}s B {b["path"]} {b["fps"]}fps {b["frames"]}f {b["duration"]}s']
104
+ lines += [f'{p["path"]} t={p["times"]} {p["bytes"] // 1024} KB' for p in result['pages']]
105
+ if 'cuts' in result:
106
+ c = result['cuts']
107
+ lines.append(f'cuts A {len(c["a"])}, B {len(c["b"])}: {c["matched"]} matched within {CUT_TOLERANCE}s'
108
+ + (f', mean |offset| {c["mean_abs_offset"]}s' if c['mean_abs_offset'] is not None else ''))
109
+ lines += [f' A {p["a"]}s -> B {p["b"]}s ({p["offset"]:+}s)' if p['b'] is not None else f' A {p["a"]}s -> no cut in B'
110
+ for p in c['pairs']]
111
+ if c['only_b']:
112
+ lines.append(f' only in B: {c["only_b"]}')
113
+ if 'video' in result:
114
+ lines.append(f'side by side: {result["video"]} (A left, B right)')
115
+ return lines
rn/cuts.py ADDED
@@ -0,0 +1,81 @@
1
+ """rn cuts: shot boundaries of a video, by decoded frame index. Measurement only: it reports where the picture
2
+ changes, never where to cut. Continuous camera moves and morphs score low; the suspect list is for a human-style look."""
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from functools import lru_cache
7
+ from pathlib import Path
8
+
9
+ from .ff import RnError, probe, run, source_fps
10
+
11
+ DEFAULT_THRESHOLD = 0.3
12
+ _FRAME = re.compile(r'^frame:(\d+)')
13
+ _SCORE = re.compile(r'lavfi\.scene_score=([0-9.]+)')
14
+
15
+
16
+ def scene_scores(path: str | Path) -> list[float]:
17
+ """ffmpeg's scene-change score (0..1) for every decoded frame, in decode order (frame 0 scores 0)."""
18
+ result = run(['ffmpeg', '-i', str(path), '-an', '-vf', "select='gte(scene,0)',metadata=print:key=lavfi.scene_score:file=-",
19
+ '-f', 'null', '-'])
20
+ scores: list[float] = []
21
+ for line in result.stdout.splitlines():
22
+ if (match := _FRAME.match(line)) and int(match.group(1)) == len(scores):
23
+ scores.append(0.0)
24
+ elif (match := _SCORE.search(line)) and scores:
25
+ scores[-1] = float(match.group(1))
26
+ if not scores:
27
+ raise RnError(f'{path}: no frames decoded')
28
+ return scores
29
+
30
+
31
+ @lru_cache(maxsize=32)
32
+ def detect(path: str, threshold: float = DEFAULT_THRESHOLD, min_gap: float = 0.3) -> dict:
33
+ info = probe(path)
34
+ if not info['video'] or info['video']['still']:
35
+ raise RnError(f'{path}: not a video')
36
+ fps = source_fps(info)
37
+ scores = scene_scores(path)
38
+ gap = max(1, round(min_gap * fps))
39
+ cuts, suspects = [], []
40
+ for index, score in enumerate(scores):
41
+ if index == 0:
42
+ continue
43
+ entry = {'frame': index, 'time': round(float(index / fps), 4), 'score': round(score, 3)}
44
+ if score >= threshold:
45
+ # A flash or a two-frame dissolve scores twice in a row; keep the stronger of close neighbours. The weaker
46
+ # stays a suspect: it may be a real 1-2 frame insert or a burst of micro-cuts.
47
+ if cuts and index - cuts[-1]['frame'] < gap:
48
+ if score > cuts[-1]['score']:
49
+ cuts[-1], entry = entry, cuts[-1]
50
+ suspects.append(entry)
51
+ continue
52
+ cuts.append(entry)
53
+ elif score >= threshold / 2:
54
+ suspects.append(entry)
55
+ suspects.sort(key=lambda s: s['frame'])
56
+ edges = [0, *(c['frame'] for c in cuts), len(scores)]
57
+ shots = [{'shot': i, 'start_frame': a, 'end_frame': b, 'start': round(float(a / fps), 4), 'end': round(float(b / fps), 4),
58
+ 'duration': round(float((b - a) / fps), 4)} for i, (a, b) in enumerate(zip(edges, edges[1:]))]
59
+ return {'path': str(path), 'fps': str(fps), 'frames': len(scores), 'threshold': threshold, 'cuts': cuts,
60
+ 'suspects': suspects, 'shots': shots}
61
+
62
+
63
+ def shot_span(path: str | Path, shot: int) -> tuple[float, float]:
64
+ """Seconds [start, end) of shot `shot` (0-based) with the default detector; used by EDL {"shot": n} positions."""
65
+ shots = detect(str(Path(path).resolve()))['shots']
66
+ if not 0 <= shot < len(shots):
67
+ raise RnError(f'{path}: shot {shot} does not exist (rn cuts found {len(shots)} shots: 0..{len(shots) - 1})')
68
+ return shots[shot]['start'], shots[shot]['end']
69
+
70
+
71
+ def cuts_command(path: str, *, threshold: float, min_gap: float, sheet: str | None, max_kb: int) -> dict:
72
+ result = dict(detect(str(Path(path).resolve()), threshold, min_gap))
73
+ result['path'] = path
74
+ if sheet:
75
+ from .frames import sheet_command
76
+ # Both sides of every cut and suspect, so a look confirms or rejects each one.
77
+ indices = sorted({i for c in result['cuts'] + result['suspects'] for i in (c['frame'] - 1, c['frame'])})
78
+ if indices:
79
+ result['sheet'] = sheet_command([path], out=sheet, every=None, rate=None, indices=indices, seconds=None, count=None,
80
+ width=192, columns=6, max_kb=max_kb)['pages']
81
+ return result
rn/editops.py ADDED
@@ -0,0 +1,252 @@
1
+ """rn edit: change an EDL by operations on clip ids instead of rewriting its JSON. Each operation is checked by laying the
2
+ whole edit out again (as rn assemble --dry-run would) and only then written back, so a bad step never lands.
3
+
4
+ The main track always plays back to back, so removing or trimming a clip there ripples everything after it; overlays and
5
+ audio anchored by clip id ({"clip": "id"}) follow, ones placed at fixed seconds do not.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from fractions import Fraction
12
+ from pathlib import Path
13
+
14
+ from .edl import Clip, load
15
+ from .ff import RnError
16
+
17
+
18
+ def _read(path: Path) -> dict:
19
+ try:
20
+ return json.loads(path.read_text())
21
+ except FileNotFoundError:
22
+ raise RnError(f'no such file: {path}') from None
23
+ except json.JSONDecodeError as error:
24
+ raise RnError(f'{path}: invalid JSON: {error}') from None
25
+
26
+
27
+ def _write_checked(path: Path, raw: dict) -> dict:
28
+ """Lays the edited JSON out next to the original (relative paths resolve the same), then replaces it."""
29
+ trial = path.with_name(f'.{path.stem}.rn-edit.json')
30
+ trial.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + '\n')
31
+ try:
32
+ summary = load(trial).summary()
33
+ except RnError:
34
+ trial.unlink()
35
+ raise
36
+ os.replace(trial, path)
37
+ return summary
38
+
39
+
40
+ def _containers(raw: dict) -> list[tuple[str, list]]:
41
+ tracks = raw.get('tracks') or []
42
+ return [*((f'tracks[{t}]', track.get('clips', [])) for t, track in enumerate(tracks)), ('audio', raw.get('audio') or [])]
43
+
44
+
45
+ def _locate(raw: dict, ref: str) -> tuple[str, list, int]:
46
+ """A clip by id, or by position as "track:index" (e.g. 0:3) / "audio:index"."""
47
+ if ':' in ref:
48
+ name, _, index = ref.partition(':')
49
+ lists = dict(_containers(raw))
50
+ key = 'audio' if name == 'audio' else f'tracks[{name}]'
51
+ if key not in lists or not index.isdigit() or int(index) >= len(lists[key]):
52
+ raise RnError(f'{ref}: no such clip (positions are "track:index" or "audio:index")')
53
+ return key, lists[key], int(index)
54
+ for key, items in _containers(raw):
55
+ for i, item in enumerate(items):
56
+ if isinstance(item, dict) and item.get('id') == ref:
57
+ return key, items, i
58
+ raise RnError(f'no clip with id "{ref}" (rn edit <edl> ids gives every clip one)')
59
+
60
+
61
+ def _all_ids(raw: dict) -> set[str]:
62
+ return {item.get('id') for _, items in _containers(raw) for item in items if isinstance(item, dict) and item.get('id')}
63
+
64
+
65
+ def _fresh(raw: dict, base: str) -> str:
66
+ taken = _all_ids(raw)
67
+ n = 2
68
+ while f'{base}-{n}' in taken:
69
+ n += 1
70
+ return f'{base}-{n}'
71
+
72
+
73
+ def _parsed(path: Path, key: str, index: int) -> tuple[Clip, Fraction]:
74
+ edit = load(path)
75
+ where = f'{key}.clips[{index}]'
76
+ clip = next((c for c in edit.base + edit.overlays if c.where == where), None)
77
+ if clip is None:
78
+ raise RnError(f'{where}: only video, image, sequence and colour clips can be split')
79
+ return clip, edit.fps
80
+
81
+
82
+ def ids_op(path: Path) -> dict:
83
+ raw = _read(path)
84
+ taken, added = _all_ids(raw), []
85
+ for key, items in _containers(raw):
86
+ prefix = 'a' if key == 'audio' else ('c' if key == 'tracks[0]' else 'o')
87
+ n = 1
88
+ for item in items:
89
+ if isinstance(item, dict) and not item.get('id'):
90
+ while f'{prefix}{n}' in taken:
91
+ n += 1
92
+ item['id'] = f'{prefix}{n}'
93
+ taken.add(item['id'])
94
+ added.append(item['id'])
95
+ summary = _write_checked(path, raw)
96
+ return {'op': 'ids', 'added': added, 'timeline': summary}
97
+
98
+
99
+ def _assign(target: dict, dotted: str, value) -> None:
100
+ *parents, last = dotted.split('.')
101
+ for name in parents:
102
+ target = target.setdefault(name, {})
103
+ if not isinstance(target, dict):
104
+ raise RnError(f'{dotted}: {name} is not an object')
105
+ target[last] = value
106
+
107
+
108
+ def set_op(path: Path, ref: str, assignments: list[str], unset: list[str]) -> dict:
109
+ """key=value with value as JSON when it parses (numbers, objects, true) and as a string otherwise; dotted keys reach
110
+ into objects (meta.label=hero, transition.duration=0.3)."""
111
+ raw = _read(path)
112
+ _, items, index = _locate(raw, ref)
113
+ item = items[index]
114
+ for assignment in assignments:
115
+ key, sep, text = assignment.partition('=')
116
+ if not sep or not key:
117
+ raise RnError(f'{assignment}: expected key=value')
118
+ try:
119
+ value = json.loads(text)
120
+ except json.JSONDecodeError:
121
+ value = text
122
+ _assign(item, key, value)
123
+ for key in unset:
124
+ *parents, last = key.split('.')
125
+ target = item
126
+ for name in parents:
127
+ target = target.get(name, {}) if isinstance(target, dict) else {}
128
+ if isinstance(target, dict):
129
+ target.pop(last, None)
130
+ return {'op': 'set', 'clip': ref, 'timeline': _write_checked(path, raw)}
131
+
132
+
133
+ def split_op(path: Path, ref: str, at: float, new_id: str | None) -> dict:
134
+ """Cuts a clip at timeline second `at` into two that play exactly as the one did."""
135
+ raw = _read(path)
136
+ key, items, index = _locate(raw, ref)
137
+ if key == 'audio':
138
+ raise RnError(f'{ref}: audio items are split by giving two items in/out ranges')
139
+ original = items[index]
140
+ for blocked in ('keys', 'ranges'):
141
+ if blocked in original:
142
+ raise RnError(f'{ref}: has "{blocked}", which are relative to the clip; split it by hand')
143
+ clip, fps = _parsed(path, key, index)
144
+ k = round(at * fps) - clip.timeline_start
145
+ if not 0 < k < clip.frames:
146
+ raise RnError(f'{ref}: {at}s is not inside the clip (timeline frames {clip.timeline_start}..{clip.timeline_start + clip.frames - 1})')
147
+ head_s, tail_s = float(k / fps), float((clip.frames - k) / fps)
148
+ first, second = dict(original), {k2: v for k2, v in original.items() if k2 not in ('transition', 'fade_in')}
149
+ first.pop('fade_out', None)
150
+ first.pop('until', None)
151
+ if clip.kind in ('video', 'sequence', 'edit'):
152
+ cut = round(clip.start_s + head_s * clip.speed, 6)
153
+ second['in'] = cut
154
+ if 'duration' in original or 'until' in original:
155
+ first['duration'] = round(head_s, 6)
156
+ if 'duration' in original:
157
+ second['duration'] = round(tail_s, 6)
158
+ else:
159
+ first['out'] = cut
160
+ else: # stills, colours and held frames: durations only
161
+ first['duration'] = round(head_s, 6)
162
+ first.pop('out', None)
163
+ if 'until' not in original:
164
+ second['duration'] = round(tail_s, 6)
165
+ second.pop('out', None)
166
+ if key != 'tracks[0]':
167
+ second['at'] = round(float((clip.timeline_start + k) / fps), 6)
168
+ if original.get('id'):
169
+ second['id'] = new_id or _fresh(raw, original['id'])
170
+ elif new_id:
171
+ second['id'] = new_id
172
+ items[index:index + 1] = [first, second]
173
+ return {'op': 'split', 'clip': ref, 'into': [first.get('id'), second.get('id')], 'timeline': _write_checked(path, raw)}
174
+
175
+
176
+ def delete_op(path: Path, ref: str, lift: bool) -> dict:
177
+ """Removes a clip. On the main track the rest ripples left; --lift leaves a background-coloured gap instead."""
178
+ raw = _read(path)
179
+ key, items, index = _locate(raw, ref)
180
+ if lift:
181
+ if key != 'tracks[0]':
182
+ raise RnError('--lift is for the main track (overlays and audio leave no gap to fill)')
183
+ clip, fps = _parsed(path, key, index)
184
+ gap = {'color': (raw.get('output') or {}).get('background', '#000000'), 'duration': round(float(clip.frames / fps), 6)}
185
+ items[index] = gap
186
+ else:
187
+ del items[index]
188
+ return {'op': 'delete', 'clip': ref, 'lift': lift, 'timeline': _write_checked(path, raw)}
189
+
190
+
191
+ def _destination(raw: dict, before: str | None, after: str | None, track: str | None) -> tuple[list, int]:
192
+ if sum(x is not None for x in (before, after, track)) != 1:
193
+ raise RnError('give exactly one of --before ID, --after ID or --track N|audio')
194
+ if track is not None:
195
+ if track == 'audio':
196
+ return raw.setdefault('audio', []), len(raw['audio'])
197
+ if not track.isdigit():
198
+ raise RnError('--track: a track number (0 = main) or "audio"')
199
+ tracks = raw.setdefault('tracks', [])
200
+ number = int(track)
201
+ if number == len(tracks):
202
+ tracks.append({'clips': []})
203
+ if number >= len(tracks):
204
+ raise RnError(f'--track {number}: tracks are 0..{len(tracks) - 1}; {len(tracks)} adds one')
205
+ return tracks[number].setdefault('clips', []), len(tracks[number]['clips'])
206
+ _, items, index = _locate(raw, before or after)
207
+ return items, index + (1 if after else 0)
208
+
209
+
210
+ def insert_op(path: Path, clip_json: str, before: str | None, after: str | None, track: str | None) -> dict:
211
+ """Adds a clip given as JSON (or @file.json)."""
212
+ text = Path(clip_json[1:]).read_text() if clip_json.startswith('@') else clip_json
213
+ try:
214
+ clip = json.loads(text)
215
+ except json.JSONDecodeError as error:
216
+ raise RnError(f'--clip: invalid JSON: {error}') from None
217
+ if not isinstance(clip, dict):
218
+ raise RnError('--clip: one clip object')
219
+ raw = _read(path)
220
+ items, index = _destination(raw, before, after, track)
221
+ items.insert(index, clip)
222
+ return {'op': 'insert', 'clip': clip.get('id'), 'timeline': _write_checked(path, raw)}
223
+
224
+
225
+ def move_op(path: Path, ref: str, before: str | None, after: str | None, track: str | None) -> dict:
226
+ raw = _read(path)
227
+ _, items, index = _locate(raw, ref)
228
+ clip = items.pop(index)
229
+ marker = object() # keep the clip's slot while the destination is looked up, so --before its neighbour works
230
+ items.insert(index, marker)
231
+ target, position = _destination(raw, before, after, track)
232
+ target.insert(position, clip)
233
+ for container in (items, target):
234
+ if marker in container:
235
+ container.remove(marker)
236
+ return {'op': 'move', 'clip': ref, 'timeline': _write_checked(path, raw)}
237
+
238
+
239
+ def summary_lines(result: dict) -> list[str]:
240
+ head = {'ids': lambda r: f'ids added: {", ".join(r["added"]) or "none (every clip has one)"}',
241
+ 'set': lambda r: f'set {r["clip"]}',
242
+ 'split': lambda r: f'split {r["clip"]} into {r["into"][0]} + {r["into"][1]}',
243
+ 'delete': lambda r: f'deleted {r["clip"]}' + (' (lifted: gap kept)' if r['lift'] else ''),
244
+ 'insert': lambda r: f'inserted {r["clip"] or "clip"}',
245
+ 'move': lambda r: f'moved {r["clip"]}'}[result['op']](result)
246
+ out = result['timeline']['output']
247
+ lines = [head, f'timeline {out["width"]}x{out["height"]} {out["fps"]}fps {out["frames"]} frames {out["duration"]}s']
248
+ lines += [f' {c["id"] or c["where"]}: frames {c["start_frame"]}..{c["start_frame"] + c["frames"] - 1}' for c in result['timeline']['base']]
249
+ lines += [f' {c["id"] or c["where"]}: overlay frames {c["start_frame"]}..{c["start_frame"] + c["frames"] - 1}'
250
+ for c in result['timeline']['overlays']]
251
+ lines += [f'warning: {w}' for w in result['timeline']['warnings']]
252
+ return lines