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.
- {renoise_kit-0.5.1/renoise_kit.egg-info → renoise_kit-0.6.0}/PKG-INFO +20 -10
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/README.md +19 -9
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/pyproject.toml +1 -1
- {renoise_kit-0.5.1 → renoise_kit-0.6.0/renoise_kit.egg-info}/PKG-INFO +20 -10
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/renoise_kit.egg-info/SOURCES.txt +1 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/__init__.py +1 -1
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/cli.py +25 -15
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/editops.py +11 -2
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/edl.py +157 -37
- renoise_kit-0.6.0/tests/test_edit_v2.py +95 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/LICENSE +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/renoise_kit.egg-info/dependency_links.txt +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/renoise_kit.egg-info/entry_points.txt +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/renoise_kit.egg-info/requires.txt +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/renoise_kit.egg-info/top_level.txt +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/__main__.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/align.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/assemble.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/assets.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/audio.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/captions.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/capture.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/compare.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/cuts.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/export.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/faces.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/ff.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/fonts.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/frames.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/fx/__init__.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/fx/fx.js +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/listen.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/matte.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/qa.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/ref.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/ref_guide.md +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/signal.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/rn/text.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/setup.cfg +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_captions.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_capture.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_edl_v3.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_faces.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_fx.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_listen.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_portability.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_rn.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_v3_commands.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_v4_commands.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_v5_composite.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_v5_data.py +0 -0
- {renoise_kit-0.5.1 → renoise_kit-0.6.0}/tests/test_v5_measure.py +0 -0
- {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.
|
|
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, "
|
|
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 `
|
|
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]
|
|
243
|
-
main track `scale`, `
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
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, "
|
|
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 `
|
|
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]
|
|
207
|
-
main track `scale`, `
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
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.
|
|
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, "
|
|
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 `
|
|
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]
|
|
243
|
-
main track `scale`, `
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
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
|
|
|
@@ -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.
|
|
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;
|
|
219
|
-
'
|
|
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
|
|
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,
|
|
230
|
-
' {"image": "title.png", "at": 0.5, "duration": 2, "
|
|
231
|
-
' "
|
|
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)
|
|
245
|
-
'main track scale/
|
|
246
|
-
'gain_db; overlays scale/
|
|
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
|
|
252
|
-
'
|
|
253
|
-
'
|
|
254
|
-
'
|
|
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 = {'
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
#
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
630
|
-
clip.
|
|
631
|
-
clip.
|
|
632
|
-
clip.
|
|
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', '
|
|
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}
|
|
664
|
-
unknown = set(raw) - {'src', 'frames', 'fps', 'image', 'color', 'in', 'out', 'duration', 'until', 'speed', '
|
|
665
|
-
'at', '
|
|
666
|
-
|
|
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
|
-
|
|
683
|
-
if
|
|
684
|
-
raise RnError(f'{where}: a mask covers the whole frame;
|
|
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
|
|
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
|
|
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
|