renoise-kit 0.5.0__tar.gz → 0.5.1__tar.gz
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/renoise_kit.egg-info → renoise_kit-0.5.1}/PKG-INFO +12 -8
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/README.md +11 -7
- {renoise_kit-0.5.0 → renoise_kit-0.5.1/renoise_kit.egg-info}/PKG-INFO +12 -8
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/renoise_kit.egg-info/SOURCES.txt +1 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/__init__.py +1 -1
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/assemble.py +4 -2
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/assets.py +1 -1
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/audio.py +1 -1
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/captions.py +9 -5
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/capture.py +2 -2
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/cli.py +8 -2
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/editops.py +3 -3
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/edl.py +20 -12
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/ff.py +21 -3
- renoise_kit-0.5.1/rn/fonts.py +154 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/listen.py +2 -2
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/qa.py +3 -3
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/ref.py +1 -1
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/text.py +2 -2
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_captions.py +9 -2
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_capture.py +6 -6
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_edl_v3.py +9 -8
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_fx.py +1 -1
- renoise_kit-0.5.1/tests/test_portability.py +56 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_rn.py +8 -7
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_v3_commands.py +3 -3
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_v4_commands.py +5 -5
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_v5_data.py +12 -12
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_v5_measure.py +8 -8
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_v5_time.py +2 -2
- renoise_kit-0.5.0/rn/fonts.py +0 -87
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/LICENSE +0 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/pyproject.toml +0 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/renoise_kit.egg-info/dependency_links.txt +0 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/renoise_kit.egg-info/entry_points.txt +0 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/renoise_kit.egg-info/requires.txt +0 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/renoise_kit.egg-info/top_level.txt +0 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/__main__.py +0 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/align.py +0 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/compare.py +0 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/cuts.py +0 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/export.py +0 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/faces.py +0 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/frames.py +0 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/fx/__init__.py +0 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/fx/fx.js +0 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/matte.py +0 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/ref_guide.md +0 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/signal.py +0 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/setup.cfg +0 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_faces.py +0 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_listen.py +0 -0
- {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_v5_composite.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: renoise-kit
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: rn: frame-exact probing, contact sheets, EDL assembly, finishing and QA for AI video edits
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://renoise.ai
|
|
@@ -58,17 +58,20 @@ machine.
|
|
|
58
58
|
|
|
59
59
|
`rn` drives the system's ffmpeg and ffprobe, version 7 or later, built with libass for burned-in subtitles.
|
|
60
60
|
|
|
61
|
-
| | macOS | Debian / Ubuntu |
|
|
62
|
-
|
|
63
|
-
| ffmpeg with libass | `brew install ffmpeg-full`, then `export PATH="$(brew --prefix ffmpeg-full)/bin:$PATH"` (keg-only; Homebrew's plain `ffmpeg` has no libass) | `apt install ffmpeg fontconfig` |
|
|
64
|
-
| OCR for `rn text` (optional) | comes with `ffmpeg-full` | `apt install tesseract-ocr tesseract-ocr-chi-sim` |
|
|
61
|
+
| | macOS | Debian / Ubuntu | Windows 10 / 11 |
|
|
62
|
+
|---|---|---|---|
|
|
63
|
+
| ffmpeg with libass | `brew install ffmpeg-full`, then `export PATH="$(brew --prefix ffmpeg-full)/bin:$PATH"` (keg-only; Homebrew's plain `ffmpeg` has no libass) | `apt install ffmpeg fontconfig` | `winget install Gyan.FFmpeg` (the full build, libass and fontconfig included), then open a new terminal |
|
|
64
|
+
| OCR for `rn text` (optional) | comes with `ffmpeg-full` | `apt install tesseract-ocr tesseract-ocr-chi-sim` | `winget install UB-Mannheim.TesseractOCR`, then add `C:\Program Files\Tesseract-OCR` to `PATH` |
|
|
65
65
|
|
|
66
66
|
```sh
|
|
67
|
-
uv tool install
|
|
67
|
+
uv tool install "renoise-kit[all]" # or: pipx install "renoise-kit[all]"
|
|
68
68
|
playwright install chromium # only for rn capture
|
|
69
69
|
rn --version
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
+
The same commands work in a POSIX shell, PowerShell and cmd (hence the double quotes). On Windows `rn fonts` reads the
|
|
73
|
+
system font folders directly when fontconfig is not installed, and `rn` writes UTF-8 when its output goes to a pipe.
|
|
74
|
+
|
|
72
75
|
The core install is Pillow and numpy. Heavier dependencies come as extras, so you can install only what you use. When
|
|
73
76
|
a command needs a missing extra, its error line names the extra to install.
|
|
74
77
|
|
|
@@ -286,8 +289,9 @@ In the Renoise media sandbox, `rn` comes preinstalled at the version pinned by t
|
|
|
286
289
|
uv run pytest -q # Python 3.12 (as the sandbox); needs ffmpeg with libass; playwright install chromium for capture tests
|
|
287
290
|
```
|
|
288
291
|
|
|
289
|
-
CI runs on Debian trixie with ffmpeg 7.1 (the same base as the sandbox)
|
|
290
|
-
|
|
292
|
+
CI runs on Debian trixie with ffmpeg 7.1 (the same base as the sandbox), on macOS with ffmpeg-full and on Windows with
|
|
293
|
+
gyan.dev's full build (with Windows' own default encoding, so a file read without `encoding='utf-8'` fails there). The
|
|
294
|
+
tests generate their own media.
|
|
291
295
|
|
|
292
296
|
To release:
|
|
293
297
|
|
|
@@ -22,17 +22,20 @@ machine.
|
|
|
22
22
|
|
|
23
23
|
`rn` drives the system's ffmpeg and ffprobe, version 7 or later, built with libass for burned-in subtitles.
|
|
24
24
|
|
|
25
|
-
| | macOS | Debian / Ubuntu |
|
|
26
|
-
|
|
27
|
-
| ffmpeg with libass | `brew install ffmpeg-full`, then `export PATH="$(brew --prefix ffmpeg-full)/bin:$PATH"` (keg-only; Homebrew's plain `ffmpeg` has no libass) | `apt install ffmpeg fontconfig` |
|
|
28
|
-
| OCR for `rn text` (optional) | comes with `ffmpeg-full` | `apt install tesseract-ocr tesseract-ocr-chi-sim` |
|
|
25
|
+
| | macOS | Debian / Ubuntu | Windows 10 / 11 |
|
|
26
|
+
|---|---|---|---|
|
|
27
|
+
| ffmpeg with libass | `brew install ffmpeg-full`, then `export PATH="$(brew --prefix ffmpeg-full)/bin:$PATH"` (keg-only; Homebrew's plain `ffmpeg` has no libass) | `apt install ffmpeg fontconfig` | `winget install Gyan.FFmpeg` (the full build, libass and fontconfig included), then open a new terminal |
|
|
28
|
+
| OCR for `rn text` (optional) | comes with `ffmpeg-full` | `apt install tesseract-ocr tesseract-ocr-chi-sim` | `winget install UB-Mannheim.TesseractOCR`, then add `C:\Program Files\Tesseract-OCR` to `PATH` |
|
|
29
29
|
|
|
30
30
|
```sh
|
|
31
|
-
uv tool install
|
|
31
|
+
uv tool install "renoise-kit[all]" # or: pipx install "renoise-kit[all]"
|
|
32
32
|
playwright install chromium # only for rn capture
|
|
33
33
|
rn --version
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
+
The same commands work in a POSIX shell, PowerShell and cmd (hence the double quotes). On Windows `rn fonts` reads the
|
|
37
|
+
system font folders directly when fontconfig is not installed, and `rn` writes UTF-8 when its output goes to a pipe.
|
|
38
|
+
|
|
36
39
|
The core install is Pillow and numpy. Heavier dependencies come as extras, so you can install only what you use. When
|
|
37
40
|
a command needs a missing extra, its error line names the extra to install.
|
|
38
41
|
|
|
@@ -250,8 +253,9 @@ In the Renoise media sandbox, `rn` comes preinstalled at the version pinned by t
|
|
|
250
253
|
uv run pytest -q # Python 3.12 (as the sandbox); needs ffmpeg with libass; playwright install chromium for capture tests
|
|
251
254
|
```
|
|
252
255
|
|
|
253
|
-
CI runs on Debian trixie with ffmpeg 7.1 (the same base as the sandbox)
|
|
254
|
-
|
|
256
|
+
CI runs on Debian trixie with ffmpeg 7.1 (the same base as the sandbox), on macOS with ffmpeg-full and on Windows with
|
|
257
|
+
gyan.dev's full build (with Windows' own default encoding, so a file read without `encoding='utf-8'` fails there). The
|
|
258
|
+
tests generate their own media.
|
|
255
259
|
|
|
256
260
|
To release:
|
|
257
261
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: renoise-kit
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: rn: frame-exact probing, contact sheets, EDL assembly, finishing and QA for AI video edits
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://renoise.ai
|
|
@@ -58,17 +58,20 @@ machine.
|
|
|
58
58
|
|
|
59
59
|
`rn` drives the system's ffmpeg and ffprobe, version 7 or later, built with libass for burned-in subtitles.
|
|
60
60
|
|
|
61
|
-
| | macOS | Debian / Ubuntu |
|
|
62
|
-
|
|
63
|
-
| ffmpeg with libass | `brew install ffmpeg-full`, then `export PATH="$(brew --prefix ffmpeg-full)/bin:$PATH"` (keg-only; Homebrew's plain `ffmpeg` has no libass) | `apt install ffmpeg fontconfig` |
|
|
64
|
-
| OCR for `rn text` (optional) | comes with `ffmpeg-full` | `apt install tesseract-ocr tesseract-ocr-chi-sim` |
|
|
61
|
+
| | macOS | Debian / Ubuntu | Windows 10 / 11 |
|
|
62
|
+
|---|---|---|---|
|
|
63
|
+
| ffmpeg with libass | `brew install ffmpeg-full`, then `export PATH="$(brew --prefix ffmpeg-full)/bin:$PATH"` (keg-only; Homebrew's plain `ffmpeg` has no libass) | `apt install ffmpeg fontconfig` | `winget install Gyan.FFmpeg` (the full build, libass and fontconfig included), then open a new terminal |
|
|
64
|
+
| OCR for `rn text` (optional) | comes with `ffmpeg-full` | `apt install tesseract-ocr tesseract-ocr-chi-sim` | `winget install UB-Mannheim.TesseractOCR`, then add `C:\Program Files\Tesseract-OCR` to `PATH` |
|
|
65
65
|
|
|
66
66
|
```sh
|
|
67
|
-
uv tool install
|
|
67
|
+
uv tool install "renoise-kit[all]" # or: pipx install "renoise-kit[all]"
|
|
68
68
|
playwright install chromium # only for rn capture
|
|
69
69
|
rn --version
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
+
The same commands work in a POSIX shell, PowerShell and cmd (hence the double quotes). On Windows `rn fonts` reads the
|
|
73
|
+
system font folders directly when fontconfig is not installed, and `rn` writes UTF-8 when its output goes to a pipe.
|
|
74
|
+
|
|
72
75
|
The core install is Pillow and numpy. Heavier dependencies come as extras, so you can install only what you use. When
|
|
73
76
|
a command needs a missing extra, its error line names the extra to install.
|
|
74
77
|
|
|
@@ -286,8 +289,9 @@ In the Renoise media sandbox, `rn` comes preinstalled at the version pinned by t
|
|
|
286
289
|
uv run pytest -q # Python 3.12 (as the sandbox); needs ffmpeg with libass; playwright install chromium for capture tests
|
|
287
290
|
```
|
|
288
291
|
|
|
289
|
-
CI runs on Debian trixie with ffmpeg 7.1 (the same base as the sandbox)
|
|
290
|
-
|
|
292
|
+
CI runs on Debian trixie with ffmpeg 7.1 (the same base as the sandbox), on macOS with ffmpeg-full and on Windows with
|
|
293
|
+
gyan.dev's full build (with Windows' own default encoding, so a file read without `encoding='utf-8'` fails there). The
|
|
294
|
+
tests generate their own media.
|
|
291
295
|
|
|
292
296
|
To release:
|
|
293
297
|
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""Renoise kit (`rn`, PyPI `renoise-kit`). Released on its own; the media sandbox template pins a version."""
|
|
2
|
-
__version__ = '0.5.
|
|
2
|
+
__version__ = '0.5.1'
|
|
@@ -211,7 +211,9 @@ def _join(edit: Edit, slots: list[Path], work: Path) -> Path:
|
|
|
211
211
|
'-map', '[v]', *INTERMEDIATE, '-r', str(edit.fps), '-y', str(piece)])
|
|
212
212
|
pieces.append(piece)
|
|
213
213
|
listing = work / 'main.txt'
|
|
214
|
-
|
|
214
|
+
# Forward slashes work on every OS (a backslash is the demuxer's escape character); a quote closes and reopens.
|
|
215
|
+
quoted = (piece.resolve().as_posix().replace("'", "'\\''") for piece in pieces)
|
|
216
|
+
listing.write_text(''.join(f"file '{path}'\n" for path in quoted), encoding='utf-8')
|
|
215
217
|
return listing
|
|
216
218
|
|
|
217
219
|
|
|
@@ -467,7 +469,7 @@ def _cached(cache: Path | None, key: str, suffix: str, work: Path, render) -> Pa
|
|
|
467
469
|
def _fingerprint(path: Path, placeholders: bool) -> str:
|
|
468
470
|
"""A nested edit's render depends on its text and, recursively, on every file it reads."""
|
|
469
471
|
edit = load(path, placeholders=placeholders)
|
|
470
|
-
parts = [__version__, path.read_text(), placeholders]
|
|
472
|
+
parts = [__version__, path.read_text(encoding='utf-8'), placeholders]
|
|
471
473
|
for clip in edit.base + edit.overlays:
|
|
472
474
|
parts.append(_fingerprint(clip.source, placeholders) if clip.kind == 'edit' else _files(clip.source, clip.kind == 'sequence'))
|
|
473
475
|
parts += [_files(item.source) for item in edit.audio] + [_files(edit.subtitles)]
|
|
@@ -56,7 +56,7 @@ def fetch_checked(source: str, digest: str, cached: Path, *, name: str) -> Path:
|
|
|
56
56
|
if hasher.hexdigest() != digest:
|
|
57
57
|
partial.unlink()
|
|
58
58
|
raise RnError(f'{name}: checksum mismatch, refusing the download')
|
|
59
|
-
partial.
|
|
59
|
+
partial.replace(cached) # rename() refuses to overwrite on Windows
|
|
60
60
|
return cached
|
|
61
61
|
|
|
62
62
|
|
|
@@ -20,7 +20,7 @@ MAX_LIMITER_PASSES = 6
|
|
|
20
20
|
def measure(path: str | Path, *, stream: str = 'a:0') -> dict:
|
|
21
21
|
"""Integrated loudness (LUFS), loudness range and true peak (dBTP) via ebur128."""
|
|
22
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)
|
|
23
|
+
'-af', 'ebur128=peak=true:framelog=quiet', '-f', 'null', '-'], capture_output=True, text=True, encoding='utf-8', errors='replace')
|
|
24
24
|
if result.returncode != 0:
|
|
25
25
|
raise RnError(f'loudness measurement failed: {result.stderr.strip().splitlines()[-1:]}')
|
|
26
26
|
summary = result.stderr[result.stderr.rfind('Summary:'):]
|
|
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
import json
|
|
9
9
|
import re
|
|
10
|
+
import sys
|
|
10
11
|
import unicodedata
|
|
11
12
|
from difflib import SequenceMatcher
|
|
12
13
|
from pathlib import Path
|
|
@@ -27,7 +28,9 @@ OPENERS = set('「『(([“‘《〈【')
|
|
|
27
28
|
SENTENCE_END = set('。!?.!?…‼⁇')
|
|
28
29
|
SOFT_BREAK = set(',、;:,;:—')
|
|
29
30
|
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
|
+
FONTS = {'zh': 'Noto Sans CJK SC', 'yue': 'Noto Sans CJK SC', 'ja': 'Noto Sans CJK JP', 'ko': 'Noto Sans CJK KR', '': 'Noto Sans'}
|
|
32
|
+
# Windows ships no Noto face and libass would fall back silently, so name the faces every Windows has.
|
|
33
|
+
WINDOWS_FONTS = {'zh': 'Microsoft YaHei', 'yue': 'Microsoft YaHei', 'ja': 'Yu Gothic', 'ko': 'Malgun Gothic', '': 'Segoe UI'}
|
|
31
34
|
PAUSE_BREAK = 0.5 # seconds of silence that ends a caption
|
|
32
35
|
|
|
33
36
|
|
|
@@ -220,7 +223,7 @@ def captions_command(words_json: str, *, script: str | None = None, faces_json:
|
|
|
220
223
|
if width <= 0 or height <= 0 or max_lines < 1 or min_duration > max_duration:
|
|
221
224
|
raise RnError('captions: width/height must be positive, max_lines >= 1, min_duration <= max_duration')
|
|
222
225
|
try:
|
|
223
|
-
listen = json.loads(Path(words_json).read_text(encoding='utf-8'))
|
|
226
|
+
listen = json.loads(Path(words_json).read_text(encoding='utf-8-sig'))
|
|
224
227
|
except (OSError, ValueError) as error:
|
|
225
228
|
raise RnError(f'cannot read words json {words_json}: {error}') from error
|
|
226
229
|
words = [w for w in listen.get('words', []) if str(w.get('word', '')).strip()]
|
|
@@ -234,7 +237,7 @@ def captions_command(words_json: str, *, script: str | None = None, faces_json:
|
|
|
234
237
|
alignment = None
|
|
235
238
|
if script is not None:
|
|
236
239
|
try:
|
|
237
|
-
script_text = Path(script).read_text(encoding='utf-8')
|
|
240
|
+
script_text = Path(script).read_text(encoding='utf-8-sig')
|
|
238
241
|
except OSError as error:
|
|
239
242
|
raise RnError(f'cannot read script {script}: {error}') from error
|
|
240
243
|
script_units = _script_units(script_text, cjk)
|
|
@@ -251,7 +254,8 @@ def captions_command(words_json: str, *, script: str | None = None, faces_json:
|
|
|
251
254
|
max_chars = max_chars or max(4, round(BASE_CHARS['cjk' if cjk else 'latin'] * width / 1080))
|
|
252
255
|
em_per_char = 1.0 if cjk else LATIN_EM
|
|
253
256
|
font_size = round(min(height * (0.03 if height > width else 0.05), safe_width / (max_chars * em_per_char)))
|
|
254
|
-
|
|
257
|
+
fonts = WINDOWS_FONTS if sys.platform == 'win32' else FONTS
|
|
258
|
+
font_name = fonts.get(language.split('-')[0].lower(), fonts['']) if language else fonts['zh' if cjk else '']
|
|
255
259
|
|
|
256
260
|
chunks = _chunk(units, max_chars=max_chars, max_lines=max_lines, min_duration=min_duration, max_duration=max_duration, cjk=cjk)
|
|
257
261
|
for chunk in chunks:
|
|
@@ -263,7 +267,7 @@ def captions_command(words_json: str, *, script: str | None = None, faces_json:
|
|
|
263
267
|
scale = (1.0, 1.0)
|
|
264
268
|
if faces_json:
|
|
265
269
|
try:
|
|
266
|
-
faces = json.loads(Path(faces_json).read_text(encoding='utf-8'))
|
|
270
|
+
faces = json.loads(Path(faces_json).read_text(encoding='utf-8-sig'))
|
|
267
271
|
except (OSError, ValueError) as error:
|
|
268
272
|
raise RnError(f'cannot read faces json {faces_json}: {error}') from error
|
|
269
273
|
frames = faces.get('frames', [])
|
|
@@ -22,7 +22,7 @@ FRAME_TIMEOUT_MS = 20_000
|
|
|
22
22
|
def _init_script(fps: float, frames: int, width: int, height: int, shard: int) -> str:
|
|
23
23
|
from .fx import js_path
|
|
24
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()
|
|
25
|
+
return f'window.RN = {meta};\n' + js_path().read_text(encoding='utf-8')
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def _shard(args: tuple) -> dict:
|
|
@@ -64,7 +64,7 @@ def capture_command(page: str, *, out: str, frames: int, width: int, height: int
|
|
|
64
64
|
try:
|
|
65
65
|
import playwright # noqa: F401
|
|
66
66
|
except ImportError:
|
|
67
|
-
raise RnError(
|
|
67
|
+
raise RnError('rn capture needs playwright (extra "capture"): uv tool install "renoise-kit[all]" && playwright install chromium') from None
|
|
68
68
|
out_dir = Path(out)
|
|
69
69
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
70
70
|
for stale in out_dir.glob('*.png'):
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import argparse
|
|
11
11
|
import json
|
|
12
|
+
import os
|
|
12
13
|
import shutil
|
|
13
14
|
import sys
|
|
14
15
|
from pathlib import Path
|
|
@@ -492,6 +493,11 @@ def _run_summarized(args) -> tuple[dict, list[str]]:
|
|
|
492
493
|
|
|
493
494
|
|
|
494
495
|
def main(argv: list[str] | None = None) -> int:
|
|
496
|
+
if sys.platform == 'win32' and not os.environ.get('PYTHONIOENCODING'):
|
|
497
|
+
# A pipe (an agent reading rn) would get the ANSI code page, which cannot hold every file name or caption.
|
|
498
|
+
for stream in (sys.stdout, sys.stderr):
|
|
499
|
+
if not stream.isatty() and hasattr(stream, 'reconfigure'):
|
|
500
|
+
stream.reconfigure(encoding='utf-8', errors='replace')
|
|
495
501
|
args = _parser().parse_args(argv)
|
|
496
502
|
for tool in ('ffmpeg', 'ffprobe'):
|
|
497
503
|
if not shutil.which(tool):
|
|
@@ -536,7 +542,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
536
542
|
except ModuleNotFoundError as error:
|
|
537
543
|
extra = EXTRAS.get(args.command, 'all')
|
|
538
544
|
print(f"rn: error: rn {args.command} needs the Python module {error.name} (extra \"{extra}\"): "
|
|
539
|
-
|
|
545
|
+
'uv tool install "renoise-kit[all]"', file=sys.stderr)
|
|
540
546
|
return 1
|
|
541
547
|
except Exception as error: # a kit bug: keep the traceback for the report, end on the usual one-liner
|
|
542
548
|
import traceback
|
|
@@ -545,7 +551,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
545
551
|
return 1
|
|
546
552
|
if args.json:
|
|
547
553
|
Path(args.json).parent.mkdir(parents=True, exist_ok=True)
|
|
548
|
-
Path(args.json).write_text(json.dumps(result, indent=1, ensure_ascii=False, default=str))
|
|
554
|
+
Path(args.json).write_text(json.dumps(result, indent=1, ensure_ascii=False, default=str), encoding='utf-8')
|
|
549
555
|
if args.command in SUMMARIZED:
|
|
550
556
|
print('\n'.join(lines))
|
|
551
557
|
else:
|
|
@@ -17,7 +17,7 @@ from .ff import RnError
|
|
|
17
17
|
|
|
18
18
|
def _read(path: Path) -> dict:
|
|
19
19
|
try:
|
|
20
|
-
return json.loads(path.read_text())
|
|
20
|
+
return json.loads(path.read_text(encoding='utf-8-sig'))
|
|
21
21
|
except FileNotFoundError:
|
|
22
22
|
raise RnError(f'no such file: {path}') from None
|
|
23
23
|
except json.JSONDecodeError as error:
|
|
@@ -27,7 +27,7 @@ def _read(path: Path) -> dict:
|
|
|
27
27
|
def _write_checked(path: Path, raw: dict) -> dict:
|
|
28
28
|
"""Lays the edited JSON out next to the original (relative paths resolve the same), then replaces it."""
|
|
29
29
|
trial = path.with_name(f'.{path.stem}.rn-edit.json')
|
|
30
|
-
trial.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + '\n')
|
|
30
|
+
trial.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
|
31
31
|
try:
|
|
32
32
|
summary = load(trial).summary()
|
|
33
33
|
except RnError:
|
|
@@ -209,7 +209,7 @@ def _destination(raw: dict, before: str | None, after: str | None, track: str |
|
|
|
209
209
|
|
|
210
210
|
def insert_op(path: Path, clip_json: str, before: str | None, after: str | None, track: str | None) -> dict:
|
|
211
211
|
"""Adds a clip given as JSON (or @file.json)."""
|
|
212
|
-
text = Path(clip_json[1:]).read_text() if clip_json.startswith('@') else clip_json
|
|
212
|
+
text = Path(clip_json[1:]).read_text(encoding='utf-8-sig') if clip_json.startswith('@') else clip_json
|
|
213
213
|
try:
|
|
214
214
|
clip = json.loads(text)
|
|
215
215
|
except json.JSONDecodeError as error:
|
|
@@ -242,7 +242,7 @@ class Timeline:
|
|
|
242
242
|
path = _resolve(self.edit_dir, value, where)
|
|
243
243
|
if str(path) not in self._listen:
|
|
244
244
|
try:
|
|
245
|
-
self._listen[str(path)] = json.loads(path.read_text())
|
|
245
|
+
self._listen[str(path)] = json.loads(path.read_text(encoding='utf-8-sig'))
|
|
246
246
|
except json.JSONDecodeError as error:
|
|
247
247
|
raise RnError(f'{where}: {path.name} is not JSON ({error}); write it with rn listen --json') from None
|
|
248
248
|
return self._listen[str(path)]
|
|
@@ -388,21 +388,29 @@ def _ident(raw: dict, where: str) -> tuple[str | None, object]:
|
|
|
388
388
|
return ident, raw.get('meta')
|
|
389
389
|
|
|
390
390
|
|
|
391
|
-
_SANS_CJK = ('Noto Sans CJK', 'NotoSansCJK', 'PingFang', 'Source Han Sans', 'SourceHanSans', 'Hiragino Sans', 'Heiti',
|
|
391
|
+
_SANS_CJK = ('Noto Sans CJK', 'NotoSansCJK', 'NotoSansSC', 'PingFang', 'Source Han Sans', 'SourceHanSans', 'Hiragino Sans', 'Heiti',
|
|
392
|
+
'WenQuanYi', 'wqy', 'msyh', 'Microsoft YaHei', 'Deng', 'simhei')
|
|
393
|
+
_CJK_FILES = _SANS_CJK + ('simsun', 'STHeiti', 'Songti', 'NotoSerifCJK') # without fontconfig: file names to try, sans first
|
|
392
394
|
|
|
393
395
|
|
|
394
396
|
def _font(size: int):
|
|
395
|
-
"""A face that covers Chinese when
|
|
396
|
-
fc-match ':lang=zh' is not enough: it falls back to any font; fc-list only lists fonts that cover the language.
|
|
397
|
+
"""A face that covers Chinese when one is installed (placeholder text is often a prompt), else Pillow's default.
|
|
398
|
+
fc-match ':lang=zh' is not enough: it falls back to any font; fc-list only lists fonts that cover the language.
|
|
399
|
+
Without fontconfig (Windows) the system font folders are searched for well-known Chinese faces by file name."""
|
|
397
400
|
from PIL import ImageFont
|
|
401
|
+
from .fonts import FONT_SUFFIXES, font_files
|
|
398
402
|
if shutil.which('fc-list'):
|
|
399
|
-
files = subprocess.run(['fc-list', ':lang=zh-cn', '--format', '%{file}\n'], capture_output=True, text=True).stdout.split('\n')
|
|
400
|
-
files = [f for f in files if Path(f).suffix.lower() in
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
403
|
+
files = subprocess.run(['fc-list', ':lang=zh-cn', '--format', '%{file}\n'], capture_output=True, text=True, encoding='utf-8', errors='replace').stdout.split('\n')
|
|
404
|
+
files = [f for f in files if Path(f).suffix.lower() in FONT_SUFFIXES]
|
|
405
|
+
files = sorted(files, key=lambda f: not any(name in f for name in _SANS_CJK))
|
|
406
|
+
else:
|
|
407
|
+
rank = lambda f: next((i for i, name in enumerate(_CJK_FILES) if name.lower() in f.name.lower()), None) # noqa: E731
|
|
408
|
+
files = [str(f) for f in sorted((f for f in font_files() if rank(f) is not None), key=rank)]
|
|
409
|
+
for path in files:
|
|
410
|
+
try:
|
|
411
|
+
return ImageFont.truetype(path, size)
|
|
412
|
+
except OSError:
|
|
413
|
+
continue
|
|
406
414
|
return ImageFont.load_default(size=size)
|
|
407
415
|
|
|
408
416
|
|
|
@@ -765,7 +773,7 @@ def load(path: str | Path, *, placeholders: bool = False, _stack: tuple[Path, ..
|
|
|
765
773
|
path = Path(path).resolve()
|
|
766
774
|
stack = (*_stack, path)
|
|
767
775
|
try:
|
|
768
|
-
raw = json.loads(path.read_text())
|
|
776
|
+
raw = json.loads(path.read_text(encoding='utf-8-sig'))
|
|
769
777
|
except FileNotFoundError:
|
|
770
778
|
raise RnError(f'no such file: {path}') from None
|
|
771
779
|
except json.JSONDecodeError as error:
|
|
@@ -5,6 +5,7 @@ import json
|
|
|
5
5
|
import re
|
|
6
6
|
import struct
|
|
7
7
|
import subprocess
|
|
8
|
+
import tempfile
|
|
8
9
|
from fractions import Fraction
|
|
9
10
|
from functools import lru_cache
|
|
10
11
|
from pathlib import Path
|
|
@@ -14,11 +15,28 @@ class RnError(Exception):
|
|
|
14
15
|
"""A failure the agent can act on; the CLI prints it as one line."""
|
|
15
16
|
|
|
16
17
|
|
|
18
|
+
# Windows caps a whole command line at 32767 characters, Linux one argument at 128 KiB: a long filter graph (many
|
|
19
|
+
# overlays, keyframes) goes to ffmpeg as a file instead, through ffmpeg 7's "-/option file".
|
|
20
|
+
LONG_FILTER = 8000
|
|
21
|
+
FILTER_OPTIONS = ('-filter_complex', '-lavfi', '-vf', '-af', '-filter:v', '-filter:a')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _filters_from_files(args: list[str], folder: Path) -> list[str]:
|
|
25
|
+
out = list(args)
|
|
26
|
+
for i in range(len(out) - 1):
|
|
27
|
+
if out[i] in FILTER_OPTIONS and len(out[i + 1]) > LONG_FILTER:
|
|
28
|
+
script = folder / f'filter{i}.txt'
|
|
29
|
+
script.write_text(out[i + 1], encoding='utf-8')
|
|
30
|
+
out[i], out[i + 1] = '-/' + out[i][1:], str(script)
|
|
31
|
+
return out
|
|
32
|
+
|
|
33
|
+
|
|
17
34
|
def run(args: list[str], *, quiet: bool = True, cwd: str | Path | None = None) -> subprocess.CompletedProcess:
|
|
18
35
|
"""Runs ffmpeg (or ffprobe); raises RnError with the tail of stderr on failure."""
|
|
19
36
|
flags = ['-nostdin', *(['-v', 'error'] if quiet else [])] if args[0] == 'ffmpeg' else []
|
|
20
|
-
|
|
21
|
-
|
|
37
|
+
with tempfile.TemporaryDirectory(prefix='rn-filters-') as tmp:
|
|
38
|
+
command = [args[0], '-hide_banner', *flags, *_filters_from_files(args[1:], Path(tmp))]
|
|
39
|
+
result = subprocess.run(command, capture_output=True, text=True, encoding='utf-8', errors='replace', cwd=cwd)
|
|
22
40
|
if result.returncode != 0:
|
|
23
41
|
lines = result.stderr.strip().splitlines()
|
|
24
42
|
# The cause is usually an earlier line than the generic 'Task finished with error' tail.
|
|
@@ -31,7 +49,7 @@ def run(args: list[str], *, quiet: bool = True, cwd: str | Path | None = None) -
|
|
|
31
49
|
@lru_cache(maxsize=None)
|
|
32
50
|
def has_filter(name: str) -> bool:
|
|
33
51
|
"""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
|
|
52
|
+
filters = subprocess.run(['ffmpeg', '-hide_banner', '-filters'], capture_output=True, text=True, encoding='utf-8', errors='replace').stdout
|
|
35
53
|
return any(line.split()[1:2] == [name] for line in filters.splitlines())
|
|
36
54
|
|
|
37
55
|
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""rn fonts: what is actually installed, from fontconfig, or where there is none (Windows) from the system font folders.
|
|
2
|
+
A missing weight makes browsers and libass fall back silently (Katana's sandbox only had some families in ExtraBold), so
|
|
3
|
+
check the weight and script here before using a face."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .ff import RnError
|
|
13
|
+
|
|
14
|
+
# fontconfig weight -> CSS weight (fontconfig.h FC_WEIGHT_*).
|
|
15
|
+
_CSS = [(0, 100), (40, 200), (50, 300), (80, 400), (100, 500), (180, 600), (200, 700), (205, 800), (210, 900)]
|
|
16
|
+
_SCRIPTS = {'latin': ('en',), 'zh-hans': ('zh-cn', 'zh-sg'), 'zh-hant': ('zh-tw', 'zh-hk'), 'ja': ('ja',), 'ko': ('ko',),
|
|
17
|
+
'th': ('th',), 'ar': ('ar',), 'he': ('he',), 'hi': ('hi',), 'ru': ('ru',)}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
FONT_SUFFIXES = ('.ttf', '.otf', '.ttc', '.otc')
|
|
21
|
+
# Without fontconfig a font covers a script when its cmap has all of these characters.
|
|
22
|
+
_SAMPLES = {'latin': 'AZaz', 'zh-hans': '这们说', 'zh-hant': '這們說', 'ja': 'あアの', 'ko': '한국어', 'th': 'กขค', 'ar': 'بتث',
|
|
23
|
+
'he': 'אבג', 'hi': 'कखग', 'ru': 'ЖЯЫ', 'emoji': '\U0001F600\U0001F389\U0001F44D'}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def font_dirs() -> list[Path]:
|
|
27
|
+
"""Where the OS keeps installed fonts, system-wide and per user."""
|
|
28
|
+
home = Path.home()
|
|
29
|
+
if sys.platform == 'win32':
|
|
30
|
+
local = Path(os.environ.get('LOCALAPPDATA') or home / 'AppData' / 'Local')
|
|
31
|
+
dirs = [Path(os.environ.get('WINDIR') or 'C:/Windows') / 'Fonts', local / 'Microsoft' / 'Windows' / 'Fonts']
|
|
32
|
+
elif sys.platform == 'darwin':
|
|
33
|
+
dirs = [Path('/System/Library/Fonts'), Path('/Library/Fonts'), home / 'Library' / 'Fonts']
|
|
34
|
+
else:
|
|
35
|
+
data = Path(os.environ.get('XDG_DATA_HOME') or home / '.local' / 'share')
|
|
36
|
+
dirs = [Path('/usr/share/fonts'), Path('/usr/local/share/fonts'), data / 'fonts', home / '.fonts']
|
|
37
|
+
return [d for d in dirs if d.is_dir()]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def font_files() -> list[Path]:
|
|
41
|
+
return sorted({f for d in font_dirs() for f in d.rglob('*') if f.suffix.lower() in FONT_SUFFIXES and f.is_file()})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def css_weight(fc_weight: float) -> int:
|
|
45
|
+
return min(_CSS, key=lambda pair: abs(pair[0] - fc_weight))[1]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _license(path: str) -> str | None:
|
|
49
|
+
try:
|
|
50
|
+
from fontTools.ttLib import TTFont
|
|
51
|
+
except ImportError:
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
font = TTFont(path, fontNumber=0, lazy=True)
|
|
55
|
+
names = font['name']
|
|
56
|
+
text = names.getDebugName(13) or names.getDebugName(14)
|
|
57
|
+
except Exception: # unreadable or exotic font: no license info rather than a failure
|
|
58
|
+
return None
|
|
59
|
+
return ' '.join(text.split())[:120] if text else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _entry(families: dict, family: str) -> dict:
|
|
63
|
+
return families.setdefault(family, {'family': family, 'weights': set(), 'styles': set(), 'variable': False,
|
|
64
|
+
'scripts': set(), 'files': set()})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def installed() -> dict[str, dict]:
|
|
68
|
+
if not shutil.which('fc-list'):
|
|
69
|
+
return _scanned()
|
|
70
|
+
output = subprocess.run(['fc-list', '--format', '%{family[0]}\t%{style[0]}\t%{weight}\t%{variable}\t%{file}\t%{lang}\n'],
|
|
71
|
+
capture_output=True, text=True, encoding='utf-8', errors='replace', check=True).stdout
|
|
72
|
+
families: dict[str, dict] = {}
|
|
73
|
+
for line in output.splitlines():
|
|
74
|
+
parts = line.split('\t')
|
|
75
|
+
if len(parts) != 6 or not parts[0] or parts[0].startswith('.'):
|
|
76
|
+
continue
|
|
77
|
+
family, style, weight, variable, path, langs = parts
|
|
78
|
+
entry = _entry(families, family)
|
|
79
|
+
try:
|
|
80
|
+
entry['weights'].add(css_weight(float(weight)))
|
|
81
|
+
except ValueError:
|
|
82
|
+
pass
|
|
83
|
+
entry['styles'].add(style)
|
|
84
|
+
entry['variable'] |= variable == 'True'
|
|
85
|
+
entry['files'].add(path)
|
|
86
|
+
codes = set(langs.split('|'))
|
|
87
|
+
entry['scripts'] |= {name for name, keys in _SCRIPTS.items() if codes & set(keys)}
|
|
88
|
+
if 'emoji' in family.lower():
|
|
89
|
+
entry['scripts'].add('emoji')
|
|
90
|
+
return families
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _scanned() -> dict[str, dict]:
|
|
94
|
+
"""The same facts read from the font files themselves: name table, OS/2 weight, fvar axes, cmap coverage."""
|
|
95
|
+
try:
|
|
96
|
+
from fontTools.ttLib import TTCollection, TTFont
|
|
97
|
+
except ImportError:
|
|
98
|
+
raise RnError('fc-list not found: install fontconfig, or fontTools (extra "fonts") to read the system font '
|
|
99
|
+
'folders directly: uv tool install "renoise-kit[all]"') from None
|
|
100
|
+
families: dict[str, dict] = {}
|
|
101
|
+
for path in font_files():
|
|
102
|
+
try:
|
|
103
|
+
faces = TTCollection(str(path), lazy=True).fonts if path.suffix.lower() in ('.ttc', '.otc') else [TTFont(str(path), lazy=True)]
|
|
104
|
+
for font in faces:
|
|
105
|
+
names = font['name']
|
|
106
|
+
family = names.getDebugName(16) or names.getDebugName(1)
|
|
107
|
+
if not family or family.startswith('.'):
|
|
108
|
+
continue
|
|
109
|
+
style = names.getDebugName(17) or names.getDebugName(2) or 'Regular'
|
|
110
|
+
weight = font['OS/2'].usWeightClass if 'OS/2' in font else 400
|
|
111
|
+
weight = weight * 100 if weight < 10 else weight # a few old fonts store 1-9
|
|
112
|
+
weights = {min(900, max(100, round(weight / 100) * 100))}
|
|
113
|
+
axes = {axis.axisTag: axis for axis in font['fvar'].axes} if 'fvar' in font else {}
|
|
114
|
+
if 'wght' in axes:
|
|
115
|
+
weights |= {w for w in range(100, 1000, 100) if axes['wght'].minValue <= w <= axes['wght'].maxValue}
|
|
116
|
+
cmap = font.getBestCmap() or {}
|
|
117
|
+
entry = _entry(families, family)
|
|
118
|
+
entry['styles'].add(style)
|
|
119
|
+
entry['weights'] |= weights
|
|
120
|
+
entry['variable'] |= bool(axes)
|
|
121
|
+
entry['scripts'] |= {name for name, chars in _SAMPLES.items() if all(ord(c) in cmap for c in chars)}
|
|
122
|
+
entry['files'].add(str(path))
|
|
123
|
+
except Exception: # unreadable or exotic font file: skip it rather than fail the listing
|
|
124
|
+
continue
|
|
125
|
+
return families
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def fonts_command(*, family: str | None, script: str | None, licenses: bool) -> dict:
|
|
129
|
+
if script and script not in (*_SCRIPTS, 'emoji'):
|
|
130
|
+
raise RnError(f'--script: one of {", ".join([*_SCRIPTS, "emoji"])}')
|
|
131
|
+
rows = []
|
|
132
|
+
for entry in sorted(installed().values(), key=lambda e: e['family'].lower()):
|
|
133
|
+
if family and family.lower() not in entry['family'].lower():
|
|
134
|
+
continue
|
|
135
|
+
if script and script not in entry['scripts']:
|
|
136
|
+
continue
|
|
137
|
+
files = sorted(entry['files'])
|
|
138
|
+
row = {'family': entry['family'], 'weights': sorted(entry['weights']), 'styles': sorted(entry['styles']),
|
|
139
|
+
'variable': entry['variable'], 'scripts': sorted(entry['scripts']), 'files': files}
|
|
140
|
+
if licenses:
|
|
141
|
+
row['license'] = _license(files[0])
|
|
142
|
+
rows.append(row)
|
|
143
|
+
if family and not rows:
|
|
144
|
+
raise RnError(f'no installed family matches "{family}"; run rn fonts to list them, or download one into your project')
|
|
145
|
+
return {'count': len(rows), 'fonts': rows}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def summary_lines(result: dict) -> list[str]:
|
|
149
|
+
lines = []
|
|
150
|
+
for row in result['fonts']:
|
|
151
|
+
weights = ','.join(str(w) for w in row['weights']) + (' (variable)' if row['variable'] else '')
|
|
152
|
+
lines.append(f'{row["family"]}: {weights} [{" ".join(row["scripts"]) or "-"}]' + (f' {row["license"]}' if row.get('license') else ''))
|
|
153
|
+
lines.append(f'{result["count"]} families')
|
|
154
|
+
return lines
|
|
@@ -22,7 +22,7 @@ BEAT_CLEAR = 0.15 # autocorrelation of the onset envelope at the beat period, 0
|
|
|
22
22
|
def _short_term_curve(wav: Path) -> list[dict]:
|
|
23
23
|
"""Short-term (3 s) loudness every CURVE_STEP seconds from ebur128's frame log (it logs every 0.1 s)."""
|
|
24
24
|
result = subprocess.run(['ffmpeg', '-hide_banner', '-nostdin', '-i', str(wav), '-af', 'ebur128',
|
|
25
|
-
'-f', 'null', '-'], capture_output=True, text=True)
|
|
25
|
+
'-f', 'null', '-'], capture_output=True, text=True, encoding='utf-8', errors='replace')
|
|
26
26
|
curve, next_t = [], 0.0
|
|
27
27
|
for match in re.finditer(r't:\s*([\d.]+)\s.*?M:\s*(-?[\d.]+|-inf)\s+S:\s*(-?[\d.]+|-inf)', result.stderr):
|
|
28
28
|
t = float(match.group(1))
|
|
@@ -82,7 +82,7 @@ def _transcribe(wav: Path, *, language: str | None, model: str) -> dict:
|
|
|
82
82
|
try:
|
|
83
83
|
from faster_whisper import WhisperModel
|
|
84
84
|
except ImportError:
|
|
85
|
-
raise RnError(
|
|
85
|
+
raise RnError('--transcribe needs faster-whisper (extra "transcribe"): uv tool install "renoise-kit[all]"')
|
|
86
86
|
import numpy as np
|
|
87
87
|
# Decode here and pass samples: faster-whisper's own file decoding goes through PyAV, whose API it does not pin
|
|
88
88
|
# (av 16 dropped the `metadata_errors` argument it passes).
|