ffmpeg-skill 0.10.0 → 0.12.5

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 (56) hide show
  1. package/README.md +70 -10
  2. package/SKILL.md +89 -12
  3. package/bin/install.js +15 -1
  4. package/mcp/__pycache__/server.cpython-311.pyc +0 -0
  5. package/mcp/server.py +2 -0
  6. package/package.json +2 -2
  7. package/references/ci-platform-pitfalls.md +111 -0
  8. package/references/process-pitfalls.md +85 -0
  9. package/references/scripts.md +139 -12
  10. package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
  11. package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
  12. package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
  13. package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
  14. package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
  15. package/scripts/__pycache__/check.cpython-311.pyc +0 -0
  16. package/scripts/__pycache__/color.cpython-311.pyc +0 -0
  17. package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
  18. package/scripts/__pycache__/export.cpython-311.pyc +0 -0
  19. package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
  20. package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
  21. package/scripts/__pycache__/join.cpython-311.pyc +0 -0
  22. package/scripts/__pycache__/look.cpython-311.pyc +0 -0
  23. package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
  24. package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
  25. package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
  26. package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
  27. package/scripts/__pycache__/render.cpython-311.pyc +0 -0
  28. package/scripts/__pycache__/report.cpython-311.pyc +0 -0
  29. package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
  30. package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
  31. package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
  32. package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
  33. package/scripts/_common.py +255 -14
  34. package/scripts/_contract.py +276 -13
  35. package/scripts/audio.py +1 -1
  36. package/scripts/background.py +73 -0
  37. package/scripts/caption.py +106 -24
  38. package/scripts/color.py +129 -30
  39. package/scripts/crop.py +79 -0
  40. package/scripts/cut.py +2 -2
  41. package/scripts/export.py +1 -1
  42. package/scripts/fit.py +73 -14
  43. package/scripts/graphics.py +18 -7
  44. package/scripts/insert.py +128 -0
  45. package/scripts/join.py +14 -5
  46. package/scripts/look.py +13 -8
  47. package/scripts/loudness.py +3 -3
  48. package/scripts/multicam.py +1 -1
  49. package/scripts/overlay.py +78 -12
  50. package/scripts/proxy.py +82 -0
  51. package/scripts/reverse.py +56 -0
  52. package/scripts/scenes.py +8 -2
  53. package/scripts/sequence.py +124 -0
  54. package/scripts/silence.py +2 -2
  55. package/scripts/stabilize.py +101 -0
  56. package/scripts/sync.py +1 -1
@@ -0,0 +1,85 @@
1
+ # Process pitfalls
2
+
3
+ Mistakes made (or nearly made) while developing this repo that were not about FFmpeg
4
+ or a platform's behaviour — about the *process* of making a change safely. Written down
5
+ for the same reason `references/ci-platform-pitfalls.md` exists: a mistake that isn't
6
+ recorded gets repeated the next time a session starts fresh with no memory of it.
7
+
8
+ **This file is a living record.** Whenever a change here is made (or nearly made, then
9
+ caught before landing) because an existing guardrail — a pinned test, an environment
10
+ constraint, a platform's real behaviour under repeated attempts — wasn't checked first,
11
+ add an entry below. Don't wait to be asked.
12
+
13
+ ## Before narrowing a `required`/`optional` capability list, grep for the pinned test that checks it
14
+
15
+ `scripts/_contract.py`'s `TOOL_META[...]["required"]` drives `doctor`'s per-tool `usable`
16
+ answer (`_tool_usability()` in `_contract.py` only reads `required`, never `optional`).
17
+ Moving a capability from `required` to `optional` — even when it's honestly true that a
18
+ new flag makes it conditional — silently changes what `doctor` reports as `usable: no`
19
+ on a machine missing that capability, for the tool's *default* invocation too.
20
+
21
+ `tests/test_contract.py`'s `DoctorDetectionTests` pins specific `usable` outcomes against
22
+ real captured `ffmpeg -filters`/`-encoders` fixtures (e.g. a plain Homebrew macOS build
23
+ correctly reporting `caption.usable: "no"` because it lacks `filter:subtitles`). A change
24
+ to `required` that isn't checked against these first can pass a quick unit test and still
25
+ break this fixture-based guarantee.
26
+
27
+ Caught twice while adding capability metadata for new flags (`caption.py --mode mux` in
28
+ #51, `doctor`'s `gpu_encoders` in #52) — in both cases the fix was to grep
29
+ `tests/test_contract.py` for `usable` and `_doctor(` *before* editing `TOOL_META`, not
30
+ after a test failure revealed it. Do that grep first, every time `required`/`optional`
31
+ changes.
32
+
33
+ ## Git tag push and GitHub Release creation are not reachable from this environment
34
+
35
+ The git credentials available here can push to `refs/heads/*` (branches) but not
36
+ `refs/tags/*` — confirmed by a 403 straight from the git-receive-pack endpoint, not an
37
+ auth failure, meaning it's a deliberate scope restriction, not a bug to route around.
38
+ The GitHub MCP tool surface has no `create_release`/`create_tag` equivalent either
39
+ (`create_branch`, `create_pull_request`, `create_or_update_file` exist; nothing for
40
+ releases). A direct call to the GitHub REST API's `/releases` endpoint with a raw token
41
+ is also blocked by the outbound proxy itself (its own 403, pointing at Anthropic's docs,
42
+ not GitHub's).
43
+
44
+ Confirmed once (retried the tag push a second time "just in case" before accepting it).
45
+ Don't retry either path a second time — if `git push origin <tag>` 403s, or no
46
+ release-creation tool is found in one `ToolSearch` pass, say so once and hand the user
47
+ the two-minute browser-only path instead (open the repo's `/releases/new`, type the new
48
+ tag name in the tag field — GitHub creates it from the target branch on publish, no git
49
+ command needed).
50
+
51
+ ## A quantitative test failing three different ways across fixture redesigns means the platform, not the fixture, is the problem
52
+
53
+ `test_stabilize_reduces_frame_to_frame_motion` (macOS CI, `stabilize.py`) failed with
54
+ three independently redesigned shake fixtures in a row — each time the instinct was "the
55
+ fixture's frequencies must be wrong," each time the retuned fixture failed a *different*
56
+ way on the next CI run. The actual cause (libvidstab behaving differently across the
57
+ Linux and macOS ffmpeg builds) was diagnosable from the first failure: a synthetic
58
+ fixture that reliably improves under one implementation and reliably gets worse under
59
+ another is evidence the implementations disagree, not that the fixture is miscalibrated.
60
+
61
+ If a quantitative assertion fails on one platform, survives a redesign, and fails again
62
+ on the *same* platform in a different way: stop redesigning the fixture. Either restrict
63
+ the strict assertion to the platform where it's provably correct (keeping a weaker,
64
+ platform-general check — output exists, has the right duration — everywhere), or escalate
65
+ before spending a third CI cycle on it.
66
+
67
+ ## A fix merged after CHANGELOG.md's current-version section was drafted can silently miss it
68
+
69
+ `CHANGELOG.md`'s `## 0.12.0` section was written once, covering everything merged up to
70
+ that point. Two fixes that closed real issues after that point (#62's `--audio-stream`
71
+ extension via PR #72, #77's dry-run-dims fix via PR #88) landed with no further nudge to
72
+ go back and add a bullet — #62's fix actually got a bullet (its content is genuinely
73
+ described) but the `Closes #62` link was left off, and #77 was missed outright until a
74
+ direct question ("shouldn't this bump the version?") prompted a manual check. Neither was
75
+ caught by CI, because nothing checked CHANGELOG.md against what had actually been closed.
76
+
77
+ Caught by hand both times, then closed properly with `tests/test_contract.py`'s
78
+ `test_changelog_mentions_every_closed_issue_since_last_tag`, which walks `git log` back to
79
+ the latest release tag, extracts every `Closes #N.` from a commit body, and fails if that
80
+ issue number doesn't appear anywhere in `CHANGELOG.md`. This needs real history (`ci.yml`'s
81
+ `actions/checkout` step now passes `fetch-depth: 0` for exactly this reason — the default
82
+ shallow clone leaves no tag reachable to diff against, which would make the test silently
83
+ skip itself in CI, not fail). If this test ever needs to skip a genuinely changelog-less
84
+ closed issue (a pure process note, a duplicate, a revert of an unreleased change), name the
85
+ exemption in the test itself with a reason — don't just widen the regex or drop the check.
@@ -1,11 +1,17 @@
1
1
  # Script reference
2
2
 
3
- Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--fast`, `--progress`, `-o OUT`.
3
+ Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--fast`, `--progress`, `-o OUT` -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) and `check` (skips only the loudness-measurement pass) still run ffprobe/ffmpeg, `sync`/`multicam`/`scenes`/`report` still run ffmpeg/ffprobe to measure or analyse (they just don't write the final artifact), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
4
4
 
5
5
  ## Contents
6
6
  - probe.py — inspect
7
7
  - cut.py — cut / join segments
8
- - fit.py — target duration and/or aspect
8
+ - fit.py — target duration and/or aspect, rotate/flip
9
+ - crop.py — crop to an exact pixel rectangle
10
+ - insert.py — still image to a timed silent clip, with Ken Burns zoom/pan
11
+ - background.py — generate a solid-colour or gradient clip
12
+ - reverse.py — reverse playback
13
+ - stabilize.py — motion stabilisation (vidstab)
14
+ - sequence.py — numbered/globbed image sequence to video
9
15
  - silence.py — remove dead air / jump cuts
10
16
  - join.py — concatenate with transitions
11
17
  - render.py — the whole edit in one project.json
@@ -21,12 +27,13 @@ Every script prints the same information with `--help`; this file exists so the
21
27
  - verify.py — real-footage verification kit
22
28
  - look.py — see the result
23
29
  - caption.py — subtitles (static, animated, karaoke)
24
- - overlay.py — logo, image, title
30
+ - overlay.py — logo, image, title, video picture-in-picture, chroma key
25
31
  - sync.py — offset detection, alignment, drift correction
26
32
  - color.py — HDR to SDR, LUTs, colour tags, Dolby Vision
27
33
  - audio.py — clean-up, music, ducking, layout
28
34
  - loudness.py — EBU R128 normalisation
29
35
  - export.py — delivery presets
36
+ - proxy.py — low-bitrate proxy for analysis/preview
30
37
 
31
38
  ## Scripts
32
39
 
@@ -48,18 +55,90 @@ keyframes, instant, lossless); if the snapped result deviates more than
48
55
  Multiple segments are concatenated in the order given. stderr reports whether
49
56
  the result was "lossless stream copy" or "re-encoded".
50
57
 
51
- ### fit.py — target duration and/or aspect
58
+ ### fit.py — target duration and/or aspect, rotate/flip
52
59
  ```
53
60
  fit.py INPUT [--duration T --method speed|trim [--from-center] [--max-speed 4]]
54
- [--aspect 16:9|9:16|1:1|4:5|W:H --fit pad|crop [--width W] [--pad-color black]]
55
- [--fps N] [-o OUT]
61
+ [--aspect 16:9|9:16|1:1|4:5|W:H --fit pad|crop [--width W] [--height H] [--pad-color black]]
62
+ [--rotate 90|180|270] [--flip h|v] [--fps N] [-o OUT]
56
63
  ```
57
64
  `speed` retimes video and audio together (pitch-preserving `atempo`); it
58
65
  refuses factors beyond `--max-speed`. For slow motion add `--smooth blend`
59
66
  (frame blending, fast) or `--smooth interpolate` (motion-compensated
60
67
  `minterpolate`, fluid but roughly 10-20x slower than realtime). `trim` keeps
61
- the head (or the middle with `--from-center`). `--fps` forces a constant frame
62
- rate; VFR sources are conformed automatically even without it.
68
+ the head (or the middle with `--from-center`). `--width`/`--height` set the
69
+ output size: give one and the other follows the aspect (source aspect if
70
+ `--aspect` isn't also given); give both for an exact frame. `--rotate` applies
71
+ a new clockwise rotation (90/180/270 swap width/height for 90 and 270 — this
72
+ is separate from the rotation *metadata* fit.py already reads to size a
73
+ source correctly); `--flip h|v` mirrors the picture; both can combine, rotate
74
+ first. `--fps` forces a constant frame rate; VFR sources are conformed
75
+ automatically even without it.
76
+
77
+ ### crop.py — crop to an exact pixel rectangle
78
+ ```
79
+ crop.py INPUT --x X --y Y --width W --height H [-o OUT]
80
+ ```
81
+ Crops to a literal `{x, y, width, height}` rectangle in source pixels —
82
+ distinct from `fit.py --fit crop`, which crops to an *aspect ratio* and picks
83
+ the rectangle for you. Use this when the rectangle is already known (a
84
+ face-detection box, a saved crop, a hand-picked region). The rectangle must
85
+ lie entirely inside the source frame (after accounting for display rotation);
86
+ `--width`/`--height` must be even (4:2:0 chroma) and are refused, never
87
+ rounded, if they aren't.
88
+
89
+ ### insert.py — still image to a timed silent clip
90
+ ```
91
+ insert.py IMAGE --duration T [--width W] [--height H] [--fps N]
92
+ [--zoom in|out [--zoom-amount 1.3]] [--pan left|right|up|down] [-o OUT]
93
+ ```
94
+ Produces a silent, exact-duration clip from one image. `--width`/`--height`
95
+ resolve like `fit.py`'s (one given -> the other follows the image's aspect;
96
+ both given -> exact frame, scaled to fill and centre-cropped, never
97
+ distorted). `--zoom in|out` is a Ken Burns effect: a slow linear zoom across
98
+ the whole clip, ending (zoom in) or starting (zoom out) at `--zoom-amount`
99
+ (default 1.3). `--pan` drifts the visible window across the image while
100
+ zoomed — it needs `--zoom` (panning uses the extra image area a zoom exposes).
101
+
102
+ ### background.py — generate a solid-colour or gradient clip
103
+ ```
104
+ background.py -o OUT --duration T --width W --height H
105
+ [--color C | --gradient C1:C2 [--angle DEG]]
106
+ ```
107
+ No input file: ffmpeg's own `color`/`gradients` source filters generate the
108
+ clip directly. For a title-card background, a placeholder layer, or a base
109
+ for `overlay.py` to composite onto. `--width`/`--height` must be even.
110
+
111
+ ### reverse.py — reverse playback
112
+ ```
113
+ reverse.py INPUT [--no-audio] [-o OUT]
114
+ ```
115
+ Reverses video (and audio, unless `--no-audio`) with ffmpeg's `reverse`/
116
+ `areverse` filters, which buffer the whole clip in memory — keep this to
117
+ clips it makes sense to reverse (seconds to a couple of minutes), not
118
+ something this tool limits for you.
119
+
120
+ ### stabilize.py — motion stabilisation
121
+ ```
122
+ stabilize.py INPUT [--shakiness 1-10] [--smoothing N] [--zoom 0-100] [-o OUT]
123
+ ```
124
+ Two-pass `vidstabdetect`/`vidstabtransform`: pass 1 analyses camera motion to
125
+ a temporary transforms file (deleted after the run), pass 2 smooths and
126
+ re-renders. `--shakiness` (default 5) trades analysis time for how much
127
+ motion it looks for; `--smoothing` (default 15) is how many neighbouring
128
+ frames the camera path is averaged over; `--zoom` crops in slightly to hide
129
+ the black edges stabilizing can introduce. Needs an ffmpeg built with
130
+ `--enable-libvidstab`; `doctor` reports this tool `usable: no` when that's
131
+ missing (a plain Homebrew ffmpeg build, for example) rather than failing at
132
+ run time.
133
+
134
+ ### sequence.py — numbered/globbed image sequence to video
135
+ ```
136
+ sequence.py --dir DIR --pattern "frame_%04d.png"|"*.png" --fps N
137
+ [--start-number N] [--width W] [--height H] [-o OUT]
138
+ ```
139
+ Turns a numbered or glob-matched set of still images into a video. The match
140
+ is checked on disk before ffmpeg runs (an empty match or a missing first
141
+ frame is refused here, not discovered from an opaque ffmpeg error).
63
142
 
64
143
  ### silence.py — remove dead air / jump cuts
65
144
  ```
@@ -204,25 +283,47 @@ frame like an editor would. Use `--compare` to show before/after to the user.
204
283
  ### caption.py — subtitles (static, animated, karaoke)
205
284
  ```
206
285
  caption.py INPUT --srt FILE | --ass FILE | --text CUES.txt [--write-srt OUT.srt]
286
+ [--mode burn|mux] [--audio-stream N] [--fps N]
207
287
  [--font NAME] [--fonts-dir DIR] [--size N] [--color RRGGBB] [--outline N] [--outline-color RRGGBB]
208
288
  [--bold] [--box] [--position bottom|top|center|top-left|...] [--margin N]
209
289
  [--animate none|fade|pop|slide] [--karaoke [--highlight-color RRGGBB]] [--write-ass OUT.ass] [-o OUT]
210
290
  caption.py --text CUES.txt --write-srt OUT.srt # generate the SRT only
211
291
  ```
212
- Text cue format, one per line: `0:00-0:03 Hello`, `00:00:03.500 --> 00:00:06 Two | lines`.
292
+ Text cue format, one per line: `0:00-0:03 Hello`, `00:00:03.500 --> 00:00:06 Two | lines`,
293
+ or `00:00:03:15 --> 00:00:06:00 SMPTE non-drop-frame timecode` (`hh:mm:ss:ff`, frame count
294
+ converted with `--fps`, or the input video's own fps when `--input` is given and `--fps` is
295
+ not — a timecode-shaped cue with no fps available is refused rather than misread as plain text).
213
296
  Lines without a time run for `--auto-seconds` (3 s) after the previous cue. `|` is a line break.
214
297
  `--animate`/`--karaoke` generate a styled ASS (PlayRes = video size) from the
215
298
  SRT/text cues: `pop` is the short-form "bouncy" entrance, `--karaoke` fills each
216
299
  word from `--color` to `--highlight-color` evenly across the cue (word timing
217
300
  is distributed, not transcribed). The ASS is kept next to the output so the
218
301
  user can hand-tune timings and re-run with `--ass`.
302
+ `--mode burn` (default) renders subtitles into the picture and always
303
+ re-encodes both streams. `--mode mux` copies video and audio untouched and
304
+ adds the SRT as a separate, player-toggleable subtitle stream instead —
305
+ takes only a plain SRT (`--srt`/`--text`/`--transcribe`, not `--ass`, since
306
+ styling has no soft-subtitle equivalent) and no `--animate`/`--karaoke`. The
307
+ subtitle codec follows the output container: `mov_text` for `.mp4`/`.m4v`/`.mov`,
308
+ `srt` for `.mkv`, `webvtt` for `.webm`.
309
+ `--audio-stream N` (default 0, the first track) picks which audio stream of a
310
+ multi-track input (dubbed languages, M&E stems) is kept — applies to burn's
311
+ re-encoded audio, mux's stream-copied audio, `--transcribe`'s speech-to-text
312
+ source, and karaoke's energy-timing analysis alike, so all four agree on the
313
+ same track instead of each silently defaulting to whichever one ffmpeg's own
314
+ stream selection would have picked.
219
315
 
220
- ### overlay.py — logo, image, title
316
+ ### overlay.py — logo, image, title, video picture-in-picture, chroma key
221
317
  ```
222
318
  overlay.py INPUT --image PNG [--scale W | --scale-percent P] | --text "..." [--font-file F.ttf] [--font-size N] [--box]
319
+ | --video CLIP [--chromakey COLOR [--chromakey-similarity 0-1] [--chromakey-blend 0-1]]
223
320
  [--position top-right|bottom-left|center|X,Y] [--margin N] [--start T] [--end T] [--fade S] [--opacity 0-1] [-o OUT]
224
321
  ```
225
- Alpha in PNGs is respected. Fades apply to the overlay only; the video keeps playing.
322
+ Alpha in PNGs is respected. Fades apply to the overlay only; the video keeps
323
+ playing. `--video` composites a second video as a picture-in-picture layer
324
+ (same position/scale/opacity/time-range knobs as `--image`); only the main
325
+ input's audio is kept, the PiP layer's own audio is dropped. `--chromakey`
326
+ (with `--video`) keys out that colour first for green-screen compositing.
226
327
 
227
328
  ### sync.py — offset detection, alignment, drift correction
228
329
  ```
@@ -251,7 +352,23 @@ color.py INPUT --to-sdr [--tonemap hable|mobius|reinhard|bt2390] [--peak 1000] [
251
352
  color.py INPUT --lut grade.cube [--lut-strength 0..1] [-o OUT]
252
353
  color.py INPUT --retag bt709|bt2020-pq|bt2020-hlg|bt601 [-o OUT] # metadata only, stream copy
253
354
  color.py INPUT --strip-dovi [-o OUT] # drop the Dolby Vision RPU, keep the HLG/HDR10 base layer (stream copy)
254
- ```
355
+ color.py INPUT --correct [--exposure -3..3] [--contrast 0..2] [--saturation 0..2] [--gamma 0.1..10]
356
+ [--temperature 2000..12000] [--tint -1..1] [--lift -1..1] [--gain -1..1]
357
+ [--levels-in-black 0..255] [--levels-in-white 0..255] [--levels-out-black 0..255] [--levels-out-white 0..255]
358
+ [--curves color_negative|cross_process|darker|increase_contrast|lighter|linear_contrast|medium_contrast|negative|strong_contrast|vintage] [-o OUT]
359
+ ```
360
+ `--correct` is typed primary colour correction, no filter string ever accepted:
361
+ `--exposure`/`--contrast`/`--saturation`/`--gamma` (`exposure`/`eq` filters),
362
+ `--temperature`/`--tint`/`--lift`/`--gain` (`colortemperature`/`colorbalance`
363
+ filters — `--tint` sets midtones, `--lift` shadows, `--gain` highlights, a
364
+ classic three-way correction), `--levels-*` (`colorlevels`, 8-bit units
365
+ converted to the filter's own 0..1 range, only added to the chain when at
366
+ least one is given) and `--curves` (the `curves` filter's own built-in
367
+ presets, only added when given). Every flag is range-checked against this
368
+ script's own safe subset of what `ffmpeg -h filter=<name>` documents before
369
+ ffmpeg runs. `--json`'s `measurements` reports `analyze_levels()` (signalstats
370
+ luma/saturation) for input and output side by side.
371
+
255
372
  iPhone "HDR" video is Dolby Vision profile 8.4 on an HLG base layer:
256
373
  `probe.py` reports `hdr_format: Dolby Vision profile 8` and `--to-sdr`
257
374
  tone-maps it from the HLG base layer. When the user wants to keep HDR but
@@ -292,3 +409,13 @@ export.py --list
292
409
  Scales into the preset frame (pad by default), tags BT.709, sets `+faststart`,
293
410
  trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`.
294
411
 
412
+ ### proxy.py — low-bitrate proxy for analysis/preview
413
+ ```
414
+ proxy.py INPUT [--width W | --scale F] [--crf N] [--fps N] [--no-audio] [-o OUT]
415
+ ```
416
+ Not a delivery preset: resizes to `--width` (default 640) or by `--scale`
417
+ factor, re-encodes at a proxy-grade `--crf` (default 30) with the fastest
418
+ x264/x265 preset, keeps the source's own dynamic range (HDR stays HDR;
419
+ run `color.py --to-sdr` first if SDR is wanted). Only executes the spec
420
+ given — does not decide which asset to proxy or what for.
421
+