ffmpeg-skill 1.7.2 → 1.8.0
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 +1 -0
- package/SKILL.md +1 -1
- package/docs/contract.md +12 -2
- package/package.json +1 -1
- package/references/scripts.md +1 -1
- package/scripts/_common.py +116 -3
- package/scripts/_contract.py +7 -2
- package/scripts/export.py +3 -0
package/README.md
CHANGED
|
@@ -441,6 +441,7 @@ Contributing a change: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
441
441
|
| | |
|
|
442
442
|
|---|---|
|
|
443
443
|
| [CONTRIBUTING.md](CONTRIBUTING.md) | scope, dev setup, tests, PR expectations |
|
|
444
|
+
| [docs/roadmap.md](docs/roadmap.md) | 1.8.0 to 1.20.0 one theme per minor (1.8 to 1.10 pre-ship 2.0), and what 2.0.0 then removes |
|
|
444
445
|
| [docs/design-decisions.md](docs/design-decisions.md) | behaviours that look like bugs but are decisions, with rationale and the pinning test; read before filing a bug |
|
|
445
446
|
| [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | Contributor Covenant 2.1; reports go through the SECURITY.md channel |
|
|
446
447
|
| [SECURITY.md](SECURITY.md) | how to report a vulnerability privately |
|
package/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: 'Edit video and audio with local FFmpeg from natural-language reque
|
|
|
5
5
|
|
|
6
6
|
# ffmpeg-skill
|
|
7
7
|
|
|
8
|
-
Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`. Every script has `--help`, and all of them accept `--dry-run`, `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress`, `--timeout SECONDS` (a single ffmpeg run is killed past this and reported as `kind: timeout`; default 1800) and `--overwrite` (consent to replace an output that already exists; without it the tool warns today and refuses from 2.0) and `--plan FILE` (a dry run written as a plan: fingerprinted inputs, commands, expected output, verify steps; `render.py FILE` executes it later and refuses if an input changed, so "plan → user confirms → execute" is one round trip). Writing tools run nothing under `--dry-run`; `probe`/`check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` may still run ffmpeg/ffprobe to measure or analyse — they just don't write their final artifact (nor side files such as `--edl`, `--sheet` or a generated `.ass`); `verify` accepts the flag but ignores it. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`). Details for every flag: `references/scripts.md`. Device-specific behaviour (iPhone HDR, GoPro, DJI, screen recordings, Zoom): `references/devices.md`.
|
|
8
|
+
Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`. Every script has `--help`, and all of them accept `--dry-run`, `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress`, `--timeout SECONDS` (a single ffmpeg run is killed past this and reported as `kind: timeout`; default 1800) and `--overwrite` (consent to replace an output that already exists; without it the tool warns today and refuses from 2.0) and `--plan FILE` (a dry run written as a plan: fingerprinted inputs, commands, expected output, verify steps; `render.py FILE` executes it later and refuses if an input changed, so "plan → user confirms → execute" is one round trip). Writing tools run nothing under `--dry-run`; `probe`/`check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` may still run ffmpeg/ffprobe to measure or analyse — they just don't write their final artifact (nor side files such as `--edl`, `--sheet` or a generated `.ass`); `verify` accepts the flag but ignores it. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`). Every tool that re-encodes also takes `--codec h264|hevc|av1|prores` and `--quality N` (CRF scale; overrides `--crf`): without them SDR is x264 and HDR is x265 Main10, as before; `--codec prores` needs a `.mov` output, `--codec h264` refuses an HDR source (run `color.py --to-sdr` first). Details for every flag: `references/scripts.md`. Device-specific behaviour (iPhone HDR, GoPro, DJI, screen recordings, Zoom): `references/devices.md`.
|
|
9
9
|
|
|
10
10
|
## Workflow (always follow this order)
|
|
11
11
|
|
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`1.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`1.8.0`) | any release |
|
|
25
25
|
|
|
26
26
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
27
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -83,7 +83,7 @@ on, the line says so.
|
|
|
83
83
|
```json
|
|
84
84
|
{
|
|
85
85
|
"contract_version": "1.0",
|
|
86
|
-
"skill": {"id": "ffmpeg-skill", "version": "1.
|
|
86
|
+
"skill": {"id": "ffmpeg-skill", "version": "1.8.0", "execution_mode": "local", "kind": "execution",
|
|
87
87
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
88
88
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
89
89
|
"requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
|
|
@@ -162,6 +162,16 @@ tool's job; only the artifact is skipped, including side files such as `--edl`,
|
|
|
162
162
|
generated `.ass`), and `verify` does not support dry-run (its steps run). `SKILL.md` and
|
|
163
163
|
`references/scripts.md` repeat the same list; the contract is the authority.
|
|
164
164
|
|
|
165
|
+
`--codec h264|hevc|av1|prores` and `--quality N` (1.8) are on every tool whose schema has
|
|
166
|
+
`crf` (the ones that re-encode), marked `common`. They are resolved in one place
|
|
167
|
+
(`_common.encoder_args()`): hevc keeps an HDR source Main10 with its tags and writes 8-bit
|
|
168
|
+
BT.709 for SDR; av1 is SVT-AV1 with libaom as the fallback; prores is 422 HQ and needs a
|
|
169
|
+
`.mov`/`.mkv` output; h264 refuses an HDR source (`kind: input`). `--quality` is the CRF scale
|
|
170
|
+
and overrides `--crf`. Without `--codec` the encoder is what it always was (x264 for SDR, x265
|
|
171
|
+
Main10 for HDR), so the flags add no behaviour to a caller that does not pass them. The
|
|
172
|
+
encoder each value needs is listed under the tool's optional capabilities (`--codec hevc` and
|
|
173
|
+
so on). `export.py` refuses `--codec`: its presets decide the codec.
|
|
174
|
+
|
|
165
175
|
`--plan FILE` (1.6) is a dry run that also writes a plan document: `{"plan_version": 1,
|
|
166
176
|
"tool", "argv", "cwd", "inputs": [{"path", "size", "sha256_head_tail"}], "commands",
|
|
167
177
|
"output", "verify": [{"tool": "probe"}, {"tool": "check", "platform"}], "notes"}`. It
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 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
|
@@ -1,6 +1,6 @@
|
|
|
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`, `--timeout SECONDS`, `--overwrite`, `--plan FILE` (the dry run written as a plan document that `render.py FILE` executes later; see render.py), `-o OUT` -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; 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`).
|
|
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`, `--timeout SECONDS`, `--overwrite`, `--plan FILE` (the dry run written as a plan document that `render.py FILE` executes later; see render.py), `-o OUT`; every tool that re-encodes (the ones with `--crf`) also takes `--codec h264|hevc|av1|prores` (the encoder for the re-encode; default x264 for SDR, x265 Main10 for HDR, unchanged) and `--quality N` (CRF scale, overrides `--crf`; up to 63 for av1; ignored by prores). `--codec hevc` on SDR writes 8-bit BT.709 HEVC (`hvc1`), `av1` uses SVT-AV1 (libaom fallback), `prores` is 422 HQ and needs a `.mov`/`.mkv` output, `h264` refuses an HDR source (`kind: input`, run `color.py --to-sdr` first). `export.py` keeps choosing the codec from its preset and refuses `--codec` -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; 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
|
package/scripts/_common.py
CHANGED
|
@@ -218,6 +218,10 @@ def require_tool(name: str) -> str:
|
|
|
218
218
|
|
|
219
219
|
|
|
220
220
|
X264_PRESETS = ("ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow", "placebo")
|
|
221
|
+
CODECS = ("h264", "hevc", "av1", "prores")
|
|
222
|
+
# x264 preset names mapped onto SVT-AV1's 0-13 speed scale (lower = slower / better)
|
|
223
|
+
SVT_PRESET = {"ultrafast": 12, "superfast": 11, "veryfast": 10, "faster": 9, "fast": 8, "medium": 6, "slow": 4, "slower": 3, "veryslow": 2, "placebo": 1}
|
|
224
|
+
_ENCODERS: Optional[set] = None
|
|
221
225
|
|
|
222
226
|
|
|
223
227
|
class Context:
|
|
@@ -228,7 +232,7 @@ class Context:
|
|
|
228
232
|
makes it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
|
|
229
233
|
"""
|
|
230
234
|
|
|
231
|
-
__slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs")
|
|
235
|
+
__slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec")
|
|
232
236
|
|
|
233
237
|
def __init__(self) -> None:
|
|
234
238
|
self.reset()
|
|
@@ -247,6 +251,7 @@ class Context:
|
|
|
247
251
|
self.plan: Optional[str] = None # --plan FILE: write the dry-run as a plan document (implies --dry-run)
|
|
248
252
|
self.plan_written = False # write_plan() ran (emit or the exit hook), so the hook does not write twice
|
|
249
253
|
self.plan_inputs: List[str] = [] # side inputs (srt/ass/lut/font files) a tool named through escape_filter_path
|
|
254
|
+
self.codec: Optional[str] = None # --codec: encoder for the re-encode (None = x264 for SDR, x265 for HDR)
|
|
250
255
|
|
|
251
256
|
|
|
252
257
|
|
|
@@ -267,6 +272,13 @@ def add_common(ap: "argparse.ArgumentParser") -> None:
|
|
|
267
272
|
help="allow replacing an existing output (warned today, refused from 2.0)")
|
|
268
273
|
g.add_argument("--plan", metavar="FILE",
|
|
269
274
|
help="write the dry run as a plan (inputs fingerprinted, commands, expected output, verify steps) that render.py FILE executes later; implies --dry-run")
|
|
275
|
+
if "--crf" in ap._option_string_actions:
|
|
276
|
+
# only the tools that re-encode (they declare --crf before add_common): one encoder choice
|
|
277
|
+
# resolved in video_args(), the 2.0 encoder abstraction pre-shipped in 1.8 (docs/roadmap.md)
|
|
278
|
+
g.add_argument("--codec", choices=CODECS, default=None,
|
|
279
|
+
help="video encoder for the re-encode: h264 (x264, the default for SDR), hevc (x265, the default for HDR), av1 (SVT-AV1 or libaom), prores (422 HQ, needs a .mov/.mkv output); HDR sources keep their colour on hevc/av1/prores")
|
|
280
|
+
g.add_argument("--quality", type=int, default=None, metavar="N",
|
|
281
|
+
help="encoder quality on the CRF scale (lower = better; 18 visually lossless for x264/x265, up to 63 for av1); overrides --crf, ignored by prores")
|
|
270
282
|
|
|
271
283
|
|
|
272
284
|
def apply_common(args: "argparse.Namespace") -> None:
|
|
@@ -285,8 +297,20 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
285
297
|
STATE.timeout = max(0.0, float(args.timeout))
|
|
286
298
|
if STATE.fast and getattr(args, "preset", None) in X264_PRESETS:
|
|
287
299
|
args.preset = "veryfast"
|
|
300
|
+
STATE.codec = getattr(args, "codec", None) or None
|
|
301
|
+
quality = getattr(args, "quality", None)
|
|
302
|
+
if quality is not None:
|
|
303
|
+
top = 63 if STATE.codec == "av1" else 51
|
|
304
|
+
if not 0 <= int(quality) <= top:
|
|
305
|
+
die(f"--quality must be between 0 and {top} for {STATE.codec or 'h264'} (CRF scale; 18 is visually lossless), got {quality}")
|
|
306
|
+
args.crf = int(quality) # every tool reads args.crf; --quality is the codec-neutral spelling of it
|
|
307
|
+
if STATE.codec == "prores":
|
|
308
|
+
out = getattr(args, "output", None)
|
|
309
|
+
if out and os.path.splitext(str(out))[1].lower() not in (".mov", ".mkv"):
|
|
310
|
+
die(f"--codec prores needs a .mov (or .mkv) output; {os.path.basename(str(out))} cannot hold ProRes",
|
|
311
|
+
hint="give -o NAME.mov")
|
|
288
312
|
crf = getattr(args, "crf", None)
|
|
289
|
-
if crf is not None and not 0 <= int(crf) <= 51:
|
|
313
|
+
if crf is not None and not 0 <= int(crf) <= (63 if STATE.codec == "av1" else 51):
|
|
290
314
|
die(f"--crf must be between 0 and 51 (x264/x265 scale; 18 is visually lossless, 23 the encoder default), got {crf}")
|
|
291
315
|
install_signal_handlers()
|
|
292
316
|
|
|
@@ -1619,13 +1643,100 @@ def bt709_tag_args(encoder: str = "libx264") -> List[str]:
|
|
|
1619
1643
|
return ["-x264-params", "colorprim=bt709:transfer=bt709:colormatrix=bt709"]
|
|
1620
1644
|
|
|
1621
1645
|
|
|
1622
|
-
def
|
|
1646
|
+
def ffmpeg_encoders() -> set:
|
|
1647
|
+
"""Names from `ffmpeg -encoders`, read once; empty when ffmpeg is missing. Used only to pick
|
|
1648
|
+
an AV1 encoder and to refuse --codec av1 / prores before ffmpeg would."""
|
|
1649
|
+
global _ENCODERS
|
|
1650
|
+
if _ENCODERS is None:
|
|
1651
|
+
_ENCODERS = set()
|
|
1652
|
+
try:
|
|
1653
|
+
out = subprocess.run([shutil.which("ffmpeg") or "ffmpeg", "-hide_banner", "-encoders"], stdout=subprocess.PIPE,
|
|
1654
|
+
stderr=subprocess.DEVNULL, text=True, timeout=PROBE_TIMEOUT).stdout
|
|
1655
|
+
_ENCODERS = set(re.findall(r"^\s*[VAS][.\w]{5}\s+(\S+)", out, re.M))
|
|
1656
|
+
except (OSError, subprocess.SubprocessError):
|
|
1657
|
+
pass
|
|
1658
|
+
return _ENCODERS
|
|
1659
|
+
|
|
1660
|
+
|
|
1661
|
+
def _sdr_bt709(encoder: str) -> "Tuple[str, List[str]]":
|
|
1662
|
+
"""BT.709 SDR tags for `encoder` as (encoder-params string, extra output options): the two
|
|
1663
|
+
spellings bt709_tag_args() picks between, split so a caller that already builds an encoder
|
|
1664
|
+
params string can merge them (the option given twice keeps only the last)."""
|
|
1665
|
+
if ffmpeg_version() < (7, 1):
|
|
1666
|
+
return "", ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
|
|
1667
|
+
if encoder == "libsvtav1":
|
|
1668
|
+
return "color-primaries=1:transfer-characteristics=1:matrix-coefficients=1", []
|
|
1669
|
+
if encoder == "libaom-av1":
|
|
1670
|
+
return "", [] # no VUI params option; an untagged 8-bit stream reads as BT.709 everywhere
|
|
1671
|
+
return "colorprim=bt709:transfer=bt709:colormatrix=bt709", []
|
|
1672
|
+
|
|
1673
|
+
|
|
1674
|
+
def encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str, Any]] = None) -> List[str]:
|
|
1675
|
+
"""The one place that turns (--codec, --quality, --preset, source) into encoder options.
|
|
1676
|
+
|
|
1677
|
+
h264 -> x264 8-bit BT.709 (refuses HDR: 8-bit H.264 cannot carry it); hevc -> x265, Main10
|
|
1678
|
+
with the source's tags for HDR, 8-bit BT.709 otherwise; av1 -> SVT-AV1 (libaom fallback),
|
|
1679
|
+
10-bit for HDR; prores -> ProRes 422 HQ, source tags kept. 1.8: chosen by --codec; without
|
|
1680
|
+
it video_args() does what it always did (x264 for SDR, x265 Main10 for HDR).
|
|
1681
|
+
"""
|
|
1682
|
+
v = (meta or {}).get("video") or {}
|
|
1683
|
+
hdr = bool(v.get("hdr"))
|
|
1684
|
+
cs = v.get("color_space") or "bt2020nc"
|
|
1685
|
+
prim = v.get("color_primaries") or "bt2020"
|
|
1686
|
+
trc = v.get("color_transfer") or "arib-std-b67"
|
|
1687
|
+
hdr_tags = ["-colorspace", cs, "-color_primaries", prim, "-color_trc", trc]
|
|
1688
|
+
if codec == "h264":
|
|
1689
|
+
if hdr:
|
|
1690
|
+
die(f"--codec h264 cannot carry HDR ({v.get('hdr_format') or 'BT.2020'}): 8-bit H.264 is SDR only",
|
|
1691
|
+
hint="run color.py --to-sdr first, or use --codec hevc / av1 / prores, which keep the source's HDR")
|
|
1692
|
+
return _x264_raw(crf, preset)
|
|
1693
|
+
if codec == "hevc":
|
|
1694
|
+
if hdr:
|
|
1695
|
+
x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
|
|
1696
|
+
return ["-c:v", "libx265", "-preset", preset, "-crf", str(crf + 2), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
|
|
1697
|
+
"-x265-params", x265] + hdr_tags + ["-movflags", "+faststart"]
|
|
1698
|
+
params, extra = _sdr_bt709("libx265")
|
|
1699
|
+
return ["-c:v", "libx265", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p", "-tag:v", "hvc1",
|
|
1700
|
+
"-x265-params", "log-level=error" + (":" + params if params else "")] + extra + ["-movflags", "+faststart"]
|
|
1701
|
+
if codec == "av1":
|
|
1702
|
+
pix = "yuv420p10le" if hdr else "yuv420p"
|
|
1703
|
+
if "libsvtav1" in ffmpeg_encoders():
|
|
1704
|
+
args = ["-c:v", "libsvtav1", "-preset", str(SVT_PRESET.get(preset, 6)), "-crf", str(min(63, crf)), "-pix_fmt", pix]
|
|
1705
|
+
if hdr:
|
|
1706
|
+
args += hdr_tags
|
|
1707
|
+
else:
|
|
1708
|
+
params, extra = _sdr_bt709("libsvtav1")
|
|
1709
|
+
args += (["-svtav1-params", params] if params else []) + extra
|
|
1710
|
+
elif "libaom-av1" in ffmpeg_encoders():
|
|
1711
|
+
args = ["-c:v", "libaom-av1", "-crf", str(min(63, crf)), "-b:v", "0", "-cpu-used", "6", "-row-mt", "1", "-pix_fmt", pix]
|
|
1712
|
+
args += hdr_tags if hdr else _sdr_bt709("libaom-av1")[1]
|
|
1713
|
+
else:
|
|
1714
|
+
die("--codec av1 needs an AV1 encoder (libsvtav1 or libaom-av1) and this ffmpeg build has neither", kind="missing_tool",
|
|
1715
|
+
hint="install an ffmpeg built with SVT-AV1 (most distribution builds are), or use --codec hevc")
|
|
1716
|
+
return args + ["-movflags", "+faststart"]
|
|
1717
|
+
if codec == "prores":
|
|
1718
|
+
if "prores_ks" not in ffmpeg_encoders():
|
|
1719
|
+
die("--codec prores needs the prores_ks encoder and this ffmpeg build lacks it", kind="missing_tool")
|
|
1720
|
+
return ["-c:v", "prores_ks", "-profile:v", "3", "-vendor", "apl0", "-pix_fmt", "yuv422p10le"] + (hdr_tags if hdr else [])
|
|
1721
|
+
die(f"unknown --codec {codec!r} (one of {', '.join(CODECS)})")
|
|
1722
|
+
return []
|
|
1723
|
+
|
|
1724
|
+
|
|
1725
|
+
def _x264_raw(crf: int, preset: str, keep_bt709: bool = True) -> List[str]:
|
|
1623
1726
|
args = ["-c:v", "libx264", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p", "-movflags", "+faststart"]
|
|
1624
1727
|
if keep_bt709:
|
|
1625
1728
|
args += bt709_tag_args("libx264")
|
|
1626
1729
|
return args
|
|
1627
1730
|
|
|
1628
1731
|
|
|
1732
|
+
def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True) -> List[str]:
|
|
1733
|
+
"""SDR H.264 encoder args -- or, when --codec named another encoder, that encoder's SDR args
|
|
1734
|
+
(color.py's --to-sdr path builds its own H.264 line; the flag still has to reach it)."""
|
|
1735
|
+
if STATE.codec and STATE.codec != "h264":
|
|
1736
|
+
return encoder_args(STATE.codec, crf, preset, None)
|
|
1737
|
+
return _x264_raw(crf, preset, keep_bt709)
|
|
1738
|
+
|
|
1739
|
+
|
|
1629
1740
|
def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium") -> List[str]:
|
|
1630
1741
|
"""Encoder args that preserve what the source is.
|
|
1631
1742
|
|
|
@@ -1634,6 +1745,8 @@ def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "med
|
|
|
1634
1745
|
so cutting/captioning/fitting an iPhone HDR clip stays HDR instead of becoming a
|
|
1635
1746
|
washed-out file mislabelled as BT.709. Use color.py --to-sdr when SDR is wanted.
|
|
1636
1747
|
"""
|
|
1748
|
+
if STATE.codec:
|
|
1749
|
+
return encoder_args(STATE.codec, crf, preset, meta)
|
|
1637
1750
|
v = (meta or {}).get("video") or {}
|
|
1638
1751
|
if not v.get("hdr"):
|
|
1639
1752
|
return x264_args(crf, preset)
|
package/scripts/_contract.py
CHANGED
|
@@ -57,6 +57,10 @@ ROLES = {
|
|
|
57
57
|
FF = ["ffmpeg", "ffprobe"]
|
|
58
58
|
X264 = "encoder:libx264"
|
|
59
59
|
X265 = "encoder:libx265"
|
|
60
|
+
# --codec (1.8, every tool that re-encodes): the encoder the flag names, on top of the tool's own list
|
|
61
|
+
CODEC_CAPS = [{"capability": X265, "when": "--codec hevc"},
|
|
62
|
+
{"capability": "encoder:libsvtav1", "when": "--codec av1 (libaom-av1 is the fallback)"},
|
|
63
|
+
{"capability": "encoder:prores_ks", "when": "--codec prores"}]
|
|
60
64
|
AAC = "encoder:aac"
|
|
61
65
|
HDR_X265 = {"capability": X265, "when": "the source is HDR (kept as HEVC Main10)"}
|
|
62
66
|
AUDIO_OUT = [
|
|
@@ -333,7 +337,7 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
|
|
|
333
337
|
props: Dict[str, Any] = {}
|
|
334
338
|
required: List[str] = []
|
|
335
339
|
positional: List[str] = []
|
|
336
|
-
common = {"dry_run", "json", "progress", "fast", "timeout", "overwrite", "plan"}
|
|
340
|
+
common = {"dry_run", "json", "progress", "fast", "timeout", "overwrite", "plan", "codec", "quality"}
|
|
337
341
|
for action in parser._actions:
|
|
338
342
|
if isinstance(action, argparse._HelpAction):
|
|
339
343
|
continue
|
|
@@ -648,6 +652,7 @@ def required_capabilities() -> Dict[str, List[str]]:
|
|
|
648
652
|
for meta in TOOL_META.values():
|
|
649
653
|
req.update(meta["required"])
|
|
650
654
|
opt.update(o["capability"] for o in meta["optional"] if o["capability"] != "delegated")
|
|
655
|
+
opt.update(o["capability"] for o in CODEC_CAPS)
|
|
651
656
|
opt -= req
|
|
652
657
|
return {"required": sorted(req), "optional": sorted(opt)}
|
|
653
658
|
|
|
@@ -898,7 +903,7 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
|
|
|
898
903
|
"description": (parser.description or "").strip().splitlines()[0] if parser.description else "",
|
|
899
904
|
"executable": f"scripts/{name}.py",
|
|
900
905
|
"role": meta["role"],
|
|
901
|
-
"capabilities": {"required": list(meta["required"]), "optional": list(meta["optional"])},
|
|
906
|
+
"capabilities": {"required": list(meta["required"]), "optional": list(meta["optional"]) + (CODEC_CAPS if "codec" in schema["properties"] else [])},
|
|
902
907
|
"inputs": list(meta["inputs"]),
|
|
903
908
|
"outputs": list(meta["outputs"]),
|
|
904
909
|
"input_schema": schema,
|
package/scripts/export.py
CHANGED
|
@@ -73,6 +73,9 @@ def main() -> int:
|
|
|
73
73
|
if not args.input or not args.preset:
|
|
74
74
|
die("input and --preset are required (or use --list)")
|
|
75
75
|
validate_color(args.pad_color, "--pad-color")
|
|
76
|
+
if args.codec:
|
|
77
|
+
die(f"export.py's presets decide the codec (--preset h265 for HEVC, prores for ProRes); --codec {args.codec} is for the editing tools",
|
|
78
|
+
hint="drop --codec here, or run the edit with --codec and export with --preset copy")
|
|
76
79
|
|
|
77
80
|
p = PRESETS[args.preset]
|
|
78
81
|
meta = probe(args.input)
|