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