ffmpeg-skill 3.8.22 → 3.8.23

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
@@ -26,7 +26,7 @@ The contract is derived from the code that runs, not maintained beside it:
26
26
  | Field | Meaning | Changes when |
27
27
  |---|---|---|
28
28
  | `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
29
- | `skill.version` | the npm / package.json version (`3.8.22`) | any release |
29
+ | `skill.version` | the npm / package.json version (`3.8.23`) | any release |
30
30
 
31
31
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
32
32
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -154,7 +154,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
154
154
  "deprecated": [],
155
155
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
156
156
  {"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"}],
157
- "skill": {"id": "ffmpeg-skill", "version": "3.8.22", "execution_mode": "local", "kind": "execution",
157
+ "skill": {"id": "ffmpeg-skill", "version": "3.8.23", "execution_mode": "local", "kind": "execution",
158
158
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
159
159
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
160
160
  "requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.8.22",
3
+ "version": "3.8.23",
4
4
  "mcpName": "io.github.kajisho5/ffmpeg-skill",
5
5
  "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.",
6
6
  "keywords": [
@@ -175,7 +175,9 @@ the next source frame and may keep up to one frame of timestamp. A `.mp4`/`.mov`
175
175
  edit list hides the priming. A source whose audio starts after its picture keeps
176
176
  that offset. An audio-only `--accurate` cut
177
177
  still uses `make_zero`; that priming is what `precision: codec_frame` counts
178
- in the length. Since 3.0.0 `keyframe_snapped` equals `start_snapped` (2.x: true
178
+ in the length, and it pads a sound that starts after `--start` with silence. A stream copy to an audio output
179
+ cannot: it starts at the sound's first packet, and an `audio_lead_dropped` warning (`{seconds,
180
+ segments}`) says how much silence it left out (from one frame of the picture on). Since 3.0.0 `keyframe_snapped` equals `start_snapped` (2.x: true
179
181
  for every stream copy, `precision` `packet`); `start_snapped` is measured: true when a copied picture starts more
180
182
  than a frame from `--start`, or when its start could not be measured (an unknown start is
181
183
  never claimed exact); `null` under `--dry-run`.
@@ -344,7 +346,10 @@ the rectangle for you. Use this when the rectangle is already known (a
344
346
  face-detection box, a saved crop, a hand-picked region). The rectangle must
345
347
  lie entirely inside the source frame (after accounting for display rotation);
346
348
  `--width`/`--height` must be even (4:2:0 chroma) and are refused, never
347
- rounded, if they aren't; an odd `--x`/`--y` is taken as given.
349
+ rounded, if they aren't; an odd `--x`/`--y` is taken as given. The source's
350
+ subtitle track is kept (converted to mov_text in MP4/MOV, SubRip in Matroska);
351
+ `dropped_non_av_streams: true` says it was dropped: the container refused it, or
352
+ refused a data stream beside it (a timecode track), and the retry drops both.
348
353
 
349
354
  ### cropdetect.py — measure black bars, report the crop rectangle
350
355
  ```
@@ -515,7 +520,8 @@ default, keeps the original framing and lets the edges show: vidstab's own
515
520
  "optimal zoom" is off). Needs an ffmpeg built with
516
521
  `--enable-libvidstab`; `doctor` reports this tool `usable: no` when that's
517
522
  missing (a plain Homebrew ffmpeg build, for example) rather than failing at
518
- run time.
523
+ run time. The frames keep their times, so the source's subtitle track is kept
524
+ as `crop.py` keeps it (`dropped_non_av_streams` says when it could not be).
519
525
 
520
526
  ### sequence.py — numbered/globbed image sequence to video
521
527
  ```
@@ -2813,6 +2813,30 @@ def default_selection_maps(meta: Optional[Dict[str, Any]], dst: str) -> List[str
2813
2813
  return maps
2814
2814
 
2815
2815
 
2816
+ def subtitle_codec_args(src: Optional[str], output: str) -> List[str]:
2817
+ """`-c:s` for a command that maps every subtitle stream of `src` (`-map 0:s?`) into `output`:
2818
+ copy, and for each text track in a format the container does not take as it is the container's
2819
+ own (subtitle_codec_for: mov_text in MP4/MOV, SubRip in Matroska for mov_text, WebVTT in WebM),
2820
+ as export.py and a crop of several tracks convert it. A plain copy of SubRip into MP4 failed,
2821
+ and the retry dropped the track (#520). Copy alone when the source cannot be read here."""
2822
+ args = ["-c:s", "copy"]
2823
+ if not src or not os.path.isfile(src):
2824
+ return args
2825
+ try:
2826
+ out = subprocess.run([found_exe("ffprobe"), "-v", "error", "-select_streams", "s", "-show_entries",
2827
+ "stream=codec_name", "-of", "csv=p=0", *still_input_args(src), src],
2828
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8",
2829
+ errors="replace", timeout=_probe_limit()).stdout
2830
+ except (OSError, subprocess.TimeoutExpired):
2831
+ return args
2832
+ ext = os.path.splitext(output)[1]
2833
+ for n, codec in enumerate(ln.strip() for ln in (out or "").splitlines() if ln.strip()):
2834
+ conv = subtitle_codec_for(codec, ext)
2835
+ if conv != "copy":
2836
+ args += [f"-c:s:{n}", conv]
2837
+ return args
2838
+
2839
+
2816
2840
  def run_keeping_subtitles(cmd: List[str], output: str, relabel_timecode: bool = False) -> bool:
2817
2841
  """Run an ffmpeg command that already maps its video/audio, trying first to also
2818
2842
  stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
@@ -2821,6 +2845,8 @@ def run_keeping_subtitles(cmd: List[str], output: str, relabel_timecode: bool =
2821
2845
  extra maps rather than let a tool that never touched subtitles start hard-failing because of
2822
2846
  them. `cmd` is the full argv *without* the output path. Returns True only when the
2823
2847
  retry-without-subtitles path was actually needed (i.e. subtitle/data streams were dropped).
2848
+ A text track the container takes in another format is converted (subtitle_codec_args), so the
2849
+ retry is left for a track no conversion fits (a bitmap track in MPEG-4) or a refused data stream.
2824
2850
 
2825
2851
  The retry is recorded with the first attempt's error (`diagnostics.fallbacks`,
2826
2852
  `non_av_streams_dropped`): the caller's `dropped_non_av_streams` says that streams went, the
@@ -2833,7 +2859,7 @@ def run_keeping_subtitles(cmd: List[str], output: str, relabel_timecode: bool =
2833
2859
  skip_tmcd = ["-map", "-0:d:m:timecode"] if relabel_timecode else []
2834
2860
  src = next((str(cmd[i + 1]) for i, a in enumerate(cmd[:-1]) if a == "-i"), None)
2835
2861
  first = run(cmd + ["-map", "0:s?", "-map", "0:d?"] + skip_tmcd + carried_stream_maps(src, output)
2836
- + ["-c:s", "copy", "-c:d", "copy", output], check=False)
2862
+ + subtitle_codec_args(src, output) + ["-c:d", "copy", output], check=False)
2837
2863
  if first.returncode == 0:
2838
2864
  return False
2839
2865
  if classify_ffmpeg_failure(first.stderr or "") or not _has_non_av_streams(cmd):
@@ -495,6 +495,7 @@ DIAGNOSTIC_CODES = {
495
495
  "hdr10_metadata_dropped": ("warnings", "a PQ output measured without the source's HDR10 mastering-display and/or content-light metadata (an encoder with no option for it, e.g. prores_ks or libaom on FFmpeg before 7); {dropped, codec}"),
496
496
  "source_items_dropped": ("warnings", "the written file lacks a subtitle track, chapters, a cover picture, surround channels, attached files or a second video stream the first input carried (source_carry lists everything kept and dropped, tags included); not repeated for a subtitle track the tool already reports through dropped_non_av_streams; {dropped}"),
497
497
  "audio_tracks_dropped": ("warnings", "the source has more audio tracks than the output kept (the tools write one; cut.py also reports dropped_audio_streams). Not compared when two or more inputs have sound (grid --audio-from, a mix): which source the output carries is the tool's choice; {source, kept, dropped}"),
498
+ "audio_lead_dropped": ("warnings", "cut.py: an audio-only stream copy of a source whose sound starts after --start (by at least one frame of its picture) begins at the sound's first packet, so that much silence is left out and the extraction is early against the picture; kept as a copy within --tolerance, as a start that snaps is (--accurate pads it); a dry run reports it from the source, also where a run may re-encode instead (length_unjudged); {seconds: the total, segments: [{index, seconds}]}"),
498
499
  "grid_no_drawtext": ("fallbacks", "grid.py --label auto on a build without the drawtext filter: the cells were not labelled, as with --label none"),
499
500
  "scenes_sheet_no_drawtext": ("fallbacks", "scenes.py --sheet on a build without the drawtext filter: the sheet was drawn without timecode stamps, as with --no-timecode"),
500
501
  "look_no_drawtext": ("fallbacks", "look.py on a build without the drawtext filter: the sheet was drawn without timecode stamps, as with --no-timecode"),
@@ -616,9 +617,9 @@ FONT_USED_SCHEMA = {
616
617
 
617
618
 
618
619
  # the tools whose --json carries `dropped_non_av_streams` (a test reads the sources)
619
- NON_AV_STREAM_TOOLS = frozenset({"audio", "broll", "color", "cut", "deinterlace", "denoise", "export", "fit", "freeze",
620
+ NON_AV_STREAM_TOOLS = frozenset({"audio", "broll", "color", "crop", "cut", "deinterlace", "denoise", "export", "fit", "freeze",
620
621
  "graphics", "join", "loudness", "overlay", "pad", "proxy", "redact", "speedramp",
621
- "sphere", "straighten"})
622
+ "sphere", "stabilize", "straighten"})
622
623
 
623
624
 
624
625
  # Top-level keys a tool emits that its own branch in output_schema() does not list (#466 A194: the
package/scripts/crop.py CHANGED
@@ -22,9 +22,8 @@ Examples:
22
22
  import argparse
23
23
  import sys
24
24
 
25
- from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs
26
- from _common.probe import display_size, square_pixels, square_pixels_note
27
- from _common.runner import default_selection_maps
25
+ from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
26
+ from _common.probe import display_size, probed_maps, square_pixels, square_pixels_note
28
27
  from _common.emit import note
29
28
 
30
29
 
@@ -70,20 +69,21 @@ def main() -> int:
70
69
  vf.insert(0, square_pixels(meta["video"]))
71
70
  note(square_pixels_note(meta["video"]))
72
71
  # the picture the rectangle was checked against, not the largest one default selection takes
73
- cmd = ffmpeg_base() + ["-i", args.input] + default_selection_maps(meta, output) + ["-vf", with_cfr(",".join(vf), meta, args.fps)]
72
+ cmd = ffmpeg_base() + ["-i", args.input] + probed_maps(meta) + ["-vf", with_cfr(",".join(vf), meta, args.fps)]
74
73
  cmd += video_args(meta, args.crf, args.preset)
75
74
  cmd += cfr_args(meta, args.fps)
76
75
  if has_audio:
77
76
  cmd += aac_args()
78
77
  else:
79
78
  cmd += ["-an"]
80
- cmd.append(output)
81
- run(cmd)
79
+ # the crop keeps the timeline, so the subtitle/data tracks are copied as fit.py and color.py copy
80
+ # them, and dropped (said) only where the container refuses them (#520)
81
+ dropped_streams = run_keeping_subtitles(cmd, output)
82
82
 
83
83
  result = probe(output, role="output")
84
84
  v = result["video"]
85
85
  info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']})")
86
- emit(output)
86
+ emit(output, dropped_non_av_streams=dropped_streams)
87
87
  return 0
88
88
 
89
89
 
package/scripts/cut.py CHANGED
@@ -449,6 +449,19 @@ def first_frame_from(src: str, meta: dict, start: float, reencode: bool, edit_li
449
449
  return key[0] - v0
450
450
 
451
451
 
452
+ def copied_lead(meta: dict, start: float, end: float) -> Optional[float]:
453
+ """The seconds of a sound's late start that an audio-only stream copy of start..end leaves out
454
+ (#515): the copy begins at the first packet, so a sound that starts after `start` on the file's
455
+ clock (stream_starts) comes out that much early. None when it is less than one frame of the
456
+ source's picture (1 ms without one), which no picture could show, or when the start is unknown."""
457
+ audio = meta.get("audio") or {}
458
+ if not isinstance(audio.get("start_time"), (int, float)):
459
+ return None
460
+ lead = min(stream_starts(meta)[1], end) - start
461
+ fps = (meta.get("video") or {}).get("fps")
462
+ return round(lead, 6) if lead >= (1.0 / float(fps) if fps else 0.001) else None
463
+
464
+
452
465
  def av_skew(out_meta: dict, edit_list_remedy: bool = False, expected: float = 0.0):
453
466
  """(seconds audio starts after video, warning note or None); None when either is missing.
454
467
  A lossless cut can start its streams apart -- Core Media HEVC once gave 3.7 s of sound with no
@@ -1044,11 +1057,25 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
1044
1057
  info(f"stream copy landed on a keyframe {abs(got - dur):.2f}s away from the requested cut "
1045
1058
  f"(> {tolerance:.2f}s tolerance); re-encoding this segment for accuracy{alt}")
1046
1059
  return _with_notes(cut_one(src, start, end, dst, True, crf, preset, tolerance, meta, reasons + ["tolerance"], edit_list_ok), notes)
1047
- if reencode or audio_only:
1060
+ if audio_only and not reencode:
1048
1061
  # an audio-only copy is judged by its written length too (above): a dry run that wrote
1049
1062
  # nothing says a real run may still re-encode it (#510 review)
1050
- pending = {"length_unjudged": True} if (STATE.dry_run and not reencode and copy_t is None and tolerance >= 0) else {}
1051
- return _outcome(meta, dst, reencode, reasons, **pending)
1063
+ pending = {"length_unjudged": True} if (STATE.dry_run and copy_t is None and tolerance >= 0) else {}
1064
+ lead = copied_lead(meta, start, end)
1065
+ if lead:
1066
+ # a copy cannot add silence: the sound starts at its first packet, `lead` earlier than it
1067
+ # sat after `start` (#515). Kept within --tolerance, as a start that snapped is, and said.
1068
+ # A dry run says it for a lead of --tolerance or more too: the run judges the written
1069
+ # length, which is short by up to a packet less than the lead (0.351 s for a lead of
1070
+ # 0.355), so it may keep that copy; it re-encodes and pads it otherwise (#542 review)
1071
+ unsure = STATE.dry_run and 0 <= tolerance <= lead
1072
+ notes.append(f"the sound starts {lead:.3f}s after {start:.3f}s in the source; a stream copy cannot "
1073
+ f"add silence, so the extraction starts with the sound, {lead:.3f}s early against the "
1074
+ "picture (--accurate pads it from the start"
1075
+ + ("; a run re-encodes and pads it instead when the copy comes out --tolerance or more short)" if unsure else ")"))
1076
+ return _outcome(meta, dst, False, reasons, notes=notes, audio_lead_dropped_seconds=lead or None, **pending)
1077
+ if reencode:
1078
+ return _outcome(meta, dst, reencode, reasons)
1052
1079
  if STATE.dry_run:
1053
1080
  # the planned copy: whether it keeps an edit list is known; where it lands is not, so a
1054
1081
  # real run that judges the written copy by its length may still re-encode it (#510)
@@ -1962,6 +1989,15 @@ def main() -> int:
1962
1989
  f"(cut writes one audio track, on a stream copy as on a re-encode)")
1963
1990
  warn(msg, "audio_tracks_dropped", source=len(src_audio), kept=kept, dropped=dropped_audio)
1964
1991
  notes.append(msg)
1992
+ # a copied audio extraction that starts with the sound, not where it sat after --start (#515); an
1993
+ # outcome a re-cut replaced carries none, so this is what the written file holds
1994
+ leads = [(i + 1, o["audio_lead_dropped_seconds"]) for i, o in enumerate(outcomes) if o.get("audio_lead_dropped_seconds")]
1995
+ if leads:
1996
+ where = (f" (segment{'s' if len(leads) > 1 else ''} {', '.join(str(i) for i, _ in leads)})"
1997
+ if len(outcomes) > 1 else "")
1998
+ warn(f"the stream copy left out {sum(s for _, s in leads):.3f}s of silence before the sound{where}, so the "
1999
+ "sound is early against the source's picture; --accurate keeps it", "audio_lead_dropped",
2000
+ seconds=round(sum(s for _, s in leads), 6), segments=[{"index": i, "seconds": s} for i, s in leads])
1965
2001
  emit(output, expected_duration=round(expected, 6), duration_error_ms=error_ms, precision=precision, reencoded=reencoded,
1966
2002
  expect_streams=expect_streams, stream_reference=[meta], stream_windows=[(s, e) for s, e in requested],
1967
2003
  dropped_non_av_streams=bool(DROPPED_STREAMS), dropped_audio_streams=max(dropped_audio, 0),
@@ -187,6 +187,12 @@ def enable_expr(start: Optional[float], end: Optional[float], meta: Optional[dic
187
187
  return window_expr(start, end, meta)
188
188
 
189
189
 
190
+ def fade_out_length(start: float, end: float, fade: float) -> float:
191
+ """The fade-out of a layer shown from `start` to `end`: --fade, cut to the window when the window
192
+ is shorter, so the fade still ends at `end` (audio.py and cut.py cap theirs the same way)."""
193
+ return min(fade, end - start)
194
+
195
+
190
196
  def alpha_expr(opacity: float, start: Optional[float], end: Optional[float], fade: float) -> str:
191
197
  """Time-varying alpha with linear fade in/out inside [start, end]."""
192
198
  if fade <= 0 or (start is None and end is None):
@@ -195,8 +201,8 @@ def alpha_expr(opacity: float, start: Optional[float], end: Optional[float], fad
195
201
  parts = [f"{opacity:g}"]
196
202
  fin = f"min(1,(t-{s:.3f})/{fade:g})"
197
203
  parts.append(fin)
198
- if end is not None:
199
- parts.append(f"min(1,({end:.3f}-t)/{fade:g})")
204
+ if end is not None and end > s:
205
+ parts.append(f"min(1,({end:.3f}-t)/{fade_out_length(s, end, fade):g})")
200
206
  return "max(0," + "*".join(parts) + ")"
201
207
 
202
208
 
@@ -527,8 +533,12 @@ def main() -> int:
527
533
  # the common ask, and a matching fade-out at the very end surprised every eval run)
528
534
  s = start if start is not None else 0.0
529
535
  chain.append(f"fade=t=in:st={s:.3f}:d={args.fade:g}:alpha=1")
530
- if end is not None and end > args.fade:
531
- chain.append(f"fade=t=out:st={end - args.fade:.3f}:d={args.fade:g}:alpha=1")
536
+ if end is not None and end > s:
537
+ # a fade-out longer than the layer's window is cut to it, so the layer still leaves
538
+ # at --end fully faded (#517: with --end at or before --fade it was left out, and the
539
+ # layer vanished at full opacity); the text layer's alpha_expr() caps it the same way
540
+ out = fade_out_length(s, end, args.fade)
541
+ chain.append(f"fade=t=out:st={end - out:.3f}:d={out:g}:alpha=1")
532
542
  x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
533
543
  grid = on_grid(x, y, (vw, vh), (lw, lh))
534
544
  if grid:
@@ -27,9 +27,9 @@ import sys
27
27
  import tempfile
28
28
  from pathlib import Path
29
29
 
30
- from _common.probe import PROBED_VIDEO
31
- from _common.runner import ffmpeg_failure_extra, default_selection_maps, require_filter
32
- from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs, record_fallback, EVEN_SCALE
30
+ from _common.probe import PROBED_VIDEO, probed_maps
31
+ from _common.runner import ffmpeg_failure_extra, require_filter
32
+ from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run_keeping_subtitles, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs, record_fallback, EVEN_SCALE
33
33
 
34
34
 
35
35
  def main() -> int:
@@ -104,19 +104,20 @@ def main() -> int:
104
104
  # Equivalent to relative=0:smoothing=0 -- overrides --smoothing, since averaging a
105
105
  # camera path makes no sense once every frame is locked to one fixed reference.
106
106
  transform_vf += ":tripod=1"
107
- cmd = ffmpeg_base() + ["-i", args.input] + default_selection_maps(meta, output) + ["-vf", with_cfr(transform_vf, meta)]
107
+ cmd = ffmpeg_base() + ["-i", args.input] + probed_maps(meta) + ["-vf", with_cfr(transform_vf, meta)]
108
108
  cmd += video_args(meta, args.crf, args.preset)
109
109
  cmd += cfr_args(meta)
110
110
  if has_audio:
111
111
  cmd += aac_args()
112
112
  else:
113
113
  cmd += ["-an"]
114
- cmd.append(output)
115
- run(cmd)
114
+ # the frames keep their times, so the subtitle/data tracks are copied as fit.py and color.py
115
+ # copy them, and dropped (said) only where the container refuses them (#520)
116
+ dropped_streams = run_keeping_subtitles(cmd, output)
116
117
 
117
118
  result = probe(output, role="output")
118
119
  info(f"wrote {output} ({fmt_secs(result['duration'])}, {result['video']['width']}x{result['video']['height']})")
119
- emit(output)
120
+ emit(output, dropped_non_av_streams=dropped_streams)
120
121
  return 0
121
122
 
122
123