ffmpeg-skill 3.8.3 → 3.8.5
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 +2 -2
- package/package.json +1 -1
- package/scripts/broll.py +8 -2
- package/scripts/caption.py +2 -2
- package/scripts/fit.py +11 -3
- package/scripts/sync.py +7 -2
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.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.8.5`) | 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.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.8.5", "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
|
+
"version": "3.8.5",
|
|
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,
|
|
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/caption.py
CHANGED
|
@@ -1727,8 +1727,8 @@ def main() -> int:
|
|
|
1727
1727
|
cues if (args.text or args.transcribe) else (
|
|
1728
1728
|
planned_cues if planned_cues is not None else parse_srt(srt_path)))
|
|
1729
1729
|
if args.karaoke and not getattr(args, "_word_timings", None):
|
|
1730
|
-
#
|
|
1731
|
-
args._word_timings = whisper_word_timings(args.srt if
|
|
1730
|
+
# Use the cue source's sidecar, even when layout rewrites the caller's SRT.
|
|
1731
|
+
args._word_timings = whisper_word_timings(args.srt if args.srt and not (args.text or args.transcribe) else srt_path)
|
|
1732
1732
|
ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
|
|
1733
1733
|
claim([(ass_path, "ASS")])
|
|
1734
1734
|
w, h = display_size(meta["video"])
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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)
|