ffmpeg-skill 1.4.14 → 1.5.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 CHANGED
@@ -355,6 +355,7 @@ FFmpeg 8 shortened the flag column of `ffmpeg -filters`. A parser anchored on th
355
355
  | **F1 0.97** | `scenes.py`, 53 hard cuts between single takes, precision 0.95, recall 1.00 at the default threshold |
356
356
  | **exact to the sample** | `cut.py --accurate` on WAV, FLAC (44.1 kHz) and AAC → WAV; WAV stream copy within 2 ms; AAC output +21 ms of encoder priming, reported as `codec_frame` (0.9.1) |
357
357
  | **72 / 72** | agent runs of 24 prompts (12 English edits, 8 Japanese, 4 that must be declined), three repeats, graded by an independent model: routing, honest refusals and user's language 72/72, report format 71/72, visual check whenever the picture changed 24/24 (0.8.4) |
358
+ | **36 / 36** | 1.4.15 re-run (2026-09-12, one pass per prompt, Sonnet agent, regex grader + manual review): 24-prompt set routing 20/20, honest refusals 5/5, visual check 8/8, report format 25/25, user's language 9/9; exec set real execution 6/6, honest failure on bad inputs 5/5 with 0 false successes, audio-as-audio 3/3, one Japanese report with English labels; trigger set 22/22. Both iteration-5 defects gone (no raw ffmpeg fallback, music no longer shortens the video). Details in `evals/results/iteration-6.json` |
358
359
  | **36 / 36** | 1.4.0 re-run (2026-09-11, one pass per prompt, Sonnet agent, regex grader + manual review): 24-prompt set routing 20/20, honest refusals 5/5, visual check 8/8, user's language 8/9; exec set real execution 6/6, honest failure on bad inputs 5/5 with 0 false successes, audio-as-audio 3/3; trigger set 22/22. Details and the six findings in `evals/results/iteration-5.json` |
359
360
  | **6 / 6** | 0.9.1 audio evals (audio join, extraction, track selection, sample-accurate trim, typed dynamics; 2 in Japanese): routing, report format and audio-as-audio handling 6/6 |
360
361
 
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.4.14`) | any release |
24
+ | `skill.version` | the npm / package.json version (`1.5.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`
@@ -46,7 +46,7 @@ For the whole of 1.x:
46
46
  | Tool ids (`ffmpeg-skill/<name>`) and script names | never removed or renamed |
47
47
  | CLI arguments (`argparse` dests, flags, positionals) | never removed, renamed, or made newly required; new optional arguments may be added |
48
48
  | `--json` output keys, and the keys of `contract --json` / `doctor --json` | never removed or given a different type; new keys may be added |
49
- | Exit codes (0 success, 1 failure, 2 unknown/undecidable in `doctor`) | unchanged |
49
+ | Exit codes (0 success, 1 failure incl. ffmpeg failures, 2 unknown/undecidable in `doctor`, 124 timeout, 127 missing tool, 128+signal interrupted) | unchanged |
50
50
  | `contract_version` (`1.0`) | unchanged; a ToolSpec shape change is a major |
51
51
  | MCP `tools/list` names and `inputSchema` property names | derived from the above, so covered by the same promise |
52
52
  | Behaviour of a tool for the same input and arguments | may change only to fix a defect or to track an FFmpeg change, and every such change gets a CHANGELOG line |
@@ -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.4.14", "execution_mode": "local", "kind": "execution",
86
+ "skill": {"id": "ffmpeg-skill", "version": "1.5.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"},
@@ -299,6 +299,15 @@ Success (`exit 0`): one document matching `output_schema`, always with
299
299
  `status: "completed"`, `output`, `dry_run`, `commands`, and `probe` of the output when a
300
300
  file was written. `probe` prints its measurement document directly.
301
301
 
302
+ With `FFMPEG_SKILL_RESULT_V2=1` in the environment, every writing tool's success document
303
+ also carries `result_v2`: a preview of the one shape 2.0 will use for every tool
304
+ (issue #189). `{"schema": 2, "output", "probe", "commands", "metrics", "notes", "dropped":
305
+ {"non_av_streams"}, "details"}` -- `metrics` holds the numbers a caller keys on (loudness's
306
+ measurement dicts flattened, plus any numeric top-level key such as `expected_duration` or
307
+ `offset_seconds`), `notes` the free text, `details` the tool's remaining keys unchanged. The
308
+ 1.x keys are not moved; the environment variable only adds the key, and its absence is the
309
+ default until 2.0.
310
+
302
311
  Success is decided by `verify_output` in `_common.py`, not by the ffmpeg exit code alone:
303
312
  the file must exist, be non-empty and give ffprobe at least one stream. A tool that ran
304
313
  ffmpeg successfully but has no usable artifact fails with `kind: output` (a 0-byte file is
@@ -318,7 +327,8 @@ before, and, when `--json` was given, on stdout:
318
327
  `message` carries the script's own reason (missing input, ffprobe failure, the last
319
328
  stderr lines of ffmpeg, the verification that failed); an optional `error.hint` names the
320
329
  flag change that would make a retry meaningful (never a diagnosis of the media); `commands` lists what was planned
321
- or run so the caller can retry or report without re-deriving the command. `code` is a
330
+ or run so the caller can retry or report without re-deriving the command; `kind: ffmpeg`
331
+ failures add `ffmpeg_returncode` (ffmpeg's own exit code; the process exits 1). `code` is a
322
332
  purely additive, statically-mapped relabelling of `kind` (never a new distinction `kind`
323
333
  doesn't already make) for a caller that wants a stable enum instead of matching `kind`
324
334
  strings. `retryable` is currently always `false`: none of the kinds are distinguishable
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.4.14",
3
+ "version": "1.5.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",
@@ -281,6 +281,8 @@ rather than a continuous curve. `START`/`END` take seconds or `mm:ss`;
281
281
  0.5 = half speed). Picking exactly where a ramp should ease in or out is a
282
282
  judgement call for the calling agent, made concrete here as the segment
283
283
  boundaries it supplies.
284
+ A subtitle/data track in the source is not carried into the retimed/concatenated
285
+ output; the result says so with `dropped_non_av_streams: true`.
284
286
 
285
287
  ### loop.py — repeat a clip
286
288
  ```
@@ -308,6 +310,8 @@ overlap or run past A's end, and B must have enough material from `--from`.
308
310
  `--audio a` (default) keeps A's audio untouched and stream-copied; `b` replaces
309
311
  it inside each window with B's; `mix` plays both. The output's length is
310
312
  verified against A's.
313
+ A subtitle/data track in the source is not carried into the retimed/concatenated
314
+ output; the result says so with `dropped_non_av_streams: true`.
311
315
 
312
316
  ### metadata.py — chapter markers and container tags, streams copied
313
317
  ```
@@ -362,6 +366,8 @@ channel layout (the widest clip's -- a 5.1 clip keeps 5.1 -- or `--channels`;
362
366
  silent track generated for clips without audio), then chains `xfade` +
363
367
  `acrossfade`. Output length = sum of clips − transition × (n−1). Clips must be
364
368
  longer than 2 × the transition. Use `--transition none` for a plain cut.
369
+ A subtitle/data track in the source is not carried into the retimed/concatenated
370
+ output; the result says so with `dropped_non_av_streams: true`.
365
371
 
366
372
  ### render.py — the whole edit in one project.json
367
373
  ```
@@ -596,7 +602,8 @@ standard talking-head chain. `--duck` uses a sidechain compressor keyed by the
596
602
  speech so music dips under dialogue and swells in pauses. `--downmix` uses the
597
603
  ITU centre/LFE weights for 5.1/7.1 → stereo. `--mono` averages a stereo pair,
598
604
  leaves a 1-channel input untouched and downmixes >2 channels through
599
- swresample. Video is always stream-copied.
605
+ swresample. Video is always stream-copied, and so is a subtitle/data track
606
+ when the container can hold it (`dropped_non_av_streams` says when it could not).
600
607
  Run `loudness.py` after this for final levels.
601
608
 
602
609
  ### loudness.py — EBU R128 normalisation
@@ -604,7 +611,8 @@ Run `loudness.py` after this for final levels.
604
611
  loudness.py INPUT [-I -14] [--tp -1] [--lra 11] [--measure-only] [-o OUT]
605
612
  ```
606
613
  Two-pass `loudnorm`: measure, then apply with measured values (linear mode when
607
- the true-peak ceiling allows). Video is stream-copied; audio becomes AAC in
614
+ the true-peak ceiling allows). Video and any subtitle/data track are
615
+ stream-copied (`dropped_non_av_streams` reports a track the container refused); audio becomes AAC in
608
616
  video containers or the codec matching the extension (.wav → PCM, .flac, .mp3).
609
617
  The written file is measured again: a lossy encoder can push peaks past the
610
618
  ceiling loudnorm held (ffmpeg's AAC at 192k turned one transient from -2.4 to
@@ -629,6 +637,7 @@ proxy.py INPUT [--width W | --scale F] [--crf N] [--fps N] [--no-audio] [-o OUT]
629
637
  Not a delivery preset: resizes to `--width` (default 640) or by `--scale`
630
638
  factor, re-encodes at a proxy-grade `--crf` (default 30) with the fastest
631
639
  x264/x265 preset, keeps the source's own dynamic range (HDR stays HDR;
632
- run `color.py --to-sdr` first if SDR is wanted). Only executes the spec
640
+ run `color.py --to-sdr` first if SDR is wanted) and keeps a subtitle/data
641
+ track when the container can hold it (`dropped_non_av_streams`). Only executes the spec
633
642
  given — does not decide which asset to proxy or what for.
634
643
 
@@ -348,11 +348,44 @@ def emit(output: Optional[str], **extra: Any) -> None:
348
348
  if meta:
349
349
  doc["probe"] = meta
350
350
  doc.update(extra)
351
+ if os.environ.get("FFMPEG_SKILL_RESULT_V2", "") not in ("", "0"):
352
+ doc["result_v2"] = _result_v2(output, meta, extra)
351
353
  print_json(doc)
352
354
  elif output:
353
355
  print(output)
354
356
 
355
357
 
358
+ _V2_HANDLED = ("result", "measured", "notes", "dropped_non_av_streams")
359
+
360
+
361
+ def _result_v2(output: Optional[str], meta: Dict[str, Any], extra: Dict[str, Any]) -> Dict[str, Any]:
362
+ """The 2.0 success-document shape, previewed in 1.x as a parallel `result_v2` key when
363
+ FFMPEG_SKILL_RESULT_V2=1 (issue #189 B). Every tool gets the same six slots: `output`,
364
+ `probe`, `commands`, `metrics` (numbers a caller keys on: loudness's `result`/`measured`
365
+ dicts flattened, plus every top-level numeric extra such as `expected_duration` or
366
+ `offset_seconds`), `notes` (free text), `dropped` (what did not make it into the output),
367
+ and `details` (the tool's remaining extras, unchanged). The 1.x keys stay where they are;
368
+ this key is additive and its shape is what 2.0 promotes to the top level."""
369
+ metrics: Dict[str, Any] = {}
370
+ for key in ("measured", "result"):
371
+ if isinstance(extra.get(key), dict):
372
+ metrics.update(extra[key])
373
+ for key, value in extra.items():
374
+ if key not in _V2_HANDLED and isinstance(value, (int, float)) and not isinstance(value, bool):
375
+ metrics[key] = value
376
+ notes = extra.get("notes")
377
+ return {
378
+ "schema": 2,
379
+ "output": output,
380
+ "probe": meta or None,
381
+ "commands": list(STATE.commands),
382
+ "metrics": metrics,
383
+ "notes": list(notes) if isinstance(notes, (list, tuple)) else ([notes] if notes else []),
384
+ "dropped": {"non_av_streams": bool(extra.get("dropped_non_av_streams", False))},
385
+ "details": {k: v for k, v in extra.items() if k not in _V2_HANDLED and k not in metrics},
386
+ }
387
+
388
+
356
389
  def _cmdline(cmd: Sequence[str]) -> str:
357
390
  return " ".join(shell_quote(c) for c in cmd)
358
391
 
@@ -398,8 +431,12 @@ def _cleanup_partial_output(cmd: Sequence[str]) -> None:
398
431
  def _fail(cmd: Sequence[str], returncode: int, stderr: str) -> None:
399
432
  # Partial-output cleanup already ran in the caller (_run_captured/_run_with_progress) for
400
433
  # every failed ffmpeg invocation, not just this check=True path -- see _cleanup_partial_output.
434
+ # The process exit code is always 1 for an ffmpeg failure: ffmpeg's own code (1, 69, 218, 234,
435
+ # a negative signal number...) varies by build and by the failing stage, and 124/127/130/143
436
+ # are reserved for timeout, missing tool and interrupts. The raw code is kept in the JSON
437
+ # document as `ffmpeg_returncode` for a caller that wants it. docs/design-decisions.md.
401
438
  tail = "\n".join(stderr.strip().splitlines()[-15:])
402
- die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=returncode or 1, kind="ffmpeg")
439
+ die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=1, kind="ffmpeg", ffmpeg_returncode=returncode)
403
440
 
404
441
 
405
442
  def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
package/scripts/audio.py CHANGED
@@ -20,7 +20,7 @@ import argparse
20
20
  import sys
21
21
  from typing import List
22
22
 
23
- from _common import STATE, add_common, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, fmt_secs
23
+ from _common import STATE, add_common, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, run_keeping_subtitles, fmt_secs
24
24
 
25
25
  VOICE_CHAIN = "highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2"
26
26
 
@@ -226,8 +226,11 @@ def main() -> int:
226
226
  if not keep_video:
227
227
  # audio-only outputs: a looped music bed is infinite, -shortest ends the run with the main track
228
228
  cmd.append("-shortest")
229
- cmd.append(output)
230
- run(cmd)
229
+ if keep_video:
230
+ dropped_streams = run_keeping_subtitles(cmd, output)
231
+ else:
232
+ dropped_streams = bool(has_video and (meta.get("subtitle_streams") or meta.get("data_streams")))
233
+ run(cmd + [output])
231
234
  r = probe(output, role="output")
232
235
  a = r["audio"]
233
236
  if r.get("video") and audio_out and not STATE.dry_run:
@@ -235,7 +238,8 @@ def main() -> int:
235
238
  info(f"wrote {output} ({fmt_secs(r['duration'])}, audio {a['codec']} {a['channels']}ch {a['sample_rate']}Hz"
236
239
  + (", video stream-copied" if has_video and not audio_out else ", video dropped" if has_video else "") + ")")
237
240
  emit(output, video=bool(has_video and not audio_out), audio_stream=args.audio_stream,
238
- dynamics=[f for f in (args.gate and "agate", args.compress and "acompressor", args.limit and "alimiter") if f])
241
+ dynamics=[f for f in (args.gate and "agate", args.compress and "acompressor", args.limit and "alimiter") if f],
242
+ dropped_non_av_streams=dropped_streams)
239
243
  return 0
240
244
 
241
245
 
package/scripts/broll.py CHANGED
@@ -145,7 +145,8 @@ def main() -> int:
145
145
  if not STATE.dry_run and dur_a and abs((result.get("duration") or 0.0) - dur_a) > max(0.1, 1.5 / fps):
146
146
  die(f"output is {fmt_secs(result.get('duration'))} but the A-roll is {dur_a:.3f}s -- a cutaway must not change the length", kind="output")
147
147
  info(f"wrote {output} ({result.get('duration', 0):.3f}s, {len(cutaways)} cutaway(s), audio={args.audio})")
148
- emit(output, cutaways=[{"insert": c["path"], "at": c["at"], "end": c["at"] + c["length"], "from": c["from"]} for c in cutaways], audio=args.audio)
148
+ emit(output, cutaways=[{"insert": c["path"], "at": c["at"], "end": c["at"] + c["length"], "from": c["from"]} for c in cutaways], audio=args.audio,
149
+ dropped_non_av_streams=bool(meta_a.get("subtitle_streams") or meta_a.get("data_streams")))
149
150
  return 0
150
151
 
151
152
 
package/scripts/join.py CHANGED
@@ -206,7 +206,8 @@ def main() -> int:
206
206
  expected = sum(durs) - d * (n - 1)
207
207
  r = probe(output, role="output")
208
208
  info(f"wrote {output} ({fmt_secs(r['duration'])}, expected ~{expected:.3f}s, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
209
- emit(output, mode="video", clips=n, transition=args.transition, expected_duration=round(expected, 3))
209
+ emit(output, mode="video", clips=n, transition=args.transition, expected_duration=round(expected, 3),
210
+ dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
210
211
  return 0
211
212
 
212
213
 
@@ -18,7 +18,7 @@ import os
18
18
  import re
19
19
  import sys
20
20
 
21
- from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, dry_run_input_pending
21
+ from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, dry_run_input_pending
22
22
 
23
23
 
24
24
 
@@ -90,7 +90,10 @@ def main() -> int:
90
90
  bitrate_pinned = args.audio_bitrate is not None
91
91
  bitrate = args.audio_bitrate or "192k"
92
92
 
93
+ dropped_streams = False
94
+
93
95
  def encode(tp: float, bitrate: str) -> None:
96
+ nonlocal dropped_streams
94
97
  af = (
95
98
  f"loudnorm=I={args.lufs}:TP={tp}:LRA={args.lra}"
96
99
  f":measured_I={stats['input_i']}:measured_TP={stats['input_tp']}:measured_LRA={stats['input_lra']}"
@@ -101,6 +104,8 @@ def main() -> int:
101
104
  cmd += ["-vn"] + audio_codec_for(output, bitrate)
102
105
  else:
103
106
  cmd += ["-map", "0:v:0", "-map", "0:a:0", "-c:v", "copy", "-c:a", "aac", "-b:a", bitrate]
107
+ dropped_streams = run_keeping_subtitles(cmd, output)
108
+ return
104
109
  cmd.append(output)
105
110
  run(cmd)
106
111
 
@@ -149,7 +154,7 @@ def main() -> int:
149
154
  f"the encoder overshoots more than the loudnorm ceiling can absorb at this bitrate",
150
155
  kind="verification", output=output, result=result,
151
156
  hint="raise --audio-bitrate (e.g. 256k) or deliver a lossless format (wav/flac) and let the platform encode")
152
- emit(output, result=result)
157
+ emit(output, result=result, dropped_non_av_streams=dropped_streams)
153
158
  return 0
154
159
 
155
160
 
package/scripts/proxy.py CHANGED
@@ -25,7 +25,7 @@ Examples:
25
25
  import argparse
26
26
  import sys
27
27
 
28
- from _common import add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, fmt_secs
28
+ from _common import add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, fmt_secs
29
29
 
30
30
 
31
31
  def even(n: float) -> int:
@@ -67,14 +67,13 @@ def main() -> int:
67
67
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", f"scale={out_w}:-2"]
68
68
  cmd += video_args(meta, args.crf, "veryfast")
69
69
  cmd += cfr_args(meta, args.fps)
70
- cmd += ["-c:a", "aac", "-b:a", "96k"] if has_audio else ["-an"]
71
- cmd.append(output)
72
- run(cmd)
70
+ cmd += ["-map", "0:v:0"] + (["-map", "0:a:0", "-c:a", "aac", "-b:a", "96k"] if has_audio else ["-an"])
71
+ dropped_streams = run_keeping_subtitles(cmd, output)
73
72
 
74
73
  result = probe(output)
75
74
  v = result["video"]
76
75
  info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']}, crf {args.crf})")
77
- emit(output)
76
+ emit(output, dropped_non_av_streams=dropped_streams)
78
77
  return 0
79
78
 
80
79
 
@@ -115,7 +115,7 @@ def main() -> int:
115
115
  result = probe(output, role="output")
116
116
  v = result["video"]
117
117
  info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {len(segments)} speed segments)")
118
- emit(output)
118
+ emit(output, dropped_non_av_streams=bool(meta.get("subtitle_streams") or meta.get("data_streams")))
119
119
  return 0
120
120
 
121
121