renoise-kit 0.5.1__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 (53) hide show
  1. {renoise_kit-0.5.1/renoise_kit.egg-info → renoise_kit-0.6.0}/PKG-INFO +20 -10
  2. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/README.md +19 -9
  3. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/pyproject.toml +1 -1
  4. {renoise_kit-0.5.1 → renoise_kit-0.6.0/renoise_kit.egg-info}/PKG-INFO +20 -10
  5. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/renoise_kit.egg-info/SOURCES.txt +1 -0
  6. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/__init__.py +1 -1
  7. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/cli.py +25 -15
  8. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/editops.py +11 -2
  9. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/edl.py +157 -37
  10. renoise_kit-0.6.0/tests/test_edit_v2.py +95 -0
  11. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/LICENSE +0 -0
  12. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/renoise_kit.egg-info/dependency_links.txt +0 -0
  13. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/renoise_kit.egg-info/entry_points.txt +0 -0
  14. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/renoise_kit.egg-info/requires.txt +0 -0
  15. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/renoise_kit.egg-info/top_level.txt +0 -0
  16. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/__main__.py +0 -0
  17. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/align.py +0 -0
  18. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/assemble.py +0 -0
  19. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/assets.py +0 -0
  20. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/audio.py +0 -0
  21. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/captions.py +0 -0
  22. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/capture.py +0 -0
  23. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/compare.py +0 -0
  24. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/cuts.py +0 -0
  25. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/export.py +0 -0
  26. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/faces.py +0 -0
  27. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/ff.py +0 -0
  28. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/fonts.py +0 -0
  29. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/frames.py +0 -0
  30. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/fx/__init__.py +0 -0
  31. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/fx/fx.js +0 -0
  32. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/listen.py +0 -0
  33. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/matte.py +0 -0
  34. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/qa.py +0 -0
  35. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/ref.py +0 -0
  36. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/ref_guide.md +0 -0
  37. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/signal.py +0 -0
  38. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/text.py +0 -0
  39. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/setup.cfg +0 -0
  40. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_captions.py +0 -0
  41. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_capture.py +0 -0
  42. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_edl_v3.py +0 -0
  43. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_faces.py +0 -0
  44. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_fx.py +0 -0
  45. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_listen.py +0 -0
  46. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_portability.py +0 -0
  47. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_rn.py +0 -0
  48. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_v3_commands.py +0 -0
  49. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_v4_commands.py +0 -0
  50. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_v5_composite.py +0 -0
  51. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_v5_data.py +0 -0
  52. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_v5_measure.py +0 -0
  53. {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_v5_time.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: renoise-kit
3
- Version: 0.5.1
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
@@ -115,16 +115,17 @@ window.renderFrame = i => {
115
115
 
116
116
  ```json
117
117
  {
118
+ "version": 2,
118
119
  "output": {"width": 1080, "height": 1920, "fps": 30},
119
120
  "tracks": [
120
121
  {"clips": [
121
122
  {"id": "open", "src": "shots/s1.mp4", "in": 0.4, "out": 3.2},
122
123
  {"id": "hero", "src": "shots/s2.mp4", "ranges": [[0, 1.5], [2.5, 4]],
123
124
  "transition": {"type": "fade", "duration": 0.3}},
124
- {"id": "end", "image": "card.png", "duration": 1.5, "fit": "contain"}
125
+ {"id": "end", "image": "card.png", "duration": 1.5, "style": {"object-fit": "contain"}}
125
126
  ]},
126
127
  {"clips": [
127
- {"id": "title", "frames": "title/%05d.png", "fps": 30, "y": 1500,
128
+ {"id": "title", "frames": "title/%05d.png", "fps": 30, "style": {"top": 1500},
128
129
  "at": {"clip": "hero", "offset": 0.2}, "until": {"clip_end": "hero"}}
129
130
  ]}
130
131
  ],
@@ -210,7 +211,7 @@ rn edit edit.json delete end --lift
210
211
 
211
212
  | Command | What it gives |
212
213
  |---|---|
213
- | `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 |
214
215
  | `rn assemble EDL -o OUT [--preview] [--range A-B] [--placeholders]` | Renders the EDL frame-exactly. `--placeholders` turns missing shots into labelled cards |
215
216
 
216
217
  **Check and deliver**
@@ -224,8 +225,14 @@ rn edit edit.json delete end --lift
224
225
 
225
226
  ## The EDL in one screen
226
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.
227
230
  - **Tracks.** `tracks[0]` is the main track: its clips play back to back. Later tracks are overlays, placed with `at`
228
- 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`.
229
236
  - **Audio items.** Items in `audio` are placed with `at`. `role` is `music`, `voice` or `sfx`; music ducks under voice
230
237
  when `mix.duck_db` is set.
231
238
  - **One time grid.** `output.fps` is the only clock. A clip of N seconds is exactly `round(N × fps)` frames.
@@ -239,14 +246,17 @@ rn edit edit.json delete end --lift
239
246
  - `{"beat": n, "listen": file}` and `{"word": n, "listen": file}`
240
247
 
241
248
  `pivot` places a point inside the source on the anchor instead of the source's start.
242
- - **Keys.** Written as `[t, value, ease]`. Overlays animate `scale`, `x`, `y`, `rotate`, `opacity` and `speed`; the
243
- main track `scale`, `x`, `y`, `rotate`, `speed` and `gain_db`; audio items `gain_db`.
244
- - **Compositing.** `blend` takes `screen`, `multiply`, `add` and other blend modes. `mask` takes a full-frame matte, as
245
- 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.
246
254
  - **Bookkeeping.** `id` and `meta` go on anything; `meta` is kept as given. `accept` waives qa warnings judged
247
255
  intended.
248
256
 
249
- 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.
250
260
 
251
261
  ## Conventions
252
262
 
@@ -79,16 +79,17 @@ window.renderFrame = i => {
79
79
 
80
80
  ```json
81
81
  {
82
+ "version": 2,
82
83
  "output": {"width": 1080, "height": 1920, "fps": 30},
83
84
  "tracks": [
84
85
  {"clips": [
85
86
  {"id": "open", "src": "shots/s1.mp4", "in": 0.4, "out": 3.2},
86
87
  {"id": "hero", "src": "shots/s2.mp4", "ranges": [[0, 1.5], [2.5, 4]],
87
88
  "transition": {"type": "fade", "duration": 0.3}},
88
- {"id": "end", "image": "card.png", "duration": 1.5, "fit": "contain"}
89
+ {"id": "end", "image": "card.png", "duration": 1.5, "style": {"object-fit": "contain"}}
89
90
  ]},
90
91
  {"clips": [
91
- {"id": "title", "frames": "title/%05d.png", "fps": 30, "y": 1500,
92
+ {"id": "title", "frames": "title/%05d.png", "fps": 30, "style": {"top": 1500},
92
93
  "at": {"clip": "hero", "offset": 0.2}, "until": {"clip_end": "hero"}}
93
94
  ]}
94
95
  ],
@@ -174,7 +175,7 @@ rn edit edit.json delete end --lift
174
175
 
175
176
  | Command | What it gives |
176
177
  |---|---|
177
- | `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 |
178
179
  | `rn assemble EDL -o OUT [--preview] [--range A-B] [--placeholders]` | Renders the EDL frame-exactly. `--placeholders` turns missing shots into labelled cards |
179
180
 
180
181
  **Check and deliver**
@@ -188,8 +189,14 @@ rn edit edit.json delete end --lift
188
189
 
189
190
  ## The EDL in one screen
190
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.
191
194
  - **Tracks.** `tracks[0]` is the main track: its clips play back to back. Later tracks are overlays, placed with `at`
192
- 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`.
193
200
  - **Audio items.** Items in `audio` are placed with `at`. `role` is `music`, `voice` or `sfx`; music ducks under voice
194
201
  when `mix.duck_db` is set.
195
202
  - **One time grid.** `output.fps` is the only clock. A clip of N seconds is exactly `round(N × fps)` frames.
@@ -203,14 +210,17 @@ rn edit edit.json delete end --lift
203
210
  - `{"beat": n, "listen": file}` and `{"word": n, "listen": file}`
204
211
 
205
212
  `pivot` places a point inside the source on the anchor instead of the source's start.
206
- - **Keys.** Written as `[t, value, ease]`. Overlays animate `scale`, `x`, `y`, `rotate`, `opacity` and `speed`; the
207
- main track `scale`, `x`, `y`, `rotate`, `speed` and `gain_db`; audio items `gain_db`.
208
- - **Compositing.** `blend` takes `screen`, `multiply`, `add` and other blend modes. `mask` takes a full-frame matte, as
209
- 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.
210
218
  - **Bookkeeping.** `id` and `meta` go on anything; `meta` is kept as given. `accept` waives qa warnings judged
211
219
  intended.
212
220
 
213
- 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.
214
224
 
215
225
  ## Conventions
216
226
 
@@ -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.1
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
@@ -115,16 +115,17 @@ window.renderFrame = i => {
115
115
 
116
116
  ```json
117
117
  {
118
+ "version": 2,
118
119
  "output": {"width": 1080, "height": 1920, "fps": 30},
119
120
  "tracks": [
120
121
  {"clips": [
121
122
  {"id": "open", "src": "shots/s1.mp4", "in": 0.4, "out": 3.2},
122
123
  {"id": "hero", "src": "shots/s2.mp4", "ranges": [[0, 1.5], [2.5, 4]],
123
124
  "transition": {"type": "fade", "duration": 0.3}},
124
- {"id": "end", "image": "card.png", "duration": 1.5, "fit": "contain"}
125
+ {"id": "end", "image": "card.png", "duration": 1.5, "style": {"object-fit": "contain"}}
125
126
  ]},
126
127
  {"clips": [
127
- {"id": "title", "frames": "title/%05d.png", "fps": 30, "y": 1500,
128
+ {"id": "title", "frames": "title/%05d.png", "fps": 30, "style": {"top": 1500},
128
129
  "at": {"clip": "hero", "offset": 0.2}, "until": {"clip_end": "hero"}}
129
130
  ]}
130
131
  ],
@@ -210,7 +211,7 @@ rn edit edit.json delete end --lift
210
211
 
211
212
  | Command | What it gives |
212
213
  |---|---|
213
- | `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 |
214
215
  | `rn assemble EDL -o OUT [--preview] [--range A-B] [--placeholders]` | Renders the EDL frame-exactly. `--placeholders` turns missing shots into labelled cards |
215
216
 
216
217
  **Check and deliver**
@@ -224,8 +225,14 @@ rn edit edit.json delete end --lift
224
225
 
225
226
  ## The EDL in one screen
226
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.
227
230
  - **Tracks.** `tracks[0]` is the main track: its clips play back to back. Later tracks are overlays, placed with `at`
228
- 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`.
229
236
  - **Audio items.** Items in `audio` are placed with `at`. `role` is `music`, `voice` or `sfx`; music ducks under voice
230
237
  when `mix.duck_db` is set.
231
238
  - **One time grid.** `output.fps` is the only clock. A clip of N seconds is exactly `round(N × fps)` frames.
@@ -239,14 +246,17 @@ rn edit edit.json delete end --lift
239
246
  - `{"beat": n, "listen": file}` and `{"word": n, "listen": file}`
240
247
 
241
248
  `pivot` places a point inside the source on the anchor instead of the source's start.
242
- - **Keys.** Written as `[t, value, ease]`. Overlays animate `scale`, `x`, `y`, `rotate`, `opacity` and `speed`; the
243
- main track `scale`, `x`, `y`, `rotate`, `speed` and `gain_db`; audio items `gain_db`.
244
- - **Compositing.** `blend` takes `screen`, `multiply`, `add` and other blend modes. `mask` takes a full-frame matte, as
245
- 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.
246
254
  - **Bookkeeping.** `id` and `meta` go on anything; `meta` is kept as given. `accept` waives qa warnings judged
247
255
  intended.
248
256
 
249
- 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.
250
260
 
251
261
  ## Conventions
252
262
 
@@ -36,6 +36,7 @@ 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
@@ -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.1'
2
+ __version__ = '0.6.0'
@@ -215,20 +215,21 @@ def _parser() -> argparse.ArgumentParser:
215
215
  'ducking, LUFS target and encoded true peak <= limit. Output: H.264 High yuv420p bt709, moov first.\n'
216
216
  'The EDL handles the mechanical assembly; render anything creative with your own code (rn capture for\n'
217
217
  'HTML/canvas, numpy + rn.fx, Manim, Blender) to a PNG sequence or alpha clip and place it in the EDL.\n\n'
218
- 'edit.json (paths relative to it; times in seconds; {"frame": n} also works for in/out;\n'
219
- '# 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'
220
220
  '{\n'
221
+ ' "version": 2,\n'
221
222
  ' "output": {"width": 1080, "height": 1920, "fps": 30, "background": "#000000", "crf": 18},\n'
222
223
  ' "tracks": [\n'
223
224
  ' {"clips": [ # track 0 = main track, clips back to back\n'
224
- ' {"src": "s1.mp4", "in": 0.4, "out": 3.2, "fit": "cover"},\n'
225
+ ' {"src": "s1.mp4", "in": 0.4, "out": 3.2},\n'
225
226
  ' {"src": "s2.mp4", "in": {"frame": 6}, "duration": 2.5, "speed": 0.5, "audio": false,\n'
226
227
  ' "transition": {"type": "fade", "duration": 0.4}}, # any xfade name: dissolve, wipeleft, slideup...\n'
227
- ' {"image": "card.png", "duration": 1.5, "fit": "contain"},\n'
228
+ ' {"image": "card.png", "duration": 1.5, "style": {"object-fit": "contain"}},\n'
228
229
  ' {"color": "#000000", "duration": 0.5}]},\n'
229
- ' {"clips": [ # later tracks = overlays, positioned in time and space\n'
230
- ' {"image": "title.png", "at": 0.5, "duration": 2, "x": 90, "y": 1400, "w": 900,\n'
231
- ' "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'
232
233
  ' {"clips": [{"frames": "title/%05d.png", "fps": 30, "at": {"clip": 1, "offset": 0.2},\n'
233
234
  ' "until": {"clip_end": 1}, "keys": {"scale": [[0, 0.9], [0.3, 1, "out3"]], "opacity": [[0, 0], [0.2, 1]]}}]}],\n'
234
235
  ' "markers": {"drop": 4.5}, # named times for anchors\n'
@@ -237,21 +238,27 @@ def _parser() -> argparse.ArgumentParser:
237
238
  ' "subtitles": {"file": "captions.ass"},\n'
238
239
  ' "mix": {"lufs": -14, "true_peak": -1.0, "duck_db": 10} # duck music under voice\n'
239
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'
240
245
  'Positions: in/out take seconds, {"frame": n} or {"shot": n, "offset": s} (shots from rn cuts).\n'
241
246
  'Overlay "at"/"until" and audio "at" take seconds or an anchor + "offset": {"clip": i or "id"},\n'
242
247
  '{"clip_end": i or "id"} (main-track clip as laid out, or an earlier overlay by id), {"marker": name},\n'
243
248
  '{"frame": n}, {"beat": n, "listen": f.json}, {"word": n, "listen": f.json, "edge": "start"|"end"}.\n'
244
- 'Keys [t, value, ease?] (t from the clip start; eases linear in out inout in3 out3 inout3 hold):\n'
245
- 'main track scale/x/y/rotate (x, y as fractions of the frame), speed (a ramp; drops the clip\'s sound),\n'
246
- 'gain_db; overlays scale/x/y/rotate/opacity/speed (x, y in px, about the layer centre); audio gain_db.\n'
247
- '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'
248
253
  'Any clip or audio item: "id" (anchors, rn edit), "meta" (anything, kept as is), "vf" / "af" (one raw\n'
249
254
  'ffmpeg filter chain, e.g. "eq=saturation=1.2,unsharp"; af on main-track clips and audio).\n'
250
255
  'Main track: "ranges": [[in, out], ...] plays spans of one source back to back; "src": "part.json"\n'
251
- 'plays another EDL. Overlays: "blend": screen|multiply|add|overlay|... ; "mask": {"frames"|"image"|"src":\n'
252
- '..., "mode": "luma"|"alpha", "invert": bool} (full frame, e.g. from rn matte). Overlays and audio:\n'
253
- '"pivot": s puts that point of the source (a hit, a downbeat) on "at". "mix": {"cut_fade": 0.01} fades\n'
254
- '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.')
255
262
  assemble.add_argument('edl')
256
263
  assemble.add_argument('-o', '--out', default='out.mp4')
257
264
  assemble.add_argument('--dry-run', action='store_true', help='validate and print the timeline without rendering')
@@ -278,6 +285,7 @@ def _parser() -> argparse.ArgumentParser:
278
285
  edit.add_argument('edl')
279
286
  ops = edit.add_subparsers(dest='op', required=True, metavar='op')
280
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)')
281
289
  op_set = ops.add_parser('set', help='set fields: key=value (JSON when it parses), dotted keys reach into objects')
282
290
  op_set.add_argument('ref')
283
291
  op_set.add_argument('assignments', nargs='*', metavar='key=value')
@@ -443,6 +451,8 @@ def _run_summarized(args) -> tuple[dict, list[str]]:
443
451
  path = Path(args.edl)
444
452
  if args.op == 'ids':
445
453
  result = editops.ids_op(path)
454
+ elif args.op == 'upgrade':
455
+ result = editops.upgrade_op(path)
446
456
  elif args.op == 'set':
447
457
  result = editops.set_op(path, args.ref, args.assignments, args.unset)
448
458
  elif args.op == 'split':
@@ -11,7 +11,7 @@ import os
11
11
  from fractions import Fraction
12
12
  from pathlib import Path
13
13
 
14
- from .edl import Clip, load
14
+ from .edl import Clip, load, upgrade
15
15
  from .ff import RnError
16
16
 
17
17
 
@@ -236,8 +236,17 @@ def move_op(path: Path, ref: str, before: str | None, after: str | None, track:
236
236
  return {'op': 'move', 'clip': ref, 'timeline': _write_checked(path, raw)}
237
237
 
238
238
 
239
+ def upgrade_op(path: Path) -> dict:
240
+ """Rewrites a version-1 edit under version-2 names ("style", keys left / top); the timeline stays the same."""
241
+ raw = _read(path)
242
+ if raw.get('version') == 2:
243
+ raise RnError(f'{path.name} is already version 2')
244
+ return {'op': 'upgrade', 'timeline': _write_checked(path, upgrade(raw))}
245
+
246
+
239
247
  def summary_lines(result: dict) -> list[str]:
240
- head = {'ids': lambda r: f'ids added: {", ".join(r["added"]) or "none (every clip has one)"}',
248
+ head = {'upgrade': lambda r: 'upgraded to version 2',
249
+ 'ids': lambda r: f'ids added: {", ".join(r["added"]) or "none (every clip has one)"}',
241
250
  'set': lambda r: f'set {r["clip"]}',
242
251
  'split': lambda r: f'split {r["clip"]} into {r["into"][0]} + {r["into"][1]}',
243
252
  'delete': lambda r: f'deleted {r["clip"]}' + (' (lifted: gap kept)' if r['lift'] else ''),
@@ -23,14 +23,26 @@ XFADE = {
23
23
  'vuslice', 'vdslice', 'hblur', 'squeezeh', 'squeezev', 'zoomin', 'coverleft', 'coverright', 'coverup', 'coverdown',
24
24
  'revealleft', 'revealright', 'revealup', 'revealdown',
25
25
  }
26
- FITS = {'cover', 'contain', 'stretch'}
27
26
  EASES = {'linear', 'in', 'out', 'inout', 'in3', 'out3', 'inout3', 'hold'}
28
- KEYS_MAIN = {'scale', 'x', 'y', 'rotate', 'speed', 'gain_db'}
29
- KEYS_OVERLAY = {'scale', 'x', 'y', 'rotate', 'opacity', 'speed'}
27
+ # Version 2 names keyed properties as CSS does: left / top in px. Inside, rn keeps x / y (main track: fractions of the frame).
28
+ KEYS_MAIN = {'scale', 'left', 'top', 'rotate', 'speed', 'gain_db'}
29
+ KEYS_OVERLAY = {'scale', 'left', 'top', 'rotate', 'opacity', 'speed'}
30
30
  KEYS_AUDIO = {'gain_db'}
31
- # ffmpeg blend modes, computed in RGB; "add" is accepted for "addition".
32
- BLENDS = {'normal', 'addition', 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'difference', 'exclusion', 'softlight',
33
- 'hardlight', 'colordodge', 'colorburn', 'linearlight', 'vividlight', 'pinlight', 'average', 'negation', 'phoenix'}
31
+ # "style" holds placement and look under CSS property names, so a model's CSS habits apply.
32
+ STYLE_MAIN = {'object-fit', 'left', 'top', 'scale', 'rotate'}
33
+ STYLE_OVERLAY = STYLE_MAIN | {'width', 'height', 'opacity', 'mix-blend-mode'}
34
+ CSS_FITS = {'cover': 'cover', 'contain': 'contain', 'fill': 'stretch'}
35
+ # CSS mix-blend-mode -> ffmpeg blend (computed in RGB). The ffmpeg-only modes after them are rn's own: other hosts of the
36
+ # format may not render them (edit.schema.json marks them x-rn-only).
37
+ CSS_BLENDS = {'normal': 'normal', 'multiply': 'multiply', 'screen': 'screen', 'overlay': 'overlay', 'darken': 'darken',
38
+ 'lighten': 'lighten', 'color-dodge': 'colordodge', 'color-burn': 'colorburn', 'hard-light': 'hardlight',
39
+ 'soft-light': 'softlight', 'difference': 'difference', 'exclusion': 'exclusion', 'plus-lighter': 'addition'}
40
+ RN_BLENDS = {'linearlight', 'vividlight', 'pinlight', 'average', 'negation', 'phoenix'}
41
+ # Version 1 fields and where version 2 keeps them.
42
+ V1_FIELDS = {'x': 'style.left', 'y': 'style.top', 'w': 'style.width', 'h': 'style.height', 'opacity': 'style.opacity',
43
+ 'blend': 'style["mix-blend-mode"]', 'fit': 'style["object-fit"]'}
44
+ _LENGTH = re.compile(r'^(-?\d+(?:\.\d+)?)(px|%)$')
45
+ _DEGREES = re.compile(r'^(-?\d+(?:\.\d+)?)deg$')
34
46
  _GRAPH_BREAKERS = re.compile(r'[;\[\]]')
35
47
  # The same curves as assemble's ffmpeg expressions (assemble._EASE), for what rn computes itself (speed ramps).
36
48
  EASE_FN = {
@@ -178,9 +190,9 @@ class Edit:
178
190
  }
179
191
 
180
192
 
181
- def _num(value, where: str, *, minimum: float | None = None, positive: bool = False) -> float:
193
+ def _num(value, where: str, *, minimum: float | None = None, positive: bool = False, what: str = 'a number of seconds') -> float:
182
194
  if isinstance(value, bool) or not isinstance(value, (int, float)):
183
- raise RnError(f'{where}: expected a number of seconds, got {json.dumps(value)}')
195
+ raise RnError(f'{where}: expected {what}, got {json.dumps(value)}')
184
196
  if positive and value <= 0:
185
197
  raise RnError(f'{where}: must be > 0')
186
198
  if minimum is not None and value < minimum:
@@ -340,6 +352,55 @@ def _keys(raw, where: str, allowed: set[str]) -> dict | None:
340
352
  return keys
341
353
 
342
354
 
355
+ def _length(value, where: str, frame: int) -> float:
356
+ """px as a number or "90px", or a percentage of the frame's width (left, width) or height (top, height)."""
357
+ match = _LENGTH.match(value.strip()) if isinstance(value, str) else None
358
+ if match:
359
+ number = float(match.group(1))
360
+ return number * frame / 100 if match.group(2) == '%' else number
361
+ return _num(value, where, what='px as a number or "90px", or a share of the frame like "50%"')
362
+
363
+
364
+ def _style(raw, where: str, overlay: bool, size: tuple[int, int]) -> dict:
365
+ """Placement and look under CSS names: left / top / width / height (px), opacity, scale, rotate (degrees),
366
+ object-fit (cover | contain | fill), mix-blend-mode. Returned in rn's own terms (x, y, w, h, fit, blend)."""
367
+ if raw is None:
368
+ return {}
369
+ where = f'{where}.style'
370
+ if not isinstance(raw, dict):
371
+ raise RnError(f'{where}: {{"left": 90, "top": "70%", "width": 900, "opacity": 0.8, "mix-blend-mode": "screen"}}')
372
+ allowed = STYLE_OVERLAY if overlay else STYLE_MAIN
373
+ unknown = set(raw) - allowed
374
+ if unknown:
375
+ hint = '' if overlay else '; main-track clips fill the frame: put a sized or see-through clip on another track'
376
+ raise RnError(f'{where}: {sorted(unknown)} not supported here (supported: {sorted(allowed)}){hint}')
377
+ look = {}
378
+ if 'object-fit' in raw:
379
+ if raw['object-fit'] not in CSS_FITS:
380
+ raise RnError(f'{where}.object-fit: one of {sorted(CSS_FITS)}')
381
+ look['fit'] = CSS_FITS[raw['object-fit']]
382
+ for css, key, frame in (('left', 'x', size[0]), ('top', 'y', size[1]), ('width', 'w', size[0]), ('height', 'h', size[1])):
383
+ if css in raw:
384
+ look[key] = _length(raw[css], f'{where}.{css}', frame)
385
+ if key in ('w', 'h') and look[key] <= 0:
386
+ raise RnError(f'{where}.{css}: must be > 0')
387
+ if 'opacity' in raw:
388
+ look['opacity'] = _num(raw['opacity'], f'{where}.opacity', minimum=0, what='a number between 0 and 1')
389
+ if look['opacity'] > 1:
390
+ raise RnError(f'{where}.opacity: between 0 and 1')
391
+ if 'scale' in raw:
392
+ look['scale'] = _num(raw['scale'], f'{where}.scale', positive=True, what='a number (1 = own size)')
393
+ if 'rotate' in raw:
394
+ match = _DEGREES.match(raw['rotate'].strip()) if isinstance(raw['rotate'], str) else None
395
+ look['rotate'] = float(match.group(1)) if match else _num(raw['rotate'], f'{where}.rotate', what='degrees as a number or "5deg"')
396
+ if 'mix-blend-mode' in raw:
397
+ mode = raw['mix-blend-mode']
398
+ if mode not in CSS_BLENDS and mode not in RN_BLENDS:
399
+ raise RnError(f'{where}.mix-blend-mode: one of {sorted(CSS_BLENDS)} (rn also renders {sorted(RN_BLENDS)})')
400
+ look['blend'] = CSS_BLENDS.get(mode, mode)
401
+ return look
402
+
403
+
343
404
  def _sequence(edit_dir: Path, pattern, where: str) -> tuple[Path, int, int, tuple[int, int]]:
344
405
  """A numbered image sequence (printf pattern like "title/%05d.png") -> (pattern path, first number, count, size)."""
345
406
  if not isinstance(pattern, str) or not _SEQ.search(pattern):
@@ -476,7 +537,8 @@ def _placeholder(raw: dict, where: str, edit_dir: Path, size: tuple[int, int], w
476
537
  card = _card(edit_dir, size, [raw.get('id') or Path(value).name, *([str(label)] if label else []), f'missing: {value}'])
477
538
  warnings.append(f'{where}: {value} is missing; placeholder card for {seconds}s')
478
539
  keep = {k: v for k, v in raw.items() if k not in ('src', 'frames', 'image', 'fps', 'in', 'out', 'speed', 'audio', 'gain_db', 'duration', 'until')}
479
- return {**keep, 'image': str(card), 'duration': seconds, 'fit': raw.get('fit', 'contain')}
540
+ style = raw.get('style') if isinstance(raw.get('style'), dict) else {}
541
+ return {**keep, 'image': str(card), 'duration': seconds, 'style': {'object-fit': 'contain', **style}}
480
542
 
481
543
 
482
544
  def _clip(raw: dict, where: str, edit_dir: Path, fps: Fraction, *, overlay: bool, warnings: list[str],
@@ -502,20 +564,28 @@ def _clip(raw: dict, where: str, edit_dir: Path, fps: Fraction, *, overlay: bool
502
564
  raw = {**raw, 'duration': until - at_s}
503
565
  elif 'until' in raw:
504
566
  raise RnError(f'{where}.until: main-track clips play back to back; "until" is for overlays')
505
- fit = raw.get('fit', 'cover')
506
- if fit not in FITS:
507
- raise RnError(f'{where}.fit: one of {sorted(FITS)}')
567
+ look = _style(raw.get('style'), where, overlay, size)
508
568
  speed = _num(raw.get('speed', 1.0), f'{where}.speed', positive=True)
509
569
  if not 0.1 <= speed <= 8:
510
570
  raise RnError(f'{where}.speed: keep between 0.1 and 8')
511
571
  gain = _num(raw.get('gain_db', 0.0), f'{where}.gain_db')
512
- clip = Clip(where=where, kind='video', source=None, color=None, start_s=0.0, speed=speed, frames=0, fit=fit,
572
+ clip = Clip(where=where, kind='video', source=None, color=None, start_s=0.0, speed=speed, frames=0, fit=look.get('fit', 'cover'),
513
573
  audio=bool(raw.get('audio', not overlay)), gain_db=gain)
514
574
  clip.id, clip.meta = _ident(raw, where)
515
575
  clip.vf, clip.af = _filters(raw, 'vf', where), _filters(raw, 'af', where)
516
576
  if overlay and clip.af:
517
577
  raise RnError(f'{where}.af: overlays are silent; filter their sound in "audio"')
518
578
  clip.keys = _keys(raw.get('keys'), f'{where}.keys', KEYS_OVERLAY if overlay else KEYS_MAIN)
579
+ # A still style value is a key held for the whole clip; a main-track move is a fraction of the frame inside rn.
580
+ still = {p: look[p] for p in ('scale', 'rotate', *(() if overlay else ('x', 'y'))) if p in look}
581
+ for css, key in (('left', 'x'), ('top', 'y'), ('scale', 'scale'), ('rotate', 'rotate')):
582
+ if key in still and clip.keys and css in clip.keys:
583
+ raise RnError(f'{where}: give style.{css} or keys.{css}, not both')
584
+ if key in still:
585
+ clip.keys = {**(clip.keys or {}), css: [(0.0, still[key], 'linear')]}
586
+ if clip.keys and css in clip.keys and css != key:
587
+ frame = size[0] if key == 'x' else size[1]
588
+ clip.keys[key] = [(t, v if overlay else v / frame, ease) for t, v, ease in clip.keys.pop(css)]
519
589
  ramp = (clip.keys or {}).get('speed')
520
590
  if ramp and 'speed' in raw:
521
591
  raise RnError(f'{where}: give "speed" or keys.speed, not both')
@@ -626,29 +696,20 @@ def _clip(raw: dict, where: str, edit_dir: Path, fps: Fraction, *, overlay: bool
626
696
  at_s = 0.0
627
697
  if overlay:
628
698
  clip.timeline_start = _frames(at_s, fps)
629
- clip.x = int(_num(raw.get('x', 0), f'{where}.x'))
630
- clip.y = int(_num(raw.get('y', 0), f'{where}.y'))
631
- clip.w = int(_num(raw['w'], f'{where}.w', positive=True)) if 'w' in raw else None
632
- clip.h = int(_num(raw['h'], f'{where}.h', positive=True)) if 'h' in raw else None
633
- clip.opacity = _num(raw.get('opacity', 1.0), f'{where}.opacity', minimum=0)
634
- if clip.opacity > 1:
635
- raise RnError(f'{where}.opacity: between 0 and 1')
699
+ clip.x, clip.y = round(look.get('x', 0)), round(look.get('y', 0))
700
+ clip.w, clip.h = (round(look[k]) if k in look else None for k in ('w', 'h'))
701
+ clip.opacity = look.get('opacity', 1.0)
702
+ clip.blend = look.get('blend')
636
703
  clip.fade_in = _num(raw.get('fade_in', 0), f'{where}.fade_in', minimum=0)
637
704
  clip.fade_out = _num(raw.get('fade_out', 0), f'{where}.fade_out', minimum=0)
638
705
  if 'transition' in raw:
639
706
  raise RnError(f'{where}.transition: transitions are for the first (main) track; use fade_in / fade_out on overlays')
640
707
  if 'audio' in raw or 'gain_db' in raw:
641
708
  raise RnError(f'{where}: overlay clips are silent; add their sound to "audio" with "at" (role "sfx")')
642
- blend = raw.get('blend')
643
- if blend is not None:
644
- blend = {'add': 'addition'}.get(blend, blend)
645
- if blend not in BLENDS:
646
- raise RnError(f'{where}.blend: one of {sorted(BLENDS | {"add"})}')
647
- clip.blend = blend
648
709
  if 'mask' in raw:
649
710
  clip.mask, clip.mask_mode, clip.mask_invert = _mask(raw['mask'], where, clip, edit_dir, fps, warnings, timeline, size)
650
711
  else:
651
- for key in ('at', 'x', 'y', 'w', 'h', 'opacity', 'fade_in', 'fade_out', 'blend', 'mask'):
712
+ for key in ('at', 'fade_in', 'fade_out', 'mask'):
652
713
  if key in raw:
653
714
  raise RnError(f'{where}.{key}: main-track clips play back to back; put positioned clips on another track')
654
715
  transition = raw.get('transition')
@@ -660,13 +721,20 @@ def _clip(raw: dict, where: str, edit_dir: Path, fps: Fraction, *, overlay: bool
660
721
  if clip.keys and 'gain_db' in clip.keys and not (clip.audio and clip.has_audio):
661
722
  raise RnError(f'{where}.keys.gain_db: the clip plays no sound of its own')
662
723
  if clip.keys and overlay and ('scale' in clip.keys or 'rotate' in clip.keys) and clip.kind == 'color' and not (clip.w and clip.h):
663
- raise RnError(f'{where}.keys: give a colour overlay "w" and "h" to scale or rotate it')
664
- unknown = set(raw) - {'src', 'frames', 'fps', 'image', 'color', 'in', 'out', 'duration', 'until', 'speed', 'fit', 'audio', 'gain_db',
665
- 'at', 'x', 'y', 'w', 'h', 'opacity', 'fade_in', 'fade_out', 'transition', 'keys', 'note', 'id', 'meta',
666
- 'pivot', 'vf', 'af', 'blend', 'mask'}
724
+ raise RnError(f'{where}: give a colour overlay style.width and style.height to scale or rotate it')
725
+ unknown = set(raw) - {'src', 'frames', 'fps', 'image', 'color', 'in', 'out', 'duration', 'until', 'speed', 'audio', 'gain_db',
726
+ 'at', 'style', 'fade_in', 'fade_out', 'transition', 'keys', 'note', 'id', 'meta', 'pivot', 'vf', 'af', 'mask'}
727
+ _reject_unknown(unknown, where)
728
+ return clip
729
+
730
+
731
+ def _reject_unknown(unknown: set[str], where: str) -> None:
732
+ old = sorted(unknown & set(V1_FIELDS))
733
+ if old:
734
+ raise RnError(f'{where}: {", ".join(f"{k} is now {V1_FIELDS[k]}" for k in old)} (edit.json version 2; '
735
+ '"rn edit FILE upgrade" rewrites a version-1 file)')
667
736
  if unknown:
668
737
  raise RnError(f'{where}: unknown field(s) {sorted(unknown)}')
669
- return clip
670
738
 
671
739
 
672
740
  def _mask(raw, where: str, layer: Clip, edit_dir: Path, fps: Fraction, warnings: list[str], timeline: Timeline,
@@ -679,14 +747,14 @@ def _mask(raw, where: str, layer: Clip, edit_dir: Path, fps: Fraction, warnings:
679
747
  mode, invert = raw.get('mode', 'luma'), raw.get('invert', False)
680
748
  if mode not in ('luma', 'alpha') or not isinstance(invert, bool):
681
749
  raise RnError(f'{where}: "mode" luma or alpha, "invert" true or false')
682
- placed = {'x', 'y', 'w', 'h', 'blend', 'mask', 'opacity'} & set(raw)
683
- if placed:
684
- raise RnError(f'{where}: a mask covers the whole frame; {sorted(placed)} do not apply')
750
+ style = raw.get('style') or {}
751
+ if 'mask' in raw or not isinstance(style, dict) or set(style) - {'object-fit'}:
752
+ raise RnError(f'{where}: a mask covers the whole frame; only style["object-fit"] applies (default fill)')
685
753
  matte = {k: v for k, v in raw.items() if k not in ('mode', 'invert')}
686
754
  matte.setdefault('at', float(layer.timeline_start / fps))
687
755
  if not {'duration', 'out', 'until'} & set(matte):
688
756
  matte['duration'] = float(layer.frames / fps)
689
- matte.setdefault('fit', 'stretch')
757
+ matte['style'] = {'object-fit': 'fill', **style}
690
758
  clip = _clip(matte, where, edit_dir, fps, overlay=True, warnings=warnings, timeline=timeline, size=size)
691
759
  clip.w, clip.h = size
692
760
  return clip, mode, invert
@@ -768,8 +836,53 @@ def _expand_ranges(clips: list) -> tuple[list[tuple[str, dict]], dict[str, tuple
768
836
  return pieces, groups
769
837
 
770
838
 
839
+ def _upgrade_clip(clip, frame: tuple[float, float] | None) -> None:
840
+ """In place: a version-1 clip's placement and look into "style"; keys x / y into left / top in px. frame: the
841
+ output size for main-track keys, whose x / y were fractions of it (None when the output size is not a number)."""
842
+ if not isinstance(clip, dict) or 'style' in clip:
843
+ return # not a clip, or already mixed with version 2: the parser names what is wrong
844
+ style = {}
845
+ for old, new in (('x', 'left'), ('y', 'top'), ('w', 'width'), ('h', 'height'), ('opacity', 'opacity')):
846
+ if old in clip:
847
+ style[new] = clip.pop(old)
848
+ if 'fit' in clip:
849
+ style['object-fit'] = {'stretch': 'fill'}.get(clip['fit'], clip['fit'])
850
+ del clip['fit']
851
+ if 'blend' in clip:
852
+ css = {ff: css for css, ff in CSS_BLENDS.items()} | {'add': 'plus-lighter'}
853
+ style['mix-blend-mode'] = css.get(clip['blend'], clip['blend'])
854
+ del clip['blend']
855
+ if style:
856
+ clip['style'] = style
857
+ keys = clip.get('keys')
858
+ if isinstance(keys, dict):
859
+ for old, new, side in (('x', 'left', 0), ('y', 'top', 1)):
860
+ points = keys.pop(old, None)
861
+ if points is not None:
862
+ if frame and isinstance(points, list):
863
+ points = [[p[0], p[1] * frame[side], *p[2:]] if isinstance(p, list) and len(p) > 1
864
+ and isinstance(p[1], (int, float)) else p for p in points]
865
+ keys[new] = points
866
+ _upgrade_clip(clip.get('mask'), None)
867
+
868
+
869
+ def upgrade(raw: dict) -> dict:
870
+ """A version-1 edit (no "version") in version-2 terms, as a new dict: placement and look move into "style" under CSS
871
+ names, and keys x / y become left / top in px (main-track x / y were fractions of the frame)."""
872
+ raw = json.loads(json.dumps(raw))
873
+ output = raw.get('output') if isinstance(raw.get('output'), dict) else {}
874
+ size = (output.get('width'), output.get('height'))
875
+ frame = size if all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in size) else None
876
+ tracks = raw.get('tracks') if isinstance(raw.get('tracks'), list) else []
877
+ for t, track in enumerate(tracks):
878
+ for clip in (track.get('clips') if isinstance(track, dict) and isinstance(track.get('clips'), list) else []):
879
+ _upgrade_clip(clip, frame if t == 0 else None)
880
+ return {'version': 2, **{k: v for k, v in raw.items() if k != 'version'}}
881
+
882
+
771
883
  def load(path: str | Path, *, placeholders: bool = False, _stack: tuple[Path, ...] = ()) -> Edit:
772
- """Parses and lays out an EDL. placeholders: a missing source becomes a card for its time span instead of an error."""
884
+ """Parses and lays out an EDL. placeholders: a missing source becomes a card for its time span instead of an error.
885
+ A version-1 edit (no "version") is upgraded on the way in."""
773
886
  path = Path(path).resolve()
774
887
  stack = (*_stack, path)
775
888
  try:
@@ -778,6 +891,13 @@ def load(path: str | Path, *, placeholders: bool = False, _stack: tuple[Path, ..
778
891
  raise RnError(f'no such file: {path}') from None
779
892
  except json.JSONDecodeError as error:
780
893
  raise RnError(f'{path}: invalid JSON: {error}') from None
894
+ if not isinstance(raw, dict):
895
+ raise RnError(f'{path}: expected a JSON object')
896
+ version = raw.get('version', 1)
897
+ if version == 1:
898
+ raw = upgrade(raw)
899
+ elif version != 2:
900
+ raise RnError('version: 2 (or 1 / absent for the old field names, upgraded on load)')
781
901
  edit_dir = path.parent
782
902
  output = raw.get('output') or {}
783
903
  width = int(_num(output.get('width'), 'output.width', positive=True))
@@ -0,0 +1,95 @@
1
+ """edit.json version 2: placement and look in "style" under CSS names, keys left / top in px, version 1 upgraded on load,
2
+ and schema/edit.schema.json agreeing with rn on the shared fixtures other hosts test against."""
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from rn.edl import load
9
+ from rn.editops import upgrade_op
10
+ from rn.ff import RnError
11
+
12
+ SCHEMA_DIR = Path(__file__).resolve().parents[1] / 'schema'
13
+ FIXTURES = SCHEMA_DIR / 'fixtures'
14
+ # Why rn rejects each invalid fixture: part of its error message.
15
+ REJECTED_FOR = {
16
+ 'blend-hue': 'mix-blend-mode', 'fit-stretch': 'object-fit', 'key-x': 'keyable here', 'length-space': 'style.left',
17
+ 'main-width': "['width'] not supported here", 'odd-width': 'must be even', 'opacity-range': 'between 0 and 1',
18
+ 'two-sources': 'exactly one of', 'until-on-main': 'main-track clips play back to back', 'v1-field': 'x is now style.left',
19
+ 'version-3': 'version: 2',
20
+ }
21
+
22
+
23
+ def schema_validator():
24
+ jsonschema = pytest.importorskip('jsonschema')
25
+ schema = json.loads((SCHEMA_DIR / 'edit.schema.json').read_text(encoding='utf-8'))
26
+ jsonschema.Draft202012Validator.check_schema(schema)
27
+ return jsonschema.Draft202012Validator(schema)
28
+
29
+
30
+ @pytest.mark.parametrize('path', sorted((FIXTURES / 'valid').glob('*.json')), ids=lambda p: p.stem)
31
+ def test_valid_fixtures_pass_the_schema_and_rn(path):
32
+ schema_validator().validate(json.loads(path.read_text(encoding='utf-8')))
33
+ load(path)
34
+
35
+
36
+ @pytest.mark.parametrize('path', sorted((FIXTURES / 'invalid').glob('*.json')), ids=lambda p: p.stem)
37
+ def test_invalid_fixtures_fail_the_schema_and_rn(path):
38
+ assert not schema_validator().is_valid(json.loads(path.read_text(encoding='utf-8')))
39
+ with pytest.raises(RnError, match=REJECTED_FOR[path.stem].replace('[', r'\[').replace(']', r'\]')):
40
+ load(path)
41
+
42
+
43
+ V1 = {'output': {'width': 1080, 'height': 1920, 'fps': 30}, 'tracks': [
44
+ {'clips': [{'color': '#203040', 'duration': 2, 'fit': 'contain',
45
+ 'keys': {'scale': [[0, 1], [2, 1.2, 'out']], 'x': [[0, 0], [2, 0.05]], 'y': [[0, -0.1]]}}]},
46
+ {'clips': [{'color': '#ffffff', 'at': 0.5, 'duration': 1, 'x': 90, 'y': 1400, 'w': 900, 'h': 200, 'opacity': 0.8, 'blend': 'add',
47
+ 'keys': {'y': [[0, 1400], [1, 1300, 'out3']]}}]}]}
48
+ V2 = {'version': 2, 'output': {'width': 1080, 'height': 1920, 'fps': 30}, 'tracks': [
49
+ {'clips': [{'color': '#203040', 'duration': 2, 'style': {'object-fit': 'contain', 'top': -192},
50
+ 'keys': {'scale': [[0, 1], [2, 1.2, 'out']], 'left': [[0, 0], [2, 54]]}}]},
51
+ {'clips': [{'color': '#ffffff', 'at': 0.5, 'duration': 1,
52
+ 'style': {'left': '8.333333%', 'top': 1400, 'width': '900px', 'height': 200, 'opacity': 0.8, 'mix-blend-mode': 'plus-lighter'},
53
+ 'keys': {'top': [[0, 1400], [1, 1300, 'out3']]}}]}]}
54
+
55
+
56
+ def laid_out(edit):
57
+ """What rn renders from: fit, placement, look and keys (rounded: px <-> fractions of the frame round-trip)."""
58
+ keys = lambda c: {p: [(t, round(v, 9), e) for t, v, e in pts] for p, pts in (c.keys or {}).items()} # noqa: E731
59
+ return [(c.fit, c.x, c.y, c.w, c.h, c.opacity, c.blend, keys(c), c.timeline_start, c.frames) for c in edit.base + edit.overlays]
60
+
61
+
62
+ def write(tmp_path, name, raw):
63
+ path = tmp_path / name
64
+ path.write_text(json.dumps(raw), encoding='utf-8')
65
+ return path
66
+
67
+
68
+ def test_version_1_and_version_2_lay_out_the_same(tmp_path):
69
+ v1, v2 = load(write(tmp_path, 'v1.json', V1)), load(write(tmp_path, 'v2.json', V2))
70
+ assert laid_out(v1) == laid_out(v2)
71
+ main, overlay = v2.base[0], v2.overlays[0]
72
+ assert main.keys['x'][-1][1] == pytest.approx(0.05) and main.keys['y'] == [(0.0, -0.1, 'linear')] # px -> fraction of the frame
73
+ assert (overlay.x, overlay.w, overlay.blend) == (90, 900, 'addition')
74
+
75
+
76
+ def test_upgrade_rewrites_the_file_and_keeps_the_timeline(tmp_path):
77
+ path = write(tmp_path, 'e.json', V1)
78
+ before = laid_out(load(path))
79
+ upgrade_op(path)
80
+ raw = json.loads(path.read_text(encoding='utf-8'))
81
+ assert raw['version'] == 2 and raw['tracks'][1]['clips'][0]['style']['mix-blend-mode'] == 'plus-lighter'
82
+ assert 'x' not in raw['tracks'][1]['clips'][0] and 'left' in raw['tracks'][0]['clips'][0]['keys']
83
+ assert laid_out(load(path)) == before
84
+ with pytest.raises(RnError, match='already version 2'):
85
+ upgrade_op(path)
86
+
87
+
88
+ def test_a_style_value_is_a_held_key_and_cannot_also_be_keyed(tmp_path):
89
+ raw = json.loads(json.dumps(V2))
90
+ raw['tracks'][1]['clips'][0]['style'] |= {'rotate': '5deg', 'scale': 1.5}
91
+ overlay = load(write(tmp_path, 'held.json', raw)).overlays[0]
92
+ assert overlay.keys['rotate'] == [(0.0, 5.0, 'linear')] and overlay.keys['scale'] == [(0.0, 1.5, 'linear')]
93
+ raw['tracks'][1]['clips'][0]['keys']['scale'] = [[0, 1], [1, 2]]
94
+ with pytest.raises(RnError, match='style.scale or keys.scale'):
95
+ load(write(tmp_path, 'both.json', raw))
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes