ffmpeg-skill 1.9.0 → 1.9.1
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/references/scripts.md +3 -1
- package/scripts/_common.py +2 -0
- package/scripts/caption.py +2 -2
- package/scripts/metadata.py +3 -5
- package/scripts/multicam.py +3 -2
- package/scripts/render.py +1 -1
- package/scripts/speedramp.py +4 -3
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.9.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`1.9.1`) | 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`
|
|
@@ -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.9.
|
|
86
|
+
"skill": {"id": "ffmpeg-skill", "version": "1.9.1", "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"},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.1",
|
|
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
|
@@ -400,7 +400,9 @@ for a `--platform` or a platform export preset), and reports `plan`, `tool`,
|
|
|
400
400
|
`tool_result` and `check`. Show the plan to the user, get the yes, execute:
|
|
401
401
|
one round trip instead of re-deriving the command.
|
|
402
402
|
`"export": {"preset": "reels", "normalize": true}` forwards `export.py --normalize`
|
|
403
|
-
so the rendered file meets the platform's loudness without a separate pass.
|
|
403
|
+
so the rendered file meets the platform's loudness without a separate pass. Since
|
|
404
|
+
1.9.0 it is on by default when the preset is a platform (`youtube|youtube4k|reels|x`)
|
|
405
|
+
and the project has no `loudness` stage; `"normalize": false` opts out.
|
|
404
406
|
|
|
405
407
|
Stages: clips (cut, optional speed) → join (transition) → silence → fit →
|
|
406
408
|
captions → graphics → overlays → audio → loudness → export → check. Keys mirror the
|
package/scripts/_common.py
CHANGED
|
@@ -1438,6 +1438,8 @@ def parse_time(value: str, fps: Optional[float] = None) -> float:
|
|
|
1438
1438
|
# time grammar); it overrides the source fps a tool passed in, and is meaningless without
|
|
1439
1439
|
# the four-part form
|
|
1440
1440
|
v, _, rate = v.rpartition("@")
|
|
1441
|
+
if "@" in v:
|
|
1442
|
+
raise ValueError(f"'{value}': only one @fps suffix is allowed")
|
|
1441
1443
|
try:
|
|
1442
1444
|
fps = float(rate)
|
|
1443
1445
|
except ValueError:
|
package/scripts/caption.py
CHANGED
|
@@ -19,7 +19,7 @@ The subtitle codec is picked from the output container: mov_text for
|
|
|
19
19
|
Text-to-SRT input format (one cue per line, blank lines ignored):
|
|
20
20
|
0:00-0:03 Hello and welcome
|
|
21
21
|
00:00:03.500 --> 00:00:06 Second line | with a manual line break
|
|
22
|
-
00:00:03:15 --> 00:00:06:00 SMPTE non-drop-frame timecode (hh:mm:ss:ff, needs --fps)
|
|
22
|
+
00:00:03:15 --> 00:00:06:00 SMPTE non-drop-frame timecode (hh:mm:ss:ff, needs --fps or an @fps suffix: 00:00:03:15@29.97)
|
|
23
23
|
Text without a time is auto-timed after the previous cue (--auto-seconds)
|
|
24
24
|
|
|
25
25
|
Examples:
|
|
@@ -41,7 +41,7 @@ from _common import STATE, color_hex, load_brand, video_args, add_common, apply_
|
|
|
41
41
|
ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
|
|
42
42
|
|
|
43
43
|
TIME_RE = re.compile(
|
|
44
|
-
r"^\s*(?P<a>[\d
|
|
44
|
+
r"^\s*(?P<a>[\d:.,@]+)\s*(?:-->|-|–|to)\s*(?P<b>[\d:.,@]+)\s+(?P<text>.+)$" # @ = the 1.9 @fps suffix
|
|
45
45
|
)
|
|
46
46
|
|
|
47
47
|
|
package/scripts/metadata.py
CHANGED
|
@@ -30,7 +30,7 @@ import tempfile
|
|
|
30
30
|
from pathlib import Path
|
|
31
31
|
from typing import Any, Dict, List, Optional
|
|
32
32
|
|
|
33
|
-
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info,
|
|
33
|
+
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, STATE, read_text_or_die
|
|
34
34
|
|
|
35
35
|
CHAPTER_CONTAINERS = {".mp4", ".m4v", ".m4a", ".mov", ".mkv", ".mka", ".webm"}
|
|
36
36
|
TAG_KEYS = ("title", "artist", "album", "comment", "date", "genre")
|
|
@@ -46,10 +46,8 @@ def parse_chapters(path: str, duration: float) -> List[Dict[str, Any]]:
|
|
|
46
46
|
if not line or line.startswith("#"):
|
|
47
47
|
continue
|
|
48
48
|
parts = line.split(None, 1)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
except ValueError: # MissingFpsError is a ValueError: chapter files carry no fps
|
|
52
|
-
die(f"{path}:{n}: cannot read the time in {line!r} (use seconds, mm:ss or hh:mm:ss.ms)")
|
|
49
|
+
# chapter files carry no fps, so hh:mm:ss:ff needs its @fps suffix; time_arg() says so
|
|
50
|
+
start = time_arg(parts[0], f"{path}:{n}")
|
|
53
51
|
title = parts[1].strip() if len(parts) > 1 else f"Chapter {len(entries) + 1}"
|
|
54
52
|
if entries and start <= entries[-1]["start"]:
|
|
55
53
|
die(f"{path}:{n}: chapter at {start:g}s does not come after the previous one at {entries[-1]['start']:g}s")
|
package/scripts/multicam.py
CHANGED
|
@@ -29,7 +29,7 @@ import argparse
|
|
|
29
29
|
import sys
|
|
30
30
|
from typing import List, Tuple
|
|
31
31
|
|
|
32
|
-
from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info,
|
|
32
|
+
from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, x264_args, X264_PRESETS, fmt_secs
|
|
33
33
|
from sync import measure_offset
|
|
34
34
|
|
|
35
35
|
|
|
@@ -42,9 +42,10 @@ def parse_switch(spec: str, n: int) -> List[Tuple[float, float, int]]:
|
|
|
42
42
|
try:
|
|
43
43
|
rng, cam = raw.rsplit(":", 1)
|
|
44
44
|
a, b = rng.rsplit("-", 1)
|
|
45
|
-
|
|
45
|
+
c = int(cam)
|
|
46
46
|
except ValueError:
|
|
47
47
|
die(f"bad switch entry '{raw}' (want START-END:CAM)")
|
|
48
|
+
s, e = time_arg(a, f"--switch {raw!r} start"), time_arg(b, f"--switch {raw!r} end")
|
|
48
49
|
if not 0 <= c < n:
|
|
49
50
|
die(f"camera {c} does not exist (inputs are 0..{n - 1})")
|
|
50
51
|
if e <= s:
|
package/scripts/render.py
CHANGED
|
@@ -27,7 +27,7 @@ Project format (all keys optional except clips):
|
|
|
27
27
|
"audio": {"voice": true, "music": "bed.mp3", "music_volume": -16, "duck": true, "music_fade_out": 2},
|
|
28
28
|
"loudness": {"lufs": -14, "tp": -1},
|
|
29
29
|
"fit": {"duration": 60},
|
|
30
|
-
"export": {"preset": "reels", "normalize": true},
|
|
30
|
+
"export": {"preset": "reels", "normalize": true}, (default for platform presets; false opts out)
|
|
31
31
|
"check": {"platform": "reels"}
|
|
32
32
|
}
|
|
33
33
|
|
package/scripts/speedramp.py
CHANGED
|
@@ -19,7 +19,7 @@ import argparse
|
|
|
19
19
|
import sys
|
|
20
20
|
from typing import List, Tuple
|
|
21
21
|
|
|
22
|
-
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs,
|
|
22
|
+
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs, time_arg
|
|
23
23
|
|
|
24
24
|
MAX_SPEED = 20.0
|
|
25
25
|
MIN_SPEED = 0.05
|
|
@@ -43,9 +43,10 @@ def parse_segment(raw: str) -> Tuple[float, float, float]:
|
|
|
43
43
|
try:
|
|
44
44
|
span, factor_s = raw.rsplit(":", 1)
|
|
45
45
|
start_s, end_s = span.rsplit("-", 1)
|
|
46
|
-
|
|
46
|
+
factor = float(factor_s)
|
|
47
47
|
except ValueError:
|
|
48
|
-
die(f"--segment must look like START-END:FACTOR
|
|
48
|
+
die(f"--segment must look like START-END:FACTOR, got '{raw}'")
|
|
49
|
+
start, end = time_arg(start_s, f"--segment {raw!r} start"), time_arg(end_s, f"--segment {raw!r} end")
|
|
49
50
|
if end <= start:
|
|
50
51
|
die(f"--segment {raw}: END must be after START")
|
|
51
52
|
if not MIN_SPEED <= factor <= MAX_SPEED:
|