ffmpeg-skill 1.4.14 → 1.4.15
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 +5 -4
- package/package.json +1 -1
- package/references/scripts.md +12 -3
- package/scripts/_common.py +5 -1
- package/scripts/audio.py +8 -4
- package/scripts/broll.py +2 -1
- package/scripts/join.py +2 -1
- package/scripts/loudness.py +7 -2
- package/scripts/proxy.py +4 -5
- package/scripts/speedramp.py +1 -1
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 (`1.4.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`1.4.15`) | 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`
|
|
@@ -46,7 +46,7 @@ For the whole of 1.x:
|
|
|
46
46
|
| Tool ids (`ffmpeg-skill/<name>`) and script names | never removed or renamed |
|
|
47
47
|
| CLI arguments (`argparse` dests, flags, positionals) | never removed, renamed, or made newly required; new optional arguments may be added |
|
|
48
48
|
| `--json` output keys, and the keys of `contract --json` / `doctor --json` | never removed or given a different type; new keys may be added |
|
|
49
|
-
| Exit codes (0 success, 1 failure, 2 unknown/undecidable in `doctor
|
|
49
|
+
| Exit codes (0 success, 1 failure incl. ffmpeg failures, 2 unknown/undecidable in `doctor`, 124 timeout, 127 missing tool, 128+signal interrupted) | unchanged |
|
|
50
50
|
| `contract_version` (`1.0`) | unchanged; a ToolSpec shape change is a major |
|
|
51
51
|
| MCP `tools/list` names and `inputSchema` property names | derived from the above, so covered by the same promise |
|
|
52
52
|
| Behaviour of a tool for the same input and arguments | may change only to fix a defect or to track an FFmpeg change, and every such change gets a CHANGELOG line |
|
|
@@ -83,7 +83,7 @@ on, the line says so.
|
|
|
83
83
|
```json
|
|
84
84
|
{
|
|
85
85
|
"contract_version": "1.0",
|
|
86
|
-
"skill": {"id": "ffmpeg-skill", "version": "1.4.
|
|
86
|
+
"skill": {"id": "ffmpeg-skill", "version": "1.4.15", "execution_mode": "local", "kind": "execution",
|
|
87
87
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
88
88
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
89
89
|
"requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
|
|
@@ -318,7 +318,8 @@ before, and, when `--json` was given, on stdout:
|
|
|
318
318
|
`message` carries the script's own reason (missing input, ffprobe failure, the last
|
|
319
319
|
stderr lines of ffmpeg, the verification that failed); an optional `error.hint` names the
|
|
320
320
|
flag change that would make a retry meaningful (never a diagnosis of the media); `commands` lists what was planned
|
|
321
|
-
or run so the caller can retry or report without re-deriving the command
|
|
321
|
+
or run so the caller can retry or report without re-deriving the command; `kind: ffmpeg`
|
|
322
|
+
failures add `ffmpeg_returncode` (ffmpeg's own exit code; the process exits 1). `code` is a
|
|
322
323
|
purely additive, statically-mapped relabelling of `kind` (never a new distinction `kind`
|
|
323
324
|
doesn't already make) for a caller that wants a stable enum instead of matching `kind`
|
|
324
325
|
strings. `retryable` is currently always `false`: none of the kinds are distinguishable
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.15",
|
|
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",
|
package/references/scripts.md
CHANGED
|
@@ -281,6 +281,8 @@ rather than a continuous curve. `START`/`END` take seconds or `mm:ss`;
|
|
|
281
281
|
0.5 = half speed). Picking exactly where a ramp should ease in or out is a
|
|
282
282
|
judgement call for the calling agent, made concrete here as the segment
|
|
283
283
|
boundaries it supplies.
|
|
284
|
+
A subtitle/data track in the source is not carried into the retimed/concatenated
|
|
285
|
+
output; the result says so with `dropped_non_av_streams: true`.
|
|
284
286
|
|
|
285
287
|
### loop.py — repeat a clip
|
|
286
288
|
```
|
|
@@ -308,6 +310,8 @@ overlap or run past A's end, and B must have enough material from `--from`.
|
|
|
308
310
|
`--audio a` (default) keeps A's audio untouched and stream-copied; `b` replaces
|
|
309
311
|
it inside each window with B's; `mix` plays both. The output's length is
|
|
310
312
|
verified against A's.
|
|
313
|
+
A subtitle/data track in the source is not carried into the retimed/concatenated
|
|
314
|
+
output; the result says so with `dropped_non_av_streams: true`.
|
|
311
315
|
|
|
312
316
|
### metadata.py — chapter markers and container tags, streams copied
|
|
313
317
|
```
|
|
@@ -362,6 +366,8 @@ channel layout (the widest clip's -- a 5.1 clip keeps 5.1 -- or `--channels`;
|
|
|
362
366
|
silent track generated for clips without audio), then chains `xfade` +
|
|
363
367
|
`acrossfade`. Output length = sum of clips − transition × (n−1). Clips must be
|
|
364
368
|
longer than 2 × the transition. Use `--transition none` for a plain cut.
|
|
369
|
+
A subtitle/data track in the source is not carried into the retimed/concatenated
|
|
370
|
+
output; the result says so with `dropped_non_av_streams: true`.
|
|
365
371
|
|
|
366
372
|
### render.py — the whole edit in one project.json
|
|
367
373
|
```
|
|
@@ -596,7 +602,8 @@ standard talking-head chain. `--duck` uses a sidechain compressor keyed by the
|
|
|
596
602
|
speech so music dips under dialogue and swells in pauses. `--downmix` uses the
|
|
597
603
|
ITU centre/LFE weights for 5.1/7.1 → stereo. `--mono` averages a stereo pair,
|
|
598
604
|
leaves a 1-channel input untouched and downmixes >2 channels through
|
|
599
|
-
swresample. Video is always stream-copied
|
|
605
|
+
swresample. Video is always stream-copied, and so is a subtitle/data track
|
|
606
|
+
when the container can hold it (`dropped_non_av_streams` says when it could not).
|
|
600
607
|
Run `loudness.py` after this for final levels.
|
|
601
608
|
|
|
602
609
|
### loudness.py — EBU R128 normalisation
|
|
@@ -604,7 +611,8 @@ Run `loudness.py` after this for final levels.
|
|
|
604
611
|
loudness.py INPUT [-I -14] [--tp -1] [--lra 11] [--measure-only] [-o OUT]
|
|
605
612
|
```
|
|
606
613
|
Two-pass `loudnorm`: measure, then apply with measured values (linear mode when
|
|
607
|
-
the true-peak ceiling allows). Video
|
|
614
|
+
the true-peak ceiling allows). Video and any subtitle/data track are
|
|
615
|
+
stream-copied (`dropped_non_av_streams` reports a track the container refused); audio becomes AAC in
|
|
608
616
|
video containers or the codec matching the extension (.wav → PCM, .flac, .mp3).
|
|
609
617
|
The written file is measured again: a lossy encoder can push peaks past the
|
|
610
618
|
ceiling loudnorm held (ffmpeg's AAC at 192k turned one transient from -2.4 to
|
|
@@ -629,6 +637,7 @@ proxy.py INPUT [--width W | --scale F] [--crf N] [--fps N] [--no-audio] [-o OUT]
|
|
|
629
637
|
Not a delivery preset: resizes to `--width` (default 640) or by `--scale`
|
|
630
638
|
factor, re-encodes at a proxy-grade `--crf` (default 30) with the fastest
|
|
631
639
|
x264/x265 preset, keeps the source's own dynamic range (HDR stays HDR;
|
|
632
|
-
run `color.py --to-sdr` first if SDR is wanted)
|
|
640
|
+
run `color.py --to-sdr` first if SDR is wanted) and keeps a subtitle/data
|
|
641
|
+
track when the container can hold it (`dropped_non_av_streams`). Only executes the spec
|
|
633
642
|
given — does not decide which asset to proxy or what for.
|
|
634
643
|
|
package/scripts/_common.py
CHANGED
|
@@ -398,8 +398,12 @@ def _cleanup_partial_output(cmd: Sequence[str]) -> None:
|
|
|
398
398
|
def _fail(cmd: Sequence[str], returncode: int, stderr: str) -> None:
|
|
399
399
|
# Partial-output cleanup already ran in the caller (_run_captured/_run_with_progress) for
|
|
400
400
|
# every failed ffmpeg invocation, not just this check=True path -- see _cleanup_partial_output.
|
|
401
|
+
# The process exit code is always 1 for an ffmpeg failure: ffmpeg's own code (1, 69, 218, 234,
|
|
402
|
+
# a negative signal number...) varies by build and by the failing stage, and 124/127/130/143
|
|
403
|
+
# are reserved for timeout, missing tool and interrupts. The raw code is kept in the JSON
|
|
404
|
+
# document as `ffmpeg_returncode` for a caller that wants it. docs/design-decisions.md.
|
|
401
405
|
tail = "\n".join(stderr.strip().splitlines()[-15:])
|
|
402
|
-
die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=
|
|
406
|
+
die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=1, kind="ffmpeg", ffmpeg_returncode=returncode)
|
|
403
407
|
|
|
404
408
|
|
|
405
409
|
def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
|
package/scripts/audio.py
CHANGED
|
@@ -20,7 +20,7 @@ import argparse
|
|
|
20
20
|
import sys
|
|
21
21
|
from typing import List
|
|
22
22
|
|
|
23
|
-
from _common import STATE, add_common, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, fmt_secs
|
|
23
|
+
from _common import STATE, add_common, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, run_keeping_subtitles, fmt_secs
|
|
24
24
|
|
|
25
25
|
VOICE_CHAIN = "highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2"
|
|
26
26
|
|
|
@@ -226,8 +226,11 @@ def main() -> int:
|
|
|
226
226
|
if not keep_video:
|
|
227
227
|
# audio-only outputs: a looped music bed is infinite, -shortest ends the run with the main track
|
|
228
228
|
cmd.append("-shortest")
|
|
229
|
-
|
|
230
|
-
|
|
229
|
+
if keep_video:
|
|
230
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
231
|
+
else:
|
|
232
|
+
dropped_streams = bool(has_video and (meta.get("subtitle_streams") or meta.get("data_streams")))
|
|
233
|
+
run(cmd + [output])
|
|
231
234
|
r = probe(output, role="output")
|
|
232
235
|
a = r["audio"]
|
|
233
236
|
if r.get("video") and audio_out and not STATE.dry_run:
|
|
@@ -235,7 +238,8 @@ def main() -> int:
|
|
|
235
238
|
info(f"wrote {output} ({fmt_secs(r['duration'])}, audio {a['codec']} {a['channels']}ch {a['sample_rate']}Hz"
|
|
236
239
|
+ (", video stream-copied" if has_video and not audio_out else ", video dropped" if has_video else "") + ")")
|
|
237
240
|
emit(output, video=bool(has_video and not audio_out), audio_stream=args.audio_stream,
|
|
238
|
-
dynamics=[f for f in (args.gate and "agate", args.compress and "acompressor", args.limit and "alimiter") if f]
|
|
241
|
+
dynamics=[f for f in (args.gate and "agate", args.compress and "acompressor", args.limit and "alimiter") if f],
|
|
242
|
+
dropped_non_av_streams=dropped_streams)
|
|
239
243
|
return 0
|
|
240
244
|
|
|
241
245
|
|
package/scripts/broll.py
CHANGED
|
@@ -145,7 +145,8 @@ def main() -> int:
|
|
|
145
145
|
if not STATE.dry_run and dur_a and abs((result.get("duration") or 0.0) - dur_a) > max(0.1, 1.5 / fps):
|
|
146
146
|
die(f"output is {fmt_secs(result.get('duration'))} but the A-roll is {dur_a:.3f}s -- a cutaway must not change the length", kind="output")
|
|
147
147
|
info(f"wrote {output} ({result.get('duration', 0):.3f}s, {len(cutaways)} cutaway(s), audio={args.audio})")
|
|
148
|
-
emit(output, cutaways=[{"insert": c["path"], "at": c["at"], "end": c["at"] + c["length"], "from": c["from"]} for c in cutaways], audio=args.audio
|
|
148
|
+
emit(output, cutaways=[{"insert": c["path"], "at": c["at"], "end": c["at"] + c["length"], "from": c["from"]} for c in cutaways], audio=args.audio,
|
|
149
|
+
dropped_non_av_streams=bool(meta_a.get("subtitle_streams") or meta_a.get("data_streams")))
|
|
149
150
|
return 0
|
|
150
151
|
|
|
151
152
|
|
package/scripts/join.py
CHANGED
|
@@ -206,7 +206,8 @@ def main() -> int:
|
|
|
206
206
|
expected = sum(durs) - d * (n - 1)
|
|
207
207
|
r = probe(output, role="output")
|
|
208
208
|
info(f"wrote {output} ({fmt_secs(r['duration'])}, expected ~{expected:.3f}s, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
|
|
209
|
-
emit(output, mode="video", clips=n, transition=args.transition, expected_duration=round(expected, 3)
|
|
209
|
+
emit(output, mode="video", clips=n, transition=args.transition, expected_duration=round(expected, 3),
|
|
210
|
+
dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
|
|
210
211
|
return 0
|
|
211
212
|
|
|
212
213
|
|
package/scripts/loudness.py
CHANGED
|
@@ -18,7 +18,7 @@ import os
|
|
|
18
18
|
import re
|
|
19
19
|
import sys
|
|
20
20
|
|
|
21
|
-
from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, dry_run_input_pending
|
|
21
|
+
from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, dry_run_input_pending
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
|
|
@@ -90,7 +90,10 @@ def main() -> int:
|
|
|
90
90
|
bitrate_pinned = args.audio_bitrate is not None
|
|
91
91
|
bitrate = args.audio_bitrate or "192k"
|
|
92
92
|
|
|
93
|
+
dropped_streams = False
|
|
94
|
+
|
|
93
95
|
def encode(tp: float, bitrate: str) -> None:
|
|
96
|
+
nonlocal dropped_streams
|
|
94
97
|
af = (
|
|
95
98
|
f"loudnorm=I={args.lufs}:TP={tp}:LRA={args.lra}"
|
|
96
99
|
f":measured_I={stats['input_i']}:measured_TP={stats['input_tp']}:measured_LRA={stats['input_lra']}"
|
|
@@ -101,6 +104,8 @@ def main() -> int:
|
|
|
101
104
|
cmd += ["-vn"] + audio_codec_for(output, bitrate)
|
|
102
105
|
else:
|
|
103
106
|
cmd += ["-map", "0:v:0", "-map", "0:a:0", "-c:v", "copy", "-c:a", "aac", "-b:a", bitrate]
|
|
107
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
108
|
+
return
|
|
104
109
|
cmd.append(output)
|
|
105
110
|
run(cmd)
|
|
106
111
|
|
|
@@ -149,7 +154,7 @@ def main() -> int:
|
|
|
149
154
|
f"the encoder overshoots more than the loudnorm ceiling can absorb at this bitrate",
|
|
150
155
|
kind="verification", output=output, result=result,
|
|
151
156
|
hint="raise --audio-bitrate (e.g. 256k) or deliver a lossless format (wav/flac) and let the platform encode")
|
|
152
|
-
emit(output, result=result)
|
|
157
|
+
emit(output, result=result, dropped_non_av_streams=dropped_streams)
|
|
153
158
|
return 0
|
|
154
159
|
|
|
155
160
|
|
package/scripts/proxy.py
CHANGED
|
@@ -25,7 +25,7 @@ Examples:
|
|
|
25
25
|
import argparse
|
|
26
26
|
import sys
|
|
27
27
|
|
|
28
|
-
from _common import add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe,
|
|
28
|
+
from _common import add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, fmt_secs
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
def even(n: float) -> int:
|
|
@@ -67,14 +67,13 @@ def main() -> int:
|
|
|
67
67
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", f"scale={out_w}:-2"]
|
|
68
68
|
cmd += video_args(meta, args.crf, "veryfast")
|
|
69
69
|
cmd += cfr_args(meta, args.fps)
|
|
70
|
-
cmd += ["-c:a", "aac", "-b:a", "96k"] if has_audio else ["-an"]
|
|
71
|
-
cmd
|
|
72
|
-
run(cmd)
|
|
70
|
+
cmd += ["-map", "0:v:0"] + (["-map", "0:a:0", "-c:a", "aac", "-b:a", "96k"] if has_audio else ["-an"])
|
|
71
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
73
72
|
|
|
74
73
|
result = probe(output)
|
|
75
74
|
v = result["video"]
|
|
76
75
|
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']}, crf {args.crf})")
|
|
77
|
-
emit(output)
|
|
76
|
+
emit(output, dropped_non_av_streams=dropped_streams)
|
|
78
77
|
return 0
|
|
79
78
|
|
|
80
79
|
|
package/scripts/speedramp.py
CHANGED
|
@@ -115,7 +115,7 @@ def main() -> int:
|
|
|
115
115
|
result = probe(output, role="output")
|
|
116
116
|
v = result["video"]
|
|
117
117
|
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {len(segments)} speed segments)")
|
|
118
|
-
emit(output)
|
|
118
|
+
emit(output, dropped_non_av_streams=bool(meta.get("subtitle_streams") or meta.get("data_streams")))
|
|
119
119
|
return 0
|
|
120
120
|
|
|
121
121
|
|