ffmpeg-skill 2.14.0 → 2.16.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/docs/contract.md +2 -2
- package/package.json +1 -1
- package/references/gotchas.md +2 -1
- package/references/scripts.md +12 -1
- package/scripts/render.py +50 -12
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 (`2.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`2.16.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`
|
|
@@ -131,7 +131,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
131
131
|
"deprecated": [{"what": "running on Python 3.9 (the requirements.python floor)", "since": "2.10.9", "replacement": "Python 3.10 or later; ...", "removed_in": "3.0.0", "where": "behaviour"},
|
|
132
132
|
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
|
|
133
133
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
134
|
-
"skill": {"id": "ffmpeg-skill", "version": "2.
|
|
134
|
+
"skill": {"id": "ffmpeg-skill", "version": "2.16.0", "execution_mode": "local", "kind": "execution",
|
|
135
135
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
136
136
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
137
137
|
"requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.10.9, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0 (5.x deprecated since 2.14.0, removed in 3.0.0; 6.1+ recommended)", "ffprobe": ">=5.0"},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.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/gotchas.md
CHANGED
|
@@ -301,7 +301,8 @@ the contact sheet (`--sheet`) before treating the picks as final.
|
|
|
301
301
|
Anything chained by hand through three re-encodes should be one `render.py`
|
|
302
302
|
project instead, so the plan is one file and the user can change one number.
|
|
303
303
|
Re-encodes use x264 `medium`; for long files add `--preset veryfast` to
|
|
304
|
-
intermediates and keep the default for the final export.
|
|
304
|
+
intermediates and keep the default for the final export. `render.py` does that by itself since
|
|
305
|
+
2.15 when its project ends in an encoding export (`--intermediate-preset medium` turns it off). On Apple Silicon,
|
|
305
306
|
`--hw` (or `FFMPEG_SKILL_HW=1` for the machine) moves h264/hevc/prores encodes to
|
|
306
307
|
VideoToolbox: 2–7× faster, 1.1–2.9× the bytes at the same quality (1.9–3.5× on HDR). The variable
|
|
307
308
|
leaves `export.py`'s delivery presets on the CPU, which is the usual split (GPU
|
package/references/scripts.md
CHANGED
|
@@ -717,7 +717,18 @@ render.py project.json [--fast] [--dry-run] [--stop-after STAGE] [--work DIR --k
|
|
|
717
717
|
render.py project.json --cache DIR [--from STAGE] # reuse identical stages from a previous run
|
|
718
718
|
render.py plan.json [--audit-log FILE] # execute a plan written by <tool> --plan plan.json
|
|
719
719
|
render.py project.json --export-timeline edit.fcpxml|edit.edl|edit.otio # the cut for an editor, nothing rendered
|
|
720
|
-
|
|
720
|
+
render.py project.json --intermediate-preset medium # the stages' own encoder preset instead of superfast
|
|
721
|
+
```
|
|
722
|
+
`--intermediate-preset` (2.15; default `superfast` since 2.16, `veryfast` in 2.15): when the project has an export stage that
|
|
723
|
+
re-encodes (any preset but `copy`) and the run goes that far (no `--stop-after` before it), the
|
|
724
|
+
stages before it that encode with x264 or x265 (clips, fit, join, silence, captions, graphics,
|
|
725
|
+
overlays, audiogram; x265 on the HDR path) encode with this preset instead of `medium`. export.py re-encodes their output and its own
|
|
726
|
+
preset sets the delivered quality, so the earlier `medium` passes cost time the file does not
|
|
727
|
+
keep: a 30 s 1080p reels render took 41.7 s instead of 51.8 s on FFmpeg 8.0.1 (SSIM 0.997
|
|
728
|
+
between the two deliveries); `superfast` (2.16) took it to 30 s from veryfast's 33 s on 8.0.1, the
|
|
729
|
+
same time on 6.1, at the same quality. `medium` restores the stages' default; `--fast` still overrides
|
|
730
|
+
everything; a stage given its own `--preset` keeps it. The result names it as
|
|
731
|
+
`intermediate_preset` (`null` when not applied).
|
|
721
732
|
`--export-timeline FILE` (2.1) writes the project's cut as an editor timeline instead of
|
|
722
733
|
rendering it: `.fcpxml` (FCPXML 1.10: Final Cut Pro, DaVinci Resolve), `.edl` (CMX 3600:
|
|
723
734
|
Premiere, Resolve, Avid) or `.otio` (OpenTimelineIO JSON: Resolve natively, others through
|
package/scripts/render.py
CHANGED
|
@@ -74,7 +74,7 @@ from typing import Any, Dict, List, Optional, Sequence
|
|
|
74
74
|
|
|
75
75
|
from export import PRESETS, PLATFORM_OF
|
|
76
76
|
from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
|
|
77
|
-
from _common import STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, ffmpeg_exe
|
|
77
|
+
from _common import STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, ffmpeg_exe, X264_PRESETS
|
|
78
78
|
from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args
|
|
79
79
|
from _common.emit import absorb_stage, audit, record_fallback, record_skip, refuse, warn
|
|
80
80
|
import subprocess
|
|
@@ -353,7 +353,8 @@ def render_pack(names: List[str], args) -> int:
|
|
|
353
353
|
argv = [str(HERE / "render.py"), args.input, "--template", name, "-o", dest_out]
|
|
354
354
|
for flag, value in (("--cues", args.cues), ("--srt", args.srt), ("--logo", args.logo),
|
|
355
355
|
("--title", args.title), ("--brand", args.brand), ("--fit", args.fit),
|
|
356
|
-
("--chapters", args.chapters), ("--image", args.image)
|
|
356
|
+
("--chapters", args.chapters), ("--image", args.image),
|
|
357
|
+
("--intermediate-preset", args.intermediate_preset)):
|
|
357
358
|
if value:
|
|
358
359
|
argv += [flag, str(value)]
|
|
359
360
|
info(f"→ pack: {name}")
|
|
@@ -545,6 +546,13 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
|
|
|
545
546
|
cache instead of re-encoded. `stages_done` is unchanged either way: a cached stage is still
|
|
546
547
|
a stage that happened.
|
|
547
548
|
"""
|
|
549
|
+
if (INTERMEDIATE["preset"] and script in _X264_STAGES
|
|
550
|
+
and "--preset" not in [str(a) for a in argv] + (extra or [])):
|
|
551
|
+
extra = (extra or []) + ["--preset", INTERMEDIATE["preset"]]
|
|
552
|
+
if not INTERMEDIATE["applied"]:
|
|
553
|
+
info(f"intermediate stages: preset {INTERMEDIATE['preset']} (the export re-encodes them; "
|
|
554
|
+
"--intermediate-preset medium for the stages' own default)")
|
|
555
|
+
INTERMEDIATE["applied"] = True
|
|
548
556
|
full = [str(a) for a in argv] + (extra or [])
|
|
549
557
|
dest = full[full.index("-o") + 1] if "-o" in full[:-1] else None
|
|
550
558
|
key = None
|
|
@@ -627,6 +635,25 @@ def _fresh_cache() -> "Dict[str, Any]":
|
|
|
627
635
|
# inherit each other's hit/miss lists.
|
|
628
636
|
CACHE: Dict[str, Any] = _fresh_cache()
|
|
629
637
|
|
|
638
|
+
# The stages whose x264 encode (x265 on the HDR path, which takes the same --preset) is an
|
|
639
|
+
# intermediate when an encoding export follows: export.py
|
|
640
|
+
# re-encodes their output, so their own `medium` pass bought nothing the delivery keeps. Measured
|
|
641
|
+
# on a 30 s 1080p reels render (4 cores): fit 18 s and captions 27 s of a 70 s run at `medium`;
|
|
642
|
+
# `veryfast` at the same CRF encodes in about half the time (SSIM 0.997 against `medium`) and
|
|
643
|
+
# the export, still `medium`, sets the delivered quality. 2.16: `superfast` (the default since): on
|
|
644
|
+
# FFmpeg 8.0.1 the same render took 30 s instead of 33 s with veryfast, SSIM against a lossless
|
|
645
|
+
# reference 0.9868 vs 0.9863 (the export decides what is kept); on 6.1 the two took the same time.
|
|
646
|
+
# `ultrafast` was faster still (26 s) but measurably worse (0.9814). `--intermediate-preset medium`
|
|
647
|
+
# restores the stages' own default.
|
|
648
|
+
_X264_STAGES = frozenset({"cut.py", "fit.py", "join.py", "silence.py", "caption.py", "graphics.py", "overlay.py", "waveform.py"})
|
|
649
|
+
INTERMEDIATE: Dict[str, Any] = {"preset": None, "applied": False}
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _intermediate_used() -> Optional[str]:
|
|
653
|
+
"""The preset `intermediate_preset` reports: only once a stage actually received it (a project
|
|
654
|
+
whose only encode is the export reports null)."""
|
|
655
|
+
return INTERMEDIATE["preset"] if INTERMEDIATE["applied"] else None
|
|
656
|
+
|
|
630
657
|
|
|
631
658
|
def _content_hash(path: str) -> str:
|
|
632
659
|
"""The same cheap content fingerprint batch.py caches on: name, size, mtime, first and last
|
|
@@ -1043,6 +1070,10 @@ def main() -> int:
|
|
|
1043
1070
|
help="append the audit records of this run (a refused plan, a lock taken over, ...) to FILE as JSON lines; "
|
|
1044
1071
|
"default for a refused plan: <plan>.audit.jsonl next to it. A record that cannot be written is a "
|
|
1045
1072
|
"warning, never a failure")
|
|
1073
|
+
ap.add_argument("--intermediate-preset", default="superfast", choices=X264_PRESETS,
|
|
1074
|
+
help="x264/x265 preset of the stages before an encoding export (default superfast: the export "
|
|
1075
|
+
"re-encodes them, and its own preset sets the delivered quality); medium = the stages' own default. "
|
|
1076
|
+
"Not used without an export stage, with --stop-after before it, with --fast, or for a copy export")
|
|
1046
1077
|
ap.add_argument("--stop-after", choices=["clips", "join", "silence", "fit", "captions", "graphics", "overlays", "audio", "loudness", "export"], help="stop after this stage (for iterating)")
|
|
1047
1078
|
tpl = ap.add_argument_group("delivery templates (one command per destination)")
|
|
1048
1079
|
tpl.add_argument("--template", metavar="NAME", help="render INPUT with a shipped template: " + ", ".join(template_names())
|
|
@@ -1175,6 +1206,13 @@ def main() -> int:
|
|
|
1175
1206
|
# unchanged -- it never gets a --platform it did not ask for.
|
|
1176
1207
|
dest = str(((proj.get("check") or {}).get("platform") or "")) if proj.get("template") else ""
|
|
1177
1208
|
platform_args: List[str] = ["--platform", dest] if dest in PLATFORMS and PLATFORMS[dest].get("frame") else []
|
|
1209
|
+
ex_cfg = proj.get("export")
|
|
1210
|
+
# a malformed "export" is refused by the export stage with its own message; here it only means
|
|
1211
|
+
# "no encoding export", never a traceback before that refusal
|
|
1212
|
+
ex_preset = str(ex_cfg.get("preset") or "") if isinstance(ex_cfg, dict) else ""
|
|
1213
|
+
if (ex_preset and "copy" not in PRESETS.get(ex_preset, {}).get("video", [])
|
|
1214
|
+
and args.stop_after in (None, "export") and args.intermediate_preset != "medium" and not STATE.fast):
|
|
1215
|
+
INTERMEDIATE["preset"] = args.intermediate_preset
|
|
1178
1216
|
stages_done: List[str] = []
|
|
1179
1217
|
# what the caption stage reported (wrapped/split counts and the fit-size keys), so a caller
|
|
1180
1218
|
# reading render.py's JSON can see whether the type was shrunk to fit or a cue was split.
|
|
@@ -1306,7 +1344,7 @@ def main() -> int:
|
|
|
1306
1344
|
stages_done.append("audiogram")
|
|
1307
1345
|
|
|
1308
1346
|
if args.stop_after == "clips":
|
|
1309
|
-
emit(current, stages=stages_done)
|
|
1347
|
+
emit(current, stages=stages_done, intermediate_preset=_intermediate_used())
|
|
1310
1348
|
return 0
|
|
1311
1349
|
|
|
1312
1350
|
# ---- join
|
|
@@ -1322,7 +1360,7 @@ def main() -> int:
|
|
|
1322
1360
|
sh("join.py", *argv, stage="join")
|
|
1323
1361
|
stages_done.append("join")
|
|
1324
1362
|
if args.stop_after == "join":
|
|
1325
|
-
emit(current, stages=stages_done)
|
|
1363
|
+
emit(current, stages=stages_done, intermediate_preset=_intermediate_used())
|
|
1326
1364
|
return 0
|
|
1327
1365
|
|
|
1328
1366
|
# ---- silence
|
|
@@ -1337,7 +1375,7 @@ def main() -> int:
|
|
|
1337
1375
|
current = nxt
|
|
1338
1376
|
stages_done.append("silence")
|
|
1339
1377
|
if args.stop_after == "silence":
|
|
1340
|
-
emit(current, stages=stages_done)
|
|
1378
|
+
emit(current, stages=stages_done, intermediate_preset=_intermediate_used())
|
|
1341
1379
|
return 0
|
|
1342
1380
|
|
|
1343
1381
|
# ---- fit (duration and/or frame)
|
|
@@ -1374,7 +1412,7 @@ def main() -> int:
|
|
|
1374
1412
|
current = nxt
|
|
1375
1413
|
stages_done.append("fit")
|
|
1376
1414
|
if args.stop_after == "fit":
|
|
1377
|
-
emit(current, stages=stages_done)
|
|
1415
|
+
emit(current, stages=stages_done, intermediate_preset=_intermediate_used())
|
|
1378
1416
|
return 0
|
|
1379
1417
|
|
|
1380
1418
|
# ---- captions
|
|
@@ -1403,7 +1441,7 @@ def main() -> int:
|
|
|
1403
1441
|
current = nxt
|
|
1404
1442
|
stages_done.append("captions")
|
|
1405
1443
|
if args.stop_after == "captions":
|
|
1406
|
-
emit(current, stages=stages_done)
|
|
1444
|
+
emit(current, stages=stages_done, intermediate_preset=_intermediate_used())
|
|
1407
1445
|
return 0
|
|
1408
1446
|
|
|
1409
1447
|
# ---- graphics
|
|
@@ -1422,7 +1460,7 @@ def main() -> int:
|
|
|
1422
1460
|
if "graphics" not in stages_done:
|
|
1423
1461
|
stages_done.append("graphics")
|
|
1424
1462
|
if args.stop_after == "graphics":
|
|
1425
|
-
emit(current, stages=stages_done)
|
|
1463
|
+
emit(current, stages=stages_done, intermediate_preset=_intermediate_used())
|
|
1426
1464
|
return 0
|
|
1427
1465
|
|
|
1428
1466
|
# ---- overlays
|
|
@@ -1453,7 +1491,7 @@ def main() -> int:
|
|
|
1453
1491
|
if "overlays" not in stages_done:
|
|
1454
1492
|
stages_done.append("overlays")
|
|
1455
1493
|
if args.stop_after == "overlays":
|
|
1456
|
-
emit(current, stages=stages_done)
|
|
1494
|
+
emit(current, stages=stages_done, intermediate_preset=_intermediate_used())
|
|
1457
1495
|
return 0
|
|
1458
1496
|
|
|
1459
1497
|
# ---- audio
|
|
@@ -1490,7 +1528,7 @@ def main() -> int:
|
|
|
1490
1528
|
current = nxt
|
|
1491
1529
|
stages_done.append("audio")
|
|
1492
1530
|
if args.stop_after == "audio":
|
|
1493
|
-
emit(current, stages=stages_done)
|
|
1531
|
+
emit(current, stages=stages_done, intermediate_preset=_intermediate_used())
|
|
1494
1532
|
return 0
|
|
1495
1533
|
|
|
1496
1534
|
# ---- loudness
|
|
@@ -1506,7 +1544,7 @@ def main() -> int:
|
|
|
1506
1544
|
current = nxt
|
|
1507
1545
|
stages_done.append("loudness")
|
|
1508
1546
|
if args.stop_after == "loudness":
|
|
1509
|
-
emit(current, stages=stages_done)
|
|
1547
|
+
emit(current, stages=stages_done, intermediate_preset=_intermediate_used())
|
|
1510
1548
|
return 0
|
|
1511
1549
|
|
|
1512
1550
|
# ---- export
|
|
@@ -1612,7 +1650,7 @@ def main() -> int:
|
|
|
1612
1650
|
snap_report = dict(snap_reports[0])
|
|
1613
1651
|
snap_report["clips"] = snap_reports
|
|
1614
1652
|
emit(output, stages=stages_done, check=check_result, snap=snap_report, cache=cache_report,
|
|
1615
|
-
caption=caption_report,
|
|
1653
|
+
caption=caption_report, intermediate_preset=_intermediate_used(),
|
|
1616
1654
|
verification=[{"step": "check", "ok": True, "platform": ck.get("platform")}] if check_result else [])
|
|
1617
1655
|
return 0
|
|
1618
1656
|
|