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/cli.py ADDED
@@ -0,0 +1,557 @@
1
+ """rn: Renoise media kit. Measure (probe, frame, sheet, cuts, listen, faces, ref, signal, align, text, matte), render
2
+ (capture, fx, fonts, assets, captions), edit and assemble an EDL frame-exactly, compare, finish, QA and export. It measures
3
+ and guarantees the mechanics; every creative decision stays yours.
4
+
5
+ Every command prints a short summary; --json FILE writes the full result. Failures exit non-zero with
6
+ `rn: error: <reason>` on the last line. Contract: SPEC.md in the renoise-kit repository.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import shutil
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from . import __version__
17
+ from .ff import RnError
18
+
19
+ EPILOG = 'Times are seconds. Run `rn <command> --help` for details.'
20
+
21
+
22
+ def _numbers(kind):
23
+ def parse(text: str):
24
+ try:
25
+ return [kind(part) for part in text.split(',') if part.strip()]
26
+ except ValueError:
27
+ raise argparse.ArgumentTypeError(f'expected comma-separated {kind.__name__}s') from None
28
+ return parse
29
+
30
+
31
+ def _parser() -> argparse.ArgumentParser:
32
+ parser = argparse.ArgumentParser(prog='rn', description=__doc__, epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
33
+ parser.add_argument('--version', action='version', version=f'rn {__version__}')
34
+ sub = parser.add_subparsers(dest='command', required=True, metavar='command')
35
+
36
+ def add(name: str, help_text: str, description: str) -> argparse.ArgumentParser:
37
+ command = sub.add_parser(name, help=help_text, description=description, formatter_class=argparse.RawDescriptionHelpFormatter)
38
+ command.add_argument('--json', metavar='FILE', help='write the full result as JSON')
39
+ return command
40
+
41
+ probe = add('probe', 'facts an edit depends on (prints JSON)',
42
+ 'Decoded frame count, rational fps, size/SAR/pix_fmt/color tags, video and audio durations, faststart.\n'
43
+ 'The frame count comes from packets (or a full decode with --exact), never from the container duration.')
44
+ probe.add_argument('file')
45
+ probe.add_argument('--exact', action='store_true', help='decode every frame to count (slower, exact for any stream)')
46
+
47
+ frame = add('frame', 'one exact frame by index, time or last',
48
+ 'Extract one frame by its decoded index (0-based). Use --last for a tail frame to continue a shot,\n'
49
+ '--time T for the frame on screen at T seconds. Decodes from the start: exact, never off by one.')
50
+ frame.add_argument('file')
51
+ pick = frame.add_mutually_exclusive_group(required=True)
52
+ pick.add_argument('--index', type=int)
53
+ pick.add_argument('--time', type=float)
54
+ pick.add_argument('--last', action='store_true')
55
+ frame.add_argument('-o', '--out', required=True, help='.png (lossless) or .jpg')
56
+
57
+ sheet = add('sheet', 'labelled contact sheets small enough to view',
58
+ 'Tiles labelled with frame index and timecode; pages of up to 24 tiles, each JPEG under --max-kb\n'
59
+ '(default 125 KB, so four pages fit one sandbox_exec image_paths view). Default: 16 evenly spaced frames.\n'
60
+ 'Examples:\n rn sheet take.mp4 --fps 4 # pick in-points from a generated take\n'
61
+ ' rn sheet ref.mp4 --every 1 -o ref.jpg # every frame of a reference, paged\n'
62
+ ' rn sheet out.mp4 --indices 0,47,48,95 # both sides of each cut')
63
+ sheet.add_argument('files', nargs='+', help='videos, or folders of stills (tiled in name order, labelled by file name)')
64
+ choose = sheet.add_mutually_exclusive_group()
65
+ choose.add_argument('--every', type=int, metavar='N', help='every Nth frame')
66
+ choose.add_argument('--fps', type=float, metavar='R', help='R frames per second of video')
67
+ choose.add_argument('--indices', type=_numbers(int), metavar='A,B,..', help='exact frame indices')
68
+ choose.add_argument('--seconds', type=_numbers(float), metavar='T1,T2,..', help='frames on screen at these times')
69
+ choose.add_argument('--count', type=int, metavar='N', help='N evenly spaced frames (default 16)')
70
+ sheet.add_argument('-o', '--out', help='output .jpg (pages get -p2, -p3...; several inputs get -<name>); default sheets/<name>.jpg')
71
+ sheet.add_argument('--width', type=int, default=256, help='tile width in px (default 256)')
72
+ sheet.add_argument('--columns', type=int, default=6)
73
+ sheet.add_argument('--max-kb', type=int, default=125)
74
+ sheet.add_argument('--around', type=_numbers(float), metavar='T1,T2,..', help='dense frames around these times (cuts, transitions, beats)')
75
+ sheet.add_argument('--span', type=int, default=3, help='frames on each side for --around (default 3)')
76
+ sheet.add_argument('--diff', action='store_true', help='show |frame - previous frame| x4: motion, flicker, repeated frames')
77
+
78
+ cuts = add('cuts', 'shot boundaries by decoded frame (+ suspects to check)',
79
+ 'Scene-change scores per decoded frame; a cut where the score passes --threshold. Continuous camera moves\n'
80
+ 'and morphs score low, so weaker changes are listed as suspects: look at --sheet before trusting either.\n'
81
+ 'The shots it finds are what EDL positions {"shot": n} mean.')
82
+ cuts.add_argument('file')
83
+ cuts.add_argument('--threshold', type=float, default=0.3)
84
+ cuts.add_argument('--min-gap', type=float, default=0.3, help='seconds; closer cuts keep the stronger one')
85
+ cuts.add_argument('--sheet', metavar='JPG', help='contact sheet of both sides of every cut and suspect')
86
+ cuts.add_argument('--max-kb', type=int, default=125)
87
+
88
+ listen = add('listen', 'audio as facts + one picture (you cannot hear)',
89
+ 'Loudness (integrated, true peak, short-term curve), silences, onsets, tempo with half/double\n'
90
+ 'alternatives and beat times, and with --transcribe word timestamps (faster-whisper). One image on a\n'
91
+ 'shared time axis: waveform, spectrogram, loudness, onset and beat ticks. Facts only, no cut suggestions.\n'
92
+ 'Its --json feeds EDL anchors {"beat": n, "listen": ...} / {"word": n, ...} and rn captions.')
93
+ listen.add_argument('file')
94
+ listen.add_argument('-o', '--out', help='image path (default listen/<name>.jpg)')
95
+ listen.add_argument('--transcribe', action='store_true')
96
+ listen.add_argument('--language', help='e.g. zh, ja, en (default: detect)')
97
+ listen.add_argument('--model', default='small', help='faster-whisper model (small is baked into the sandbox)')
98
+ listen.add_argument('--max-kb', type=int, default=125)
99
+
100
+ faces = add('faces', 'face boxes and landmarks per frame (measurement only)',
101
+ 'YuNet face boxes, eyes, nose and mouth corners in source pixels per sampled frame, plus the union\n'
102
+ 'box. For keeping captions and graphics off faces or following a subject with a separate layer; it never\n'
103
+ 'alters a face.')
104
+ faces.add_argument('file')
105
+ faces.add_argument('--every', type=int, default=1)
106
+ faces.add_argument('--min-score', type=float, default=0.6)
107
+ faces.add_argument('--sheet', metavar='JPG', help='contact sheet with the boxes drawn')
108
+ faces.add_argument('--model')
109
+ faces.add_argument('--max-kb', type=int, default=125)
110
+
111
+ ref = add('ref', 'break down a reference edit: shots, audio, every frame',
112
+ 'Writes ref/<name>/: ref.json (probe, shots, cuts, suspects, listen), cuts.jpg, listen.jpg and\n'
113
+ 'frames*.jpg covering every --every-th frame in viewable pages. Interpretation is yours.')
114
+ ref.add_argument('file')
115
+ ref.add_argument('-o', '--out', help='folder (default ref/<name>)')
116
+ ref.add_argument('--every', type=int, default=1)
117
+ ref.add_argument('--transcribe', action='store_true')
118
+ ref.add_argument('--language')
119
+ ref.add_argument('--model', default='small')
120
+ ref.add_argument('--threshold', type=float, default=0.3)
121
+
122
+ capture = add('capture', 'render an HTML page frame by frame to RGBA PNGs',
123
+ 'The page defines window.renderFrame(i) (may be async) and draws frame i completely: the frame index is\n'
124
+ 'the time, so frames are exact and any order or shard gives the same pixels. window.RN = {fps, frames,\n'
125
+ 'width, height, shard} and window.RNFX (rn fx) exist before the page runs; window.rnReady (a Promise) is\n'
126
+ 'awaited once. Transparent where the page does not paint. Put the result in an EDL as\n'
127
+ '{"frames": "<dir>/%05d.png", "fps": R}. Example:\n'
128
+ ' <canvas id=c></canvas><script>const x = c.getContext("2d"); c.width = RN.width; c.height = RN.height;\n'
129
+ ' window.renderFrame = i => { x.clearRect(0, 0, c.width, c.height); /* draw frame i */ RNFX.grain(x, i, {}); };</script>')
130
+ capture.add_argument('page')
131
+ capture.add_argument('-o', '--out', required=True, help='output folder (its PNGs are replaced)')
132
+ capture.add_argument('--frames', type=int, required=True)
133
+ capture.add_argument('--size', default='1080x1920', metavar='WxH')
134
+ capture.add_argument('--fps', type=float, default=30)
135
+ capture.add_argument('--shards', type=int, default=4, help='parallel Chromium instances (each boots in 1-3 s)')
136
+ capture.add_argument('--start', type=int, default=0, help='first frame index (render a part)')
137
+
138
+ signal = add('signal', 'frame-by-frame numbers: luma, motion, saturation, loudness, bands, onsets, clicks',
139
+ 'One series per quantity on the video\'s frame clock (30/s for audio alone): luma, frame difference and\n'
140
+ 'saturation (0..1); rms, low/mid/high band levels (dB) and onset (dB of spectral rise); plus flash frames\n'
141
+ 'and click times. Flashes, freezes, pacing, beats and music-driven motion all read from it. -o draws them.')
142
+ signal.add_argument('file')
143
+ signal.add_argument('-o', '--out', metavar='PNG', help='plot the series on one time axis (needs matplotlib: extra listen)')
144
+
145
+ align = add('align', 'where recording B sits inside A, by sound (offset + how sure)',
146
+ 'Cross-correlates onset envelopes, then refines on the waveform: a song inside a reference edit, camera\n'
147
+ 'angles, a separately recorded voice. "match" is the waveform correlation at that offset (near 1: the same\n'
148
+ 'sound); "distinct" how far ahead of other offsets it is (low for repetitive music).')
149
+ align.add_argument('a')
150
+ align.add_argument('b')
151
+ align.add_argument('--max-offset', type=float, metavar='S', help='only consider offsets within +-S seconds')
152
+
153
+ matte = add('matte', 'subject alpha per frame (rembg) as a grey PNG sequence for EDL masks',
154
+ 'White = subject. Use it as an overlay "mask" ("invert": true keeps graphics off the subject, e.g. text\n'
155
+ 'behind a person) or --cutout for RGBA cut-outs to place as an overlay. Check edges on a sheet.')
156
+ matte.add_argument('file')
157
+ matte.add_argument('-o', '--out', required=True, help='output folder (its PNGs are replaced)')
158
+ matte.add_argument('--model', default='isnet-general-use', help='rembg model: isnet-general-use (any subject), u2net_human_seg (people), ...')
159
+ matte.add_argument('--every', type=int, default=1, help='compute every Nth frame and hold in between (faster, less exact)')
160
+ matte.add_argument('--cutout', action='store_true', help='write the subject as RGBA instead of the grey matte')
161
+
162
+ text = add('text', 'words on screen and when they are there (tesseract OCR)',
163
+ 'Reads every --every-th frame (default two per second); lines with boxes per frame, and spans: the same\n'
164
+ 'line read in consecutive samples. OCR misreads stylised type: boxes are where to look, strings a draft.')
165
+ text.add_argument('file')
166
+ text.add_argument('--every', type=int, metavar='N')
167
+ text.add_argument('--lang', help='tesseract languages, e.g. eng+chi_sim (default: those installed of eng, chi_sim, chi_tra, jpn, kor)')
168
+ text.add_argument('--min-conf', type=float, default=50, help='drop words below this confidence (0-100)')
169
+
170
+ export = add('export', 'the EDL as an editable project: .otio, .fcpxml (Final Cut), .xml (Premiere, Resolve)',
171
+ 'Cuts, trims, speed, transitions, nested edits, overlay and audio timing carry over, media by absolute path.\n'
172
+ 'Position/size, keys, blend, masks, raw filters and gain curves are kept as "rn" metadata and listed.')
173
+ export.add_argument('edl')
174
+ export.add_argument('-o', '--out', required=True, help='.otio, .fcpxml or .xml')
175
+ export.add_argument('--srt', metavar='FILE', help='also write the subtitles as SRT')
176
+
177
+ fx = add('fx', 'list the effects library (grain, leak, flash, glitch, ...)',
178
+ 'Readable, editable source: RNFX.<name> in pages for rn capture (fx.js), rn.fx.<name> for numpy frames in\n'
179
+ 'Python. Call it, copy and change it, or ignore it; defaults are engine defaults, not a style.')
180
+ fx.add_argument('--source', action='store_true', help='print where fx.js and rn/fx/__init__.py live')
181
+
182
+ fonts = add('fonts', 'installed font families, real weights and scripts',
183
+ 'From fontconfig: CSS weights that actually exist (a missing weight falls back silently), variable\n'
184
+ 'fonts, covered scripts (latin, zh-hans, zh-hant, ja, ko, emoji, ...). --licenses reads each font\'s licence.')
185
+ fonts.add_argument('--family')
186
+ fonts.add_argument('--script')
187
+ fonts.add_argument('--licenses', action='store_true')
188
+
189
+ assets = add('assets', 'licensed sound effects and overlay footage from the Renoise library',
190
+ 'rn assets search "whoosh fast" --kind sfx ; rn assets get <id>... -o assets/\n'
191
+ 'Files are checked against their sha256 and cached; the licence comes with each file.')
192
+ assets.add_argument('action', choices=['search', 'get'])
193
+ assets.add_argument('terms', nargs='*', help='search words, or asset ids for get')
194
+ assets.add_argument('--kind', choices=['sfx', 'overlay', 'texture', 'music'])
195
+ assets.add_argument('--limit', type=int, default=20)
196
+ assets.add_argument('-o', '--out', default='assets')
197
+
198
+ captions = add('captions', 'timed, chunked, face-aware captions from rn listen words',
199
+ 'Mechanical only: align your script to the transcript timing, chunk lines (CJK by character with\n'
200
+ 'kinsoku, Latin by word), keep to platform safe areas and off faces. Writes .ass (one plain readable\n'
201
+ 'style), .srt and .json with per-word times to drive your own styled captions.')
202
+ captions.add_argument('words', help='rn listen --transcribe --json output')
203
+ captions.add_argument('-o', '--out', required=True, help='.ass path (.srt and .json alongside)')
204
+ captions.add_argument('--size', required=True, metavar='WxH', help='output video size')
205
+ captions.add_argument('--script', help='text file with the intended wording')
206
+ captions.add_argument('--faces', help='rn faces --json output of the footage')
207
+ captions.add_argument('--max-chars', type=int)
208
+ captions.add_argument('--max-lines', type=int, default=2)
209
+ captions.add_argument('--safe', default='auto', choices=['auto', 'tiktok', 'reels', 'youtube', 'none'])
210
+ captions.add_argument('--language')
211
+
212
+ assemble = add('assemble', 'render an EDL (edit.json) to a finished MP4',
213
+ 'Frame-exact assembly: cuts, speed, fit, transitions, overlay tracks, subtitles, clip audio + music/voice/sfx,\n'
214
+ 'ducking, LUFS target and encoded true peak <= limit. Output: H.264 High yuv420p bt709, moov first.\n'
215
+ 'The EDL handles the mechanical assembly; render anything creative with your own code (rn capture for\n'
216
+ 'HTML/canvas, numpy + rn.fx, Manim, Blender) to a PNG sequence or alpha clip and place it in the EDL.\n\n'
217
+ 'edit.json (paths relative to it; times in seconds; {"frame": n} also works for in/out;\n'
218
+ '# marks notes below, not JSON; a "note" field is allowed on any clip):\n'
219
+ '{\n'
220
+ ' "output": {"width": 1080, "height": 1920, "fps": 30, "background": "#000000", "crf": 18},\n'
221
+ ' "tracks": [\n'
222
+ ' {"clips": [ # track 0 = main track, clips back to back\n'
223
+ ' {"src": "s1.mp4", "in": 0.4, "out": 3.2, "fit": "cover"},\n'
224
+ ' {"src": "s2.mp4", "in": {"frame": 6}, "duration": 2.5, "speed": 0.5, "audio": false,\n'
225
+ ' "transition": {"type": "fade", "duration": 0.4}}, # any xfade name: dissolve, wipeleft, slideup...\n'
226
+ ' {"image": "card.png", "duration": 1.5, "fit": "contain"},\n'
227
+ ' {"color": "#000000", "duration": 0.5}]},\n'
228
+ ' {"clips": [ # later tracks = overlays, positioned in time and space\n'
229
+ ' {"image": "title.png", "at": 0.5, "duration": 2, "x": 90, "y": 1400, "w": 900,\n'
230
+ ' "opacity": 1, "fade_in": 0.2, "fade_out": 0.2}]}],\n'
231
+ ' {"clips": [{"frames": "title/%05d.png", "fps": 30, "at": {"clip": 1, "offset": 0.2},\n'
232
+ ' "until": {"clip_end": 1}, "keys": {"scale": [[0, 0.9], [0.3, 1, "out3"]], "opacity": [[0, 0], [0.2, 1]]}}]}],\n'
233
+ ' "markers": {"drop": 4.5}, # named times for anchors\n'
234
+ ' "audio": [{"src": "music.mp3", "role": "music", "gain_db": -6, "fade_out": 1.5},\n'
235
+ ' {"src": "vo.wav", "role": "voice", "at": 0.3}], # roles: music | voice | sfx\n'
236
+ ' "subtitles": {"file": "captions.ass"},\n'
237
+ ' "mix": {"lufs": -14, "true_peak": -1.0, "duck_db": 10} # duck music under voice\n'
238
+ '}\n'
239
+ 'Positions: in/out take seconds, {"frame": n} or {"shot": n, "offset": s} (shots from rn cuts).\n'
240
+ 'Overlay "at"/"until" and audio "at" take seconds or an anchor + "offset": {"clip": i or "id"},\n'
241
+ '{"clip_end": i or "id"} (main-track clip as laid out, or an earlier overlay by id), {"marker": name},\n'
242
+ '{"frame": n}, {"beat": n, "listen": f.json}, {"word": n, "listen": f.json, "edge": "start"|"end"}.\n'
243
+ 'Keys [t, value, ease?] (t from the clip start; eases linear in out inout in3 out3 inout3 hold):\n'
244
+ 'main track scale/x/y/rotate (x, y as fractions of the frame), speed (a ramp; drops the clip\'s sound),\n'
245
+ 'gain_db; overlays scale/x/y/rotate/opacity/speed (x, y in px, about the layer centre); audio gain_db.\n'
246
+ 'Keep main-track scale >= 1: edges smear.\n'
247
+ 'Any clip or audio item: "id" (anchors, rn edit), "meta" (anything, kept as is), "vf" / "af" (one raw\n'
248
+ 'ffmpeg filter chain, e.g. "eq=saturation=1.2,unsharp"; af on main-track clips and audio).\n'
249
+ 'Main track: "ranges": [[in, out], ...] plays spans of one source back to back; "src": "part.json"\n'
250
+ 'plays another EDL. Overlays: "blend": screen|multiply|add|overlay|... ; "mask": {"frames"|"image"|"src":\n'
251
+ '..., "mode": "luma"|"alpha", "invert": bool} (full frame, e.g. from rn matte). Overlays and audio:\n'
252
+ '"pivot": s puts that point of the source (a hit, a downbeat) on "at". "mix": {"cut_fade": 0.01} fades\n'
253
+ 'every hard cut\'s own sound. "accept": [{"check", "at", "note"}] waives qa warnings judged intended.')
254
+ assemble.add_argument('edl')
255
+ assemble.add_argument('-o', '--out', default='out.mp4')
256
+ assemble.add_argument('--dry-run', action='store_true', help='validate and print the timeline without rendering')
257
+ assemble.add_argument('--keep', action='store_true', help='keep per-clip slots and the mix next to the output (<out>.parts/)')
258
+ assemble.add_argument('--preview', action='store_true',
259
+ help='half-size draft with a fast encode: check timing and layout before the full render (not for delivery)')
260
+ assemble.add_argument('--placeholders', action='store_true',
261
+ help='a missing source becomes a card (id, meta.label or meta.prompt) for its time span instead of an error')
262
+ assemble.add_argument('--range', metavar='A-B', help='render only seconds A to B of the timeline (laid out in full, so anchors hold)')
263
+ assemble.add_argument('--no-cache', action='store_true',
264
+ help='render every slot afresh (default: reuse slots and nested edits from .rn-cache next to the EDL, or RN_CACHE)')
265
+
266
+ edit = add('edit', 'change an EDL by operations on clip ids (checked before writing)',
267
+ 'Each operation lays the whole edit out again and writes the file only if it is valid. Clips are addressed\n'
268
+ 'by "id" (rn edit e.json ids gives every clip one) or by position "track:index" / "audio:index".\n'
269
+ 'The main track plays back to back: deleting or trimming there ripples what follows; overlays and audio\n'
270
+ 'anchored with {"clip": "<id>"} follow, ones at fixed seconds stay. Examples:\n'
271
+ ' rn edit e.json ids\n'
272
+ ' rn edit e.json split c3 --at 4.2\n'
273
+ ' rn edit e.json set c3 out=5.5 meta.take=2 --unset speed\n'
274
+ ' rn edit e.json delete c4 --lift\n'
275
+ ' rn edit e.json insert --after c2 --clip \'{"id": "c9", "src": "s9.mp4", "out": 2}\'\n'
276
+ ' rn edit e.json move t1 --track 2')
277
+ edit.add_argument('edl')
278
+ ops = edit.add_subparsers(dest='op', required=True, metavar='op')
279
+ ops.add_parser('ids', help='give every clip and audio item without an id one (c1.. main, o1.. overlays, a1.. audio)')
280
+ op_set = ops.add_parser('set', help='set fields: key=value (JSON when it parses), dotted keys reach into objects')
281
+ op_set.add_argument('ref')
282
+ op_set.add_argument('assignments', nargs='*', metavar='key=value')
283
+ op_set.add_argument('--unset', action='append', default=[], metavar='KEY')
284
+ op_split = ops.add_parser('split', help='cut a clip in two at a timeline time; both halves play as the one did')
285
+ op_split.add_argument('ref')
286
+ op_split.add_argument('--at', type=float, required=True, help='timeline seconds')
287
+ op_split.add_argument('--id', help='id for the second half (default <id>-2)')
288
+ op_delete = ops.add_parser('delete', help='remove a clip (main track ripples; --lift keeps a gap)')
289
+ op_delete.add_argument('ref')
290
+ op_delete.add_argument('--lift', action='store_true')
291
+ for name, text in (('insert', 'add a clip given as JSON or @file.json'), ('move', 'move a clip next to another or onto a track')):
292
+ op = ops.add_parser(name, help=text)
293
+ if name == 'insert':
294
+ op.add_argument('--clip', required=True, help='clip JSON or @file.json')
295
+ else:
296
+ op.add_argument('ref')
297
+ op.add_argument('--before', metavar='ID')
298
+ op.add_argument('--after', metavar='ID')
299
+ op.add_argument('--track', metavar='N|audio', help='append to track N (one past the last adds a track) or to audio')
300
+
301
+ compare = add('compare', 'two videos side by side at the same times (+ cut timing)',
302
+ 'A cut against its reference, or two versions of a cut: frames at the same times, A on the top row and B\n'
303
+ 'underneath, labelled with each one\'s own frame index. --cuts runs rn cuts on both and pairs the cuts\n'
304
+ '(within 0.25 s) with their offsets: the rhythm comparison. --video writes A|B side by side to watch.\n'
305
+ 'Differences are facts to look at, not scores.')
306
+ compare.add_argument('a')
307
+ compare.add_argument('b')
308
+ compare_times = compare.add_mutually_exclusive_group()
309
+ compare_times.add_argument('--count', type=int, metavar='N', help='N evenly spaced times (default 12)')
310
+ compare_times.add_argument('--seconds', type=_numbers(float), metavar='T1,T2,..', help='exact times')
311
+ compare.add_argument('-o', '--out', help='output .jpg (pages get -p2, ...); default sheets/compare-<a>-<b>.jpg')
312
+ compare.add_argument('--width', type=int, default=256, help='tile width in px (default 256)')
313
+ compare.add_argument('--max-kb', type=int, default=125)
314
+ compare.add_argument('--cuts', action='store_true', help='compare shot boundaries (rn cuts defaults)')
315
+ compare.add_argument('--video', metavar='MP4', help='also write a side-by-side video (A left, B right, A\'s sound)')
316
+
317
+ finish = add('finish', 'bring any video to the delivery standard',
318
+ 'For a video you built yourself: H.264 High yuv420p bt709, even size, square pixels, moov first,\n'
319
+ 'loudness to --lufs and the ENCODED true peak <= --true-peak (AAC adds 1-2 dB of overshoot).')
320
+ finish.add_argument('file')
321
+ finish.add_argument('-o', '--out', required=True)
322
+ finish.add_argument('--lufs', default='-14', help='integrated loudness target, or "keep" to leave the level as is (default -14)')
323
+ finish.add_argument('--true-peak', type=float, default=-1.0)
324
+ finish.add_argument('--crf', type=int, default=18)
325
+ finish.add_argument('--preset', default='medium')
326
+ finish.add_argument('--copy-video', action='store_true', help='keep the video stream when it is already H.264 yuv420p')
327
+ finish.add_argument('--height', type=int, metavar='N', help='scale to N px high, aspect kept (e.g. a 1080p copy of a 4K master)')
328
+ finish.add_argument('--audio', metavar='FILE', help='take the sound from FILE instead (e.g. a separate WAV mix from the renderer)')
329
+ finish.add_argument('--audio-offset', type=float, default=0.0, metavar='S',
330
+ help='move the sound S seconds later (negative: earlier), e.g. -0.0437 to undo an uncompensated AAC delay')
331
+
332
+ qa = add('qa', 'delivery checks on an encoded file (exit 1 on FAIL)',
333
+ 'Frame count, fps, size, codec, pix_fmt, SAR, color tags, faststart, audio/video duration, container\n'
334
+ 'duration, true peak, loudness, dead air, level jumps, clicks, black frames, frozen frames, black bars,\n'
335
+ 'fixed frame rate. With --edl the expected frames,\n'
336
+ 'size, fps and loudness come from the edit, and its color/still clips are not flagged. FAIL blocks\n'
337
+ 'delivery; WARN means look at it (it may be intended). Check fixes on the new render, not the old one.')
338
+ qa.add_argument('file')
339
+ qa.add_argument('--edl')
340
+ qa.add_argument('--expect-frames', type=int)
341
+ qa.add_argument('--expect-size', metavar='WxH')
342
+ qa.add_argument('--expect-fps', metavar='R')
343
+ qa.add_argument('--lufs', type=float, help='loudness target to check against')
344
+ qa.add_argument('--lufs-tolerance', type=float, default=2.0)
345
+ qa.add_argument('--true-peak', type=float, help='limit in dBTP (default -1.0)')
346
+ qa.add_argument('--require-audio', action='store_true')
347
+ qa.add_argument('--accept', action='append', default=[], metavar='CHECK[@T]',
348
+ help='a WARN judged intended: the whole check, or the finding at T seconds (EDL: "accept": [{"check", "at", "note"}])')
349
+ return parser
350
+
351
+
352
+ def _print_summary(command: str, result: dict) -> None:
353
+ if command == 'probe':
354
+ print(json.dumps(result, indent=1, ensure_ascii=False))
355
+ elif command == 'frame':
356
+ print(f'frame {result["index"]} ({result["time"]}s) -> {result["path"]}')
357
+ elif command == 'sheet':
358
+ for page in result['pages']:
359
+ indices = page['indices']
360
+ print(f'{page["path"]} {len(indices)} frames #{indices[0]}..#{indices[-1]} {page["bytes"] // 1024} KB')
361
+ elif command == 'assemble':
362
+ out = result['output']
363
+ if 'path' in result:
364
+ print(f'{result["path"]} {out["width"]}x{out["height"]} {out["fps"]}fps {out["frames"]} frames {out["duration"]}s'
365
+ + (' (preview: draft size and encode, not for delivery)' if result.get('preview') else '')
366
+ + (f' (range: timeline frames {result["range"]["start_frame"]}..{result["range"]["end_frame"] - 1})' if result.get('range') else ''))
367
+ if result.get('audio'):
368
+ audio = result['audio']
369
+ print(f'audio {audio["lufs"]} LUFS, true peak {audio["true_peak"]} dBTP, gain {audio["gain_db"]} dB')
370
+ else:
371
+ print(f'timeline {out["width"]}x{out["height"]} {out["fps"]}fps {out["frames"]} frames {out["duration"]}s')
372
+ for clip in result['base']:
373
+ fade = f' (xfade {clip["transition_frames"]}f)' if clip['transition_frames'] else ''
374
+ print(f' {clip["id"] or clip["where"]}: frames {clip["start_frame"]}..{clip["start_frame"] + clip["frames"] - 1}{fade}')
375
+ for clip in result['overlays']:
376
+ print(f' {clip["id"] or clip["where"]}: overlay frames {clip["start_frame"]}..{clip["start_frame"] + clip["frames"] - 1}')
377
+ for warning in result.get('warnings', []):
378
+ print(f'warning: {warning}')
379
+ elif command == 'finish':
380
+ audio = result.get('audio')
381
+ line = f'{result["path"]} {result["frames"]} frames' + (' (video copied)' if result['video_copied'] else '')
382
+ if audio:
383
+ line += f' audio {audio["lufs"]} LUFS, true peak {audio["true_peak"]} dBTP'
384
+ print(line)
385
+ elif command == 'qa':
386
+ for item in result['checks']:
387
+ if item['status'] != 'PASS':
388
+ print(f'{item["status"]:4} {item["check"]}: {item["detail"]}')
389
+ print(f'qa {result["status"]}: {result["failed"]} failed, {result["warnings"]} warnings, '
390
+ f'{len(result["checks"]) - result["failed"] - result["warnings"]} passed ({result["path"]})')
391
+
392
+
393
+ SUMMARIZED = {'cuts', 'listen', 'faces', 'ref', 'capture', 'fx', 'fonts', 'assets', 'captions', 'compare', 'edit', 'signal', 'align',
394
+ 'matte', 'text', 'export'}
395
+ # The pip extra that brings a command's optional Python modules (the media sandbox has them all).
396
+ EXTRAS = {'listen': 'listen', 'ref': 'listen', 'faces': 'faces', 'capture': 'capture', 'captions': 'captions', 'fonts': 'fonts',
397
+ 'signal': 'listen', 'matte': 'matte', 'export': 'export'}
398
+
399
+
400
+ def _size(text: str) -> tuple[int, int]:
401
+ try:
402
+ width, height = (int(part) for part in text.lower().split('x'))
403
+ except ValueError:
404
+ raise RnError(f'size "{text}": expected WxH like 1080x1920') from None
405
+ return width, height
406
+
407
+
408
+ def _run_summarized(args) -> tuple[dict, list[str]]:
409
+ """Commands whose module prints its own summary lines."""
410
+ command = args.command
411
+ if command == 'cuts':
412
+ from .cuts import cuts_command
413
+ result = cuts_command(args.file, threshold=args.threshold, min_gap=args.min_gap, sheet=args.sheet, max_kb=args.max_kb)
414
+ lines = [f'{len(result["shots"])} shots, cuts at frames {[c["frame"] for c in result["cuts"]]}',
415
+ *(f' shot {s["shot"]}: frames {s["start_frame"]}..{s["end_frame"] - 1} {s["start"]}-{s["end"]}s' for s in result['shots']),
416
+ f'suspects (look before trusting): {[(c["frame"], c["score"]) for c in result["suspects"]]}']
417
+ lines += [f'sheet {page["path"]}' for page in result.get('sheet', [])]
418
+ return result, lines
419
+ if command == 'listen':
420
+ from .listen import listen_command, summary_lines
421
+ result = listen_command(args.file, out=args.out, transcribe=args.transcribe, language=args.language, model=args.model, max_kb=args.max_kb)
422
+ elif command == 'faces':
423
+ from .faces import faces_command, summary_lines
424
+ result = faces_command(args.file, every=args.every, model=args.model, min_score=args.min_score, out_sheet=args.sheet, max_kb=args.max_kb)
425
+ elif command == 'ref':
426
+ from .ref import ref_command, summary_lines
427
+ result = ref_command(args.file, out=args.out, every=args.every, transcribe=args.transcribe, language=args.language,
428
+ model=args.model, threshold=args.threshold)
429
+ elif command == 'capture':
430
+ from .capture import capture_command, summary_lines
431
+ width, height = _size(args.size)
432
+ result = capture_command(args.page, out=args.out, frames=args.frames, width=width, height=height, fps=args.fps,
433
+ shards=args.shards, start=args.start)
434
+ elif command == 'fx':
435
+ from .fx import catalog, js_path
436
+ result = {'effects': catalog(), 'js': str(js_path()), 'py': str(js_path().with_name('__init__.py'))}
437
+ lines = [f'{e["name"]:10} {e["js"]:16} {e["py"]:18} {e["what"]}' for e in result['effects']]
438
+ return result, lines + ([f'source: {result["js"]}', f' {result["py"]}'] if args.source else
439
+ ['rn fx --source prints the files to read or copy'])
440
+ elif command == 'edit':
441
+ from . import editops
442
+ path = Path(args.edl)
443
+ if args.op == 'ids':
444
+ result = editops.ids_op(path)
445
+ elif args.op == 'set':
446
+ result = editops.set_op(path, args.ref, args.assignments, args.unset)
447
+ elif args.op == 'split':
448
+ result = editops.split_op(path, args.ref, args.at, args.id)
449
+ elif args.op == 'delete':
450
+ result = editops.delete_op(path, args.ref, args.lift)
451
+ elif args.op == 'insert':
452
+ result = editops.insert_op(path, args.clip, args.before, args.after, args.track)
453
+ else:
454
+ result = editops.move_op(path, args.ref, args.before, args.after, args.track)
455
+ return result, editops.summary_lines(result)
456
+ elif command == 'signal':
457
+ from .signal import signal_command, summary_lines
458
+ result = signal_command(args.file, out=args.out)
459
+ elif command == 'align':
460
+ from .align import align_command, summary_lines
461
+ result = align_command(args.a, args.b, max_offset=args.max_offset)
462
+ elif command == 'matte':
463
+ from .matte import matte_command, summary_lines
464
+ result = matte_command(args.file, out=args.out, model=args.model, every=args.every, cutout=args.cutout)
465
+ elif command == 'text':
466
+ from .text import summary_lines, text_command
467
+ result = text_command(args.file, every=args.every, lang=args.lang, min_conf=args.min_conf)
468
+ elif command == 'export':
469
+ from .export import export_command, summary_lines
470
+ result = export_command(args.edl, args.out, srt=args.srt)
471
+ elif command == 'compare':
472
+ from .compare import compare_command, summary_lines
473
+ result = compare_command(args.a, args.b, out=args.out, count=args.count, seconds=args.seconds, width=args.width,
474
+ max_kb=args.max_kb, cuts=args.cuts, video=args.video)
475
+ elif command == 'fonts':
476
+ from .fonts import fonts_command, summary_lines
477
+ result = fonts_command(family=args.family, script=args.script, licenses=args.licenses)
478
+ elif command == 'assets':
479
+ from .assets import get_command, search_command, summary_lines
480
+ if args.action == 'get':
481
+ if not args.terms:
482
+ raise RnError('rn assets get <id>...: give asset ids from rn assets search')
483
+ result = get_command(args.terms, out=args.out)
484
+ else:
485
+ result = search_command(' '.join(args.terms), kind=args.kind, limit=args.limit)
486
+ else:
487
+ from .captions import captions_command, summary_lines
488
+ width, height = _size(args.size)
489
+ result = captions_command(args.words, script=args.script, faces_json=args.faces, out=args.out, width=width, height=height,
490
+ max_chars=args.max_chars, max_lines=args.max_lines, safe=args.safe, language=args.language)
491
+ return result, summary_lines(result)
492
+
493
+
494
+ def main(argv: list[str] | None = None) -> int:
495
+ args = _parser().parse_args(argv)
496
+ for tool in ('ffmpeg', 'ffprobe'):
497
+ if not shutil.which(tool):
498
+ print(f'rn: error: {tool} not found on PATH', file=sys.stderr)
499
+ return 2
500
+ lines: list[str] = []
501
+ try:
502
+ if args.command == 'probe':
503
+ from .ff import probe
504
+ result = probe(args.file, exact=args.exact)
505
+ elif args.command == 'frame':
506
+ from .frames import frame_command
507
+ result = frame_command(args.file, index=args.index, time=args.time, last=args.last, out=args.out)
508
+ elif args.command == 'sheet':
509
+ from .frames import sheet_command
510
+ result = sheet_command(args.files, out=args.out, every=args.every, rate=args.fps, indices=args.indices,
511
+ seconds=args.seconds, count=args.count, width=args.width, columns=args.columns, max_kb=args.max_kb,
512
+ around=args.around, span=args.span, diff=args.diff)
513
+ elif args.command in SUMMARIZED:
514
+ result, lines = _run_summarized(args)
515
+ elif args.command == 'assemble':
516
+ from .assemble import assemble_command
517
+ result = assemble_command(args.edl, args.out, dry_run=args.dry_run, keep=args.keep, preview=args.preview,
518
+ cache=not args.no_cache, placeholders=args.placeholders, time_range=args.range)
519
+ elif args.command == 'finish':
520
+ from .assemble import finish_command
521
+ lufs = None if args.lufs == 'keep' else float(args.lufs)
522
+ result = finish_command(args.file, args.out, lufs=lufs, true_peak=args.true_peak, crf=args.crf,
523
+ preset=args.preset, copy_video=args.copy_video, height=args.height, audio=args.audio,
524
+ audio_offset=args.audio_offset)
525
+ else:
526
+ from .qa import parse_accept, qa_command
527
+ result = qa_command(args.file, edl=args.edl, frames=args.expect_frames, size=args.expect_size, fps=args.expect_fps,
528
+ lufs=args.lufs, lufs_tolerance=args.lufs_tolerance, true_peak=args.true_peak,
529
+ require_audio=args.require_audio, accept=parse_accept(args.accept))
530
+ except RnError as error:
531
+ print(f'rn: error: {error}', file=sys.stderr)
532
+ return 1
533
+ except ValueError as error:
534
+ print(f'rn: error: {error}', file=sys.stderr)
535
+ return 1
536
+ except ModuleNotFoundError as error:
537
+ extra = EXTRAS.get(args.command, 'all')
538
+ print(f"rn: error: rn {args.command} needs the Python module {error.name} (extra \"{extra}\"): "
539
+ f"uv tool install 'renoise-kit[all]'", file=sys.stderr)
540
+ return 1
541
+ except Exception as error: # a kit bug: keep the traceback for the report, end on the usual one-liner
542
+ import traceback
543
+ traceback.print_exc()
544
+ print(f'rn: error: unexpected {type(error).__name__}: {error} (a bug in rn; work around it with ffmpeg directly)', file=sys.stderr)
545
+ return 1
546
+ if args.json:
547
+ Path(args.json).parent.mkdir(parents=True, exist_ok=True)
548
+ Path(args.json).write_text(json.dumps(result, indent=1, ensure_ascii=False, default=str))
549
+ if args.command in SUMMARIZED:
550
+ print('\n'.join(lines))
551
+ else:
552
+ _print_summary(args.command, result)
553
+ return 1 if args.command == 'qa' and result['status'] == 'FAIL' else 0
554
+
555
+
556
+ if __name__ == '__main__':
557
+ sys.exit(main())