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/export.py ADDED
@@ -0,0 +1,200 @@
1
+ """rn export: an EDL as an editable project for other editors: OpenTimelineIO (.otio), Final Cut Pro X (.fcpxml) or
2
+ Premiere / Resolve / FCP 7 (.xml), with media referenced by absolute path. Cuts, trims, speed, transitions, nesting, overlay
3
+ and audio placement in time carry over; what NLE interchange cannot express (position and size, keys, blend, masks, raw
4
+ filters, gain curves) is kept as "rn" metadata and listed in the result.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import shutil
10
+ from pathlib import Path
11
+
12
+ from .edl import Clip, Edit, load
13
+ from .ff import RnError, probe
14
+
15
+ ADAPTERS = {'.otio': 'otio_json', '.fcpxml': 'fcpx_xml', '.xml': 'fcp_xml'}
16
+ _SEQ = re.compile(r'%0?(\d*)d')
17
+
18
+
19
+ def _rt(otio, frames: float, rate: float):
20
+ return otio.opentime.RationalTime(frames, rate)
21
+
22
+
23
+ def _plain(value):
24
+ """OTIO metadata is a C++ any-dictionary: JSON types only (no tuples, None, Fractions or Paths)."""
25
+ if isinstance(value, dict):
26
+ return {str(k): _plain(v) for k, v in value.items() if v is not None}
27
+ if isinstance(value, (list, tuple)):
28
+ return [_plain(v) for v in value if v is not None]
29
+ if isinstance(value, (bool, int, float, str)):
30
+ return value
31
+ return str(value)
32
+
33
+
34
+ def _lost(clip: Clip) -> dict:
35
+ """What an NLE file cannot carry for this clip, kept as metadata."""
36
+ keep = {k: getattr(clip, k) for k in ('keys', 'vf', 'af', 'blend') if getattr(clip, k)}
37
+ if clip.mask is not None:
38
+ keep['mask'] = {'source': str(clip.mask.source), 'mode': clip.mask_mode, 'invert': clip.mask_invert}
39
+ if (clip.x, clip.y, clip.w, clip.h, clip.opacity) != (0, 0, None, None, 1.0):
40
+ keep['place'] = {'x': clip.x, 'y': clip.y, 'w': clip.w, 'h': clip.h, 'opacity': clip.opacity}
41
+ if clip.fade_in or clip.fade_out:
42
+ keep['fades'] = [clip.fade_in, clip.fade_out]
43
+ return _plain(keep)
44
+
45
+
46
+ STILL_HOURS = 1 # stills are endless in an NLE; FCP X still wants a range
47
+
48
+
49
+ def _available(otio, frames: float, rate: float):
50
+ return otio.opentime.TimeRange(_rt(otio, 0, rate), _rt(otio, frames, rate))
51
+
52
+
53
+ def _reference(otio, clip: Clip, rate: float):
54
+ """Media with its available range (Final Cut X refuses a reference without one). Not for solids (see _item)."""
55
+ if clip.kind == 'sequence':
56
+ pattern = clip.source
57
+ match = _SEQ.search(pattern.name)
58
+ count = sum(1 for f in pattern.parent.iterdir() if f.name.endswith(pattern.name[match.end():]))
59
+ return otio.schema.ImageSequenceReference(
60
+ target_url_base=str(pattern.parent.resolve()) + '/', name_prefix=pattern.name[:match.start()],
61
+ name_suffix=pattern.name[match.end():], start_frame=clip.seq_start, frame_step=1, rate=float(clip.src_fps),
62
+ frame_zero_padding=int(match.group(1) or 0), available_range=_available(otio, count, float(clip.src_fps)))
63
+ reference = otio.schema.ExternalReference(target_url=str(clip.source.resolve()))
64
+ if clip.kind == 'image':
65
+ reference.available_range = _available(otio, STILL_HOURS * 3600 * rate, rate)
66
+ else:
67
+ video = probe(clip.source)['video']
68
+ src_rate = float(clip.src_fps)
69
+ reference.available_range = _available(otio, video['frames'] or round((video['duration'] or 0) * src_rate), src_rate)
70
+ return reference
71
+
72
+
73
+ def _item(otio, clip: Clip, edit: Edit, head: int, tail: int, lost: list):
74
+ """One clip on a track: `head`/`tail` timeline frames are given to the transitions on either side."""
75
+ rate = float(edit.fps)
76
+ frames = clip.frames - head - tail
77
+ meta = _lost(clip)
78
+ if clip.kind == 'color': # no interchange format carries a solid (FCP X has no generator reference): a gap, colour kept
79
+ meta['color'] = clip.color
80
+ if meta:
81
+ lost.append({'clip': clip.id or clip.where, 'kept_as_metadata': sorted(meta)})
82
+ if clip.kind == 'edit':
83
+ stack = _stack(otio, load(clip.source), lost)
84
+ start = clip.start_s + head / rate * clip.speed
85
+ stack.source_range = otio.opentime.TimeRange(_rt(otio, start * rate, rate), _rt(otio, frames, rate))
86
+ stack.name = clip.id or clip.source.stem
87
+ stack.metadata['rn'] = _plain(meta)
88
+ return stack
89
+ if clip.kind == 'color':
90
+ return otio.schema.Gap(source_range=otio.opentime.TimeRange(duration=_rt(otio, frames, rate)), metadata={'rn': _plain(meta)})
91
+ src_rate = float(clip.src_fps) if clip.src_fps else rate
92
+ start = (clip.start_s + head / rate * clip.speed) if clip.kind in ('video', 'sequence') else head / rate
93
+ item = otio.schema.Clip(name=clip.id or (clip.source.name if clip.source else clip.color), media_reference=_reference(otio, clip, rate),
94
+ source_range=otio.opentime.TimeRange(_rt(otio, round(start * src_rate), src_rate),
95
+ _rt(otio, frames * src_rate / rate, src_rate)))
96
+ if clip.speed != 1:
97
+ item.effects.append(otio.schema.LinearTimeWarp(time_scalar=clip.speed))
98
+ item.metadata['rn'] = _plain({**meta, 'meta': clip.meta})
99
+ return item
100
+
101
+
102
+ def _lanes(items: list, start_of, length_of) -> list[list]:
103
+ """Greedy: overlapping items go to separate lanes (an NLE track holds one item at a time)."""
104
+ lanes: list[list] = []
105
+ for item in sorted(items, key=start_of):
106
+ lane = next((l for l in lanes if start_of(l[-1]) + length_of(l[-1]) <= start_of(item)), None)
107
+ if lane is None:
108
+ lanes.append([item])
109
+ else:
110
+ lane.append(item)
111
+ return lanes
112
+
113
+
114
+ def _stack(otio, edit: Edit, lost: list):
115
+ rate = float(edit.fps)
116
+ stack = otio.schema.Stack(name=edit.path.stem)
117
+ main = otio.schema.Track(name='V1', kind=otio.schema.TrackKind.Video)
118
+ for i, clip in enumerate(edit.base):
119
+ incoming = clip.transition_frames
120
+ outgoing = edit.base[i + 1].transition_frames if i + 1 < len(edit.base) else 0
121
+ if incoming:
122
+ main.append(otio.schema.Transition(name=clip.transition, transition_type=otio.schema.TransitionTypes.SMPTE_Dissolve,
123
+ in_offset=_rt(otio, incoming // 2, rate), out_offset=_rt(otio, incoming - incoming // 2, rate),
124
+ metadata={'rn': {'xfade': clip.transition}}))
125
+ # A transition's overlap is split at the cut: half from the end of one clip, half from the start of the next.
126
+ main.append(_item(otio, clip, edit, incoming - incoming // 2, outgoing // 2, lost))
127
+ stack.append(main)
128
+ for n, lane in enumerate(_lanes(edit.overlays, lambda c: c.timeline_start, lambda c: c.frames), start=2):
129
+ track = otio.schema.Track(name=f'V{n}', kind=otio.schema.TrackKind.Video)
130
+ position = 0
131
+ for clip in lane:
132
+ if clip.timeline_start > position:
133
+ track.append(otio.schema.Gap(source_range=otio.opentime.TimeRange(duration=_rt(otio, clip.timeline_start - position, rate))))
134
+ track.append(_item(otio, clip, edit, 0, 0, lost))
135
+ position = clip.timeline_start + clip.frames
136
+ stack.append(track)
137
+ lengths = {}
138
+ for item in edit.audio:
139
+ available = probe(item.source)['audio']['duration'] or 0
140
+ lengths[id(item)] = min(item.duration or available - item.start_s, edit.duration - item.at)
141
+ for n, lane in enumerate(_lanes(edit.audio, lambda a: a.at, lambda a: lengths[id(a)]), start=1):
142
+ track = otio.schema.Track(name=f'A{n}', kind=otio.schema.TrackKind.Audio)
143
+ position = 0.0
144
+ for item in lane:
145
+ if item.at > position:
146
+ track.append(otio.schema.Gap(source_range=otio.opentime.TimeRange(duration=_rt(otio, round((item.at - position) * rate), rate))))
147
+ seconds = lengths[id(item)]
148
+ meta = {k: v for k, v in (('gain_db', item.gain_db), ('fade_in', item.fade_in), ('fade_out', item.fade_out),
149
+ ('af', item.af), ('keys', item.keys), ('role', item.role)) if v}
150
+ source = otio.schema.ExternalReference(target_url=str(item.source.resolve()),
151
+ available_range=_available(otio, round((probe(item.source)['audio']['duration'] or 0) * rate), rate))
152
+ track.append(otio.schema.Clip(name=item.id or item.source.name, media_reference=source,
153
+ source_range=otio.opentime.TimeRange(_rt(otio, round(item.start_s * rate), rate), _rt(otio, round(seconds * rate), rate)),
154
+ metadata={'rn': _plain({**meta, 'meta': item.meta})}))
155
+ position = item.at + round(seconds * rate) / rate
156
+ stack.append(track)
157
+ return stack
158
+
159
+
160
+ def export_command(edl_path: str, out: str, *, srt: str | None) -> dict:
161
+ import opentimelineio as otio
162
+
163
+ target = Path(out)
164
+ adapter = ADAPTERS.get(target.suffix.lower())
165
+ if not adapter:
166
+ raise RnError(f'{out}: export to {", ".join(ADAPTERS)}')
167
+ edit = load(edl_path)
168
+ lost: list = []
169
+ stack = _stack(otio, edit, lost)
170
+ timeline = otio.schema.Timeline(name=edit.path.stem, global_start_time=_rt(otio, 0, float(edit.fps)))
171
+ timeline.tracks = stack
172
+ timeline.metadata['rn'] = _plain({'width': edit.width, 'height': edit.height, 'fps': edit.fps})
173
+ target.parent.mkdir(parents=True, exist_ok=True)
174
+ try:
175
+ otio.adapters.write_to_file(timeline, str(target), adapter_name=adapter)
176
+ except Exception as error: # adapter plugins raise their own types
177
+ raise RnError(f'{adapter} could not write {out}: {error} (install the export extra for .fcpxml / .xml)') from None
178
+ result = {'path': str(target), 'adapter': adapter, 'frames': edit.total_frames, 'fps': str(edit.fps),
179
+ 'tracks': [{'name': t.name, 'kind': t.kind, 'items': len(t)} for t in stack], 'lost': lost}
180
+ if srt:
181
+ if not edit.subtitles:
182
+ raise RnError('--srt: the edit has no "subtitles"')
183
+ if edit.subtitles.suffix.lower() == '.srt':
184
+ shutil.copyfile(edit.subtitles, srt)
185
+ else:
186
+ import pysubs2
187
+ pysubs2.load(str(edit.subtitles)).save(srt, format_='srt')
188
+ result['srt'] = srt
189
+ return result
190
+
191
+
192
+ def summary_lines(result: dict) -> list[str]:
193
+ lines = [f'{result["path"]} ({result["adapter"]}) {result["frames"]} frames at {result["fps"]} fps',
194
+ ' ' + ', '.join(f'{t["name"]} {t["items"]} items' for t in result['tracks'])]
195
+ if result['lost']:
196
+ lines.append(f' not expressible in NLE files, kept as "rn" metadata: '
197
+ + '; '.join(f'{l["clip"]}: {", ".join(l["kept_as_metadata"])}' for l in result['lost'][:10]))
198
+ if 'srt' in result:
199
+ lines.append(f' subtitles: {result["srt"]}')
200
+ return lines
rn/faces.py ADDED
@@ -0,0 +1,135 @@
1
+ """rn faces: per-frame face boxes and landmarks (YuNet), in source pixels."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import tempfile
6
+ from collections import Counter
7
+ from fractions import Fraction
8
+ from pathlib import Path
9
+
10
+ from .ff import RnError
11
+ from .frames import _compose, _save_under
12
+
13
+ DEFAULT_MODEL = '/opt/media-sandbox/models/face_detection_yunet_2023mar.onnx' # baked into the sandbox
14
+ # Outside the sandbox: fetched once into ~/.cache/rn (OpenCV zoo, MIT).
15
+ MODEL_URL = 'https://github.com/opencv/opencv_zoo/raw/main/models/face_detection_yunet/face_detection_yunet_2023mar.onnx'
16
+ MODEL_SHA256 = '8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4'
17
+ DETECT_LONG_SIDE = 960 # larger frames are detected at this size, then mapped back to source pixels
18
+ SHEET_FRAMES = 12
19
+
20
+
21
+ def _face(row, scale: float) -> dict:
22
+ """YuNet row: x y w h, right eye, left eye, nose, right mouth corner, left mouth corner (all as seen in the image), score."""
23
+ x, y, w, h, *marks, score = (float(v) for v in row[:15])
24
+ point = lambda i: [round(marks[2 * i] / scale, 1), round(marks[2 * i + 1] / scale, 1)] # noqa: E731
25
+ return {'box': [round(v / scale, 1) for v in (x, y, w, h)], 'score': round(score, 3),
26
+ 'eyes': [point(0), point(1)], 'nose': point(2), 'mouth': [point(3), point(4)]}
27
+
28
+
29
+ def faces_command(path: str, *, every: int = 1, model: str | None = None, min_score: float = 0.6,
30
+ out_sheet: str | None = None, max_kb: int = 125) -> dict:
31
+ """Measures where faces are in every `every`-th decoded frame. Measurement only: it never alters, blurs or crops a face.
32
+ Use the boxes to keep text/captions off faces, or to place separate graphics that follow a subject.
33
+ Coordinates are source pixels; `f` is the exact decoded frame index. `eyes` and `mouth` are ordered left-to-right as seen in the image."""
34
+ import cv2
35
+
36
+ model_path = model or os.environ.get('RN_YUNET_MODEL')
37
+ if not model_path:
38
+ from .assets import fetch_checked
39
+ model_path = DEFAULT_MODEL if Path(DEFAULT_MODEL).is_file() else str(
40
+ fetch_checked(MODEL_URL, MODEL_SHA256, Path.home() / '.cache' / 'rn' / Path(DEFAULT_MODEL).name, name='face model'))
41
+ if not Path(model_path).is_file():
42
+ raise RnError(f'face model not found: {model_path} (set RN_YUNET_MODEL or pass --model)')
43
+ if every < 1:
44
+ raise RnError('--every must be >= 1')
45
+ capture = cv2.VideoCapture(str(path))
46
+ if not capture.isOpened():
47
+ raise RnError(f'{path}: cannot open video')
48
+ fps = capture.get(cv2.CAP_PROP_FPS) or 0.0
49
+ detector = cv2.FaceDetectorYN.create(model_path, '', (320, 320), min_score)
50
+ width = height = 0
51
+ scale = 1.0
52
+ sampled: list[dict] = []
53
+ index = 0
54
+ while True:
55
+ ok, frame = capture.read()
56
+ if not ok:
57
+ break
58
+ if index == 0:
59
+ height, width = frame.shape[:2]
60
+ scale = min(1.0, DETECT_LONG_SIDE / max(width, height))
61
+ if index % every == 0:
62
+ small = frame if scale == 1.0 else cv2.resize(frame, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA)
63
+ detector.setInputSize((small.shape[1], small.shape[0]))
64
+ _, rows = detector.detect(small)
65
+ sampled.append({'f': index, 't': round(index / fps, 4) if fps else None,
66
+ 'faces': [_face(row, scale) for row in (rows if rows is not None else [])]})
67
+ index += 1
68
+ capture.release()
69
+ if index == 0:
70
+ raise RnError(f'{path}: no decodable video frames')
71
+
72
+ with_faces = [s for s in sampled if s['faces']]
73
+ boxes = [f['box'] for s in with_faces for f in s['faces']]
74
+ union = None
75
+ if boxes:
76
+ x0, y0 = min(b[0] for b in boxes), min(b[1] for b in boxes)
77
+ x1, y1 = max(b[0] + b[2] for b in boxes), max(b[1] + b[3] for b in boxes)
78
+ union = [round(x0, 1), round(y0, 1), round(x1 - x0, 1), round(y1 - y0, 1)]
79
+ result = {
80
+ 'meta': {'path': str(path), 'width': width, 'height': height, 'fps': round(fps, 4), 'frames': index, 'every': every, 'model': model_path},
81
+ 'frames': sampled,
82
+ 'summary': {
83
+ 'sampled': len(sampled), 'with_faces': len(with_faces),
84
+ 'max_faces': max((len(s['faces']) for s in sampled), default=0),
85
+ 'union_box': union,
86
+ 'union_box_fraction': [round(union[0] / width, 4), round(union[1] / height, 4), round(union[2] / width, 4), round(union[3] / height, 4)] if union else None,
87
+ 'face_count_histogram': {str(k): v for k, v in sorted(Counter(len(s['faces']) for s in sampled).items())},
88
+ },
89
+ }
90
+ if out_sheet:
91
+ result['sheet'] = _write_sheet(path, sampled, Fraction(fps).limit_denominator(1001) or Fraction(1), out_sheet, max_kb)
92
+ return result
93
+
94
+
95
+ def _write_sheet(path: str, sampled: list[dict], fps: Fraction, out_sheet: str, max_kb: int) -> dict:
96
+ import cv2
97
+
98
+ count = min(SHEET_FRAMES, len(sampled))
99
+ picks = {sampled[round(k * (len(sampled) - 1) / max(1, count - 1))]['f']: sampled[round(k * (len(sampled) - 1) / max(1, count - 1))] for k in range(count)}
100
+ capture = cv2.VideoCapture(str(path))
101
+ tiles: list[tuple[int, Path]] = []
102
+ with tempfile.TemporaryDirectory() as tmp:
103
+ index = 0
104
+ while len(tiles) < len(picks):
105
+ ok, frame = capture.read()
106
+ if not ok:
107
+ break
108
+ if index in picks:
109
+ shrink = min(1.0, 480 / frame.shape[1])
110
+ frame = cv2.resize(frame, None, fx=shrink, fy=shrink, interpolation=cv2.INTER_AREA)
111
+ for face in picks[index]['faces']:
112
+ x, y, w, h = (int(v * shrink) for v in face['box'])
113
+ cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
114
+ tile = Path(tmp) / f'{index:08d}.png'
115
+ cv2.imwrite(str(tile), frame)
116
+ tiles.append((index, tile))
117
+ index += 1
118
+ capture.release()
119
+ target = Path(out_sheet)
120
+ target.parent.mkdir(parents=True, exist_ok=True)
121
+ size = _save_under(_compose(tiles, fps, 4, f'{Path(path).name} faces {len(tiles)} frames'), target, max_kb)
122
+ return {'path': str(target), 'indices': [i for i, _ in tiles], 'bytes': size}
123
+
124
+
125
+ def summary_lines(result: dict) -> list[str]:
126
+ meta, summary = result['meta'], result['summary']
127
+ lines = [f'{meta["path"]}: {meta["width"]}x{meta["height"]} {meta["fps"]}fps {meta["frames"]} frames, sampled every {meta["every"]} ({summary["sampled"]} sampled)',
128
+ f'faces in {summary["with_faces"]}/{summary["sampled"]} sampled frames, max {summary["max_faces"]} at once; histogram {summary["face_count_histogram"]}']
129
+ if summary['union_box']:
130
+ x, y, w, h = summary['union_box']
131
+ fx, fy, fw, fh = summary['union_box_fraction']
132
+ lines.append(f'union box [x={x} y={y} w={w} h={h}] px = [x={fx} y={fy} w={fw} h={fh}] of frame')
133
+ if result.get('sheet'):
134
+ lines.append(f'sheet: {result["sheet"]["path"]} ({result["sheet"]["bytes"] // 1024} KB)')
135
+ return lines
rn/ff.py ADDED
@@ -0,0 +1,183 @@
1
+ """ffmpeg / ffprobe plumbing shared by every command."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ import struct
7
+ import subprocess
8
+ from fractions import Fraction
9
+ from functools import lru_cache
10
+ from pathlib import Path
11
+
12
+
13
+ class RnError(Exception):
14
+ """A failure the agent can act on; the CLI prints it as one line."""
15
+
16
+
17
+ def run(args: list[str], *, quiet: bool = True, cwd: str | Path | None = None) -> subprocess.CompletedProcess:
18
+ """Runs ffmpeg (or ffprobe); raises RnError with the tail of stderr on failure."""
19
+ flags = ['-nostdin', *(['-v', 'error'] if quiet else [])] if args[0] == 'ffmpeg' else []
20
+ command = [args[0], '-hide_banner', *flags, *args[1:]]
21
+ result = subprocess.run(command, capture_output=True, text=True, cwd=cwd)
22
+ if result.returncode != 0:
23
+ lines = result.stderr.strip().splitlines()
24
+ # The cause is usually an earlier line than the generic 'Task finished with error' tail.
25
+ causes = [line for line in lines if any(k in line for k in ('rror', 'nvalid', 'not match', 'No such', 'Unable', 'failed'))]
26
+ tail = '\n'.join((causes[:3] + lines[-2:]) if causes else lines[-6:])
27
+ raise RnError(f'{args[0]} failed: {tail or "exit " + str(result.returncode)}')
28
+ return result
29
+
30
+
31
+ @lru_cache(maxsize=None)
32
+ def has_filter(name: str) -> bool:
33
+ """Whether this ffmpeg build has a filter (Homebrew's plain ffmpeg, for one, has no libass `subtitles`)."""
34
+ filters = subprocess.run(['ffmpeg', '-hide_banner', '-filters'], capture_output=True, text=True).stdout
35
+ return any(line.split()[1:2] == [name] for line in filters.splitlines())
36
+
37
+
38
+ def ffprobe_json(path: str | Path, *extra: str) -> dict:
39
+ result = run(['ffprobe', '-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', *extra, str(path)])
40
+ return json.loads(result.stdout)
41
+
42
+
43
+ def rational(value: str | None) -> Fraction | None:
44
+ if not value or value in ('0/0', 'N/A'):
45
+ return None
46
+ try:
47
+ return Fraction(value)
48
+ except (ValueError, ZeroDivisionError):
49
+ return None
50
+
51
+
52
+ def fps_text(fps: Fraction) -> str:
53
+ return str(fps.numerator) if fps.denominator == 1 else f'{fps.numerator}/{fps.denominator}'
54
+
55
+
56
+ def mp4_faststart(path: str | Path) -> bool | None:
57
+ """True when the moov atom precedes mdat (playable before fully downloaded); None for non-MP4."""
58
+ with open(path, 'rb') as file:
59
+ while True:
60
+ header = file.read(8)
61
+ if len(header) < 8:
62
+ return None
63
+ size, kind = struct.unpack('>I4s', header)
64
+ if size == 1:
65
+ size = struct.unpack('>Q', file.read(8))[0]
66
+ body = size - 16
67
+ elif size == 0:
68
+ return None
69
+ else:
70
+ body = size - 8
71
+ if kind == b'moov':
72
+ return True
73
+ if kind == b'mdat':
74
+ return False
75
+ file.seek(body, 1)
76
+
77
+
78
+ def probe(path: str | Path, *, exact: bool = False) -> dict:
79
+ """Facts an edit depends on. Frame count comes from counting packets (or decoding with exact=True),
80
+ never from the container duration, which an audio track can stretch."""
81
+ path = Path(path)
82
+ if not path.exists():
83
+ raise RnError(f'no such file: {path}')
84
+ info = ffprobe_json(path, '-count_frames' if exact else '-count_packets', '-select_streams', 'v:0')
85
+ data = ffprobe_json(path)
86
+ video_stream = next((s for s in info.get('streams', []) if s.get('codec_type') == 'video'), None)
87
+ audio_stream = next((s for s in data.get('streams', []) if s.get('codec_type') == 'audio'), None)
88
+ fmt = data.get('format', {})
89
+ result: dict = {
90
+ 'path': str(path),
91
+ 'format': fmt.get('format_name'),
92
+ 'duration': _float(fmt.get('duration')),
93
+ 'faststart': mp4_faststart(path) if 'mp4' in (fmt.get('format_name') or '') or 'mov' in (fmt.get('format_name') or '') else None,
94
+ 'video': None,
95
+ 'audio': None,
96
+ }
97
+ if video_stream:
98
+ fps = rational(video_stream.get('avg_frame_rate')) or rational(video_stream.get('r_frame_rate'))
99
+ # Constant rate when the average rate equals the base rate; a VFR phone clip averages below it.
100
+ cfr = rational(video_stream.get('avg_frame_rate')) == rational(video_stream.get('r_frame_rate'))
101
+ frames = video_stream.get('nb_read_frames') if exact else video_stream.get('nb_read_packets')
102
+ frames = int(frames) if frames not in (None, 'N/A') else None
103
+ single_image = (video_stream.get('codec_name') in ('png', 'mjpeg', 'webp', 'bmp', 'tiff')) and (frames or 0) <= 1
104
+ sar = video_stream.get('sample_aspect_ratio')
105
+ # ffmpeg's native vp8/vp9 decoders drop the alpha side channel that WebM carries as alpha_mode=1.
106
+ vpx_alpha = str((video_stream.get('tags') or {}).get('alpha_mode') or (video_stream.get('tags') or {}).get('ALPHA_MODE') or '') == '1'
107
+ rotation = 0
108
+ for side in video_stream.get('side_data_list', []) or []:
109
+ if 'rotation' in side:
110
+ rotation = int(side['rotation'])
111
+ result['video'] = {
112
+ 'codec': video_stream.get('codec_name'),
113
+ 'profile': video_stream.get('profile'),
114
+ 'width': video_stream.get('width'),
115
+ 'height': video_stream.get('height'),
116
+ 'sar': sar if sar and sar != '0:1' else '1:1',
117
+ 'pix_fmt': video_stream.get('pix_fmt'),
118
+ 'fps': fps_text(fps) if fps and not single_image else None,
119
+ 'fps_float': round(float(fps), 6) if fps and not single_image else None,
120
+ 'frames': frames,
121
+ 'duration': None if single_image else (_float(video_stream.get('duration')) or (float(frames / fps) if frames and fps else None)),
122
+ 'color_space': video_stream.get('color_space'),
123
+ 'color_transfer': video_stream.get('color_transfer'),
124
+ 'color_primaries': video_stream.get('color_primaries'),
125
+ 'rotation': rotation,
126
+ 'alpha': vpx_alpha or (video_stream.get('pix_fmt') or '').startswith(('yuva', 'rgba', 'argb', 'bgra', 'abgr', 'gbrap', 'ya')),
127
+ 'alpha_decoder': ({'vp9': 'libvpx-vp9', 'vp8': 'libvpx'}.get(video_stream.get('codec_name')) if vpx_alpha else None),
128
+ 'still': single_image,
129
+ 'cfr': cfr and not single_image,
130
+ }
131
+ if audio_stream:
132
+ result['audio'] = {
133
+ 'codec': audio_stream.get('codec_name'),
134
+ 'sample_rate': int(audio_stream['sample_rate']) if audio_stream.get('sample_rate') else None,
135
+ 'channels': audio_stream.get('channels'),
136
+ 'duration': _float(audio_stream.get('duration')),
137
+ }
138
+ return result
139
+
140
+
141
+ def _float(value) -> float | None:
142
+ try:
143
+ return round(float(value), 6)
144
+ except (TypeError, ValueError):
145
+ return None
146
+
147
+
148
+ def source_fps(info: dict) -> Fraction:
149
+ video = info.get('video') or {}
150
+ if not video.get('fps'):
151
+ raise RnError(f'{info["path"]}: no video frame rate (is it an image? use "image" instead of "src")')
152
+ return Fraction(video['fps'])
153
+
154
+
155
+ _MATRIX_BY_TAG = {'bt709': 'bt709', 'smpte170m': 'bt601', 'bt470bg': 'bt601', 'bt2020nc': 'bt2020', 'bt2020c': 'bt2020',
156
+ 'smpte240m': 'smpte240m', 'fcc': 'fcc'}
157
+
158
+
159
+ def source_matrix(video: dict) -> str:
160
+ """The YUV matrix to decode a source with. Tagged sources use their tag; untagged ones follow player convention
161
+ (HD and up = BT.709, SD = BT.601; JPEG/JFIF is always BT.601). RGB sources ignore it. Both assemble and finish
162
+ use this, so one clip never looks different depending on the command."""
163
+ tagged = _MATRIX_BY_TAG.get(video.get('color_space') or '')
164
+ if tagged:
165
+ return tagged
166
+ if (video.get('pix_fmt') or '').startswith('yuvj'):
167
+ return 'bt601'
168
+ return 'bt709' if (video.get('height') or 0) >= 720 else 'bt601'
169
+
170
+
171
+ def to_bt709(matrix: str) -> str:
172
+ """scale options converting from `matrix` (RGB inputs: any) to limited-range BT.709 YUV."""
173
+ return f'in_color_matrix={matrix}:out_color_matrix=bt709:out_range=tv'
174
+
175
+
176
+ HEX_COLOR = re.compile(r'^#?[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$')
177
+
178
+
179
+ def ff_color(value: str) -> str:
180
+ """'#112233' -> '0x112233' for lavfi; named colors pass through."""
181
+ if HEX_COLOR.match(value):
182
+ return '0x' + value.lstrip('#')
183
+ return value
rn/fonts.py ADDED
@@ -0,0 +1,87 @@
1
+ """rn fonts: what is actually installed, from fontconfig. A missing weight makes browsers and libass fall back
2
+ silently (Katana's sandbox only had some families in ExtraBold), so check the weight and script here before using a face."""
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+
8
+ from .ff import RnError
9
+
10
+ # fontconfig weight -> CSS weight (fontconfig.h FC_WEIGHT_*).
11
+ _CSS = [(0, 100), (40, 200), (50, 300), (80, 400), (100, 500), (180, 600), (200, 700), (205, 800), (210, 900)]
12
+ _SCRIPTS = {'latin': ('en',), 'zh-hans': ('zh-cn', 'zh-sg'), 'zh-hant': ('zh-tw', 'zh-hk'), 'ja': ('ja',), 'ko': ('ko',),
13
+ 'th': ('th',), 'ar': ('ar',), 'he': ('he',), 'hi': ('hi',), 'ru': ('ru',)}
14
+
15
+
16
+ def css_weight(fc_weight: float) -> int:
17
+ return min(_CSS, key=lambda pair: abs(pair[0] - fc_weight))[1]
18
+
19
+
20
+ def _license(path: str) -> str | None:
21
+ try:
22
+ from fontTools.ttLib import TTFont
23
+ except ImportError:
24
+ return None
25
+ try:
26
+ font = TTFont(path, fontNumber=0, lazy=True)
27
+ names = font['name']
28
+ text = names.getDebugName(13) or names.getDebugName(14)
29
+ except Exception: # unreadable or exotic font: no license info rather than a failure
30
+ return None
31
+ return ' '.join(text.split())[:120] if text else None
32
+
33
+
34
+ def installed() -> dict[str, dict]:
35
+ if not shutil.which('fc-list'):
36
+ raise RnError('fc-list not found: install fontconfig (brew install fontconfig, apt install fontconfig)')
37
+ output = subprocess.run(['fc-list', '--format', '%{family[0]}\t%{style[0]}\t%{weight}\t%{variable}\t%{file}\t%{lang}\n'],
38
+ capture_output=True, text=True, check=True).stdout
39
+ families: dict[str, dict] = {}
40
+ for line in output.splitlines():
41
+ parts = line.split('\t')
42
+ if len(parts) != 6 or not parts[0] or parts[0].startswith('.'):
43
+ continue
44
+ family, style, weight, variable, path, langs = parts
45
+ entry = families.setdefault(family, {'family': family, 'weights': set(), 'styles': set(), 'variable': False,
46
+ 'scripts': set(), 'files': set()})
47
+ try:
48
+ entry['weights'].add(css_weight(float(weight)))
49
+ except ValueError:
50
+ pass
51
+ entry['styles'].add(style)
52
+ entry['variable'] |= variable == 'True'
53
+ entry['files'].add(path)
54
+ codes = set(langs.split('|'))
55
+ entry['scripts'] |= {name for name, keys in _SCRIPTS.items() if codes & set(keys)}
56
+ if 'emoji' in family.lower():
57
+ entry['scripts'].add('emoji')
58
+ return families
59
+
60
+
61
+ def fonts_command(*, family: str | None, script: str | None, licenses: bool) -> dict:
62
+ if script and script not in (*_SCRIPTS, 'emoji'):
63
+ raise RnError(f'--script: one of {", ".join([*_SCRIPTS, "emoji"])}')
64
+ rows = []
65
+ for entry in sorted(installed().values(), key=lambda e: e['family'].lower()):
66
+ if family and family.lower() not in entry['family'].lower():
67
+ continue
68
+ if script and script not in entry['scripts']:
69
+ continue
70
+ files = sorted(entry['files'])
71
+ row = {'family': entry['family'], 'weights': sorted(entry['weights']), 'styles': sorted(entry['styles']),
72
+ 'variable': entry['variable'], 'scripts': sorted(entry['scripts']), 'files': files}
73
+ if licenses:
74
+ row['license'] = _license(files[0])
75
+ rows.append(row)
76
+ if family and not rows:
77
+ raise RnError(f'no installed family matches "{family}"; run rn fonts to list them, or download one into your project')
78
+ return {'count': len(rows), 'fonts': rows}
79
+
80
+
81
+ def summary_lines(result: dict) -> list[str]:
82
+ lines = []
83
+ for row in result['fonts']:
84
+ weights = ','.join(str(w) for w in row['weights']) + (' (variable)' if row['variable'] else '')
85
+ lines.append(f'{row["family"]}: {weights} [{" ".join(row["scripts"]) or "-"}]' + (f' {row["license"]}' if row.get('license') else ''))
86
+ lines.append(f'{result["count"]} families')
87
+ return lines