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/assets.py ADDED
@@ -0,0 +1,125 @@
1
+ """rn assets: licensed sound effects and real overlay footage (film grain scans, light leaks, dust, paper) from the
2
+ Renoise asset library on the CDN. Not baked into the image: the files are large, each needs its licence recorded, and a
3
+ template upgrade replaces every user's sandbox, so content that changes often must not ride on the image.
4
+
5
+ Manifest (JSON at RN_ASSETS_URL): {"version": 1, "assets": [{"id", "kind": "sfx"|"overlay"|"texture"|"music", "title",
6
+ "tags": [...], "duration"?, "width"?, "height"?, "license", "source", "url" (absolute or relative to the manifest),
7
+ "sha256", "bytes"}]}. Every file is checked against its sha256 and cached by it.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import shutil
15
+ import urllib.error
16
+ import urllib.parse
17
+ import urllib.request
18
+ from pathlib import Path
19
+
20
+ from .ff import RnError
21
+
22
+ DEFAULT_URL = 'https://assets.renoise.ai/static/rn-assets/manifest.json'
23
+ KINDS = ('sfx', 'overlay', 'texture', 'music')
24
+
25
+
26
+ def _url() -> str:
27
+ return os.environ.get('RN_ASSETS_URL', DEFAULT_URL)
28
+
29
+
30
+ def _cache() -> Path:
31
+ return Path(os.environ.get('RN_ASSETS_CACHE', Path.home() / '.cache' / 'rn-assets'))
32
+
33
+
34
+ def _open(url: str):
35
+ try:
36
+ return urllib.request.urlopen(url, timeout=30)
37
+ except urllib.error.HTTPError as error:
38
+ if error.code in (403, 404):
39
+ raise RnError(f'the asset library is not published at {url} yet ({error.code})') from None
40
+ raise RnError(f'{url}: HTTP {error.code}') from None
41
+ except (urllib.error.URLError, OSError) as error:
42
+ raise RnError(f'{url}: {getattr(error, "reason", error)}') from None
43
+
44
+
45
+ def fetch_checked(source: str, digest: str, cached: Path, *, name: str) -> Path:
46
+ """Downloads `source` to `cached` once, refusing content whose sha256 is not `digest`."""
47
+ if cached.exists():
48
+ return cached
49
+ cached.parent.mkdir(parents=True, exist_ok=True)
50
+ partial = cached.with_name(f'{cached.name}.part')
51
+ hasher = hashlib.sha256()
52
+ with _open(source) as response, open(partial, 'wb') as file:
53
+ while chunk := response.read(1 << 20):
54
+ hasher.update(chunk)
55
+ file.write(chunk)
56
+ if hasher.hexdigest() != digest:
57
+ partial.unlink()
58
+ raise RnError(f'{name}: checksum mismatch, refusing the download')
59
+ partial.rename(cached)
60
+ return cached
61
+
62
+
63
+ def manifest() -> tuple[str, list[dict]]:
64
+ url = _url()
65
+ if '://' not in url:
66
+ url = Path(url).resolve().as_uri()
67
+ with _open(url) as response:
68
+ try:
69
+ data = json.loads(response.read())
70
+ except json.JSONDecodeError as error:
71
+ raise RnError(f'{url}: not JSON ({error})') from None
72
+ assets = data.get('assets') if isinstance(data, dict) else None
73
+ if not isinstance(assets, list):
74
+ raise RnError(f'{url}: expected {{"version": 1, "assets": [...]}}')
75
+ return url, assets
76
+
77
+
78
+ def search_command(query: str | None, *, kind: str | None, limit: int) -> dict:
79
+ if kind and kind not in KINDS:
80
+ raise RnError(f'--kind: one of {", ".join(KINDS)}')
81
+ url, assets = manifest()
82
+ words = [w for w in (query or '').lower().split() if w]
83
+ scored = []
84
+ for asset in assets:
85
+ if kind and asset.get('kind') != kind:
86
+ continue
87
+ haystack = ' '.join([asset.get('id', ''), asset.get('title', ''), *asset.get('tags', [])]).lower()
88
+ score = sum(haystack.count(w) for w in words)
89
+ if words and not score:
90
+ continue
91
+ scored.append((score, asset))
92
+ scored.sort(key=lambda pair: (-pair[0], pair[1].get('id', '')))
93
+ keep = ('id', 'kind', 'title', 'tags', 'duration', 'width', 'height', 'license')
94
+ return {'manifest': url, 'total': len(scored), 'assets': [{k: a[k] for k in keep if k in a} for _, a in scored[:limit]]}
95
+
96
+
97
+ def get_command(ids: list[str], *, out: str) -> dict:
98
+ url, assets = manifest()
99
+ by_id = {a.get('id'): a for a in assets}
100
+ missing = [i for i in ids if i not in by_id]
101
+ if missing:
102
+ raise RnError(f'unknown asset id(s) {missing}; find ids with rn assets search')
103
+ out_dir = Path(out)
104
+ out_dir.mkdir(parents=True, exist_ok=True)
105
+ cache = _cache()
106
+ cache.mkdir(parents=True, exist_ok=True)
107
+ files = []
108
+ for asset_id in ids:
109
+ asset = by_id[asset_id]
110
+ digest = asset.get('sha256', '')
111
+ source = urllib.parse.urljoin(url, asset['url'])
112
+ cached = fetch_checked(source, digest, cache / digest, name=asset_id)
113
+ target = out_dir / f'{asset_id}{Path(urllib.parse.urlparse(source).path).suffix}'
114
+ shutil.copyfile(cached, target)
115
+ files.append({'id': asset_id, 'path': str(target), 'license': asset.get('license'), 'source': asset.get('source')})
116
+ return {'files': files}
117
+
118
+
119
+ def summary_lines(result: dict) -> list[str]:
120
+ if 'files' in result:
121
+ return [f'{f["path"]} ({f["license"]})' for f in result['files']]
122
+ lines = [f'{a["id"]} [{a["kind"]}] {a.get("title", "")}' + (f' {a["duration"]}s' if a.get('duration') else '')
123
+ + f' tags: {", ".join(a.get("tags", []))}' for a in result['assets']]
124
+ lines.append(f'{len(result["assets"])} of {result["total"]} matches')
125
+ return lines
rn/audio.py ADDED
@@ -0,0 +1,79 @@
1
+ """Loudness and true-peak finishing shared by `rn assemble` and `rn finish`.
2
+
3
+ The rule (spec/mcp/03-media-kit.md §5): mix without normalisation, gain to the LUFS target, limit with
4
+ alimiter's auto-level off, then measure the true peak on the ENCODED AAC — the encoder adds 1-2 dB of
5
+ overshoot, so a limiter that looks safe on PCM fails after encoding. Re-encode only the audio until it fits.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import subprocess
11
+ from pathlib import Path
12
+
13
+ from .ff import RnError, run
14
+
15
+ AAC_BITRATE = '192k'
16
+ SAMPLE_RATE = 48000
17
+ MAX_LIMITER_PASSES = 6
18
+
19
+
20
+ def measure(path: str | Path, *, stream: str = 'a:0') -> dict:
21
+ """Integrated loudness (LUFS), loudness range and true peak (dBTP) via ebur128."""
22
+ result = subprocess.run(['ffmpeg', '-hide_banner', '-nostdin', '-i', str(path), '-map', f'0:{stream}',
23
+ '-af', 'ebur128=peak=true:framelog=quiet', '-f', 'null', '-'], capture_output=True, text=True)
24
+ if result.returncode != 0:
25
+ raise RnError(f'loudness measurement failed: {result.stderr.strip().splitlines()[-1:]}')
26
+ summary = result.stderr[result.stderr.rfind('Summary:'):]
27
+
28
+ def pick(label: str) -> float | None:
29
+ match = re.search(rf'{label}:\s+(-?inf|-?[\d.]+)', summary)
30
+ if not match:
31
+ return None
32
+ value = match.group(1)
33
+ return float('-inf') if 'inf' in value else float(value)
34
+
35
+ return {'lufs': pick('I'), 'lra': pick('LRA'), 'true_peak': pick('Peak')}
36
+
37
+
38
+ def finish_audio(mix_wav: Path, out_m4a: Path, *, lufs: float | None, true_peak: float, reference: Path | None = None) -> dict:
39
+ """mix_wav -> AAC whose encoded true peak is at or below `true_peak` dBTP. The LUFS gain is measured on `reference`
40
+ (default mix_wav): an excerpt keeps the level of the whole mix."""
41
+ gain_db = 0.0
42
+ if lufs is not None:
43
+ measured = measure(reference or mix_wav)['lufs']
44
+ if measured is not None and measured != float('-inf'):
45
+ gain_db = max(-30.0, min(30.0, lufs - measured))
46
+ ceiling = true_peak - 0.5 # headroom for encoder overshoot; tightened below if still too hot
47
+ attempts = []
48
+ for _ in range(MAX_LIMITER_PASSES):
49
+ limit = 10 ** (ceiling / 20)
50
+ run(['ffmpeg', '-i', str(mix_wav), '-af',
51
+ f'volume={gain_db:.3f}dB,alimiter=limit={max(0.0625, limit):.5f}:level=disabled:latency=1:attack=5:release=50,'
52
+ f'aresample={SAMPLE_RATE}', '-c:a', 'aac', '-b:a', AAC_BITRATE, '-ar', str(SAMPLE_RATE), '-ac', '2', '-y', str(out_m4a)])
53
+ measured = measure(out_m4a)
54
+ attempts.append({'limiter_dbfs': round(ceiling, 2), 'true_peak': measured['true_peak']})
55
+ if measured['true_peak'] is None or measured['true_peak'] <= true_peak:
56
+ return {'gain_db': round(gain_db, 2), **measured, 'attempts': attempts}
57
+ ceiling -= max(0.5, measured['true_peak'] - true_peak)
58
+ raise RnError(f'true peak stayed above {true_peak} dBTP after {MAX_LIMITER_PASSES} limiter passes: {attempts}')
59
+
60
+
61
+ # Frame properties win over encoder flags in ffmpeg 7+, so tag the frames themselves too.
62
+ BT709 = 'setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709'
63
+ VIDEO_ENCODE = ['-c:v', 'libx264', '-profile:v', 'high', '-pix_fmt', 'yuv420p',
64
+ '-colorspace', 'bt709', '-color_primaries', 'bt709', '-color_trc', 'bt709']
65
+ # fixed_frame_rate_flag=1 in the SPS: with 0, some players and platforms time frames from the VUI tick and show a
66
+ # 30 fps film at half its length (promo-video, 2026-09-29). Only for constant-rate input: x264 then ignores timestamps.
67
+ CFR_VUI = ['-x264-params', 'force-cfr=1']
68
+
69
+
70
+ def mux(video: Path, audio_m4a: Path | None, out: Path, *, frames: int) -> None:
71
+ """Copies both streams into an MP4 with moov first. The audio was already cut to the video's length by sample;
72
+ no -shortest: on ffmpeg 7 it trims the video to the AAC stream, which can end a few ms early, dropping frames."""
73
+ args = ['ffmpeg', '-i', str(video)]
74
+ if audio_m4a:
75
+ args += ['-i', str(audio_m4a), '-map', '0:v:0', '-map', '1:a:0', '-c:a', 'copy']
76
+ else:
77
+ args += ['-map', '0:v:0']
78
+ out.parent.mkdir(parents=True, exist_ok=True)
79
+ run(args + ['-c:v', 'copy', '-frames:v', str(frames), '-movflags', '+faststart', '-y', str(out)])
rn/captions.py ADDED
@@ -0,0 +1,343 @@
1
+ """rn captions: mechanical caption chunking, script alignment, safe-area / face-aware placement.
2
+
3
+ Writes one plain .ass (single readable style), an .srt, and a JSON of events with word timing so the agent can render
4
+ its own styled captions. Never imposes a style. Contract: spec/mcp/03-media-kit.md §3.1.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ import unicodedata
11
+ from difflib import SequenceMatcher
12
+ from pathlib import Path
13
+
14
+ from .ff import RnError
15
+
16
+ # Approximate platform UI clearance as fractions of the frame, NOT exact platform specs: the apps move their overlays
17
+ # between releases. top/bottom = reserved by caption/username/progress bars, left/right = reserved by action buttons.
18
+ SAFE_MARGINS = {
19
+ 'tiktok': {'top': 0.13, 'bottom': 0.22, 'left': 0.06, 'right': 0.16},
20
+ 'reels': {'top': 0.12, 'bottom': 0.20, 'left': 0.06, 'right': 0.12},
21
+ 'youtube': {'top': 0.05, 'bottom': 0.08, 'left': 0.05, 'right': 0.05}, # 16:9 title-safe + player controls
22
+ 'none': {'top': 0.02, 'bottom': 0.02, 'left': 0.02, 'right': 0.02},
23
+ }
24
+ BASE_CHARS = {'cjk': 14, 'latin': 32} # per line at 1080 px width
25
+ LATIN_EM = 0.55 # average advance of a Latin glyph in em
26
+ OPENERS = set('「『(([“‘《〈【')
27
+ SENTENCE_END = set('。!?.!?…‼⁇')
28
+ SOFT_BREAK = set(',、;:,;:—')
29
+ CLOSERS = set('」』))]”’》〉】"\'')
30
+ FONTS = {'zh': 'Noto Sans CJK SC', 'yue': 'Noto Sans CJK SC', 'ja': 'Noto Sans CJK JP', 'ko': 'Noto Sans CJK KR'}
31
+ PAUSE_BREAK = 0.5 # seconds of silence that ends a caption
32
+
33
+
34
+ def _norm(text: str) -> str:
35
+ return ''.join(c for c in text.lower() if c.isalnum())
36
+
37
+
38
+ def _wide(char: str) -> bool:
39
+ return unicodedata.east_asian_width(char) in 'WF'
40
+
41
+
42
+ def _width(text: str, cjk: bool) -> float:
43
+ """Line-limit measure: a wide char is 1; in CJK mode a Latin char is half."""
44
+ return sum(1 if _wide(c) else (0.5 if cjk else 1) for c in text)
45
+
46
+
47
+ def _em(text: str) -> float:
48
+ return sum(1.0 if _wide(c) else LATIN_EM for c in text)
49
+
50
+
51
+ def _join(texts: list[str], cjk: bool) -> str:
52
+ out = ''
53
+ for text in texts:
54
+ glue = ' ' if out and (not cjk or (out[-1].isascii() and out[-1].isalnum() and text[0].isascii() and text[0].isalnum())) else ''
55
+ out += glue + text
56
+ return out
57
+
58
+
59
+ def _is_cjk_language(language: str, words: list[dict]) -> bool:
60
+ if language:
61
+ return language.split('-')[0].lower() in ('zh', 'ja', 'yue')
62
+ sample = ''.join(w['word'] for w in words)
63
+ return bool(sample) and sum(_wide(c) for c in sample) / len(sample) > 0.3
64
+
65
+
66
+ def _attach_punctuation(tokens: list[dict]) -> list[dict]:
67
+ """Closing punctuation joins the previous token, opening brackets join the next: no unit starts with 、。」 or ends with 「."""
68
+ units: list[dict] = []
69
+ prefix = ''
70
+ for token in tokens:
71
+ if not any(c.isalnum() for c in token['text']):
72
+ if all(c in OPENERS for c in token['text']) or not units:
73
+ prefix += token['text']
74
+ else:
75
+ units[-1]['text'] += token['text']
76
+ continue
77
+ units.append({**token, 'text': prefix + token['text']})
78
+ prefix = ''
79
+ if prefix and units:
80
+ units[-1]['text'] += prefix
81
+ return units
82
+
83
+
84
+ def _transcript_units(words: list[dict], cjk: bool) -> list[dict]:
85
+ raw: list[dict] = []
86
+ if cjk:
87
+ for word in words:
88
+ pieces = re.findall(r"[A-Za-z0-9]+(?:['’\-][A-Za-z0-9]+)*|\S", word['word'])
89
+ lexical = sum(any(c.isalnum() for c in p) for p in pieces) or 1
90
+ span = (word['end'] - word['start']) / lexical
91
+ cursor = word['start']
92
+ for piece in pieces:
93
+ if any(c.isalnum() for c in piece):
94
+ raw.append({'text': piece, 'start': cursor, 'end': cursor + span})
95
+ cursor += span
96
+ else:
97
+ raw.append({'text': piece, 'start': cursor, 'end': cursor})
98
+ else:
99
+ spaced = any(w['word'].startswith(' ') for w in words[1:])
100
+ for word in words:
101
+ text = word['word'].strip()
102
+ if not text:
103
+ continue
104
+ if spaced and raw and not word['word'].startswith(' '): # Whisper sub-word piece: glue to the previous word
105
+ raw[-1]['text'] += text
106
+ raw[-1]['end'] = word['end']
107
+ else:
108
+ raw.append({'text': text, 'start': word['start'], 'end': word['end']})
109
+ return _attach_punctuation(raw)
110
+
111
+
112
+ def _script_units(script: str, cjk: bool) -> list[dict]:
113
+ pieces = re.findall(r"[A-Za-z0-9]+(?:['’\-][A-Za-z0-9]+)*|\S", script) if cjk else script.split()
114
+ return _attach_punctuation([{'text': p} for p in pieces])
115
+
116
+
117
+ def _align_script(transcript: list[dict], script: list[dict]) -> tuple[list[dict], dict]:
118
+ """Script text with transcript timing; unmatched script runs get timing interpolated across the transcript gap."""
119
+ matcher = SequenceMatcher(None, [_norm(u['text']) for u in transcript], [_norm(u['text']) for u in script], autojunk=False)
120
+ aligned: list[dict] = [{}] * len(script)
121
+ matched = 0
122
+ unmatched: list[dict] = []
123
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
124
+ if tag == 'equal':
125
+ matched += j2 - j1
126
+ for offset in range(j2 - j1):
127
+ aligned[j1 + offset] = {'text': script[j1 + offset]['text'], 'start': transcript[i1 + offset]['start'], 'end': transcript[i1 + offset]['end']}
128
+ continue
129
+ if i2 > i1:
130
+ span_start, span_end = transcript[i1]['start'], transcript[i2 - 1]['end']
131
+ else: # script-only run: use the silence between the neighbouring transcript units
132
+ span_start = transcript[i1 - 1]['end'] if i1 > 0 else transcript[0]['start']
133
+ span_end = transcript[i1]['start'] if i1 < len(transcript) else transcript[-1]['end']
134
+ span_end = max(span_start, span_end)
135
+ step = (span_end - span_start) / max(j2 - j1, 1)
136
+ for offset in range(j2 - j1):
137
+ aligned[j1 + offset] = {'text': script[j1 + offset]['text'], 'start': span_start + step * offset, 'end': span_start + step * (offset + 1)}
138
+ unmatched.append({'script': _join([u['text'] for u in script[j1:j2]], False), 'transcript': _join([u['text'] for u in transcript[i1:i2]], False),
139
+ 'start': round(span_start, 3), 'end': round(span_end, 3)})
140
+ stats = {'match_ratio': round(matched / len(script), 3) if script else 0.0, 'script_units': len(script), 'transcript_units': len(transcript),
141
+ 'matched_units': matched, 'unmatched_spans': unmatched[:20], 'unmatched_span_count': len(unmatched)}
142
+ return aligned, stats
143
+
144
+
145
+ def _layout(units: list[dict], max_width: float, cjk: bool) -> list[list[dict]]:
146
+ """Greedy fill to find the line count, then the narrowest width that keeps it: lines come out balanced
147
+ ("and a case that fits / in your pocket.", not "and a case that fits in your / pocket.")."""
148
+ lines = _greedy(units, max_width, cjk)
149
+ if len(lines) < 2:
150
+ return lines
151
+ width = max(_width(_join([u['text'] for u in line], cjk), cjk) for line in lines)
152
+ while width > 1:
153
+ tighter = _greedy(units, width - 0.5, cjk)
154
+ if len(tighter) > len(lines):
155
+ break
156
+ lines, width = tighter, width - 0.5
157
+ return lines
158
+
159
+
160
+ def _greedy(units: list[dict], max_width: float, cjk: bool) -> list[list[dict]]:
161
+ lines: list[list[dict]] = []
162
+ for unit in units:
163
+ if lines and _width(_join([u['text'] for u in lines[-1] + [unit]], cjk), cjk) <= max_width:
164
+ lines[-1].append(unit)
165
+ else:
166
+ lines.append([unit])
167
+ return lines
168
+
169
+
170
+ def _tail(text: str) -> str:
171
+ stripped = text.rstrip(''.join(CLOSERS))
172
+ return stripped[-1] if stripped else ''
173
+
174
+
175
+ def _chunk(units: list[dict], *, max_chars: int, max_lines: int, min_duration: float, max_duration: float, cjk: bool) -> list[list[dict]]:
176
+ chunks: list[list[dict]] = []
177
+ current: list[dict] = []
178
+ for index, unit in enumerate(units):
179
+ if current and (unit['end'] - current[0]['start'] > max_duration or len(_layout(current + [unit], max_chars, cjk)) > max_lines):
180
+ chunks.append(current)
181
+ current = []
182
+ current.append(unit)
183
+ duration = unit['end'] - current[0]['start']
184
+ gap = units[index + 1]['start'] - unit['end'] if index + 1 < len(units) else 0
185
+ fill = _width(_join([u['text'] for u in current], cjk), cjk) / (max_chars * max_lines)
186
+ tail = _tail(unit['text'])
187
+ if duration >= min_duration and (tail in SENTENCE_END or gap >= PAUSE_BREAK or (tail in SOFT_BREAK and fill >= 0.5)):
188
+ chunks.append(current)
189
+ current = []
190
+ if current:
191
+ chunks.append(current)
192
+ return chunks
193
+
194
+
195
+ def _overlaps(box: tuple[float, float, float, float], faces: list[tuple[float, float, float, float]], pad: float) -> bool:
196
+ x, y, w, h = box
197
+ return any(x < fx + fw + pad and fx - pad < x + w and y < fy + fh + pad and fy - pad < y + h for fx, fy, fw, fh in faces)
198
+
199
+
200
+ def _faces_in_span(frames: list[dict], start: float, end: float, scale: tuple[float, float]) -> list[tuple[float, float, float, float]]:
201
+ before = [f for f in frames if f['t'] < start]
202
+ chosen = [f for f in frames if start <= f['t'] <= end] + before[-1:] # sparse sampling: the last frame before the span still holds
203
+ return [(f_['box'][0] * scale[0], f_['box'][1] * scale[1], f_['box'][2] * scale[0], f_['box'][3] * scale[1]) for f in chosen for f_ in f.get('faces', [])]
204
+
205
+
206
+ def _srt_time(seconds: float) -> str:
207
+ ms = round(seconds * 1000)
208
+ return f'{ms // 3600000:02d}:{ms // 60000 % 60:02d}:{ms // 1000 % 60:02d},{ms % 1000:03d}'
209
+
210
+
211
+ def captions_command(words_json: str, *, script: str | None = None, faces_json: str | None = None, out: str, width: int, height: int,
212
+ max_chars: int | None = None, max_lines: int = 2, min_duration: float = 0.6, max_duration: float = 4.0,
213
+ safe: str = 'auto', language: str | None = None) -> dict:
214
+ try:
215
+ import pysubs2
216
+ except ImportError as error:
217
+ raise RnError('captions needs pysubs2 (pip install pysubs2)') from error
218
+ if safe not in ('auto', *SAFE_MARGINS):
219
+ raise RnError(f'--safe must be one of auto, {", ".join(SAFE_MARGINS)}')
220
+ if width <= 0 or height <= 0 or max_lines < 1 or min_duration > max_duration:
221
+ raise RnError('captions: width/height must be positive, max_lines >= 1, min_duration <= max_duration')
222
+ try:
223
+ listen = json.loads(Path(words_json).read_text(encoding='utf-8'))
224
+ except (OSError, ValueError) as error:
225
+ raise RnError(f'cannot read words json {words_json}: {error}') from error
226
+ words = [w for w in listen.get('words', []) if str(w.get('word', '')).strip()]
227
+ if not words:
228
+ raise RnError(f'{words_json} has no words; run `rn listen --transcribe --json` first')
229
+ language = language or listen.get('language') or ''
230
+ cjk = _is_cjk_language(language, words)
231
+ warnings: list[str] = []
232
+
233
+ units = _transcript_units(words, cjk)
234
+ alignment = None
235
+ if script is not None:
236
+ try:
237
+ script_text = Path(script).read_text(encoding='utf-8')
238
+ except OSError as error:
239
+ raise RnError(f'cannot read script {script}: {error}') from error
240
+ script_units = _script_units(script_text, cjk)
241
+ if not script_units:
242
+ raise RnError(f'script {script} is empty')
243
+ units, alignment = _align_script(units, script_units)
244
+ if alignment['match_ratio'] < 0.5:
245
+ warnings.append(f"script matches only {alignment['match_ratio']:.0%} of the transcript; check it is the same take and language")
246
+
247
+ safe_name = safe if safe != 'auto' else ('tiktok' if width / height < 0.8 else 'youtube')
248
+ margins = SAFE_MARGINS[safe_name]
249
+ safe_left, safe_right = margins['left'] * width, width - margins['right'] * width
250
+ safe_width = safe_right - safe_left
251
+ max_chars = max_chars or max(4, round(BASE_CHARS['cjk' if cjk else 'latin'] * width / 1080))
252
+ em_per_char = 1.0 if cjk else LATIN_EM
253
+ font_size = round(min(height * (0.03 if height > width else 0.05), safe_width / (max_chars * em_per_char)))
254
+ font_name = FONTS.get(language.split('-')[0].lower(), 'Noto Sans') if language else ('Noto Sans CJK SC' if cjk else 'Noto Sans')
255
+
256
+ chunks = _chunk(units, max_chars=max_chars, max_lines=max_lines, min_duration=min_duration, max_duration=max_duration, cjk=cjk)
257
+ for chunk in chunks:
258
+ for unit in chunk:
259
+ if _width(unit['text'], cjk) > max_chars:
260
+ warnings.append(f"unit '{unit['text']}' at {unit['start']:.2f}s is wider than max_chars={max_chars}; kept whole")
261
+
262
+ frames: list[dict] = []
263
+ scale = (1.0, 1.0)
264
+ if faces_json:
265
+ try:
266
+ faces = json.loads(Path(faces_json).read_text(encoding='utf-8'))
267
+ except (OSError, ValueError) as error:
268
+ raise RnError(f'cannot read faces json {faces_json}: {error}') from error
269
+ frames = faces.get('frames', [])
270
+ meta = faces.get('meta', {})
271
+ scale = (width / meta.get('width', width), height / meta.get('height', height))
272
+
273
+ events: list[dict] = []
274
+ moved: list[dict] = []
275
+ line_height = font_size * 1.2
276
+ for index, chunk in enumerate(chunks):
277
+ lines = [_join([u['text'] for u in line], cjk) for line in _layout(chunk, max_chars, cjk)]
278
+ box_w = min(safe_width, max(_em(line) for line in lines) * font_size)
279
+ box_h = len(lines) * line_height
280
+ bands = {'bottom': height - margins['bottom'] * height - box_h, 'top': margins['top'] * height, 'middle': (height - box_h) / 2}
281
+ box_x = (width - box_w) / 2
282
+ span_faces = _faces_in_span(frames, chunk[0]['start'], chunk[-1]['end'], scale) if frames else []
283
+ band = 'bottom'
284
+ if span_faces and _overlaps((box_x, bands['bottom'], box_w, box_h), span_faces, 0.01 * height):
285
+ free = [b for b in ('top', 'middle') if not _overlaps((box_x, bands[b], box_w, box_h), span_faces, 0.01 * height)]
286
+ if free:
287
+ band = free[0]
288
+ moved.append({'event': index, 'start': round(chunk[0]['start'], 3), 'end': round(chunk[-1]['end'], 3), 'from': 'bottom', 'to': band,
289
+ 'reason': 'face box overlaps the bottom caption band'})
290
+ else:
291
+ warnings.append(f'event {index} ({chunk[0]["start"]:.2f}s): faces overlap every caption band; left at bottom')
292
+ events.append({'start': chunk[0]['start'], 'end': chunk[-1]['end'], 'lines': lines, 'text': _join([u['text'] for u in chunk], cjk),
293
+ 'words': [{'text': u['text'], 'start': round(u['start'], 3), 'end': round(u['end'], 3)} for u in chunk],
294
+ 'position': {'band': band, 'x': round(box_x), 'y': round(bands[band]), 'w': round(box_w), 'h': round(box_h),
295
+ 'anchor': {'bottom': 'bottom-center', 'top': 'top-center', 'middle': 'middle-center'}[band]},
296
+ 'moved_for_face': band != 'bottom'})
297
+ # Hold short captions up to min_duration, never into the next caption.
298
+ for index, event in enumerate(events):
299
+ next_start = events[index + 1]['start'] if index + 1 < len(events) else float('inf')
300
+ event['end'] = round(min(max(event['end'], event['start'] + min_duration), next_start), 3)
301
+ event['start'] = round(event['start'], 3)
302
+ if event['end'] - event['start'] < min_duration - 1e-6:
303
+ warnings.append(f"event {index} at {event['start']:.2f}s is {event['end'] - event['start']:.2f}s (< min_duration): the next caption follows too closely")
304
+
305
+ ass_path = Path(out)
306
+ srt_path, json_path = ass_path.with_suffix('.srt'), ass_path.with_suffix('.json')
307
+ ass_path.parent.mkdir(parents=True, exist_ok=True)
308
+
309
+ subs = pysubs2.SSAFile()
310
+ subs.info.update({'PlayResX': str(width), 'PlayResY': str(height), 'WrapStyle': '2'}) # WrapStyle 2: only our own line breaks
311
+ subs.styles = {'Default': pysubs2.SSAStyle(
312
+ fontname=font_name, fontsize=font_size, bold=True, primarycolor=pysubs2.Color(255, 255, 255, 0), outlinecolor=pysubs2.Color(0, 0, 0, 0),
313
+ backcolor=pysubs2.Color(0, 0, 0, 128), outline=max(2, round(font_size * 0.07)), shadow=1, alignment=pysubs2.Alignment.BOTTOM_CENTER,
314
+ marginl=round(safe_left), marginr=round(width - safe_right), marginv=round(margins['bottom'] * height))}
315
+ for event in events:
316
+ override = ''
317
+ if event['position']['band'] != 'bottom':
318
+ position = event['position']
319
+ an, y = (8, position['y']) if position['band'] == 'top' else (5, position['y'] + position['h'] / 2)
320
+ override = f"{{\\an{an}\\pos({round(width / 2)},{round(y)})}}"
321
+ subs.append(pysubs2.SSAEvent(start=round(event['start'] * 1000), end=round(event['end'] * 1000), style='Default', text=override + '\\N'.join(event['lines'])))
322
+ subs.save(str(ass_path), encoding='utf-8')
323
+
324
+ srt_path.write_text(''.join(f"{i}\n{_srt_time(e['start'])} --> {_srt_time(e['end'])}\n" + '\n'.join(e['lines']) + '\n\n' for i, e in enumerate(events, 1)), encoding='utf-8')
325
+ result = {'ass': str(ass_path), 'srt': str(srt_path), 'json': str(json_path), 'event_count': len(events), 'language': language or None,
326
+ 'script_alignment': alignment, 'moved_for_faces': moved, 'warnings': warnings, 'safe': safe_name,
327
+ 'font': font_name, 'font_size': font_size, 'max_chars': max_chars, 'max_lines': max_lines}
328
+ json_path.write_text(json.dumps({'meta': {k: result[k] for k in ('language', 'safe', 'font', 'font_size', 'max_chars', 'max_lines')} | {'width': width, 'height': height},
329
+ 'events': events}, ensure_ascii=False, indent=1), encoding='utf-8')
330
+ return result
331
+
332
+
333
+ def summary_lines(result: dict) -> list[str]:
334
+ lines = [f"captions: {result['event_count']} events, language {result['language'] or 'auto'}, safe={result['safe']}, "
335
+ f"{result['font']} {result['font_size']}px, max {result['max_chars']} chars x {result['max_lines']} lines",
336
+ f" ass {result['ass']} srt {result['srt']} json {result['json']}"]
337
+ if result['script_alignment']:
338
+ stats = result['script_alignment']
339
+ lines.append(f" script: {stats['match_ratio']:.0%} of {stats['script_units']} units matched Whisper; {stats['unmatched_span_count']} unmatched spans")
340
+ lines += [f" {s['start']:.2f}-{s['end']:.2f}s script '{s['script']}' vs heard '{s['transcript']}'" for s in stats['unmatched_spans'][:5]]
341
+ lines += [f" moved event {m['event']} ({m['start']:.2f}-{m['end']:.2f}s) {m['from']} -> {m['to']}: {m['reason']}" for m in result['moved_for_faces']]
342
+ lines += [f' warning: {w}' for w in result['warnings']]
343
+ return lines
rn/capture.py ADDED
@@ -0,0 +1,97 @@
1
+ """rn capture: render an HTML page frame by frame to an RGBA PNG sequence (the EDL takes it as {"frames": ...}).
2
+
3
+ Contract (after Katana's render.py + page.html): the page defines `window.renderFrame(i)` (may return a Promise) that
4
+ draws frame i completely; the frame index IS the time, so nothing depends on the wall clock and any frame renders the
5
+ same in any order or shard. Before the page's scripts run, `window.RN = {fps, frames, width, height, shard}` is set
6
+ and the effects library is available as `window.RNFX` (rn fx). An optional `window.rnReady` Promise is awaited once
7
+ (fonts, images, data). The page background stays transparent unless the page paints it.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import multiprocessing as mp
13
+ import time
14
+ from pathlib import Path
15
+
16
+ from .ff import RnError
17
+
18
+ READY_TIMEOUT_MS = 30_000
19
+ FRAME_TIMEOUT_MS = 20_000
20
+
21
+
22
+ def _init_script(fps: float, frames: int, width: int, height: int, shard: int) -> str:
23
+ from .fx import js_path
24
+ meta = json.dumps({'fps': fps, 'frames': frames, 'width': width, 'height': height, 'shard': shard})
25
+ return f'window.RN = {meta};\n' + js_path().read_text()
26
+
27
+
28
+ def _shard(args: tuple) -> dict:
29
+ url, out_dir, indices, fps, frames, width, height, shard, scale = args
30
+ from playwright.sync_api import Error as PlaywrightError, sync_playwright
31
+
32
+ errors: list[str] = []
33
+ started = time.monotonic()
34
+ with sync_playwright() as playwright:
35
+ browser = playwright.chromium.launch(args=['--disable-gpu', '--font-render-hinting=none'])
36
+ try:
37
+ page = browser.new_page(viewport={'width': width, 'height': height}, device_scale_factor=scale)
38
+ page.on('pageerror', lambda error: errors.append(str(error)))
39
+ page.add_init_script(_init_script(fps, frames, width, height, shard))
40
+ page.goto(url, wait_until='load', timeout=READY_TIMEOUT_MS)
41
+ page.evaluate('async () => { if (window.rnReady) await window.rnReady; await document.fonts.ready; }')
42
+ if not page.evaluate('typeof window.renderFrame === "function"'):
43
+ raise RnError(f'{url}: the page does not define window.renderFrame(i)')
44
+ for index in indices:
45
+ page.evaluate('async (i) => { await window.renderFrame(i); }', index)
46
+ page.screenshot(path=str(Path(out_dir) / f'{index + 1:05d}.png'), omit_background=True,
47
+ timeout=FRAME_TIMEOUT_MS, animations='disabled')
48
+ if errors:
49
+ raise RnError(f'frame {index}: page error: {errors[0]}')
50
+ except PlaywrightError as error:
51
+ raise RnError(f'shard {shard}: {str(error).splitlines()[0]}') from None
52
+ finally:
53
+ browser.close()
54
+ return {'shard': shard, 'frames': len(indices), 'seconds': round(time.monotonic() - started, 2)}
55
+
56
+
57
+ def capture_command(page: str, *, out: str, frames: int, width: int, height: int, fps: float, shards: int,
58
+ start: int = 0, scale: float = 1.0) -> dict:
59
+ source = Path(page)
60
+ if not source.exists():
61
+ raise RnError(f'no such page: {page}')
62
+ if frames < 1 or width < 2 or height < 2:
63
+ raise RnError('--frames, --size must be positive')
64
+ try:
65
+ import playwright # noqa: F401
66
+ except ImportError:
67
+ raise RnError("rn capture needs playwright (extra \"capture\"): uv tool install 'renoise-kit[all]' && playwright install chromium") from None
68
+ out_dir = Path(out)
69
+ out_dir.mkdir(parents=True, exist_ok=True)
70
+ for stale in out_dir.glob('*.png'):
71
+ stale.unlink()
72
+ indices = list(range(start, start + frames))
73
+ shards = max(1, min(shards, len(indices)))
74
+ # Contiguous runs per shard: each Chromium boots once (~1-3 s), so short renders gain nothing from more shards.
75
+ size = -(-len(indices) // shards)
76
+ jobs = [(source.resolve().as_uri(), str(out_dir), indices[k * size:(k + 1) * size], fps, start + frames, width, height, k, scale)
77
+ for k in range(shards) if indices[k * size:(k + 1) * size]]
78
+ started = time.monotonic()
79
+ if len(jobs) == 1:
80
+ reports = [_shard(jobs[0])]
81
+ else:
82
+ with mp.get_context('spawn').Pool(len(jobs)) as pool:
83
+ reports = pool.map(_shard, jobs)
84
+ written = sorted(out_dir.glob('*.png'))
85
+ if len(written) != frames:
86
+ raise RnError(f'wrote {len(written)} frames, expected {frames}')
87
+ elapsed = time.monotonic() - started
88
+ return {'dir': str(out_dir), 'pattern': str(out_dir / '%05d.png'), 'first_number': start + 1, 'frames': frames,
89
+ 'fps': fps, 'size': [round(width * scale), round(height * scale)], 'shards': reports,
90
+ 'seconds': round(elapsed, 2), 'fps_rendered': round(frames / elapsed, 1) if elapsed else None,
91
+ 'edl': {'frames': f'{out_dir.name}/%05d.png', 'fps': fps}}
92
+
93
+
94
+ def summary_lines(result: dict) -> list[str]:
95
+ return [f'{result["pattern"]} {result["frames"]} frames from #{result["first_number"]:05d} '
96
+ f'{result["size"][0]}x{result["size"][1]} {result["seconds"]}s ({result["fps_rendered"]} fps, {len(result["shards"])} shards)',
97
+ f'EDL: {json.dumps(result["edl"])}']