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 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.0`) | any release |
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.0", "execution_mode": "local", "kind": "execution",
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.0",
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",
@@ -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
@@ -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:
@@ -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:.,]+)\s*(?:-->|-|–|to)\s*(?P<b>[\d:.,]+)\s+(?P<text>.+)$"
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
 
@@ -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, parse_time, probe, run, STATE, read_text_or_die
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
- try:
50
- start = parse_time(parts[0])
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")
@@ -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, parse_time, probe, run, x264_args, X264_PRESETS, fmt_secs
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
- s, e, c = parse_time(a), parse_time(b), int(cam)
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
 
@@ -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, parse_time
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
- start, end, factor = parse_time(start_s), parse_time(end_s), float(factor_s)
46
+ factor = float(factor_s)
47
47
  except ValueError:
48
- die(f"--segment must look like START-END:FACTOR (times in seconds or mm:ss), got '{raw}'")
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: