renoise-kit 0.5.0__tar.gz → 0.6.0__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 (54) hide show
  1. {renoise_kit-0.5.0/renoise_kit.egg-info → renoise_kit-0.6.0}/PKG-INFO +31 -17
  2. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/README.md +30 -16
  3. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/pyproject.toml +1 -1
  4. {renoise_kit-0.5.0 → renoise_kit-0.6.0/renoise_kit.egg-info}/PKG-INFO +31 -17
  5. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/renoise_kit.egg-info/SOURCES.txt +2 -0
  6. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/__init__.py +1 -1
  7. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/assemble.py +4 -2
  8. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/assets.py +1 -1
  9. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/audio.py +1 -1
  10. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/captions.py +9 -5
  11. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/capture.py +2 -2
  12. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/cli.py +33 -17
  13. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/editops.py +14 -5
  14. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/edl.py +177 -49
  15. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/ff.py +21 -3
  16. renoise_kit-0.6.0/rn/fonts.py +154 -0
  17. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/listen.py +2 -2
  18. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/qa.py +3 -3
  19. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/ref.py +1 -1
  20. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/text.py +2 -2
  21. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/tests/test_captions.py +9 -2
  22. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/tests/test_capture.py +6 -6
  23. renoise_kit-0.6.0/tests/test_edit_v2.py +95 -0
  24. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/tests/test_edl_v3.py +9 -8
  25. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/tests/test_fx.py +1 -1
  26. renoise_kit-0.6.0/tests/test_portability.py +56 -0
  27. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/tests/test_rn.py +8 -7
  28. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/tests/test_v3_commands.py +3 -3
  29. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/tests/test_v4_commands.py +5 -5
  30. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/tests/test_v5_data.py +12 -12
  31. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/tests/test_v5_measure.py +8 -8
  32. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/tests/test_v5_time.py +2 -2
  33. renoise_kit-0.5.0/rn/fonts.py +0 -87
  34. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/LICENSE +0 -0
  35. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/renoise_kit.egg-info/dependency_links.txt +0 -0
  36. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/renoise_kit.egg-info/entry_points.txt +0 -0
  37. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/renoise_kit.egg-info/requires.txt +0 -0
  38. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/renoise_kit.egg-info/top_level.txt +0 -0
  39. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/__main__.py +0 -0
  40. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/align.py +0 -0
  41. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/compare.py +0 -0
  42. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/cuts.py +0 -0
  43. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/export.py +0 -0
  44. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/faces.py +0 -0
  45. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/frames.py +0 -0
  46. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/fx/__init__.py +0 -0
  47. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/fx/fx.js +0 -0
  48. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/matte.py +0 -0
  49. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/ref_guide.md +0 -0
  50. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/rn/signal.py +0 -0
  51. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/setup.cfg +0 -0
  52. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/tests/test_faces.py +0 -0
  53. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/tests/test_listen.py +0 -0
  54. {renoise_kit-0.5.0 → renoise_kit-0.6.0}/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.6.0
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
 
@@ -112,16 +115,17 @@ window.renderFrame = i => {
112
115
 
113
116
  ```json
114
117
  {
118
+ "version": 2,
115
119
  "output": {"width": 1080, "height": 1920, "fps": 30},
116
120
  "tracks": [
117
121
  {"clips": [
118
122
  {"id": "open", "src": "shots/s1.mp4", "in": 0.4, "out": 3.2},
119
123
  {"id": "hero", "src": "shots/s2.mp4", "ranges": [[0, 1.5], [2.5, 4]],
120
124
  "transition": {"type": "fade", "duration": 0.3}},
121
- {"id": "end", "image": "card.png", "duration": 1.5, "fit": "contain"}
125
+ {"id": "end", "image": "card.png", "duration": 1.5, "style": {"object-fit": "contain"}}
122
126
  ]},
123
127
  {"clips": [
124
- {"id": "title", "frames": "title/%05d.png", "fps": 30, "y": 1500,
128
+ {"id": "title", "frames": "title/%05d.png", "fps": 30, "style": {"top": 1500},
125
129
  "at": {"clip": "hero", "offset": 0.2}, "until": {"clip_end": "hero"}}
126
130
  ]}
127
131
  ],
@@ -207,7 +211,7 @@ rn edit edit.json delete end --lift
207
211
 
208
212
  | Command | What it gives |
209
213
  |---|---|
210
- | `rn edit EDL ids\|set\|split\|delete\|insert\|move` | Changes an EDL by clip id. Each step is checked against the whole timeline before the file is written |
214
+ | `rn edit EDL ids\|set\|split\|delete\|insert\|move\|upgrade` | Changes an EDL by clip id. Each step is checked against the whole timeline before the file is written |
211
215
  | `rn assemble EDL -o OUT [--preview] [--range A-B] [--placeholders]` | Renders the EDL frame-exactly. `--placeholders` turns missing shots into labelled cards |
212
216
 
213
217
  **Check and deliver**
@@ -221,8 +225,14 @@ rn edit edit.json delete end --lift
221
225
 
222
226
  ## The EDL in one screen
223
227
 
228
+ - **Version.** `"version": 2`. A file without it uses the version-1 names (`x`, `y`, `w`, `h`, `fit`, `blend`) and is
229
+ upgraded on load; `rn edit FILE upgrade` rewrites it.
224
230
  - **Tracks.** `tracks[0]` is the main track: its clips play back to back. Later tracks are overlays, placed with `at`
225
- in time and with `x / y / w / h` in space.
231
+ in time and with `style` in space.
232
+ - **Style.** Placement and look under CSS names and units: `left`, `top`, `width`, `height` (px, `"90px"`, or `"50%"`
233
+ of the frame), `opacity`, `scale`, `rotate` (degrees), `object-fit` (`cover`, `contain`, `fill`) and
234
+ `mix-blend-mode` (the CSS modes). The main track fills the frame, so it takes only `object-fit`, `left`, `top`,
235
+ `scale` and `rotate`.
226
236
  - **Audio items.** Items in `audio` are placed with `at`. `role` is `music`, `voice` or `sfx`; music ducks under voice
227
237
  when `mix.duck_db` is set.
228
238
  - **One time grid.** `output.fps` is the only clock. A clip of N seconds is exactly `round(N × fps)` frames.
@@ -236,14 +246,17 @@ rn edit edit.json delete end --lift
236
246
  - `{"beat": n, "listen": file}` and `{"word": n, "listen": file}`
237
247
 
238
248
  `pivot` places a point inside the source on the anchor instead of the source's start.
239
- - **Keys.** Written as `[t, value, ease]`. Overlays animate `scale`, `x`, `y`, `rotate`, `opacity` and `speed`; the
240
- main track `scale`, `x`, `y`, `rotate`, `speed` and `gain_db`; audio items `gain_db`.
241
- - **Compositing.** `blend` takes `screen`, `multiply`, `add` and other blend modes. `mask` takes a full-frame matte, as
242
- luma or alpha, optionally inverted. `vf` and `af` insert one raw ffmpeg filter chain.
249
+ - **Keys.** Written as `[t, value, ease]`: the moving form of a style value. Overlays animate `scale`, `left`, `top`,
250
+ `rotate`, `opacity` and `speed`; the main track `scale`, `left`, `top`, `rotate`, `speed` and `gain_db`; audio items
251
+ `gain_db`.
252
+ - **Compositing.** `mask` takes a full-frame matte, as luma or alpha, optionally inverted. `vf` and `af` insert one raw
253
+ ffmpeg filter chain.
243
254
  - **Bookkeeping.** `id` and `meta` go on anything; `meta` is kept as given. `accept` waives qa warnings judged
244
255
  intended.
245
256
 
246
- The full schema is in `rn assemble --help` and [SPEC.md](SPEC.md) §4.
257
+ The full schema is [schema/edit.schema.json](schema/edit.schema.json), with `rn assemble --help` and [SPEC.md](SPEC.md)
258
+ §4. Fields it marks `x-rn-only` (raw filters, masks, image sequences, nested edits, beat and word anchors, delivery
259
+ loudness, subtitles) are rn's own; another program that renders the rest of the format previews what rn delivers.
247
260
 
248
261
  ## Conventions
249
262
 
@@ -286,8 +299,9 @@ In the Renoise media sandbox, `rn` comes preinstalled at the version pinned by t
286
299
  uv run pytest -q # Python 3.12 (as the sandbox); needs ffmpeg with libass; playwright install chromium for capture tests
287
300
  ```
288
301
 
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.
302
+ CI runs on Debian trixie with ffmpeg 7.1 (the same base as the sandbox), on macOS with ffmpeg-full and on Windows with
303
+ gyan.dev's full build (with Windows' own default encoding, so a file read without `encoding='utf-8'` fails there). The
304
+ tests generate their own media.
291
305
 
292
306
  To release:
293
307
 
@@ -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
 
@@ -76,16 +79,17 @@ window.renderFrame = i => {
76
79
 
77
80
  ```json
78
81
  {
82
+ "version": 2,
79
83
  "output": {"width": 1080, "height": 1920, "fps": 30},
80
84
  "tracks": [
81
85
  {"clips": [
82
86
  {"id": "open", "src": "shots/s1.mp4", "in": 0.4, "out": 3.2},
83
87
  {"id": "hero", "src": "shots/s2.mp4", "ranges": [[0, 1.5], [2.5, 4]],
84
88
  "transition": {"type": "fade", "duration": 0.3}},
85
- {"id": "end", "image": "card.png", "duration": 1.5, "fit": "contain"}
89
+ {"id": "end", "image": "card.png", "duration": 1.5, "style": {"object-fit": "contain"}}
86
90
  ]},
87
91
  {"clips": [
88
- {"id": "title", "frames": "title/%05d.png", "fps": 30, "y": 1500,
92
+ {"id": "title", "frames": "title/%05d.png", "fps": 30, "style": {"top": 1500},
89
93
  "at": {"clip": "hero", "offset": 0.2}, "until": {"clip_end": "hero"}}
90
94
  ]}
91
95
  ],
@@ -171,7 +175,7 @@ rn edit edit.json delete end --lift
171
175
 
172
176
  | Command | What it gives |
173
177
  |---|---|
174
- | `rn edit EDL ids\|set\|split\|delete\|insert\|move` | Changes an EDL by clip id. Each step is checked against the whole timeline before the file is written |
178
+ | `rn edit EDL ids\|set\|split\|delete\|insert\|move\|upgrade` | Changes an EDL by clip id. Each step is checked against the whole timeline before the file is written |
175
179
  | `rn assemble EDL -o OUT [--preview] [--range A-B] [--placeholders]` | Renders the EDL frame-exactly. `--placeholders` turns missing shots into labelled cards |
176
180
 
177
181
  **Check and deliver**
@@ -185,8 +189,14 @@ rn edit edit.json delete end --lift
185
189
 
186
190
  ## The EDL in one screen
187
191
 
192
+ - **Version.** `"version": 2`. A file without it uses the version-1 names (`x`, `y`, `w`, `h`, `fit`, `blend`) and is
193
+ upgraded on load; `rn edit FILE upgrade` rewrites it.
188
194
  - **Tracks.** `tracks[0]` is the main track: its clips play back to back. Later tracks are overlays, placed with `at`
189
- in time and with `x / y / w / h` in space.
195
+ in time and with `style` in space.
196
+ - **Style.** Placement and look under CSS names and units: `left`, `top`, `width`, `height` (px, `"90px"`, or `"50%"`
197
+ of the frame), `opacity`, `scale`, `rotate` (degrees), `object-fit` (`cover`, `contain`, `fill`) and
198
+ `mix-blend-mode` (the CSS modes). The main track fills the frame, so it takes only `object-fit`, `left`, `top`,
199
+ `scale` and `rotate`.
190
200
  - **Audio items.** Items in `audio` are placed with `at`. `role` is `music`, `voice` or `sfx`; music ducks under voice
191
201
  when `mix.duck_db` is set.
192
202
  - **One time grid.** `output.fps` is the only clock. A clip of N seconds is exactly `round(N × fps)` frames.
@@ -200,14 +210,17 @@ rn edit edit.json delete end --lift
200
210
  - `{"beat": n, "listen": file}` and `{"word": n, "listen": file}`
201
211
 
202
212
  `pivot` places a point inside the source on the anchor instead of the source's start.
203
- - **Keys.** Written as `[t, value, ease]`. Overlays animate `scale`, `x`, `y`, `rotate`, `opacity` and `speed`; the
204
- main track `scale`, `x`, `y`, `rotate`, `speed` and `gain_db`; audio items `gain_db`.
205
- - **Compositing.** `blend` takes `screen`, `multiply`, `add` and other blend modes. `mask` takes a full-frame matte, as
206
- luma or alpha, optionally inverted. `vf` and `af` insert one raw ffmpeg filter chain.
213
+ - **Keys.** Written as `[t, value, ease]`: the moving form of a style value. Overlays animate `scale`, `left`, `top`,
214
+ `rotate`, `opacity` and `speed`; the main track `scale`, `left`, `top`, `rotate`, `speed` and `gain_db`; audio items
215
+ `gain_db`.
216
+ - **Compositing.** `mask` takes a full-frame matte, as luma or alpha, optionally inverted. `vf` and `af` insert one raw
217
+ ffmpeg filter chain.
207
218
  - **Bookkeeping.** `id` and `meta` go on anything; `meta` is kept as given. `accept` waives qa warnings judged
208
219
  intended.
209
220
 
210
- The full schema is in `rn assemble --help` and [SPEC.md](SPEC.md) §4.
221
+ The full schema is [schema/edit.schema.json](schema/edit.schema.json), with `rn assemble --help` and [SPEC.md](SPEC.md)
222
+ §4. Fields it marks `x-rn-only` (raw filters, masks, image sequences, nested edits, beat and word anchors, delivery
223
+ loudness, subtitles) are rn's own; another program that renders the rest of the format previews what rn delivers.
211
224
 
212
225
  ## Conventions
213
226
 
@@ -250,8 +263,9 @@ In the Renoise media sandbox, `rn` comes preinstalled at the version pinned by t
250
263
  uv run pytest -q # Python 3.12 (as the sandbox); needs ffmpeg with libass; playwright install chromium for capture tests
251
264
  ```
252
265
 
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.
266
+ CI runs on Debian trixie with ffmpeg 7.1 (the same base as the sandbox), on macOS with ffmpeg-full and on Windows with
267
+ gyan.dev's full build (with Windows' own default encoding, so a file read without `encoding='utf-8'` fails there). The
268
+ tests generate their own media.
255
269
 
256
270
  To release:
257
271
 
@@ -23,7 +23,7 @@ all = ["renoise-kit[listen,transcribe,faces,capture,captions,fonts,matte,export]
23
23
  # uv run pytest. faster-whisper and rembg are left out (large models); their tests skip, as OCR does without tesseract.
24
24
  [dependency-groups]
25
25
  dev = ["pytest", "librosa", "matplotlib", "opencv-python-headless>=4.8", "pysubs2", "playwright", "fonttools", "opentimelineio",
26
- "otio-fcpx-xml-adapter", "otio-fcp-adapter"]
26
+ "otio-fcpx-xml-adapter", "otio-fcp-adapter", "jsonschema"]
27
27
 
28
28
  [project.urls]
29
29
  Homepage = "https://renoise.ai"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: renoise-kit
3
- Version: 0.5.0
3
+ Version: 0.6.0
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
 
@@ -112,16 +115,17 @@ window.renderFrame = i => {
112
115
 
113
116
  ```json
114
117
  {
118
+ "version": 2,
115
119
  "output": {"width": 1080, "height": 1920, "fps": 30},
116
120
  "tracks": [
117
121
  {"clips": [
118
122
  {"id": "open", "src": "shots/s1.mp4", "in": 0.4, "out": 3.2},
119
123
  {"id": "hero", "src": "shots/s2.mp4", "ranges": [[0, 1.5], [2.5, 4]],
120
124
  "transition": {"type": "fade", "duration": 0.3}},
121
- {"id": "end", "image": "card.png", "duration": 1.5, "fit": "contain"}
125
+ {"id": "end", "image": "card.png", "duration": 1.5, "style": {"object-fit": "contain"}}
122
126
  ]},
123
127
  {"clips": [
124
- {"id": "title", "frames": "title/%05d.png", "fps": 30, "y": 1500,
128
+ {"id": "title", "frames": "title/%05d.png", "fps": 30, "style": {"top": 1500},
125
129
  "at": {"clip": "hero", "offset": 0.2}, "until": {"clip_end": "hero"}}
126
130
  ]}
127
131
  ],
@@ -207,7 +211,7 @@ rn edit edit.json delete end --lift
207
211
 
208
212
  | Command | What it gives |
209
213
  |---|---|
210
- | `rn edit EDL ids\|set\|split\|delete\|insert\|move` | Changes an EDL by clip id. Each step is checked against the whole timeline before the file is written |
214
+ | `rn edit EDL ids\|set\|split\|delete\|insert\|move\|upgrade` | Changes an EDL by clip id. Each step is checked against the whole timeline before the file is written |
211
215
  | `rn assemble EDL -o OUT [--preview] [--range A-B] [--placeholders]` | Renders the EDL frame-exactly. `--placeholders` turns missing shots into labelled cards |
212
216
 
213
217
  **Check and deliver**
@@ -221,8 +225,14 @@ rn edit edit.json delete end --lift
221
225
 
222
226
  ## The EDL in one screen
223
227
 
228
+ - **Version.** `"version": 2`. A file without it uses the version-1 names (`x`, `y`, `w`, `h`, `fit`, `blend`) and is
229
+ upgraded on load; `rn edit FILE upgrade` rewrites it.
224
230
  - **Tracks.** `tracks[0]` is the main track: its clips play back to back. Later tracks are overlays, placed with `at`
225
- in time and with `x / y / w / h` in space.
231
+ in time and with `style` in space.
232
+ - **Style.** Placement and look under CSS names and units: `left`, `top`, `width`, `height` (px, `"90px"`, or `"50%"`
233
+ of the frame), `opacity`, `scale`, `rotate` (degrees), `object-fit` (`cover`, `contain`, `fill`) and
234
+ `mix-blend-mode` (the CSS modes). The main track fills the frame, so it takes only `object-fit`, `left`, `top`,
235
+ `scale` and `rotate`.
226
236
  - **Audio items.** Items in `audio` are placed with `at`. `role` is `music`, `voice` or `sfx`; music ducks under voice
227
237
  when `mix.duck_db` is set.
228
238
  - **One time grid.** `output.fps` is the only clock. A clip of N seconds is exactly `round(N × fps)` frames.
@@ -236,14 +246,17 @@ rn edit edit.json delete end --lift
236
246
  - `{"beat": n, "listen": file}` and `{"word": n, "listen": file}`
237
247
 
238
248
  `pivot` places a point inside the source on the anchor instead of the source's start.
239
- - **Keys.** Written as `[t, value, ease]`. Overlays animate `scale`, `x`, `y`, `rotate`, `opacity` and `speed`; the
240
- main track `scale`, `x`, `y`, `rotate`, `speed` and `gain_db`; audio items `gain_db`.
241
- - **Compositing.** `blend` takes `screen`, `multiply`, `add` and other blend modes. `mask` takes a full-frame matte, as
242
- luma or alpha, optionally inverted. `vf` and `af` insert one raw ffmpeg filter chain.
249
+ - **Keys.** Written as `[t, value, ease]`: the moving form of a style value. Overlays animate `scale`, `left`, `top`,
250
+ `rotate`, `opacity` and `speed`; the main track `scale`, `left`, `top`, `rotate`, `speed` and `gain_db`; audio items
251
+ `gain_db`.
252
+ - **Compositing.** `mask` takes a full-frame matte, as luma or alpha, optionally inverted. `vf` and `af` insert one raw
253
+ ffmpeg filter chain.
243
254
  - **Bookkeeping.** `id` and `meta` go on anything; `meta` is kept as given. `accept` waives qa warnings judged
244
255
  intended.
245
256
 
246
- The full schema is in `rn assemble --help` and [SPEC.md](SPEC.md) §4.
257
+ The full schema is [schema/edit.schema.json](schema/edit.schema.json), with `rn assemble --help` and [SPEC.md](SPEC.md)
258
+ §4. Fields it marks `x-rn-only` (raw filters, masks, image sequences, nested edits, beat and word anchors, delivery
259
+ loudness, subtitles) are rn's own; another program that renders the rest of the format previews what rn delivers.
247
260
 
248
261
  ## Conventions
249
262
 
@@ -286,8 +299,9 @@ In the Renoise media sandbox, `rn` comes preinstalled at the version pinned by t
286
299
  uv run pytest -q # Python 3.12 (as the sandbox); needs ffmpeg with libass; playwright install chromium for capture tests
287
300
  ```
288
301
 
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.
302
+ CI runs on Debian trixie with ffmpeg 7.1 (the same base as the sandbox), on macOS with ffmpeg-full and on Windows with
303
+ gyan.dev's full build (with Windows' own default encoding, so a file read without `encoding='utf-8'` fails there). The
304
+ tests generate their own media.
291
305
 
292
306
  To release:
293
307
 
@@ -36,10 +36,12 @@ rn/fx/__init__.py
36
36
  rn/fx/fx.js
37
37
  tests/test_captions.py
38
38
  tests/test_capture.py
39
+ tests/test_edit_v2.py
39
40
  tests/test_edl_v3.py
40
41
  tests/test_faces.py
41
42
  tests/test_fx.py
42
43
  tests/test_listen.py
44
+ tests/test_portability.py
43
45
  tests/test_rn.py
44
46
  tests/test_v3_commands.py
45
47
  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.6.0'
@@ -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
@@ -214,20 +215,21 @@ def _parser() -> argparse.ArgumentParser:
214
215
  'ducking, LUFS target and encoded true peak <= limit. Output: H.264 High yuv420p bt709, moov first.\n'
215
216
  'The EDL handles the mechanical assembly; render anything creative with your own code (rn capture for\n'
216
217
  'HTML/canvas, numpy + rn.fx, Manim, Blender) to a PNG sequence or alpha clip and place it in the EDL.\n\n'
217
- 'edit.json (paths relative to it; times in seconds; {"frame": n} also works for in/out;\n'
218
- '# marks notes below, not JSON; a "note" field is allowed on any clip):\n'
218
+ 'edit.json version 2 (paths relative to it; times in seconds; # marks notes below, not JSON; a "note"\n'
219
+ 'field is allowed anywhere; schema: schema/edit.schema.json in the renoise-kit repository):\n'
219
220
  '{\n'
221
+ ' "version": 2,\n'
220
222
  ' "output": {"width": 1080, "height": 1920, "fps": 30, "background": "#000000", "crf": 18},\n'
221
223
  ' "tracks": [\n'
222
224
  ' {"clips": [ # track 0 = main track, clips back to back\n'
223
- ' {"src": "s1.mp4", "in": 0.4, "out": 3.2, "fit": "cover"},\n'
225
+ ' {"src": "s1.mp4", "in": 0.4, "out": 3.2},\n'
224
226
  ' {"src": "s2.mp4", "in": {"frame": 6}, "duration": 2.5, "speed": 0.5, "audio": false,\n'
225
227
  ' "transition": {"type": "fade", "duration": 0.4}}, # any xfade name: dissolve, wipeleft, slideup...\n'
226
- ' {"image": "card.png", "duration": 1.5, "fit": "contain"},\n'
228
+ ' {"image": "card.png", "duration": 1.5, "style": {"object-fit": "contain"}},\n'
227
229
  ' {"color": "#000000", "duration": 0.5}]},\n'
228
- ' {"clips": [ # later tracks = overlays, positioned in time and space\n'
229
- ' {"image": "title.png", "at": 0.5, "duration": 2, "x": 90, "y": 1400, "w": 900,\n'
230
- ' "opacity": 1, "fade_in": 0.2, "fade_out": 0.2}]}],\n'
230
+ ' {"clips": [ # later tracks = overlays, placed in time and space\n'
231
+ ' {"image": "title.png", "at": 0.5, "duration": 2, "fade_in": 0.2, "fade_out": 0.2,\n'
232
+ ' "style": {"left": 90, "top": "73%", "width": 900, "opacity": 0.9, "mix-blend-mode": "screen"}}]},\n'
231
233
  ' {"clips": [{"frames": "title/%05d.png", "fps": 30, "at": {"clip": 1, "offset": 0.2},\n'
232
234
  ' "until": {"clip_end": 1}, "keys": {"scale": [[0, 0.9], [0.3, 1, "out3"]], "opacity": [[0, 0], [0.2, 1]]}}]}],\n'
233
235
  ' "markers": {"drop": 4.5}, # named times for anchors\n'
@@ -236,21 +238,27 @@ def _parser() -> argparse.ArgumentParser:
236
238
  ' "subtitles": {"file": "captions.ass"},\n'
237
239
  ' "mix": {"lufs": -14, "true_peak": -1.0, "duck_db": 10} # duck music under voice\n'
238
240
  '}\n'
241
+ '"style" uses CSS names: overlays left/top/width/height (px: 90 or "90px"; "50%" of the frame), opacity,\n'
242
+ 'scale, rotate (degrees: 5 or "5deg"), object-fit (cover | contain | fill), mix-blend-mode (normal multiply\n'
243
+ 'screen overlay darken lighten color-dodge color-burn hard-light soft-light difference exclusion\n'
244
+ 'plus-lighter); the main track fills the frame: object-fit, left/top, scale, rotate.\n'
239
245
  'Positions: in/out take seconds, {"frame": n} or {"shot": n, "offset": s} (shots from rn cuts).\n'
240
246
  'Overlay "at"/"until" and audio "at" take seconds or an anchor + "offset": {"clip": i or "id"},\n'
241
247
  '{"clip_end": i or "id"} (main-track clip as laid out, or an earlier overlay by id), {"marker": name},\n'
242
248
  '{"frame": n}, {"beat": n, "listen": f.json}, {"word": n, "listen": f.json, "edge": "start"|"end"}.\n'
243
- 'Keys [t, value, ease?] (t from the clip start; eases linear in out inout in3 out3 inout3 hold):\n'
244
- 'main track scale/x/y/rotate (x, y as fractions of the frame), speed (a ramp; drops the clip\'s sound),\n'
245
- 'gain_db; overlays scale/x/y/rotate/opacity/speed (x, y in px, about the layer centre); audio gain_db.\n'
246
- 'Keep main-track scale >= 1: edges smear.\n'
249
+ 'Keys [t, value, ease?] (t from the clip start; eases linear in out inout in3 out3 inout3 hold), the\n'
250
+ 'moving form of a style value: main track scale/left/top/rotate (left, top: px the frame moves), speed\n'
251
+ '(a ramp; drops the clip\'s sound), gain_db; overlays scale/left/top/rotate/opacity/speed (scale and rotate\n'
252
+ 'about the layer centre); audio gain_db. Keep main-track scale >= 1: edges smear.\n'
247
253
  'Any clip or audio item: "id" (anchors, rn edit), "meta" (anything, kept as is), "vf" / "af" (one raw\n'
248
254
  'ffmpeg filter chain, e.g. "eq=saturation=1.2,unsharp"; af on main-track clips and audio).\n'
249
255
  'Main track: "ranges": [[in, out], ...] plays spans of one source back to back; "src": "part.json"\n'
250
- 'plays another EDL. Overlays: "blend": screen|multiply|add|overlay|... ; "mask": {"frames"|"image"|"src":\n'
251
- '..., "mode": "luma"|"alpha", "invert": bool} (full frame, e.g. from rn matte). Overlays and audio:\n'
252
- '"pivot": s puts that point of the source (a hit, a downbeat) on "at". "mix": {"cut_fade": 0.01} fades\n'
253
- 'every hard cut\'s own sound. "accept": [{"check", "at", "note"}] waives qa warnings judged intended.')
256
+ 'plays another edit. Overlays: "mask": {"frames"|"image"|"src": ..., "mode": "luma"|"alpha", "invert": bool}\n'
257
+ '(full frame, e.g. from rn matte). Overlays and audio: "pivot": s puts that point of the source (a hit, a\n'
258
+ 'downbeat) on "at". "mix": {"cut_fade": 0.01} fades every hard cut\'s own sound. "accept": [{"check", "at",\n'
259
+ '"note"}] waives qa warnings judged intended. A file without "version" uses the version-1 names (x, y, w,\n'
260
+ 'h, fit, blend; main-track keys x/y as fractions of the frame) and is upgraded on load; rn edit FILE\n'
261
+ 'upgrade rewrites it.')
254
262
  assemble.add_argument('edl')
255
263
  assemble.add_argument('-o', '--out', default='out.mp4')
256
264
  assemble.add_argument('--dry-run', action='store_true', help='validate and print the timeline without rendering')
@@ -277,6 +285,7 @@ def _parser() -> argparse.ArgumentParser:
277
285
  edit.add_argument('edl')
278
286
  ops = edit.add_subparsers(dest='op', required=True, metavar='op')
279
287
  ops.add_parser('ids', help='give every clip and audio item without an id one (c1.. main, o1.. overlays, a1.. audio)')
288
+ ops.add_parser('upgrade', help='rewrite a version-1 edit.json under version-2 names (style, keys left/top)')
280
289
  op_set = ops.add_parser('set', help='set fields: key=value (JSON when it parses), dotted keys reach into objects')
281
290
  op_set.add_argument('ref')
282
291
  op_set.add_argument('assignments', nargs='*', metavar='key=value')
@@ -442,6 +451,8 @@ def _run_summarized(args) -> tuple[dict, list[str]]:
442
451
  path = Path(args.edl)
443
452
  if args.op == 'ids':
444
453
  result = editops.ids_op(path)
454
+ elif args.op == 'upgrade':
455
+ result = editops.upgrade_op(path)
445
456
  elif args.op == 'set':
446
457
  result = editops.set_op(path, args.ref, args.assignments, args.unset)
447
458
  elif args.op == 'split':
@@ -492,6 +503,11 @@ def _run_summarized(args) -> tuple[dict, list[str]]:
492
503
 
493
504
 
494
505
  def main(argv: list[str] | None = None) -> int:
506
+ if sys.platform == 'win32' and not os.environ.get('PYTHONIOENCODING'):
507
+ # A pipe (an agent reading rn) would get the ANSI code page, which cannot hold every file name or caption.
508
+ for stream in (sys.stdout, sys.stderr):
509
+ if not stream.isatty() and hasattr(stream, 'reconfigure'):
510
+ stream.reconfigure(encoding='utf-8', errors='replace')
495
511
  args = _parser().parse_args(argv)
496
512
  for tool in ('ffmpeg', 'ffprobe'):
497
513
  if not shutil.which(tool):
@@ -536,7 +552,7 @@ def main(argv: list[str] | None = None) -> int:
536
552
  except ModuleNotFoundError as error:
537
553
  extra = EXTRAS.get(args.command, 'all')
538
554
  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)
555
+ 'uv tool install "renoise-kit[all]"', file=sys.stderr)
540
556
  return 1
541
557
  except Exception as error: # a kit bug: keep the traceback for the report, end on the usual one-liner
542
558
  import traceback
@@ -545,7 +561,7 @@ def main(argv: list[str] | None = None) -> int:
545
561
  return 1
546
562
  if args.json:
547
563
  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))
564
+ Path(args.json).write_text(json.dumps(result, indent=1, ensure_ascii=False, default=str), encoding='utf-8')
549
565
  if args.command in SUMMARIZED:
550
566
  print('\n'.join(lines))
551
567
  else: