ffmpeg-skill 2.22.3 → 2.22.4

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.22.3`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.22.4`) | 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.22.3", "execution_mode": "local", "kind": "execution",
134
+ "skill": {"id": "ffmpeg-skill", "version": "2.22.4", "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"},
@@ -730,6 +730,14 @@ pointer to cuts that may sit in a breath):
730
730
  | `cut_points` | `silence.py` (every run that cuts) | per cut, `{source_time, output_time, edge, rms_dbfs}`: the RMS level 50 ms either side of the cut on the source, the loudest channel (`null` for digital silence), `edge` `out` where the source is left and `in` where it is picked up; `filler: true` on an edge that sits on a removed filler word. The whole key is `null` when the source could not be decoded for the measurement (`cut_levels_unmeasured` warning): unmeasured, not silent |
731
731
  | `cut_warnings` | `silence.py` | the `cut_points` (not filler cuts) whose level is within `silence.CUT_SOUND_MARGIN_DB` (10 dB) of `--threshold`: a breath or a word's weak tail may be cut there. A `cut_near_sound` warning names them. `verified` is unchanged: this is where to listen, not a measured failure |
732
732
 
733
+ Keys added after 2.22.1, additive (an agent muxing soft subtitles before a platform export found
734
+ the track gone and nothing in the result saying so):
735
+
736
+ | key | tool | what it holds |
737
+ |---|---|---|
738
+ | `non_av_streams` | `export.py` | the source's subtitle and data streams: `{kept: [{type, index, codec, language, output_codec}], dropped: [{type, index, codec, language, reason}]}` (`index` is the n-th stream of its type; `language` on subtitles). Read back from the written file; under `--dry-run`, as planned. A subtitle track is copied when the container holds it, else converted to the container's text track (`mov_text` in .mp4/.mov, SubRip in .mkv); a platform trim cuts it with the picture. `reason`: `write_failed` (the attempt that mapped it failed and the encode ran again without it; the error is in its `non_av_streams_dropped` fallback, whose `dropped` says data alone first, then subtitles too), `gif`, `not_in_output` |
739
+ | `dropped_non_av_streams` | `export.py` | `true` when `non_av_streams.dropped` is not empty (a `notes` line too), as on the tools that use `run_keeping_subtitles()` |
740
+
733
741
  Per-tool keys added after 2.10.11 ("nothing happens silently"), all additive:
734
742
 
735
743
  | key | tool | what it holds |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.22.3",
3
+ "version": "2.22.4",
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",
@@ -1875,6 +1875,17 @@ a failing `{step: loudness, ok: false, reason: not_measured}` and a
1875
1875
  `loudness_not_measured` warning, so the export is `verified: false` rather than
1876
1876
  unchecked.
1877
1877
 
1878
+ Subtitle and data streams (a soft-subtitle track muxed upstream, a camera's timecode) are kept
1879
+ when the container can hold them: copied as they are, or a text track converted to the
1880
+ container's own (mov_text in .mp4/.mov, SubRip in .mkv). A platform trim cuts the track at the
1881
+ same point as the picture. A data stream the container refuses is dropped on its own retry, so
1882
+ the subtitles are not lost with it (`non_av_streams_dropped` fallback). `non_av_streams: {kept,
1883
+ dropped}` lists each source stream with its codec, its `output_codec` or the `reason` it was
1884
+ dropped (`write_failed`, `gif`, `not_in_output`), read back from the written file; `dropped_non_av_streams`
1885
+ is true when any was dropped, with a `notes` line. A GIF holds none. When the source has such a
1886
+ stream, the picture and the sound are mapped explicitly: the first video stream that is not a
1887
+ cover picture and the first audio stream, the ones `probe` describes.
1888
+
1878
1889
  Since 1.14 each social destination is its own preset rather than an alias: `tiktok`
1879
1890
  (1080x1920, max 600 s), `shorts` (1080x1920, max 180 s), `reels` (1080x1920, max 90 s),
1880
1891
  `linkedin` (1080x1080), `facebook` (1920x1080), each with its platform's loudness spec from the
@@ -445,7 +445,7 @@ DIAGNOSTIC_CODES = {
445
445
  # the runner
446
446
  "odd_dimensions_scaled_even": ("fallbacks", "an encode that failed on odd dimensions was run again scaled to even ones (the frame loses a row or column); {output, scale}"),
447
447
  "measurement_input_pending": ("skipped", "--dry-run: a measurement skipped because its input is an earlier stage's output that does not exist yet; {path}"),
448
- "non_av_streams_dropped": ("fallbacks", "the subtitle/data streams could not be copied, so the encode ran again without them (dropped_non_av_streams: true); {output, returncode, error: the first attempt's last stderr lines}"),
448
+ "non_av_streams_dropped": ("fallbacks", "the subtitle/data streams could not be copied, so the encode ran again without them (dropped_non_av_streams: true); {output, returncode, error: the first attempt's last stderr lines}; export.py adds dropped: the stream types this retry leaves out (data alone first, then subtitle too)"),
449
449
  "ffmpeg_list_unreadable": ("warnings", "`ffmpeg -encoders` / `-bsfs` could not be read; what needs the list says so instead of 'the build lacks it'; {list, tool, error}"),
450
450
  "timeout_env_invalid": ("warnings", "FFMPEG_SKILL_TIMEOUT was not a number (the default was used) or was negative (read as 0, no limit); {value, timeout}"),
451
451
  # encoder decisions
@@ -610,6 +610,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
610
610
  elif name == "export":
611
611
  extra = {"loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec; normalized true when --normalize ran loudness.py on the file; silent true (lufs \"-inf\", ok false, no normalize) when the written audio is silent; measured false (lufs, tp and ok null, reason not_measured) when the measurement could not be read"},
612
612
  "trimmed": {"type": "object", "description": "present when the source was longer than the preset's platform maximum and the export was cut to it (no --allow-long): {from, to} in seconds; notes says so too"},
613
+ "non_av_streams": {"type": "object", "description": "the source's subtitle and data streams: {kept: [{type: subtitle|data, index (n-th of its type), codec, language (subtitle), output_codec}], dropped: [{type, index, codec, language, reason: write_failed|gif|not_in_output}]}, read back from the written file (under --dry-run, as planned). A subtitle track is copied when the container holds it as it is, else converted to the container's text track (mov_text in .mp4/.mov, SubRip in .mkv); a trimmed export cuts it at the same point. Both lists are empty for a source with neither"},
614
+ "dropped_non_av_streams": {"type": "boolean", "description": "true when non_av_streams.dropped is not empty; a notes line says so too"},
613
615
  "notes": {"type": "array", "items": {"type": "string"}}}
614
616
  elif name == "loudness":
615
617
  extra = {"measured": {"type": "object", "description": "the loudnorm measurement of the input (input_i, input_tp, input_lra, input_thresh, target_offset); with --measure-only it is the whole result"},
package/scripts/export.py CHANGED
@@ -36,9 +36,9 @@ import sys
36
36
  from pathlib import Path
37
37
  from typing import Dict, List
38
38
 
39
- from _common import STATE, add_common, apply_common, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
39
+ from _common import STATE, add_common, apply_common, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, require_tool, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
40
40
  from _common.decision import hw_preset_video, restate_last_swap
41
- from _common.emit import note, record_skip, warn
41
+ from _common.emit import note, record_fallback, record_skip, warn
42
42
  from check import SPECS as PLATFORMS, measure_loudness
43
43
  from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, resolve as resolve_platform
44
44
  # Frame and duration limit come from the one platform table (scripts/_platforms.py) rather than
@@ -137,6 +137,108 @@ def _loudness_unmeasured(extra: Dict[str, object], notes: List[str], platform: s
137
137
  warn(msg, "loudness_not_measured", platform=platform, normalized=normalized)
138
138
 
139
139
 
140
+ # Text subtitle codecs ffmpeg can convert to the container's own text track: mov_text is the one
141
+ # text track MP4/MOV hold, Matroska holds SubRip/ASS/WebVTT as they are but not mov_text.
142
+ _TEXT_SUBTITLES = frozenset({"mov_text", "subrip", "srt", "ass", "ssa", "webvtt", "text"})
143
+ _MP4_FAMILY = frozenset({"mp4", "m4v", "mov"})
144
+
145
+
146
+ def _subtitle_codec(codec: str, out_ext: str) -> str:
147
+ """The -c:s for one source subtitle stream: copy when the output container holds it as it is,
148
+ else the container's own text format (a bitmap track is tried as a copy; a container that
149
+ refuses it is the fallback below)."""
150
+ if out_ext in _MP4_FAMILY and codec in _TEXT_SUBTITLES and codec != "mov_text":
151
+ return "mov_text"
152
+ if out_ext == "mkv" and codec == "mov_text":
153
+ return "srt"
154
+ return "copy"
155
+
156
+
157
+ def _data_streams(path: str) -> List[Dict[str, object]]:
158
+ """The data streams of a file ({index, codec}), without its attachments (probe()'s
159
+ data_streams counts both, and -map 0:d maps only the data streams)."""
160
+ proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "d", "-show_entries",
161
+ "stream=codec_name,codec_tag_string", "-of", "json", path], quiet=True)
162
+ try:
163
+ streams = json.loads(proc.stdout or "{}").get("streams") or []
164
+ except ValueError:
165
+ # an unread list would map nothing and report nothing dropped
166
+ die(f"ffprobe's data-stream list for {path} did not parse", kind="ffmpeg")
167
+ return [{"index": n, "codec": s.get("codec_name") or s.get("codec_tag_string")} for n, s in enumerate(streams)]
168
+
169
+
170
+ def _run_keeping_non_av(cmd: List[str], output: str, sub_codecs: List[str], data: int) -> List[str]:
171
+ """run_keeping_subtitles() for export: the subtitle streams go in the codec the container takes
172
+ (_subtitle_codec), and a refused data stream does not cost the subtitles. The rungs are
173
+ subtitles + data, subtitles alone, neither; each retry is a `non_av_streams_dropped` fallback
174
+ with the attempt's error. `cmd` already maps the picture and the sound and has no output.
175
+ Returns the stream types dropped ([] when everything mapped was written)."""
176
+ sub_args: List[str] = ["-map", "0:s"] if sub_codecs else []
177
+ for n, codec in enumerate(sub_codecs):
178
+ sub_args += [f"-c:s:{n}", codec]
179
+ data_args = ["-map", "0:d", "-c:d", "copy"] if data else []
180
+ rungs = [(sub_args + data_args, [])]
181
+ if sub_codecs and data:
182
+ rungs.append((sub_args, ["data"]))
183
+ rungs.append(([], (["subtitle"] if sub_codecs else []) + (["data"] if data else [])))
184
+ for n, (extra, dropped) in enumerate(rungs):
185
+ if n == len(rungs) - 1:
186
+ run(cmd + extra + [output])
187
+ return dropped
188
+ attempt = run(cmd + extra + [output], check=False)
189
+ if attempt.returncode == 0:
190
+ return dropped
191
+ error = " | ".join([ln.strip() for ln in (attempt.stderr or "").splitlines() if ln.strip()][-3:])[-500:]
192
+ going = rungs[n + 1][1]
193
+ record_fallback(f"{os.path.basename(output)} could not hold the source's {' and '.join(t for t in ('subtitle', 'data') if t in going)} "
194
+ f"stream(s) ({error or 'ffmpeg exit ' + str(attempt.returncode)}); encoding again without them",
195
+ "non_av_streams_dropped", output=output, returncode=attempt.returncode, error=error, dropped=going)
196
+ return [] # not reached: the last rung either returns or exits
197
+
198
+
199
+ def _written_data_codec(n: int, sources: List[Dict], written: List[Dict]) -> bool:
200
+ """True when the written file holds a data track for source data stream `n`: tracks are
201
+ matched by codec, never by position, so with k source streams of one codec and j tracks of it
202
+ written, the first min(j, k) of those sources count as kept (a muxer's own timecode track is
203
+ not credited to an unrelated stream, and two copied streams of one codec are both kept)."""
204
+ codec = sources[n].get("codec")
205
+ if not codec:
206
+ return False
207
+ rank = sum(1 for s in sources[:n] if s.get("codec") == codec)
208
+ return rank < sum(1 for w in written if w.get("codec") == codec)
209
+
210
+
211
+ def _non_av_report(subs: List[Dict], sub_codecs: List[str], data: List[Dict], dropped: List[str],
212
+ output: str, reason: str) -> Dict[str, List[Dict]]:
213
+ """`non_av_streams`: each source subtitle/data stream as kept (with the codec it was written in)
214
+ or dropped (with why), read back from the written file; under --dry-run, as planned."""
215
+ kept: List[Dict] = []
216
+ gone: List[Dict] = []
217
+ written_subs: List[Dict] = []
218
+ written_data: List[Dict] = []
219
+ if not STATE.dry_run and (subs or data):
220
+ written_subs = probe(output, role="output").get("subtitle_stream_details") or []
221
+ written_data = _data_streams(output)
222
+ for kind, streams, written in (("subtitle", subs, written_subs), ("data", data, written_data)):
223
+ for n, s in enumerate(streams):
224
+ row = {"type": kind, "index": n, "codec": s.get("codec")}
225
+ if kind == "subtitle":
226
+ row["language"] = s.get("language")
227
+ if STATE.dry_run and kind not in dropped:
228
+ planned = sub_codecs[n] if kind == "subtitle" else "copy"
229
+ kept.append(dict(row, output_codec=s.get("codec") if planned == "copy" else planned))
230
+ elif kind == "subtitle" and n < len(written):
231
+ kept.append(dict(row, output_codec=written[n].get("codec")))
232
+ elif kind == "data" and _written_data_codec(n, streams, written):
233
+ # read back, not assumed, and matched by codec: an MP4/MOV muxer writes a
234
+ # timecode (tmcd) track of its own from the source's timecode tag even when the
235
+ # copied one was refused
236
+ kept.append(dict(row, output_codec=s.get("codec")))
237
+ else:
238
+ gone.append(dict(row, reason=reason if kind in dropped else "not_in_output"))
239
+ return {"kept": kept, "dropped": gone}
240
+
241
+
140
242
  def main() -> int:
141
243
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
142
244
  ap.add_argument("input", nargs="?")
@@ -202,6 +304,15 @@ def main() -> int:
202
304
  else:
203
305
  vf.append(f"scale={p['w']}:-2")
204
306
  cmd = ffmpeg_base() + ["-i", args.input]
307
+ # Subtitle and data streams (a soft-subtitle track muxed upstream, a camera's timecode) are
308
+ # kept where the container can hold them: ffmpeg's default stream selection takes one video
309
+ # and one audio stream only, and through 2.22.1 every preset dropped the rest without a word.
310
+ # Mapping them means mapping the picture and the sound too, the same streams probe() describes.
311
+ subs = meta.get("subtitle_stream_details") or []
312
+ data = _data_streams(args.input) if meta.get("data_streams") else []
313
+ sub_codecs = [_subtitle_codec(s.get("codec") or "", out_ext) for s in subs]
314
+ if args.preset != "gif" and (subs or data):
315
+ cmd += ["-map", "0:V:0"] + (["-map", "0:a:0"] if has_audio else [])
205
316
 
206
317
  if args.preset == "gif":
207
318
  chain = ",".join(["fps=12"] + vf) if vf else "fps=12"
@@ -209,7 +320,10 @@ def main() -> int:
209
320
  cmd += ["-filter_complex", fc, "-loop", "0", output]
210
321
  run(cmd)
211
322
  info(f"wrote {output}")
212
- emit(output, **({"notes": notes} if notes else {}))
323
+ non_av = _non_av_report(subs, [], data, ["subtitle", "data"], output, "gif")
324
+ if non_av["dropped"]:
325
+ notes.append(f"a GIF holds no subtitle or data stream: the source's {len(non_av['dropped'])} were not written")
326
+ emit(output, non_av_streams=non_av, dropped_non_av_streams=bool(non_av["dropped"]), **({"notes": notes} if notes else {}))
213
327
  return 0
214
328
 
215
329
  if vf:
@@ -259,13 +373,15 @@ def main() -> int:
259
373
  trimmed = None
260
374
  if p["max"] and not args.allow_long and (meta.get("duration") or 0) > p["max"]:
261
375
  # the preset's promise (the platform's maximum), but the caller loses the tail: say how much
262
- # in the document (`trimmed`, `notes`), not only on stderr
376
+ # in the document (`trimmed`, `notes`), not only on stderr. -t is an output option, so a
377
+ # kept subtitle track is cut at the same point (a cue after it is not written)
263
378
  trimmed = {"from": round(float(meta["duration"]), 3), "to": round(float(p["max"]), 3)}
264
379
  note(f"trimmed from {trimmed['from']:.3f}s to the {args.preset} maximum of {p['max']:.0f}s "
265
380
  "(use --allow-long to keep full length)")
266
381
  cmd += ["-t", f"{p['max']:.3f}"]
267
- cmd.append(output)
268
- run(cmd)
382
+ dropped = _run_keeping_non_av(cmd, output, sub_codecs, len(data)) if (subs or data) else []
383
+ if not (subs or data):
384
+ run(cmd + [output])
269
385
  result = probe(output, role="output")
270
386
  v = result["video"]
271
387
  info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']})")
@@ -347,6 +463,13 @@ def main() -> int:
347
463
  step = {"step": "duration", "property": "duration", "ok": False, "expected": {"max": p["max"]},
348
464
  "reason": "not_measured: the written file reports no duration"}
349
465
  extra.setdefault("verification", []).append(step)
466
+ # read back after --normalize, which rewrote the file
467
+ non_av = _non_av_report(subs, sub_codecs, data, dropped, output, "write_failed")
468
+ if non_av["dropped"]:
469
+ notes.append(f"{len(non_av['dropped'])} subtitle/data stream(s) of the source are not in {os.path.basename(output)} "
470
+ f"({', '.join(sorted({d['type'] + ': ' + d['reason'] for d in non_av['dropped']}))}); non_av_streams lists them")
471
+ extra["non_av_streams"] = non_av
472
+ extra["dropped_non_av_streams"] = bool(non_av["dropped"])
350
473
  if notes:
351
474
  extra["notes"] = notes
352
475
  # the delivery keeps the source's picture and, except as a GIF, its sound