ffmpeg-skill 3.8.2 → 3.8.3

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 (`2.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`3.8.2`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.8.3`) | 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`
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
149
149
  "deprecated": [],
150
150
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
151
151
  {"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"}],
152
- "skill": {"id": "ffmpeg-skill", "version": "3.8.2", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.8.3", "execution_mode": "local", "kind": "execution",
153
153
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
154
154
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
155
155
  "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.2",
3
+ "version": "3.8.3",
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": [
@@ -74,7 +74,7 @@ from _common.probe import (
74
74
  display_size, pixel_aspect, square_pixels, square_pixels_note
75
75
  )
76
76
  from _common.decision import (
77
- aac_args, add_pad_fill_args, conform_filter, conform_to_length, conform_output_t, aspect_ratio, audio_codec_for, AUDIO_CODECS, brand_caption_style, BRAND_DEFAULTS,
77
+ aac_args, add_pad_fill_args, conform_filter, conform_to_length, conform_output_t, length_output_t, silence_filter, aspect_ratio, audio_codec_for, AUDIO_CODECS, brand_caption_style, BRAND_DEFAULTS,
78
78
  description_block, _evidence_rank, fmt_chapter_time, propose_chapters,
79
79
  frame_flow, label_shot_flow, MOTION_GRID, MOTION_SEARCH, MOTION_STATIC_PX, MOTION_PAN_SPREAD,
80
80
  filler_spans, FILLER_WORDS, FILLER_AMBIGUOUS, FILLER_DISCOURSE_MARKERS, FILLER_MAX_WORD,
@@ -456,6 +456,62 @@ def conform_output_t(length: float, rate: int) -> str:
456
456
  return f"{length + 0.5 / rate:.6f}"
457
457
 
458
458
 
459
+ def length_output_t(length: float, rate: Optional[int], runs_on: bool = False) -> str:
460
+ """The output `-t` of a tool that leaves the sound's end to it (overlay.py, waveform.py,
461
+ loop.py, sync.py --replace-audio): the millisecond text they always wrote unless it cuts
462
+ samples off the track at `rate` (`-t 1.234` wrote 59244 samples at 48 kHz as 59232), and then
463
+ the length to the microsecond, which FFmpeg rounds to the nearest sample. `runs_on` is for
464
+ sound that goes on past the length (an endless apad, a loop's next repeat): there the
465
+ millisecond text is also left when it rounds up (`-t 1.235` wrote 59261 as 59280). No half
466
+ sample of slack as in conform_output_t(): this `-t` ends the picture too, and with the slack a
467
+ layer's frame that starts exactly at the end came through on a 1/60000 or 1/90000 time base
468
+ (FFmpeg 6.1.1). A rate of 0 or None (no sound, a dry run's pending input) keeps the
469
+ millisecond text."""
470
+ text = f"{length:.3f}"
471
+ if rate:
472
+ def names(micro: int) -> int:
473
+ # the samples FFmpeg makes of a -t of `micro` microseconds: a rescale that rounds a
474
+ # half up, where Python's round() takes it to the even count (0.005 s at 44.1 kHz is
475
+ # 220.5 samples: 221 written, round() says 220)
476
+ return (micro * rate + 500_000) // 1_000_000
477
+ written, asked = names(round(float(text) * 1_000_000)), round(length * rate)
478
+ if written < asked or (runs_on and written != asked):
479
+ # the microsecond text is a rounding too (2.00001042 s is 96001 samples at 48 kHz and
480
+ # "2.000010" names 96000): the microsecond nearest the length, moved by whole
481
+ # microseconds until it names the count asked. A microsecond is a twentieth of a
482
+ # sample at 48 kHz, so this is a step or two, not conform_output_t()'s half sample
483
+ micro = round(length * 1_000_000)
484
+ while names(micro) < asked:
485
+ micro += 1
486
+ while names(micro) > asked:
487
+ micro -= 1
488
+ return f"{micro // 1_000_000}.{micro % 1_000_000:06d}"
489
+ return text
490
+
491
+
492
+ def silence_filter(rate: Optional[int], lead: Optional[float] = None, tail: Optional[float] = None) -> str:
493
+ """Silence of `lead` seconds ahead of a track and/or `tail` seconds after it (pad.py,
494
+ freeze.py). An amount in whole milliseconds is written as it always was (`adelay=MS:all=1`,
495
+ `apad=pad_dur=S.mmm`), the millisecond rounded, not cut: int(1.001 * 1000) is 1000. One that
496
+ is not is counted in samples at `rate`, pinned by an aformat as in conform_filter():
497
+ `adelay=234` and `apad=pad_dur=0.234` for 0.23425 s left the sound 12 samples short at 48 kHz.
498
+ A rate of 0 or None (a dry run's pending input) keeps the millisecond form."""
499
+ amounts = [v for v in (lead, tail) if v is not None]
500
+ if rate and any(round(v * rate) != round(round(v, 3) * rate) for v in amounts):
501
+ parts = [f"aformat=sample_rates={rate}"]
502
+ if lead:
503
+ parts.append(f"adelay={round(lead * rate)}S:all=1")
504
+ if tail:
505
+ parts.append(f"apad=pad_len={round(tail * rate)}")
506
+ return ",".join(parts)
507
+ parts = []
508
+ if lead is not None:
509
+ parts.append(f"adelay={round(lead * 1000)}:all=1")
510
+ if tail is not None:
511
+ parts.append(f"apad=pad_dur={tail:.3f}")
512
+ return ",".join(parts)
513
+
514
+
459
515
  def av_clip_length(meta: Optional[Dict[str, Any]], fps: float) -> float:
460
516
  """The one length a clip holds once both streams start at its first frame (av_trim from the
461
517
  video's start): the video's, or where the sound ends when it runs more than a frame past the
package/scripts/freeze.py CHANGED
@@ -19,7 +19,7 @@ Examples:
19
19
  import argparse
20
20
  import sys
21
21
 
22
- from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, stream_starts
22
+ from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, silence_filter, stream_starts
23
23
  from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, MissingFpsError, time_arg, fmt_secs, run
24
24
  from _common.emit import input_length
25
25
 
@@ -73,7 +73,7 @@ def main() -> int:
73
73
  vf = f"tpad=stop_mode=clone:stop_duration={args.hold:.3f}"
74
74
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
75
75
  if has_audio:
76
- cmd += ["-map", "0:a:0?", "-af", f"apad=pad_dur={args.hold:.3f}"]
76
+ cmd += ["-map", "0:a:0?", "-af", silence_filter(meta["audio"].get("sample_rate"), tail=args.hold)]
77
77
  elif at == 0 or cut_at <= v0 + 1e-6:
78
78
  # A freeze at the very start has no preceding "head" segment to hold on to (the
79
79
  # split/trim/concat approach below needs a non-empty head, which trim=end=0 can't give
@@ -82,7 +82,7 @@ def main() -> int:
82
82
  vf = f"tpad=start_mode=clone:start_duration={args.hold:.3f}"
83
83
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
84
84
  if has_audio:
85
- cmd += ["-map", "0:a:0?", "-af", f"adelay={int(args.hold * 1000)}:all=1"]
85
+ cmd += ["-map", "0:a:0?", "-af", silence_filter(meta["audio"].get("sample_rate"), lead=args.hold)]
86
86
  else:
87
87
  # freeze N frames at `at` by holding on that one source frame for --hold seconds, then
88
88
  # resuming the rest of the clip: split the timeline at `at` and hold the remainder's
package/scripts/loop.py CHANGED
@@ -34,7 +34,7 @@ import tempfile
34
34
 
35
35
  from _common import STATE, concat_list_line, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, refuse_output_is_input, require_filter, require_tool, run, video_args, X264_PRESETS, time_arg, fmt_secs
36
36
  from _common.emit import warn
37
- from _common.decision import av_trim
37
+ from _common.decision import av_trim, length_output_t
38
38
  from _common.probe import decode_gray_frames, decode_pcm_mono
39
39
  from _common.emit import input_length
40
40
 
@@ -320,7 +320,8 @@ def main() -> int:
320
320
  else:
321
321
  cmd += ["-map", "0:v:0", "-vf", vtrim]
322
322
  if target is not None:
323
- cmd += ["-t", f"{target:.3f}"]
323
+ # the sound ends at --duration to the sample, not to the millisecond (length_output_t)
324
+ cmd += ["-t", length_output_t(target, (meta.get("audio") or {}).get("sample_rate") if has_audio else None, runs_on=True)]
324
325
  cmd += video_args(meta, args.crf, args.preset)
325
326
  # a crossfade's repeat is already constant at `rate` (its graph's fps=): the loop keeps that rate,
326
327
  # not a variable-rate source's average, which re-timed every repeat by a frame or two
@@ -38,6 +38,7 @@ from _common import still_input_args # noqa: E402
38
38
  from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, layer_base_chain, matrix_chain, overlay_format, range_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
39
39
  from _common.emit import note, warn
40
40
  from _common.color import is_rgb
41
+ from _common.decision import length_output_t
41
42
  from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
42
43
  has_alpha_plane, report as alpha_report, source_alpha, source_format, tag_args, video_line)
43
44
  from _common.probe import display_size, square_pixels, square_pixels_note
@@ -362,6 +363,10 @@ def main() -> int:
362
363
  die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
363
364
  if args.audio_stream and not audio_streams:
364
365
  die("--audio-stream needs an input with audio streams")
366
+ # the -t that ends a layer at the main input's length: to the sample of the kept track when the
367
+ # millisecond would cut it (length_output_t); no sound, or a pending input, keeps the millisecond
368
+ kept_track = audio_streams[args.audio_stream] if audio_streams else (meta.get("audio") or {})
369
+ main_t = length_output_t(meta["duration"], kept_track.get("sample_rate")) if meta.get("duration") else None
365
370
  # ffmpeg applies display rotation before the filter graph, so the frame this overlay is
366
371
  # composited on is the displayed one. A 320x180 phone file tagged 90 degrees is 180 wide here.
367
372
  # A non-square-pixel source is made square at its display aspect first (#411), so a logo or
@@ -454,7 +459,7 @@ def main() -> int:
454
459
  # streams -- a preserved subtitle/data stream that ends earlier (run_keeping_subtitles)
455
460
  # must not be allowed to cut the whole output short via -shortest's "stop at whichever
456
461
  # mapped stream finishes first" semantics.
457
- cmd += ["-t", f"{meta['duration']:.3f}"]
462
+ cmd += ["-t", main_t]
458
463
  else:
459
464
  # No known duration to bound by -t (e.g. probe found no video duration): -shortest is
460
465
  # the only thing stopping the looped still from running forever. FFmpeg 7+'s
@@ -567,7 +572,7 @@ def main() -> int:
567
572
  if meta.get("duration"):
568
573
  # See the --image branch above: -t (exact, bounds only the main input) instead of
569
574
  # -shortest (would also stop at a preserved subtitle/data stream that ends earlier).
570
- cmd += ["-t", f"{meta['duration']:.3f}"]
575
+ cmd += ["-t", main_t]
571
576
  else:
572
577
  cmd += ["-shortest"]
573
578
  else:
package/scripts/pad.py CHANGED
@@ -17,6 +17,7 @@ import argparse
17
17
  import sys
18
18
 
19
19
  from _common.emit import record_fallback
20
+ from _common.decision import silence_filter
20
21
  from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, drawn_chain, emit, EVEN_SCALE, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs, run
21
22
 
22
23
 
@@ -64,7 +65,8 @@ def main() -> int:
64
65
  # front (one -filter_complex), so the record is made here, as broll and stabilize do
65
66
  record_fallback(f"{args.input} is {v.get('width')}x{v.get('height')}: scaled to even dimensions first",
66
67
  "odd_dimensions_scaled_even", output=output, scale=EVEN_SCALE)
67
- af = f"adelay={int(args.start * 1000)}:all=1,apad=pad_dur={args.end:.3f}"
68
+ # the silence is counted in samples when --start/--end is not a whole millisecond
69
+ af = silence_filter(meta["audio"].get("sample_rate"), args.start, args.end)
68
70
  cmd += ["-filter_complex", f"[0:v:0]{even}{vf}[v];[0:a:0]{af}[a]", "-map", "[v]", "-map", "[a]"]
69
71
  else:
70
72
  cmd += ["-vf", vf, "-map", "0:v:0"]
package/scripts/sync.py CHANGED
@@ -35,7 +35,7 @@ from typing import Dict, List
35
35
  from _common.runner import fail_if_classified
36
36
  from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args, decode_pcm_mono, rms_envelope
37
37
  from _common.emit import record_skip, warn
38
- from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, stream_starts
38
+ from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, length_output_t, stream_starts
39
39
  from _common.timeline import output_timecode_args
40
40
  from _common.runner import default_selection_maps
41
41
  from _common.emit import input_length
@@ -379,7 +379,10 @@ def main() -> int:
379
379
  ref_dur = probe(args.reference).get("duration")
380
380
  af_parts = on_clock + drift_af + ([f"adelay={delay_ms}:all=1"] if delay_ms > 0 else []) + ["apad"]
381
381
  cmd += ["-af", ",".join(af_parts)]
382
- cmd += aac_args() + (["-t", f"{ref_dur:.3f}"] if ref_dur else ["-shortest"]) + [output]
382
+ # to the sample of the rate the second's sound is written at, not to the millisecond:
383
+ # the endless apad is ended by this -t alone (length_output_t)
384
+ ref_t = length_output_t(ref_dur, (second_meta.get("audio") or {}).get("sample_rate"), runs_on=True) if ref_dur else None
385
+ cmd += aac_args() + (["-t", ref_t] if ref_dur else ["-shortest"]) + [output]
383
386
  proc = run(cmd, check=False)
384
387
  fail_if_classified(proc) # a cause the re-encode would hit too is reported, not retried (#415)
385
388
  if proc.returncode != 0:
@@ -41,6 +41,7 @@ from _common import (add_common, apply_common, aac_args, default_output, die, dr
41
41
  info, load_brand, pad_filters, probe, rgb_chain_to_bt709, run, STATE, validate_color, video_args, X264_PRESETS,
42
42
  fmt_secs, child_args, run_tool, measured_level_dbfs, sibling_temp)
43
43
  from _common.emit import absorb_stage_hw, note, warn
44
+ from _common.decision import length_output_t
44
45
 
45
46
  WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
46
47
 
@@ -232,7 +233,9 @@ def main() -> int:
232
233
  # audio ends (a 12 s source came out 14.08 s on 5.1.1, #146), so the output is also capped
233
234
  # at the source's own duration when probe knows it.
234
235
  if meta.get("duration"):
235
- cmd += ["-t", f"{float(meta['duration']):.3f}"]
236
+ # to the sample of the drawn track when the millisecond would cut it (length_output_t)
237
+ drawn_track = audio_streams[args.audio_stream] if audio_streams else meta["audio"]
238
+ cmd += ["-t", length_output_t(float(meta["duration"]), drawn_track.get("sample_rate"))]
236
239
  cmd += ["-shortest", render]
237
240
  titled_tmp = sibling_temp(output, "titled")
238
241
  intermediates = [p for p in (render, titled_tmp) if p != output]