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