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/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"])}']
|