ffmpeg-skill 2.14.0 → 2.15.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 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.14.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.15.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.14.0", "execution_mode": "local", "kind": "execution",
134
+ "skill": {"id": "ffmpeg-skill", "version": "2.15.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.14.0",
3
+ "version": "2.15.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",
@@ -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. On Apple Silicon,
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
@@ -717,7 +717,17 @@ 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 veryfast
721
+ ```
722
+ `--intermediate-preset` (2.15, default `veryfast`): 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). `medium` restores the stages' default; `--fast` still overrides
729
+ everything; a stage given its own `--preset` keeps it. The result names it as
730
+ `intermediate_preset` (`null` when not applied).
721
731
  `--export-timeline FILE` (2.1) writes the project's cut as an editor timeline instead of
722
732
  rendering it: `.fcpxml` (FCPXML 1.10: Final Cut Pro, DaVinci Resolve), `.edl` (CMX 3600:
723
733
  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,21 @@ 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. `--intermediate-preset medium` restores it.
644
+ _X264_STAGES = frozenset({"cut.py", "fit.py", "join.py", "silence.py", "caption.py", "graphics.py", "overlay.py", "waveform.py"})
645
+ INTERMEDIATE: Dict[str, Any] = {"preset": None, "applied": False}
646
+
647
+
648
+ def _intermediate_used() -> Optional[str]:
649
+ """The preset `intermediate_preset` reports: only once a stage actually received it (a project
650
+ whose only encode is the export reports null)."""
651
+ return INTERMEDIATE["preset"] if INTERMEDIATE["applied"] else None
652
+
630
653
 
631
654
  def _content_hash(path: str) -> str:
632
655
  """The same cheap content fingerprint batch.py caches on: name, size, mtime, first and last
@@ -1043,6 +1066,10 @@ def main() -> int:
1043
1066
  help="append the audit records of this run (a refused plan, a lock taken over, ...) to FILE as JSON lines; "
1044
1067
  "default for a refused plan: <plan>.audit.jsonl next to it. A record that cannot be written is a "
1045
1068
  "warning, never a failure")
1069
+ ap.add_argument("--intermediate-preset", default="veryfast", choices=X264_PRESETS,
1070
+ help="x264/x265 preset of the stages before an encoding export (default veryfast: the export "
1071
+ "re-encodes them, and its own preset sets the delivered quality); medium = the stages' own default. "
1072
+ "Not used without an export stage, with --stop-after before it, with --fast, or for a copy export")
1046
1073
  ap.add_argument("--stop-after", choices=["clips", "join", "silence", "fit", "captions", "graphics", "overlays", "audio", "loudness", "export"], help="stop after this stage (for iterating)")
1047
1074
  tpl = ap.add_argument_group("delivery templates (one command per destination)")
1048
1075
  tpl.add_argument("--template", metavar="NAME", help="render INPUT with a shipped template: " + ", ".join(template_names())
@@ -1175,6 +1202,13 @@ def main() -> int:
1175
1202
  # unchanged -- it never gets a --platform it did not ask for.
1176
1203
  dest = str(((proj.get("check") or {}).get("platform") or "")) if proj.get("template") else ""
1177
1204
  platform_args: List[str] = ["--platform", dest] if dest in PLATFORMS and PLATFORMS[dest].get("frame") else []
1205
+ ex_cfg = proj.get("export")
1206
+ # a malformed "export" is refused by the export stage with its own message; here it only means
1207
+ # "no encoding export", never a traceback before that refusal
1208
+ ex_preset = str(ex_cfg.get("preset") or "") if isinstance(ex_cfg, dict) else ""
1209
+ if (ex_preset and "copy" not in PRESETS.get(ex_preset, {}).get("video", [])
1210
+ and args.stop_after in (None, "export") and args.intermediate_preset != "medium" and not STATE.fast):
1211
+ INTERMEDIATE["preset"] = args.intermediate_preset
1178
1212
  stages_done: List[str] = []
1179
1213
  # what the caption stage reported (wrapped/split counts and the fit-size keys), so a caller
1180
1214
  # reading render.py's JSON can see whether the type was shrunk to fit or a cue was split.
@@ -1306,7 +1340,7 @@ def main() -> int:
1306
1340
  stages_done.append("audiogram")
1307
1341
 
1308
1342
  if args.stop_after == "clips":
1309
- emit(current, stages=stages_done)
1343
+ emit(current, stages=stages_done, intermediate_preset=_intermediate_used())
1310
1344
  return 0
1311
1345
 
1312
1346
  # ---- join
@@ -1322,7 +1356,7 @@ def main() -> int:
1322
1356
  sh("join.py", *argv, stage="join")
1323
1357
  stages_done.append("join")
1324
1358
  if args.stop_after == "join":
1325
- emit(current, stages=stages_done)
1359
+ emit(current, stages=stages_done, intermediate_preset=_intermediate_used())
1326
1360
  return 0
1327
1361
 
1328
1362
  # ---- silence
@@ -1337,7 +1371,7 @@ def main() -> int:
1337
1371
  current = nxt
1338
1372
  stages_done.append("silence")
1339
1373
  if args.stop_after == "silence":
1340
- emit(current, stages=stages_done)
1374
+ emit(current, stages=stages_done, intermediate_preset=_intermediate_used())
1341
1375
  return 0
1342
1376
 
1343
1377
  # ---- fit (duration and/or frame)
@@ -1374,7 +1408,7 @@ def main() -> int:
1374
1408
  current = nxt
1375
1409
  stages_done.append("fit")
1376
1410
  if args.stop_after == "fit":
1377
- emit(current, stages=stages_done)
1411
+ emit(current, stages=stages_done, intermediate_preset=_intermediate_used())
1378
1412
  return 0
1379
1413
 
1380
1414
  # ---- captions
@@ -1403,7 +1437,7 @@ def main() -> int:
1403
1437
  current = nxt
1404
1438
  stages_done.append("captions")
1405
1439
  if args.stop_after == "captions":
1406
- emit(current, stages=stages_done)
1440
+ emit(current, stages=stages_done, intermediate_preset=_intermediate_used())
1407
1441
  return 0
1408
1442
 
1409
1443
  # ---- graphics
@@ -1422,7 +1456,7 @@ def main() -> int:
1422
1456
  if "graphics" not in stages_done:
1423
1457
  stages_done.append("graphics")
1424
1458
  if args.stop_after == "graphics":
1425
- emit(current, stages=stages_done)
1459
+ emit(current, stages=stages_done, intermediate_preset=_intermediate_used())
1426
1460
  return 0
1427
1461
 
1428
1462
  # ---- overlays
@@ -1453,7 +1487,7 @@ def main() -> int:
1453
1487
  if "overlays" not in stages_done:
1454
1488
  stages_done.append("overlays")
1455
1489
  if args.stop_after == "overlays":
1456
- emit(current, stages=stages_done)
1490
+ emit(current, stages=stages_done, intermediate_preset=_intermediate_used())
1457
1491
  return 0
1458
1492
 
1459
1493
  # ---- audio
@@ -1490,7 +1524,7 @@ def main() -> int:
1490
1524
  current = nxt
1491
1525
  stages_done.append("audio")
1492
1526
  if args.stop_after == "audio":
1493
- emit(current, stages=stages_done)
1527
+ emit(current, stages=stages_done, intermediate_preset=_intermediate_used())
1494
1528
  return 0
1495
1529
 
1496
1530
  # ---- loudness
@@ -1506,7 +1540,7 @@ def main() -> int:
1506
1540
  current = nxt
1507
1541
  stages_done.append("loudness")
1508
1542
  if args.stop_after == "loudness":
1509
- emit(current, stages=stages_done)
1543
+ emit(current, stages=stages_done, intermediate_preset=_intermediate_used())
1510
1544
  return 0
1511
1545
 
1512
1546
  # ---- export
@@ -1612,7 +1646,7 @@ def main() -> int:
1612
1646
  snap_report = dict(snap_reports[0])
1613
1647
  snap_report["clips"] = snap_reports
1614
1648
  emit(output, stages=stages_done, check=check_result, snap=snap_report, cache=cache_report,
1615
- caption=caption_report,
1649
+ caption=caption_report, intermediate_preset=_intermediate_used(),
1616
1650
  verification=[{"step": "check", "ok": True, "platform": ck.get("platform")}] if check_result else [])
1617
1651
  return 0
1618
1652