ffmpeg-skill 3.8.3 → 3.8.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 (`2.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`3.8.3`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.8.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`
@@ -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.3", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.8.4", "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.3",
3
+ "version": "3.8.4",
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": [
package/scripts/broll.py CHANGED
@@ -21,7 +21,7 @@ import argparse
21
21
  import sys
22
22
  from typing import Any, Dict, List
23
23
 
24
- from _common.decision import av_trim, conform_output_t, conform_to_length, frame_rate, grid_at_or_after, stream_starts
24
+ from _common.decision import av_trim, conform_output_t, conform_to_length, frame_rate, grid_at_or_after, silence_filter, stream_starts
25
25
  from _common import STATE, add_common, aac_args, apply_common, cfr_args, as_limited, default_output, die, draws_chroma, drawn_chain, emit, ffmpeg_base, info, time_arg, label_chain, matrix_chain, overlay_format, range_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
26
26
  from _common.emit import note, record_fallback
27
27
  from _common.probe import display_size, square_pixels, square_pixels_note
@@ -162,8 +162,14 @@ def main() -> int:
162
162
  # sample moved a track that starts 0.3 s late 0.279 s early (#409)
163
163
  v0b = stream_starts(c["meta"])[0]
164
164
  first = max(v0b, grid_at_or_after(c["from"], v0b, frame_rate(c["meta"])))
165
+ # the delay is --at to the sample of the mix's rate: `int(at * 1000)` cut 1.001 to
166
+ # 1000 ms and wrote 1.2346 s as 1234 ms, the cutaway's sound 48 and 29 samples
167
+ # early. A whole millisecond keeps the text it had, rounded (silence_filter)
168
+ delay = silence_filter(MIX_RATE, lead=c["at"])
169
+ if "S:" not in delay:
170
+ delay = f"adelay={round(c['at'] * 1000)}|{round(c['at'] * 1000)}"
165
171
  parts.append(f"[{i + 1}:a:0]{av_trim(first, first + c['length'])[1]},"
166
- f"aformat=sample_rates=48000:channel_layouts=stereo,adelay={int(c['at'] * 1000)}|{int(c['at'] * 1000)}[ab{i}]")
172
+ f"aformat=sample_rates=48000:channel_layouts=stereo,{delay}[ab{i}]")
167
173
  layers.append(f"[ab{i}]")
168
174
  # the mix ends with the A-roll, to the sample (#493): every layer is 48 kHz, so the count
169
175
  # is in that rate. `atrim=duration={:.3f}` and `-t {:.3f}` cut it to the millisecond. A
package/scripts/fit.py CHANGED
@@ -39,7 +39,7 @@ import sys
39
39
  from fractions import Fraction
40
40
  from typing import List
41
41
 
42
- from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, stream_starts
42
+ from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, length_output_t, stream_starts
43
43
  from _common.timeline import exact_rate, output_timecode_args
44
44
  from _common import video_args, STATE, add_common, apply_common, emit, aac_args, aspect_ratio, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg, fmt_secs, frame_size, display_size, square_pixels, square_pixels_note, note
45
45
  from _common.emit import input_length
@@ -162,6 +162,12 @@ def main() -> int:
162
162
  # (#491). Not under --dry-run, where render.py plans a clip's speed stage on the source in
163
163
  # place of a cut that does not exist yet and records the stand-in itself (A97)
164
164
  src_dur = (meta.get("duration") or 0.0) if STATE.dry_run else input_length(meta)
165
+ # The output's -t is all that ends the kept track where the source's sound runs on past
166
+ # the target (a trim; a speed change too small to retime), so it is written to the sample
167
+ # of that track's rate, not to the millisecond: `-t 1.234` wrote 59244 samples (48 kHz)
168
+ # as 59232 and `-t 1.235` wrote 59261 as 59280 (length_output_t). No sound, or a dry
169
+ # run's pending input, has no rate and keeps the millisecond.
170
+ rate = (audio_streams[args.audio_stream].get("sample_rate") if has_audio and audio_streams else None)
165
171
  if args.method == "speed":
166
172
  if src_dur <= 0 and STATE.dry_run:
167
173
  src_dur = target # planning against an intermediate that does not exist yet
@@ -178,7 +184,9 @@ def main() -> int:
178
184
  vf.append("tblend=all_mode=average")
179
185
  if has_audio:
180
186
  af.append(atempo_chain(factor))
181
- post += ["-t", f"{target:.3f}"]
187
+ # atempo's sound ends before the target (13 to 62 ms before it on FFmpeg 6.1.1), so the
188
+ # millisecond cuts nothing off a retimed track and stays
189
+ post += ["-t", f"{target:.3f}" if af else length_output_t(target, rate, runs_on=True)]
182
190
  STATE.duration_hint = target
183
191
  else:
184
192
  if target < src_dur:
@@ -189,7 +197,7 @@ def main() -> int:
189
197
  # picture would not be the one the start timecode below names
190
198
  m = min(SEEK_MARGIN, round(start, 3))
191
199
  pre_input += ["-ss", f"{start - m:.3f}"]
192
- post += (["-ss", f"{m:.3f}"] if m > 0 else []) + ["-t", f"{target:.3f}"]
200
+ post += (["-ss", f"{m:.3f}"] if m > 0 else []) + ["-t", length_output_t(target, rate, runs_on=True)]
193
201
  # the output starts on the first frame at or after the seek (a seek before a
194
202
  # picture that starts after the file's origin: on its first frame): its start
195
203
  # timecode is that frame's, not the source's first frame's
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, length_output_t, stream_starts
38
+ from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, length_output_t, silence_filter, 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
@@ -377,7 +377,12 @@ def main() -> int:
377
377
  # its length instead of -shortest, which cut the reference's tail whenever the second
378
378
  # file started earlier (head_trim > 0) or was simply shorter
379
379
  ref_dur = probe(args.reference).get("duration")
380
- af_parts = on_clock + drift_af + ([f"adelay={delay_ms}:all=1"] if delay_ms > 0 else []) + ["apad"]
380
+ # the delay is the measured offset to the sample of the second's rate: at the default
381
+ # --fine-ms 1 without --fix-drift the offset is a whole millisecond and the text is the
382
+ # one it was; a finer step (0.25 ms: 0.23425 s) or a drift-adjusted offset (kept to
383
+ # 0.1 ms) was rounded to `adelay=234` (silence_filter)
384
+ lead = [silence_filter(sec_sr, lead=offset)] if offset > 0 and round(offset * sec_sr) else []
385
+ af_parts = on_clock + drift_af + lead + ["apad"]
381
386
  cmd += ["-af", ",".join(af_parts)]
382
387
  # to the sample of the rate the second's sound is written at, not to the millisecond:
383
388
  # the endless apad is ended by this -t alone (length_output_t)