ffmpeg-skill 0.12.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.
- package/README.md +37 -1
- package/SKILL.md +12 -0
- package/mcp/__pycache__/server.cpython-311.pyc +0 -0
- package/package.json +1 -1
- package/references/scripts.md +17 -1
- package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
- package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
- package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
- package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
- package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check.cpython-311.pyc +0 -0
- package/scripts/__pycache__/color.cpython-311.pyc +0 -0
- package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
- package/scripts/__pycache__/export.cpython-311.pyc +0 -0
- package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
- package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
- package/scripts/__pycache__/join.cpython-311.pyc +0 -0
- package/scripts/__pycache__/look.cpython-311.pyc +0 -0
- package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
- package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
- package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
- package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
- package/scripts/__pycache__/render.cpython-311.pyc +0 -0
- package/scripts/__pycache__/report.cpython-311.pyc +0 -0
- package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
- package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
- package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
- package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
- package/scripts/_common.py +55 -2
- package/scripts/_contract.py +72 -1
- package/scripts/caption.py +11 -2
- package/scripts/color.py +93 -21
- package/scripts/fit.py +13 -4
- package/scripts/graphics.py +6 -4
- package/scripts/look.py +13 -8
- package/scripts/overlay.py +21 -9
- package/scripts/scenes.py +8 -2
- package/scripts/stabilize.py +22 -4
package/README.md
CHANGED
|
@@ -29,6 +29,12 @@ npx ffmpeg-skill
|
|
|
29
29
|
|
|
30
30
|
If `ffmpeg` and `python3` are on your PATH, it works: offline, on footage you would rather not upload.
|
|
31
31
|
|
|
32
|
+
> **SPEC** (Self-Producing Execution Contract), coined by this project's author
|
|
33
|
+
> [kajisho5](https://github.com/kajisho5): each tool's `input_schema` — the part of its contract
|
|
34
|
+
> and MCP tool definition that has to track the CLI flag-for-flag — is never hand-authored beside
|
|
35
|
+
> the code. It's derived, at run time, from the same `argparse` parser that already defines the
|
|
36
|
+
> CLI, and CI fails the build if any of it drifts. → [full explanation](#what-is-spec)
|
|
37
|
+
|
|
32
38
|
---
|
|
33
39
|
|
|
34
40
|
## Standalone, and in an ecosystem
|
|
@@ -94,6 +100,8 @@ python3 $S/fit.py input.mp4 --duration 60 --aspect 9:16 --dry-run # print the
|
|
|
94
100
|
python3 $S/export.py input.mp4 --preset reels --json # structured result with a probe of the output
|
|
95
101
|
```
|
|
96
102
|
|
|
103
|
+
On Windows in Git Bash, `python3` is only on PATH if Python was installed from the Microsoft Store; a python.org install exposes `python` (or the `py` launcher) instead — replace `python3` with `python` above if you see a "command not found". `bin/install.js` and `doctor`/`contract` already handle this for you; only the raw script examples above need it spelled out manually.
|
|
104
|
+
|
|
97
105
|
More requests and the commands behind them: [examples/README.md](examples/README.md). To see everything run end-to-end on generated footage: `npm run demo`.
|
|
98
106
|
|
|
99
107
|
## How it works
|
|
@@ -180,7 +188,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
|
|
|
180
188
|
| `caption.py` | Burn SRT/ASS with font, size, colour, outline, position; build SRT from timed plain text; animated and word-by-word karaoke timed to the speech energy; optional local transcription |
|
|
181
189
|
| `overlay.py` | Logos, watermarks and titles with position, time range, opacity, fades; `--video` for picture-in-picture, `--chromakey` for green-screen compositing |
|
|
182
190
|
| `graphics.py` | Lower-thirds, title cards, chapter chips, progress bars, countdowns, corner bugs drawn by FFmpeg from a brand kit |
|
|
183
|
-
| `color.py` | HDR10 / HLG / Dolby Vision → SDR BT.709 tone mapping, DV layer stripping, 3D LUT (.cube), colour-tag rewriting, typed primary correction (exposure/contrast/saturation/white balance) |
|
|
191
|
+
| `color.py` | HDR10 / HLG / Dolby Vision → SDR BT.709 tone mapping, DV layer stripping, 3D LUT (.cube), colour-tag rewriting, typed primary correction (exposure/contrast/saturation/gamma/white balance/lift-gain/levels/curves) |
|
|
184
192
|
|
|
185
193
|
**Delivery**
|
|
186
194
|
|
|
@@ -216,6 +224,34 @@ Picture tools (`fit`, `caption`, `overlay`, `graphics`, `color`, `export`, `scen
|
|
|
216
224
|
|
|
217
225
|
## Built for agents
|
|
218
226
|
|
|
227
|
+
### What is SPEC?
|
|
228
|
+
|
|
229
|
+
This project's author, [kajisho5](https://github.com/kajisho5), coined **SPEC** (Self-Producing
|
|
230
|
+
Execution Contract) for the pattern this skill's tool layer is built on: each tool's `input_schema`
|
|
231
|
+
— the part of its contract that has to track the CLI exactly, flag for flag — is never
|
|
232
|
+
hand-authored side by side with the code. It is derived, at run time, from the one thing that
|
|
233
|
+
actually has to be correct for the CLI to work at all: the script's own `argparse` parser.
|
|
234
|
+
|
|
235
|
+
Concretely, `scripts/_contract.py`'s `_capture_parser()` imports every tool script and
|
|
236
|
+
intercepts its `parse_args()` call to get the live, fully-built parser object — flags, types,
|
|
237
|
+
choices, defaults, required/positional, mutually exclusive groups, all of it. `input_schema` is
|
|
238
|
+
built straight from that object. (The rest of a `ToolSpec` — `role`, `capabilities`, `inputs`,
|
|
239
|
+
`outputs`, `output_schema` — comes from a hand-authored table, `TOOL_META`, since those facts
|
|
240
|
+
aren't things a parser can express; only `input_schema` is parser-derived.)
|
|
241
|
+
|
|
242
|
+
- **The contract**'s `input_schema` for every tool is generated from the live parser directly.
|
|
243
|
+
- **The MCP server** (`mcp/server.py`) carries no schema of its own; `tools/list` is translated
|
|
244
|
+
straight from the contract, `input_schema` included.
|
|
245
|
+
- **The docs** (`docs/contract.md`'s field reference, this README's tool table) describe the same
|
|
246
|
+
shape. `tests/test_contract.py` runs on every CI run and fails the build if any of them drift
|
|
247
|
+
out of sync with what the code actually does — it catches drift, it doesn't fix it for you.
|
|
248
|
+
|
|
249
|
+
The result: add a flag to a script's `argparse` block, and `input_schema` and the MCP tool
|
|
250
|
+
definition follow with no second edit; if a docs page or a `TOOL_META` entry falls behind, CI
|
|
251
|
+
catches it rather than letting it drift silently. There is no separate `input_schema` file to
|
|
252
|
+
forget to update, and no version of "what CLI flags does this tool accept" that can quietly go
|
|
253
|
+
stale.
|
|
254
|
+
|
|
219
255
|
### Machine-readable contract
|
|
220
256
|
|
|
221
257
|
```bash
|
package/SKILL.md
CHANGED
|
@@ -299,6 +299,18 @@ Every script prints `{"status": "failed", "error": {"kind": input | ffmpeg | out
|
|
|
299
299
|
`caption.py --fonts-dir ./fonts --font "Noto Sans CJK JP"`). Without a
|
|
300
300
|
matching font you get boxes, not an error. Install: `apt install fonts-noto-cjk`,
|
|
301
301
|
`brew install --cask font-noto-sans-cjk`.
|
|
302
|
+
- **Windows drawtext crashes on some real builds.** On certain Windows ffmpeg
|
|
303
|
+
builds (e.g. winget's gyan.dev), `drawtext` crashes with an access violation
|
|
304
|
+
whenever it resolves a font by family name through fontconfig, even with a
|
|
305
|
+
valid `fonts.conf` (#100). `look.py`, `scenes.py --sheet`, `overlay.py --text`
|
|
306
|
+
and `graphics.py` all resolve a concrete `--font-file` by default when one is
|
|
307
|
+
available (`fontfile=` skips fontconfig entirely and is the form confirmed
|
|
308
|
+
not to crash), so this should already be handled automatically. If a
|
|
309
|
+
drawtext tool still crashes, pass `--font-file` explicitly rather than
|
|
310
|
+
relying on `--font`/`font=` resolution; `doctor` also runs a real one-frame
|
|
311
|
+
drawtext probe and reports `filter:drawtext` missing (with the crash detail
|
|
312
|
+
in `errors[]`) rather than a false "available" from the `-filters` listing
|
|
313
|
+
alone.
|
|
302
314
|
- **Keyframe cuts.** A lossless `cut.py` result may start up to one GOP (often
|
|
303
315
|
1–10 s) earlier than requested; the script re-encodes automatically when the
|
|
304
316
|
deviation exceeds 0.5 s. If the user insists on lossless output, pass
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.5",
|
|
4
4
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 28 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ffmpeg",
|
package/references/scripts.md
CHANGED
|
@@ -352,7 +352,23 @@ color.py INPUT --to-sdr [--tonemap hable|mobius|reinhard|bt2390] [--peak 1000] [
|
|
|
352
352
|
color.py INPUT --lut grade.cube [--lut-strength 0..1] [-o OUT]
|
|
353
353
|
color.py INPUT --retag bt709|bt2020-pq|bt2020-hlg|bt601 [-o OUT] # metadata only, stream copy
|
|
354
354
|
color.py INPUT --strip-dovi [-o OUT] # drop the Dolby Vision RPU, keep the HLG/HDR10 base layer (stream copy)
|
|
355
|
-
|
|
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
|
+
|
|
356
372
|
iPhone "HDR" video is Dolby Vision profile 8.4 on an HLG base layer:
|
|
357
373
|
`probe.py` reports `hdr_format: Dolby Vision profile 8` and `--to-sdr`
|
|
358
374
|
tone-maps it from the HLG base layer. When the user wants to keep HDR but
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/scripts/_common.py
CHANGED
|
@@ -269,6 +269,20 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
|
|
|
269
269
|
return _run_captured(list(cmd), check)
|
|
270
270
|
|
|
271
271
|
|
|
272
|
+
def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
|
|
273
|
+
"""Run an ffmpeg command that already maps its video/audio, trying first to also
|
|
274
|
+
stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
|
|
275
|
+
there are none). A source whose subtitle codec cannot be copied into the target container
|
|
276
|
+
(e.g. a container change) makes that first attempt fail; retry the same command without the
|
|
277
|
+
extra maps rather than let a tool that never touched subtitles start hard-failing because of
|
|
278
|
+
them. `cmd` is the full argv *without* the output path. Returns True only when the
|
|
279
|
+
retry-without-subtitles path was actually needed (i.e. subtitle/data streams were dropped)."""
|
|
280
|
+
if run(cmd + ["-map", "0:s?", "-map", "0:d?", "-c:s", "copy", "-c:d", "copy", output], check=False).returncode == 0:
|
|
281
|
+
return False
|
|
282
|
+
run(cmd + [output])
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
|
|
272
286
|
def _run_captured(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
|
|
273
287
|
"""Plain run with stdout/stderr captured."""
|
|
274
288
|
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
@@ -325,7 +339,7 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
|
|
|
325
339
|
|
|
326
340
|
|
|
327
341
|
def shell_quote(s: str) -> str:
|
|
328
|
-
if not s or any(ch in s for ch in " \t
|
|
342
|
+
if not s or any(ch in s for ch in " \t\\\"';|&<>()[]{}$*?"):
|
|
329
343
|
return "'" + s.replace("'", "'\\''") + "'"
|
|
330
344
|
return s
|
|
331
345
|
|
|
@@ -387,7 +401,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
387
401
|
return {"file": path, "dry_run": True, "format": None, "duration": 0.0, "size_bytes": 0, "bitrate": None,
|
|
388
402
|
"video": {"codec": None, "width": 0, "height": 0, "fps": 0.0, "pix_fmt": None, "hdr": False,
|
|
389
403
|
"color_transfer": None, "color_primaries": None, "rotation": 0, "variable_frame_rate_suspected": False},
|
|
390
|
-
"audio": {"codec": None, "channels": 0, "sample_rate": 0}, "subtitle_streams": 0}
|
|
404
|
+
"audio": {"codec": None, "channels": 0, "sample_rate": 0}, "subtitle_streams": 0, "data_streams": 0}
|
|
391
405
|
die(f"input not found: {path}")
|
|
392
406
|
ffprobe = require_tool("ffprobe")
|
|
393
407
|
proc = run(
|
|
@@ -405,6 +419,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
405
419
|
video = next((s for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic", 0) == 0), None)
|
|
406
420
|
audio = next((s for s in streams if s.get("codec_type") == "audio"), None)
|
|
407
421
|
subs = [s for s in streams if s.get("codec_type") == "subtitle"]
|
|
422
|
+
data_stream_count = sum(1 for s in streams if s.get("codec_type") in ("data", "attachment"))
|
|
408
423
|
|
|
409
424
|
duration = _to_float(fmt.get("duration"))
|
|
410
425
|
if duration is None and video:
|
|
@@ -423,6 +438,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
423
438
|
"video": None,
|
|
424
439
|
"audio": None,
|
|
425
440
|
"subtitle_streams": len(subs),
|
|
441
|
+
"data_streams": data_stream_count,
|
|
426
442
|
# every subtitle stream in file order: index n here is `-map 0:s:n`
|
|
427
443
|
"subtitle_stream_details": [{
|
|
428
444
|
"index": n,
|
|
@@ -582,6 +598,43 @@ def escape_filter_path(path: str) -> str:
|
|
|
582
598
|
return p
|
|
583
599
|
|
|
584
600
|
|
|
601
|
+
def default_font_file(font_name: str) -> Optional[str]:
|
|
602
|
+
"""Resolve `font_name` to a concrete on-disk font file, so a caller can tell drawtext
|
|
603
|
+
`fontfile=<path>` instead of `font=<name>`, when possible.
|
|
604
|
+
|
|
605
|
+
On some real Windows ffmpeg builds (winget's gyan.dev 9.x), drawtext's own fontconfig
|
|
606
|
+
resolution crashes with an access violation whenever it has to resolve a font by family name
|
|
607
|
+
-- with or without a valid fonts.conf on FONTCONFIG_FILE. `fontfile=` is the only form
|
|
608
|
+
confirmed not to crash (#100), since it never touches fontconfig at all. `font_name` itself is
|
|
609
|
+
ignored on Windows for that reason: a fixed, near-universally-present system font is used
|
|
610
|
+
instead of trying to resolve the requested family (which would crash the same way).
|
|
611
|
+
|
|
612
|
+
On Linux/macOS this is best-effort and uses the real requested family: `fc-match` reports the
|
|
613
|
+
same file fontconfig would resolve `font_name` to anyway, so a caller gets the identical font,
|
|
614
|
+
just already resolved to a path -- fontfile= skips a redundant fontconfig lookup and equally
|
|
615
|
+
sidesteps the same class of crash if it exists on some build there too, but the fallback below
|
|
616
|
+
(returning None) is exercised routinely there, not just on failure.
|
|
617
|
+
|
|
618
|
+
Returns None when nothing could be resolved (fc-match missing/unavailable, or no well-known
|
|
619
|
+
Windows font file present); the caller falls back to font=<font_name>, the prior behaviour.
|
|
620
|
+
"""
|
|
621
|
+
if platform.system() == "Windows":
|
|
622
|
+
windir = os.environ.get("WINDIR", "C:\\Windows")
|
|
623
|
+
candidate = Path(windir) / "Fonts" / "arial.ttf"
|
|
624
|
+
return str(candidate) if candidate.exists() else None
|
|
625
|
+
exe = shutil.which("fc-match")
|
|
626
|
+
if not exe:
|
|
627
|
+
return None
|
|
628
|
+
try:
|
|
629
|
+
proc = subprocess.run([exe, "--format=%{file}\n", font_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=5)
|
|
630
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
631
|
+
return None
|
|
632
|
+
if proc.returncode != 0:
|
|
633
|
+
return None
|
|
634
|
+
path = proc.stdout.splitlines()[0].strip() if proc.stdout.strip() else ""
|
|
635
|
+
return path if path and os.path.exists(path) else None
|
|
636
|
+
|
|
637
|
+
|
|
585
638
|
def escape_drawtext(text: str) -> str:
|
|
586
639
|
return (
|
|
587
640
|
text.replace("\\", "\\\\")
|
package/scripts/_contract.py
CHANGED
|
@@ -127,7 +127,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
127
127
|
required=FF, optional=[{"capability": X264, "when": "--to-sdr / --lut / --correct"}, {"capability": "filter:zscale", "when": "--to-sdr"}, {"capability": "filter:tonemap", "when": "--to-sdr"},
|
|
128
128
|
{"capability": "filter:lut3d", "when": "--lut"}, {"capability": "bsf:filter_units", "when": "--strip-dovi"}, {"capability": X265, "when": "--lut on an HDR source"}, {"capability": AAC, "when": "re-encode"},
|
|
129
129
|
{"capability": "filter:exposure", "when": "--correct"}, {"capability": "filter:eq", "when": "--correct"},
|
|
130
|
-
{"capability": "filter:colorbalance", "when": "--correct"}, {"capability": "filter:colortemperature", "when": "--correct"}
|
|
130
|
+
{"capability": "filter:colorbalance", "when": "--correct"}, {"capability": "filter:colortemperature", "when": "--correct"},
|
|
131
|
+
{"capability": "filter:colorlevels", "when": "--correct with any --levels-*"}, {"capability": "filter:curves", "when": "--correct --curves"}],
|
|
131
132
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
132
133
|
"proxy": dict(role="execution", inputs=["video asset"], outputs=["low-resolution, low-bitrate proxy artifact for downstream analysis, preview or editing decisions"],
|
|
133
134
|
required=FF + [X264, AAC], optional=[HDR_X265],
|
|
@@ -482,6 +483,50 @@ def _default_font() -> str:
|
|
|
482
483
|
return str(BRAND_DEFAULTS["font"])
|
|
483
484
|
|
|
484
485
|
|
|
486
|
+
def _drawtext_probe() -> Dict[str, Any]:
|
|
487
|
+
"""Actually render one frame through drawtext, rather than trusting `-filters` alone.
|
|
488
|
+
|
|
489
|
+
`-filters` only reports whether this ffmpeg build was compiled with the filter; it never
|
|
490
|
+
proves drawtext can actually execute. On some real Windows ffmpeg builds (winget's gyan.dev
|
|
491
|
+
9.x), drawtext crashes with an access violation whenever it has to resolve a font through
|
|
492
|
+
fontconfig -- with or without a valid fonts.conf -- so `-filters` correctly reports drawtext
|
|
493
|
+
present and doctor used to report the capability `available` anyway; every tool that actually
|
|
494
|
+
used it (look, scenes --sheet, overlay --text, graphics) then crashed on first real use (#100).
|
|
495
|
+
|
|
496
|
+
This runs the cheapest real drawtext render there is: a one-frame synthetic clip, no font=
|
|
497
|
+
given at all (ffmpeg's own default resolution -- the same path that crashed). A clean exit
|
|
498
|
+
means drawtext genuinely works here. Anything that could not prove either way (no ffmpeg,
|
|
499
|
+
timeout, an ordinary nonzero exit with a real ffmpeg error) is `unknown`, same "unknown is not
|
|
500
|
+
missing" principle as every other capability here. A crash specifically -- killed by signal on
|
|
501
|
+
POSIX, or an unhandled access violation surfacing as a huge unsigned exit code on Windows -- is
|
|
502
|
+
the one case this function exists to catch, and folds into `missing`: the filter is present in
|
|
503
|
+
the build but cannot actually be used as ffmpeg's own default would use it.
|
|
504
|
+
"""
|
|
505
|
+
exe = shutil.which("ffmpeg")
|
|
506
|
+
if not exe:
|
|
507
|
+
return {"status": "unknown", "detail": "ffmpeg not on PATH"}
|
|
508
|
+
try:
|
|
509
|
+
proc = subprocess.run(
|
|
510
|
+
[exe, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=black:s=64x64:d=1",
|
|
511
|
+
"-vf", "drawtext=text=x", "-frames:v", "1", "-f", "null", "-"],
|
|
512
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=_DETECT_TIMEOUT,
|
|
513
|
+
)
|
|
514
|
+
except subprocess.TimeoutExpired:
|
|
515
|
+
return {"status": "unknown", "detail": f"drawtext probe did not exit within {_DETECT_TIMEOUT}s"}
|
|
516
|
+
except OSError as e:
|
|
517
|
+
return {"status": "unknown", "detail": f"drawtext probe: {e}"}
|
|
518
|
+
if proc.returncode == 0:
|
|
519
|
+
return {"status": "available", "detail": "one-frame drawtext render succeeded"}
|
|
520
|
+
if proc.returncode < 0 or proc.returncode >= 0x80000000:
|
|
521
|
+
return {"status": "missing",
|
|
522
|
+
"detail": f"drawtext render crashed (exit {proc.returncode}) instead of failing cleanly -- "
|
|
523
|
+
"the filter is present in this build but cannot be used as-is, likely a fontconfig "
|
|
524
|
+
"resolution crash (see https://github.com/kajisho5/ffmpeg-skill/issues/100); "
|
|
525
|
+
"pass an explicit --font-file to every drawtext tool as a workaround"}
|
|
526
|
+
tail = " ".join(proc.stderr.strip().splitlines()[-2:])
|
|
527
|
+
return {"status": "unknown", "detail": f"drawtext probe exited {proc.returncode}: {tail}"}
|
|
528
|
+
|
|
529
|
+
|
|
485
530
|
def _font_available(font_name: str) -> Dict[str, Any]:
|
|
486
531
|
"""Whether `font_name` (a fontconfig family name, as passed to drawtext's `font=`) is actually
|
|
487
532
|
installed, distinct from silently resolving to a substitute.
|
|
@@ -561,6 +606,7 @@ def doctor() -> Dict[str, Any]:
|
|
|
561
606
|
sets = {k: set(v["names"]) for k, v in listings.items()}
|
|
562
607
|
state: Dict[str, str] = {} # capability -> available | missing | unknown
|
|
563
608
|
wanted = required_capabilities()
|
|
609
|
+
drawtext_probe: Optional[Dict[str, Any]] = None
|
|
564
610
|
|
|
565
611
|
def _from(kind: str, name: str) -> str:
|
|
566
612
|
lst = listings[kind]
|
|
@@ -577,6 +623,23 @@ def doctor() -> Dict[str, Any]:
|
|
|
577
623
|
state[cap] = "available" if shutil.which("ffprobe") else "missing"
|
|
578
624
|
elif cap.startswith("encoder:"):
|
|
579
625
|
state[cap] = _from("encoders", cap[8:])
|
|
626
|
+
elif cap == "filter:drawtext":
|
|
627
|
+
listing_state = _from("filters", "drawtext")
|
|
628
|
+
if listing_state == "available":
|
|
629
|
+
# Only escalate an "available" listing to "missing" on an unambiguous crash --
|
|
630
|
+
# an ordinary nonzero exit (a real ffmpeg's own -h/-filters-only build variance, or
|
|
631
|
+
# in tests a fake ffmpeg shim that only implements -filters/-encoders/-bsfs/-version)
|
|
632
|
+
# proves nothing either way, so it leaves the listing-based result standing rather
|
|
633
|
+
# than downgrading it; see _drawtext_probe()'s own docstring for why a crash alone
|
|
634
|
+
# is the one case this exists to catch.
|
|
635
|
+
probe = _drawtext_probe()
|
|
636
|
+
if probe["status"] == "missing":
|
|
637
|
+
drawtext_probe = probe
|
|
638
|
+
state[cap] = "missing"
|
|
639
|
+
else:
|
|
640
|
+
state[cap] = "available"
|
|
641
|
+
else:
|
|
642
|
+
state[cap] = listing_state
|
|
580
643
|
elif cap.startswith("filter:"):
|
|
581
644
|
state[cap] = _from("filters", cap[7:])
|
|
582
645
|
elif cap.startswith("bsf:"):
|
|
@@ -591,6 +654,8 @@ def doctor() -> Dict[str, Any]:
|
|
|
591
654
|
unknown = sorted(c for c, st in state.items() if st == "unknown")
|
|
592
655
|
unknown_required = [c for c in unknown if c in wanted["required"]]
|
|
593
656
|
errors = [f"{k}: {v['detail']}" for k, v in listings.items() if v["status"] in ("unparsed", "failed")]
|
|
657
|
+
if drawtext_probe is not None and drawtext_probe["status"] != "available":
|
|
658
|
+
errors.append(f"filter:drawtext: {drawtext_probe['detail']}")
|
|
594
659
|
return {
|
|
595
660
|
"version": skill_version(),
|
|
596
661
|
"python": ".".join(str(x) for x in sys.version_info[:3]),
|
|
@@ -630,6 +695,12 @@ def _capability_fix_hint(cap: str) -> str:
|
|
|
630
695
|
full_hint = "install/build ffmpeg with it enabled"
|
|
631
696
|
if cap.startswith("encoder:"):
|
|
632
697
|
return f"this ffmpeg build has no {cap[8:]} encoder; {full_hint}"
|
|
698
|
+
if cap == "filter:drawtext":
|
|
699
|
+
return ("drawtext crashed instead of rendering a frame (see errors[] for the exit detail) -- "
|
|
700
|
+
"every drawtext tool already resolves a concrete font file automatically when one can "
|
|
701
|
+
"be found (#100); if it still crashes, use --no-timecode with look.py or scenes.py "
|
|
702
|
+
"--sheet to skip drawtext entirely, or pass --font-file explicitly to overlay.py/"
|
|
703
|
+
"graphics.py (the two that accept it) rather than relying on font= resolution")
|
|
633
704
|
if cap.startswith("filter:"):
|
|
634
705
|
return f"this ffmpeg build has no {cap[7:]} filter; {full_hint}"
|
|
635
706
|
if cap.startswith("bsf:"):
|
package/scripts/caption.py
CHANGED
|
@@ -427,14 +427,23 @@ def main() -> int:
|
|
|
427
427
|
if not srt_path or (not os.path.exists(srt_path) and not (STATE.dry_run and args.text)):
|
|
428
428
|
die(f"SRT file not found: {srt_path}")
|
|
429
429
|
codec = mux_subtitle_codec(output)
|
|
430
|
+
# Keep any subtitle track(s) the input already has (e.g. chaining --mode mux once per
|
|
431
|
+
# language to build a multi-language set) -- copied byte-identical, distinct from the
|
|
432
|
+
# newly-added SRT's own codec below.
|
|
433
|
+
existing_subs = meta.get("subtitle_streams") or 0
|
|
430
434
|
maps = ["-map", "0:v:0"]
|
|
431
435
|
cmd = ffmpeg_base() + ["-i", args.input, "-i", srt_path]
|
|
432
436
|
if meta.get("audio"):
|
|
433
437
|
maps += ["-map", f"0:a:{args.audio_stream}"]
|
|
438
|
+
if existing_subs:
|
|
439
|
+
maps += ["-map", "0:s?"]
|
|
434
440
|
maps += ["-map", "1:0"]
|
|
435
|
-
cmd += maps + ["-c:v", "copy"] + (["-c:a", "copy"] if meta.get("audio") else [])
|
|
441
|
+
cmd += maps + ["-c:v", "copy"] + (["-c:a", "copy"] if meta.get("audio") else [])
|
|
442
|
+
for i in range(existing_subs):
|
|
443
|
+
cmd += [f"-c:s:{i}", "copy"]
|
|
444
|
+
cmd += [f"-c:s:{existing_subs}", codec]
|
|
436
445
|
if args.language:
|
|
437
|
-
cmd += ["-metadata:s:s:
|
|
446
|
+
cmd += [f"-metadata:s:s:{existing_subs}", f"language={args.language}"]
|
|
438
447
|
cmd += [output]
|
|
439
448
|
run(cmd)
|
|
440
449
|
result = probe(output, role="output")
|
package/scripts/color.py
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
"""Colour management: convert HDR (HDR10/PQ, HLG, BT.2020) to SDR BT.709 with
|
|
3
3
|
real tone mapping, apply a .cube LUT (Log footage, creative grades), fix wrong
|
|
4
4
|
colour tags without re-encoding, or apply typed primary colour correction
|
|
5
|
-
(exposure, contrast, saturation, white balance
|
|
5
|
+
(exposure, contrast, saturation, gamma, white balance, three-way
|
|
6
|
+
lift/gain, levels, curves).
|
|
6
7
|
|
|
7
8
|
Examples:
|
|
8
9
|
python3 color.py iphone_hdr.mov --to-sdr # PQ/HLG -> BT.709 SDR, hable tonemap
|
|
@@ -13,21 +14,26 @@ Examples:
|
|
|
13
14
|
python3 color.py iphone_dv.mov --strip-dovi # drop Dolby Vision RPU, keep HLG base layer
|
|
14
15
|
python3 color.py iphone_dv.mov --to-sdr # DV 8.4 = HLG base layer -> tone-mapped SDR
|
|
15
16
|
python3 color.py flat.mp4 --correct --exposure 0.3 --contrast 1.1 --saturation 1.05 --temperature 5600 --tint -0.05
|
|
17
|
+
python3 color.py flat.mp4 --correct --gamma 1.2 --lift 0.04 --gain -0.03 # three-way shadows/gamma/highlights
|
|
18
|
+
python3 color.py flat.mp4 --correct --levels-in-black 16 --levels-in-white 235 --curves medium_contrast
|
|
16
19
|
"""
|
|
17
20
|
import argparse
|
|
18
21
|
import os
|
|
19
22
|
import sys
|
|
20
23
|
from typing import List
|
|
21
24
|
|
|
22
|
-
from _common import add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, x264_args
|
|
25
|
+
from _common import add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, run_keeping_subtitles, x264_args
|
|
23
26
|
|
|
24
27
|
TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
|
|
25
28
|
|
|
26
29
|
# Typed primary correction: each flag is one option of one real, always-available libavfilter filter
|
|
27
30
|
# (never a caller-supplied filter string). Range is this script's own safe subset of what the filter
|
|
28
31
|
# documents (`ffmpeg -h filter=<name>`), not the filter's full technical range. default is each filter's
|
|
29
|
-
# own documented no-op value, so every stage
|
|
30
|
-
# which flags were actually given.
|
|
32
|
+
# own documented no-op value, so every stage in this dict is always emitted and the chain never depends
|
|
33
|
+
# on which flags were actually given (exposure/temperature/tint/gamma/lift/gain). `colorlevels` and
|
|
34
|
+
# `curves` (see LEVELS and CURVES_PRESETS below) are the exception: they only add a term to the chain
|
|
35
|
+
# when the caller actually asks for them, because "levels 0..255 in, 0..255 out" and "no curve" are
|
|
36
|
+
# already the identity operation without emitting a no-op filter term for it.
|
|
31
37
|
CORRECTION = {
|
|
32
38
|
# flag default lo hi unit
|
|
33
39
|
"exposure": (0.0, -3.0, 3.0, "stops"), # exposure filter's own full range (linear-domain stops)
|
|
@@ -35,8 +41,27 @@ CORRECTION = {
|
|
|
35
41
|
"saturation": (1.0, 0.0, 2.0, "x"), # eq filter; 0=grayscale, 1=unchanged, 2=double saturation
|
|
36
42
|
"temperature": (6500.0, 2000.0, 12000.0, "K"), # colortemperature filter; 6500=unchanged (its own default)
|
|
37
43
|
"tint": (0.0, -1.0, 1.0, "x"), # mapped to colorbalance midtones, see correction_chain()
|
|
44
|
+
"gamma": (1.0, 0.1, 10.0, "x"), # eq filter's own gamma option; 1=unchanged (its own default)
|
|
45
|
+
"lift": (0.0, -1.0, 1.0, "x"), # colorbalance shadows (rs=gs=bs); 0=unchanged
|
|
46
|
+
"gain": (0.0, -1.0, 1.0, "x"), # colorbalance highlights (rh=gh=bh); 0=unchanged
|
|
38
47
|
}
|
|
39
48
|
|
|
49
|
+
# colorlevels takes fractional 0.0..1.0 input/output black/white points; this tool exposes the
|
|
50
|
+
# familiar 8-bit 0..255 unit instead and divides by 255.0 when building the filter (same convention
|
|
51
|
+
# as the rest of CORRECTION: a human-friendly CLI unit formatted into the filter's own native unit).
|
|
52
|
+
LEVELS = {
|
|
53
|
+
# flag default lo hi
|
|
54
|
+
"levels_in_black": (0, 0, 255),
|
|
55
|
+
"levels_in_white": (255, 0, 255),
|
|
56
|
+
"levels_out_black": (0, 0, 255),
|
|
57
|
+
"levels_out_white": (255, 0, 255),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# curves filter's real built-in presets (`ffmpeg -h filter=curves`), excluding its own "none" (0):
|
|
61
|
+
# omitting --curves already gets that identity result without adding a filter term for it.
|
|
62
|
+
CURVES_PRESETS = ["color_negative", "cross_process", "darker", "increase_contrast", "lighter",
|
|
63
|
+
"linear_contrast", "medium_contrast", "negative", "strong_contrast", "vintage"]
|
|
64
|
+
|
|
40
65
|
|
|
41
66
|
def _checked(args: argparse.Namespace, flag: str) -> float:
|
|
42
67
|
_, lo, hi, unit = CORRECTION[flag]
|
|
@@ -46,26 +71,65 @@ def _checked(args: argparse.Namespace, flag: str) -> float:
|
|
|
46
71
|
return value
|
|
47
72
|
|
|
48
73
|
|
|
74
|
+
def _checked_levels(args: argparse.Namespace, flag: str) -> int:
|
|
75
|
+
_, lo, hi = LEVELS[flag]
|
|
76
|
+
value = getattr(args, flag)
|
|
77
|
+
if not (lo <= value <= hi):
|
|
78
|
+
die(f"--{flag.replace('_', '-')} {value} is outside {lo}..{hi} (8-bit units, scaled to colorlevels' own 0..1 range)")
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
|
|
49
82
|
def correction_chain(args: argparse.Namespace) -> str:
|
|
50
|
-
"""
|
|
51
|
-
corrected by the previous one: exposure (linear light level) -> white balance (temperature/tint
|
|
52
|
-
contrast/saturation act on colour-balanced footage) -> contrast
|
|
53
|
-
adjacent stage
|
|
54
|
-
expressed as colorbalance's three midtone channels (gm=-tint,
|
|
55
|
-
midtones toward magenta and a negative one toward green
|
|
56
|
-
the same balanced-axis convention colour tools use
|
|
83
|
+
"""Always-present filter stages, in a fixed order chosen so each stage sees a picture already
|
|
84
|
+
corrected by the previous one: exposure (linear light level) -> white balance (temperature/tint/
|
|
85
|
+
lift/gain, so contrast/saturation act on colour-balanced footage) -> contrast/saturation/gamma
|
|
86
|
+
(the most creative-adjacent stage of the always-on chain). `tint` (-1 green .. +1 magenta) is not
|
|
87
|
+
a single ffmpeg option: it is expressed as colorbalance's three midtone channels (gm=-tint,
|
|
88
|
+
rm=bm=tint/2) so a positive tint shifts midtones toward magenta and a negative one toward green
|
|
89
|
+
without changing overall midtone lightness, the same balanced-axis convention colour tools use
|
|
90
|
+
for a one-dial tint control. `lift` and `gain` extend the same colorbalance call to the shadow
|
|
91
|
+
(rs=gs=bs=lift) and highlight (rh=gh=bh=gain) channels, giving a classic three-way shadows/
|
|
92
|
+
midtones/highlights correction in one filter invocation. `gamma` is folded into the same `eq`
|
|
93
|
+
term contrast/saturation already use, as `eq`'s own `gamma` option. Two further stages are
|
|
94
|
+
appended only when asked for, since their own identity value would otherwise add a no-op filter
|
|
95
|
+
term to the chain: `colorlevels` (--levels-*, 8-bit units scaled to the filter's 0..1 range) and
|
|
96
|
+
`curves` (--curves, one of the filter's own named presets)."""
|
|
57
97
|
exposure = _checked(args, "exposure")
|
|
58
98
|
contrast = _checked(args, "contrast")
|
|
59
99
|
saturation = _checked(args, "saturation")
|
|
60
100
|
temperature = _checked(args, "temperature")
|
|
61
101
|
tint = _checked(args, "tint")
|
|
102
|
+
gamma = _checked(args, "gamma")
|
|
103
|
+
lift = _checked(args, "lift")
|
|
104
|
+
gain = _checked(args, "gain")
|
|
105
|
+
in_black = _checked_levels(args, "levels_in_black")
|
|
106
|
+
in_white = _checked_levels(args, "levels_in_white")
|
|
107
|
+
out_black = _checked_levels(args, "levels_out_black")
|
|
108
|
+
out_white = _checked_levels(args, "levels_out_white")
|
|
109
|
+
if in_black >= in_white:
|
|
110
|
+
die(f"--levels-in-black {in_black} must be less than --levels-in-white {in_white}")
|
|
111
|
+
if out_black >= out_white:
|
|
112
|
+
die(f"--levels-out-black {out_black} must be less than --levels-out-white {out_white}")
|
|
113
|
+
|
|
62
114
|
gm, rm, bm = -tint, tint / 2.0, tint / 2.0
|
|
63
|
-
|
|
115
|
+
terms = [
|
|
64
116
|
f"exposure=exposure={exposure:g}",
|
|
65
117
|
f"colortemperature=temperature={temperature:g}",
|
|
66
|
-
f"colorbalance=rm={rm:g}:gm={gm:g}:bm={bm:g}",
|
|
67
|
-
f"eq=contrast={contrast:g}:saturation={saturation:g}",
|
|
68
|
-
]
|
|
118
|
+
f"colorbalance=rs={lift:g}:gs={lift:g}:bs={lift:g}:rm={rm:g}:gm={gm:g}:bm={bm:g}:rh={gain:g}:gh={gain:g}:bh={gain:g}",
|
|
119
|
+
f"eq=contrast={contrast:g}:saturation={saturation:g}:gamma={gamma:g}",
|
|
120
|
+
]
|
|
121
|
+
if (in_black, in_white, out_black, out_white) != (0, 255, 0, 255):
|
|
122
|
+
rimin, rimax = in_black / 255.0, in_white / 255.0
|
|
123
|
+
romin, romax = out_black / 255.0, out_white / 255.0
|
|
124
|
+
terms.append(
|
|
125
|
+
f"colorlevels=rimin={rimin:g}:gimin={rimin:g}:bimin={rimin:g}:"
|
|
126
|
+
f"rimax={rimax:g}:gimax={rimax:g}:bimax={rimax:g}:"
|
|
127
|
+
f"romin={romin:g}:gomin={romin:g}:bomin={romin:g}:"
|
|
128
|
+
f"romax={romax:g}:gomax={romax:g}:bomax={romax:g}"
|
|
129
|
+
)
|
|
130
|
+
if args.curves:
|
|
131
|
+
terms.append(f"curves=preset={args.curves}")
|
|
132
|
+
return ",".join(terms)
|
|
69
133
|
|
|
70
134
|
|
|
71
135
|
def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str:
|
|
@@ -94,7 +158,7 @@ def main() -> int:
|
|
|
94
158
|
mode.add_argument("--lut", help=".cube LUT to apply (3D)")
|
|
95
159
|
mode.add_argument("--retag", choices=["bt709", "bt2020-pq", "bt2020-hlg", "bt601"], help="rewrite colour tags only (no re-encode)")
|
|
96
160
|
mode.add_argument("--strip-dovi", action="store_true", help="remove the Dolby Vision RPU (profile 8.4 iPhone clips) so players use the plain HLG/HDR10 base layer; stream copy")
|
|
97
|
-
mode.add_argument("--correct", action="store_true", help="typed primary colour correction: --exposure/--contrast/--saturation/--temperature/--tint")
|
|
161
|
+
mode.add_argument("--correct", action="store_true", help="typed primary colour correction: --exposure/--contrast/--saturation/--temperature/--tint/--gamma/--lift/--gain/--levels-*/--curves")
|
|
98
162
|
ap.add_argument("--tonemap", choices=TONEMAPS, default="hable", help="tone-mapping curve (default hable)")
|
|
99
163
|
ap.add_argument("--peak", type=float, default=1000.0, help="source peak brightness in nits used for PQ (default 1000)")
|
|
100
164
|
ap.add_argument("--desat", type=float, default=0.0, help="tonemap desaturation strength (default 0)")
|
|
@@ -105,6 +169,14 @@ def main() -> int:
|
|
|
105
169
|
ap.add_argument("--saturation", type=float, default=CORRECTION["saturation"][0], help="--correct: saturation, 0..2, 1=unchanged (default 1)")
|
|
106
170
|
ap.add_argument("--temperature", type=float, default=CORRECTION["temperature"][0], help="--correct: white-balance temperature in Kelvin, 2000..12000, 6500=unchanged (default 6500)")
|
|
107
171
|
ap.add_argument("--tint", type=float, default=CORRECTION["tint"][0], help="--correct: green(-1)/magenta(+1) tint, 0=unchanged (default 0)")
|
|
172
|
+
ap.add_argument("--gamma", type=float, default=CORRECTION["gamma"][0], help="--correct: master gamma (eq filter's own gamma), 0.1..10, 1=unchanged (default 1)")
|
|
173
|
+
ap.add_argument("--lift", type=float, default=CORRECTION["lift"][0], help="--correct: shadows lift (colorbalance rs/gs/bs), -1..1, 0=unchanged (default 0)")
|
|
174
|
+
ap.add_argument("--gain", type=float, default=CORRECTION["gain"][0], help="--correct: highlights gain (colorbalance rh/gh/bh), -1..1, 0=unchanged (default 0)")
|
|
175
|
+
ap.add_argument("--levels-in-black", type=int, default=LEVELS["levels_in_black"][0], help="--correct: colorlevels input black point, 0..255 (default 0, unchanged)")
|
|
176
|
+
ap.add_argument("--levels-in-white", type=int, default=LEVELS["levels_in_white"][0], help="--correct: colorlevels input white point, 0..255 (default 255, unchanged)")
|
|
177
|
+
ap.add_argument("--levels-out-black", type=int, default=LEVELS["levels_out_black"][0], help="--correct: colorlevels output black point, 0..255 (default 0, unchanged)")
|
|
178
|
+
ap.add_argument("--levels-out-white", type=int, default=LEVELS["levels_out_white"][0], help="--correct: colorlevels output white point, 0..255 (default 255, unchanged)")
|
|
179
|
+
ap.add_argument("--curves", choices=CURVES_PRESETS, default=None, help="--correct: curves filter built-in preset (default: none, no curves term added)")
|
|
108
180
|
ap.add_argument("--audio-stream", type=int, default=0,
|
|
109
181
|
help="which audio stream of the input to keep, 0-based in file order (probe.py lists them under "
|
|
110
182
|
"audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
|
|
@@ -212,16 +284,16 @@ def main() -> int:
|
|
|
212
284
|
tag = "lut"
|
|
213
285
|
|
|
214
286
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
|
|
215
|
-
cmd += x264_args(args.crf, args.preset) + cfr_args(meta) + (aac_args() if has_audio else [])
|
|
216
|
-
|
|
287
|
+
cmd += x264_args(args.crf, args.preset) + cfr_args(meta) + (aac_args() if has_audio else [])
|
|
288
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
217
289
|
r = probe(output, role="output")
|
|
218
290
|
info(f"wrote {output} ({r['duration']:.3f}s, {r['video']['width']}x{r['video']['height']}, "
|
|
219
291
|
f"{r['video']['color_transfer']}/{r['video']['color_primaries']}, {tag})")
|
|
292
|
+
extra = {"dropped_non_av_streams": dropped_streams}
|
|
220
293
|
if measurements is not None:
|
|
221
294
|
measurements["output"] = analyze_levels(output)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
emit(output)
|
|
295
|
+
extra["measurements"] = measurements
|
|
296
|
+
emit(output, **extra)
|
|
225
297
|
return 0
|
|
226
298
|
|
|
227
299
|
|
package/scripts/fit.py
CHANGED
|
@@ -37,7 +37,7 @@ import sys
|
|
|
37
37
|
from fractions import Fraction
|
|
38
38
|
from typing import List
|
|
39
39
|
|
|
40
|
-
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
40
|
+
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, x264_args
|
|
41
41
|
|
|
42
42
|
ASPECT_PRESETS = {"16:9": Fraction(16, 9), "9:16": Fraction(9, 16), "1:1": Fraction(1, 1), "4:5": Fraction(4, 5), "4:3": Fraction(4, 3), "21:9": Fraction(21, 9)}
|
|
43
43
|
|
|
@@ -225,15 +225,24 @@ def main() -> int:
|
|
|
225
225
|
cmd += aac_args()
|
|
226
226
|
else:
|
|
227
227
|
cmd += ["-an"]
|
|
228
|
-
cmd += post
|
|
229
|
-
|
|
228
|
+
cmd += post
|
|
229
|
+
if abs(factor - 1.0) > 1e-4:
|
|
230
|
+
# A subtitle/data stream stream-copied by run_keeping_subtitles keeps the source's
|
|
231
|
+
# original timestamps; --method speed retimes video (setpts) and audio (atempo) but has
|
|
232
|
+
# no equivalent way to retime a copied subtitle track, so it would desync from the
|
|
233
|
+
# now-faster/slower picture. Drop them here rather than ship a captions track that lies
|
|
234
|
+
# about when a line is spoken.
|
|
235
|
+
run(cmd + [output])
|
|
236
|
+
dropped_streams = bool(meta.get("subtitle_streams") or meta.get("data_streams"))
|
|
237
|
+
else:
|
|
238
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
230
239
|
|
|
231
240
|
result = probe(output, role="output")
|
|
232
241
|
msg = f"wrote {output} ({result['duration']:.3f}s, {result['video']['width']}x{result['video']['height']})"
|
|
233
242
|
if abs(factor - 1.0) > 1e-4:
|
|
234
243
|
msg += f", speed {factor:.3f}x"
|
|
235
244
|
info(msg)
|
|
236
|
-
emit(output)
|
|
245
|
+
emit(output, dropped_non_av_streams=dropped_streams)
|
|
237
246
|
return 0
|
|
238
247
|
|
|
239
248
|
|
package/scripts/graphics.py
CHANGED
|
@@ -21,7 +21,7 @@ import argparse
|
|
|
21
21
|
import sys
|
|
22
22
|
from typing import List, Optional
|
|
23
23
|
|
|
24
|
-
from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, video_args
|
|
24
|
+
from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args
|
|
25
25
|
|
|
26
26
|
TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug"]
|
|
27
27
|
|
|
@@ -33,6 +33,9 @@ def ff_color(hex_rgb: str, alpha: float = 1.0) -> str:
|
|
|
33
33
|
def font_opts(brand: dict, font: Optional[str], font_file: Optional[str]) -> str:
|
|
34
34
|
if font_file or brand.get("font_file"):
|
|
35
35
|
return f"fontfile={escape_filter_path(font_file or brand['font_file'])}"
|
|
36
|
+
resolved = default_font_file(font or brand.get("font", "DejaVu Sans"))
|
|
37
|
+
if resolved:
|
|
38
|
+
return f"fontfile={escape_filter_path(resolved)}"
|
|
36
39
|
return f"font='{font or brand.get('font', 'DejaVu Sans')}'"
|
|
37
40
|
|
|
38
41
|
|
|
@@ -163,11 +166,10 @@ def main() -> int:
|
|
|
163
166
|
cmd += ["-vf", ",".join(filters), "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
|
|
164
167
|
cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
|
|
165
168
|
cmd += aac_args() if meta.get("audio") else ["-an"]
|
|
166
|
-
cmd
|
|
167
|
-
run(cmd)
|
|
169
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
168
170
|
r = probe(output, role="output")
|
|
169
171
|
info(f"wrote {output} ({r['duration']:.3f}s, {args.template})")
|
|
170
|
-
emit(output, template=args.template)
|
|
172
|
+
emit(output, template=args.template, dropped_non_av_streams=dropped_streams)
|
|
171
173
|
return 0
|
|
172
174
|
|
|
173
175
|
|
package/scripts/look.py
CHANGED
|
@@ -15,7 +15,7 @@ import sys
|
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
from typing import List
|
|
17
17
|
|
|
18
|
-
from _common import add_common, apply_common, die, emit, escape_drawtext, ffmpeg_base, info, parse_time, probe, run
|
|
18
|
+
from _common import add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run
|
|
19
19
|
|
|
20
20
|
FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
|
|
21
21
|
|
|
@@ -26,8 +26,8 @@ def fmt_hms(sec: float) -> str:
|
|
|
26
26
|
return f"{int(h):02d}:{int(m):02d}:{s_:06.3f}"
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def timecode_filter() -> str:
|
|
30
|
-
return f"drawtext=text='%{{pts\\:hms}}':{FONT}"
|
|
29
|
+
def timecode_filter(font_prefix: str) -> str:
|
|
30
|
+
return f"drawtext=text='%{{pts\\:hms}}':{font_prefix}{FONT}"
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def main() -> int:
|
|
@@ -49,7 +49,12 @@ def main() -> int:
|
|
|
49
49
|
dur = meta.get("duration") or 0.0
|
|
50
50
|
stem = Path(args.input).stem
|
|
51
51
|
outdir = str(Path(args.output).parent) if args.output else str(Path(args.input).parent)
|
|
52
|
-
|
|
52
|
+
# a resolvable font file, given as fontfile=, is the only form confirmed not to crash drawtext's
|
|
53
|
+
# own fontconfig resolution on some real Windows ffmpeg builds (#100); font= is the fallback when
|
|
54
|
+
# nothing can be resolved, unchanged from before this existed.
|
|
55
|
+
default_font = default_font_file("DejaVu Sans")
|
|
56
|
+
font_prefix = f"fontfile={escape_filter_path(default_font)}:" if default_font else ""
|
|
57
|
+
tc = "" if args.no_timecode else "," + timecode_filter(font_prefix)
|
|
53
58
|
# HDR sources: tone-map for the PNG so the agent judges representative colours, not raw HLG/PQ
|
|
54
59
|
if meta["video"].get("hdr"):
|
|
55
60
|
v = meta["video"]
|
|
@@ -67,8 +72,8 @@ def main() -> int:
|
|
|
67
72
|
sec = parse_time(t)
|
|
68
73
|
out = args.output or os.path.join(outdir, f"{stem}_vs_{Path(args.compare).stem}_{sec:.3f}s.png")
|
|
69
74
|
half = args.width // 2
|
|
70
|
-
stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{FONT}"
|
|
71
|
-
tcs = tc.replace("," + timecode_filter(), "") + stamp
|
|
75
|
+
stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
|
|
76
|
+
tcs = tc.replace("," + timecode_filter(font_prefix), "") + stamp
|
|
72
77
|
fc = (f"[0:v]scale={half}:-2{tcs}[a];[1:v]scale={half}:-2{tcs}[b];"
|
|
73
78
|
f"[a][b]scale2ref=w=iw:h=ih[a2][b2];[a2][b2]hstack=inputs=2[out]")
|
|
74
79
|
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-ss", f"{sec:.3f}", "-i", args.compare,
|
|
@@ -81,8 +86,8 @@ def main() -> int:
|
|
|
81
86
|
if dur and sec > dur:
|
|
82
87
|
die(f"--at {t} is beyond the duration ({dur:.2f}s)")
|
|
83
88
|
out = os.path.join(outdir, f"{args.output and Path(args.output).stem or stem}_{sec:.3f}s.png")
|
|
84
|
-
stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{FONT}"
|
|
85
|
-
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-vf", f"scale={args.width}:-2{tc.replace(',' + timecode_filter(), '')}{stamp}", "-frames:v", "1", out]
|
|
89
|
+
stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
|
|
90
|
+
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-vf", f"scale={args.width}:-2{tc.replace(',' + timecode_filter(font_prefix), '')}{stamp}", "-frames:v", "1", out]
|
|
86
91
|
run(cmd)
|
|
87
92
|
outputs.append(out)
|
|
88
93
|
else:
|
package/scripts/overlay.py
CHANGED
|
@@ -24,7 +24,7 @@ import argparse
|
|
|
24
24
|
import sys
|
|
25
25
|
from typing import List, Optional
|
|
26
26
|
|
|
27
|
-
from _common import STATE, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
27
|
+
from _common import STATE, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, x264_args
|
|
28
28
|
|
|
29
29
|
POS = {
|
|
30
30
|
"top-left": ("{m}", "{m}"),
|
|
@@ -144,6 +144,8 @@ def main() -> int:
|
|
|
144
144
|
args.font = brand.get("font", args.font)
|
|
145
145
|
if not args.font_file and brand.get("font_file"):
|
|
146
146
|
args.font_file = brand["font_file"]
|
|
147
|
+
if not args.font_file:
|
|
148
|
+
args.font_file = default_font_file(args.font)
|
|
147
149
|
meta = probe(args.input)
|
|
148
150
|
if not meta.get("video"):
|
|
149
151
|
die("input has no video stream")
|
|
@@ -193,12 +195,19 @@ def main() -> int:
|
|
|
193
195
|
# -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
|
|
194
196
|
cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", "-i", args.image]
|
|
195
197
|
fc = f"[1:v]{','.join(chain)},setpts=PTS-STARTPTS[ov];[0:v][ov]{ov}[out]"
|
|
196
|
-
cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"
|
|
197
|
-
# -shortest alone is not exact on FFmpeg 7+: the muxer keeps up to shortest_buf_duration (10 s)
|
|
198
|
-
# of the looped still after the video ended, and the file came out 2 s long on 8.1 / 9.0.
|
|
199
|
-
# The output must be as long as the main input, so say so explicitly.
|
|
198
|
+
cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"]
|
|
200
199
|
if meta.get("duration"):
|
|
200
|
+
# An explicit -t is exact and, unlike -shortest, only bounds the *main* input's
|
|
201
|
+
# streams -- a preserved subtitle/data stream that ends earlier (run_keeping_subtitles)
|
|
202
|
+
# must not be allowed to cut the whole output short via -shortest's "stop at whichever
|
|
203
|
+
# mapped stream finishes first" semantics.
|
|
201
204
|
cmd += ["-t", f"{meta['duration']:.3f}"]
|
|
205
|
+
else:
|
|
206
|
+
# No known duration to bound by -t (e.g. probe found no video duration): -shortest is
|
|
207
|
+
# the only thing stopping the looped still from running forever. FFmpeg 7+'s
|
|
208
|
+
# shortest_buf_duration slack (up to 10s) is an accepted imprecision here since there is
|
|
209
|
+
# no better bound available.
|
|
210
|
+
cmd += ["-shortest"]
|
|
202
211
|
elif args.video:
|
|
203
212
|
pip_meta = probe(args.video)
|
|
204
213
|
if not pip_meta.get("video"):
|
|
@@ -219,9 +228,13 @@ def main() -> int:
|
|
|
219
228
|
ov += f":enable='{enable}'"
|
|
220
229
|
cmd = ffmpeg_base() + ["-i", args.input, "-i", args.video]
|
|
221
230
|
fc = f"[1:v]{','.join(chain)}[ov];[0:v][ov]{ov}[out]"
|
|
222
|
-
cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"
|
|
231
|
+
cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"]
|
|
223
232
|
if meta.get("duration"):
|
|
233
|
+
# See the --image branch above: -t (exact, bounds only the main input) instead of
|
|
234
|
+
# -shortest (would also stop at a preserved subtitle/data stream that ends earlier).
|
|
224
235
|
cmd += ["-t", f"{meta['duration']:.3f}"]
|
|
236
|
+
else:
|
|
237
|
+
cmd += ["-shortest"]
|
|
225
238
|
else:
|
|
226
239
|
x, y = position_exprs(args.position, args.margin, text_mode=True)
|
|
227
240
|
opts = [f"text='{escape_drawtext(args.text)}'", f"fontsize={args.font_size}", f"x={x}", f"y={y}",
|
|
@@ -245,12 +258,11 @@ def main() -> int:
|
|
|
245
258
|
|
|
246
259
|
cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
|
|
247
260
|
cmd += aac_args() if meta.get("audio") else ["-an"]
|
|
248
|
-
cmd
|
|
249
|
-
run(cmd)
|
|
261
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
250
262
|
if not STATE.dry_run:
|
|
251
263
|
result = probe(output, role="output")
|
|
252
264
|
info(f"wrote {output} ({result['duration']:.3f}s)")
|
|
253
|
-
emit(output)
|
|
265
|
+
emit(output, dropped_non_av_streams=dropped_streams)
|
|
254
266
|
return 0
|
|
255
267
|
|
|
256
268
|
|
package/scripts/scenes.py
CHANGED
|
@@ -26,7 +26,7 @@ import subprocess
|
|
|
26
26
|
import sys
|
|
27
27
|
from typing import Dict, List, Tuple
|
|
28
28
|
|
|
29
|
-
from _common import add_common, apply_common, die, emit, ffmpeg_base, info, print_json, probe, require_tool, run
|
|
29
|
+
from _common import add_common, apply_common, default_font_file, die, emit, escape_filter_path, ffmpeg_base, info, print_json, probe, require_tool, run
|
|
30
30
|
|
|
31
31
|
SCORE_RE = re.compile(r"frame:(\d+)\s+pts:\d+\s+pts_time:([0-9.]+)")
|
|
32
32
|
|
|
@@ -107,6 +107,7 @@ def main() -> int:
|
|
|
107
107
|
ap.add_argument("--max-scene", type=float, default=15.0, help="cap a highlight range at this many seconds (default 15)")
|
|
108
108
|
ap.add_argument("--edl", help="write highlight ranges as START-END lines (cut.py --segments format)")
|
|
109
109
|
ap.add_argument("--sheet", help="write a contact sheet PNG with the first frame of every scene")
|
|
110
|
+
ap.add_argument("--no-timecode", action="store_true", help="--sheet without the burnt-in timecode stamp (a way out if drawtext itself is unusable, see doctor)")
|
|
110
111
|
add_common(ap)
|
|
111
112
|
args = ap.parse_args()
|
|
112
113
|
apply_common(args)
|
|
@@ -183,7 +184,12 @@ def main() -> int:
|
|
|
183
184
|
# exactly one frame per scene: the frame index at the scene start
|
|
184
185
|
fps = meta["video"].get("fps") or 30.0
|
|
185
186
|
expr = "+".join(f"eq(n\\,{int(round(sc['start'] * fps))})" for sc in scenes)
|
|
186
|
-
|
|
187
|
+
stamp = ""
|
|
188
|
+
if not args.no_timecode:
|
|
189
|
+
default_font = default_font_file("DejaVu Sans")
|
|
190
|
+
font_prefix = f"fontfile={escape_filter_path(default_font)}:" if default_font else ""
|
|
191
|
+
stamp = f",drawtext=text='%{{pts\\:hms}}':{font_prefix}fontcolor=white:fontsize=h/14:box=1:boxcolor=black@0.55:boxborderw=4:x=6:y=6"
|
|
192
|
+
vf = (f"select='{expr}',scale={tile_w}:-2{stamp},"
|
|
187
193
|
f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
|
|
188
194
|
run(ffmpeg_base() + ["-i", args.input, "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", args.sheet])
|
|
189
195
|
info(f"wrote {args.sheet}")
|
package/scripts/stabilize.py
CHANGED
|
@@ -9,12 +9,18 @@ this run only -- it is not a caller-facing artifact.
|
|
|
9
9
|
--shakiness (1 = barely shaky, fast; 10 = very shaky, slow analysis) and
|
|
10
10
|
--smoothing (how many neighbouring frames to average the camera path over)
|
|
11
11
|
are the two knobs that matter most; --zoom crops in slightly to hide the
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
edges stabilizing can introduce (0 = keep the original framing and let edges
|
|
13
|
+
show). --crop chooses what happens to any edge vidstab reveals that --zoom
|
|
14
|
+
doesn't crop away: "keep" (default) stretches the border pixels, "black"
|
|
15
|
+
fills it in solid black instead. --tripod locks the frame fully still
|
|
16
|
+
against a single reference frame (e.g. a camera meant to be static but
|
|
17
|
+
nudged, or a shot you want dead-locked rather than merely smoothed) instead
|
|
18
|
+
of following the camera's intended motion.
|
|
14
19
|
|
|
15
20
|
Examples:
|
|
16
21
|
python3 stabilize.py shaky.mp4
|
|
17
22
|
python3 stabilize.py shaky.mp4 --shakiness 8 --smoothing 20 --zoom 5
|
|
23
|
+
python3 stabilize.py locked-off.mp4 --tripod --crop black
|
|
18
24
|
"""
|
|
19
25
|
import argparse
|
|
20
26
|
import sys
|
|
@@ -31,6 +37,8 @@ def main() -> int:
|
|
|
31
37
|
ap.add_argument("--shakiness", type=int, default=5, help="1 (barely shaky) .. 10 (very shaky), default 5")
|
|
32
38
|
ap.add_argument("--smoothing", type=int, default=15, help="frames of camera-path smoothing on each side, default 15")
|
|
33
39
|
ap.add_argument("--zoom", type=float, default=0.0, help="percent to zoom in to hide stabilization edges, 0..100 (default 0)")
|
|
40
|
+
ap.add_argument("--crop", choices=["keep", "black"], default="keep", help="edges --zoom doesn't crop away: keep (stretch border pixels, default) or black (fill solid black)")
|
|
41
|
+
ap.add_argument("--tripod", action="store_true", help="lock the frame fully still against a single reference frame instead of smoothing the camera's motion")
|
|
34
42
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
35
43
|
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
36
44
|
add_common(ap)
|
|
@@ -56,13 +64,23 @@ def main() -> int:
|
|
|
56
64
|
|
|
57
65
|
if not STATE["dry_run"]:
|
|
58
66
|
ffmpeg = require_tool("ffmpeg")
|
|
67
|
+
detect_vf = f"vidstabdetect=shakiness={args.shakiness}:result={trf_arg}"
|
|
68
|
+
if args.tripod:
|
|
69
|
+
# A frame number, not a boolean: frame 1 is the standard reference for "lock to
|
|
70
|
+
# this fixed frame" (mirrors vidstabtransform's own tripod=1 below).
|
|
71
|
+
detect_vf += ":tripod=1"
|
|
59
72
|
detect_cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", args.input,
|
|
60
|
-
"-vf",
|
|
73
|
+
"-vf", detect_vf, "-f", "null", "-"]
|
|
61
74
|
proc = run(detect_cmd, check=False)
|
|
62
75
|
if proc.returncode != 0:
|
|
63
76
|
die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}")
|
|
64
77
|
|
|
65
|
-
|
|
78
|
+
crop_mode = {"keep": 0, "black": 1}[args.crop]
|
|
79
|
+
transform_vf = f"vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=1"
|
|
80
|
+
if args.tripod:
|
|
81
|
+
# Equivalent to relative=0:smoothing=0 -- overrides --smoothing, since averaging a
|
|
82
|
+
# camera path makes no sense once every frame is locked to one fixed reference.
|
|
83
|
+
transform_vf += ":tripod=1"
|
|
66
84
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", transform_vf]
|
|
67
85
|
cmd += video_args(meta, args.crf, args.preset)
|
|
68
86
|
cmd += cfr_args(meta)
|