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.
Files changed (53) hide show
  1. {renoise_kit-0.5.0/renoise_kit.egg-info → renoise_kit-0.5.1}/PKG-INFO +12 -8
  2. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/README.md +11 -7
  3. {renoise_kit-0.5.0 → renoise_kit-0.5.1/renoise_kit.egg-info}/PKG-INFO +12 -8
  4. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/renoise_kit.egg-info/SOURCES.txt +1 -0
  5. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/__init__.py +1 -1
  6. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/assemble.py +4 -2
  7. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/assets.py +1 -1
  8. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/audio.py +1 -1
  9. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/captions.py +9 -5
  10. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/capture.py +2 -2
  11. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/cli.py +8 -2
  12. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/editops.py +3 -3
  13. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/edl.py +20 -12
  14. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/ff.py +21 -3
  15. renoise_kit-0.5.1/rn/fonts.py +154 -0
  16. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/listen.py +2 -2
  17. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/qa.py +3 -3
  18. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/ref.py +1 -1
  19. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/text.py +2 -2
  20. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_captions.py +9 -2
  21. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_capture.py +6 -6
  22. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_edl_v3.py +9 -8
  23. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_fx.py +1 -1
  24. renoise_kit-0.5.1/tests/test_portability.py +56 -0
  25. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_rn.py +8 -7
  26. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_v3_commands.py +3 -3
  27. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_v4_commands.py +5 -5
  28. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_v5_data.py +12 -12
  29. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_v5_measure.py +8 -8
  30. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_v5_time.py +2 -2
  31. renoise_kit-0.5.0/rn/fonts.py +0 -87
  32. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/LICENSE +0 -0
  33. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/pyproject.toml +0 -0
  34. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/renoise_kit.egg-info/dependency_links.txt +0 -0
  35. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/renoise_kit.egg-info/entry_points.txt +0 -0
  36. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/renoise_kit.egg-info/requires.txt +0 -0
  37. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/renoise_kit.egg-info/top_level.txt +0 -0
  38. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/__main__.py +0 -0
  39. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/align.py +0 -0
  40. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/compare.py +0 -0
  41. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/cuts.py +0 -0
  42. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/export.py +0 -0
  43. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/faces.py +0 -0
  44. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/frames.py +0 -0
  45. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/fx/__init__.py +0 -0
  46. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/fx/fx.js +0 -0
  47. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/matte.py +0 -0
  48. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/ref_guide.md +0 -0
  49. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/rn/signal.py +0 -0
  50. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/setup.cfg +0 -0
  51. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_faces.py +0 -0
  52. {renoise_kit-0.5.0 → renoise_kit-0.5.1}/tests/test_listen.py +0 -0
  53. {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.0
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 'renoise-kit[all]' # or: pipx install 'renoise-kit[all]'
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) and on macOS with ffmpeg-full. The tests
290
- generate their own media.
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 'renoise-kit[all]' # or: pipx install 'renoise-kit[all]'
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) and on macOS with ffmpeg-full. The tests
254
- generate their own media.
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.0
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 'renoise-kit[all]' # or: pipx install 'renoise-kit[all]'
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) and on macOS with ffmpeg-full. The tests
290
- generate their own media.
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
 
@@ -40,6 +40,7 @@ tests/test_edl_v3.py
40
40
  tests/test_faces.py
41
41
  tests/test_fx.py
42
42
  tests/test_listen.py
43
+ tests/test_portability.py
43
44
  tests/test_rn.py
44
45
  tests/test_v3_commands.py
45
46
  tests/test_v4_commands.py
@@ -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.0'
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
- listing.write_text(''.join(f"file '{piece.resolve()}'\n" for piece in pieces))
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.rename(cached)
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
- font_name = FONTS.get(language.split('-')[0].lower(), 'Noto Sans') if language else ('Noto Sans CJK SC' if cjk else 'Noto Sans')
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("rn capture needs playwright (extra \"capture\"): uv tool install 'renoise-kit[all]' && playwright install chromium") from None
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
- f"uv tool install 'renoise-kit[all]'", file=sys.stderr)
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', 'WenQuanYi')
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 fontconfig lists one (placeholder text is often a prompt), else Pillow's default.
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 ('.ttf', '.otf', '.ttc')]
401
- for path in sorted(files, key=lambda f: not any(name in f for name in _SANS_CJK)):
402
- try:
403
- return ImageFont.truetype(path, size)
404
- except OSError:
405
- continue
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
- command = [args[0], '-hide_banner', *flags, *args[1:]]
21
- result = subprocess.run(command, capture_output=True, text=True, cwd=cwd)
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("--transcribe needs faster-whisper (extra \"transcribe\"): uv tool install 'renoise-kit[all]'")
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).