ffmpeg-skill 2.2.0 → 2.2.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 (`2.2.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.2.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`
@@ -119,7 +119,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
119
119
  "contract_version": "1.0",
120
120
  "deprecated": [],
121
121
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
122
- "skill": {"id": "ffmpeg-skill", "version": "2.2.0", "execution_mode": "local", "kind": "execution",
122
+ "skill": {"id": "ffmpeg-skill", "version": "2.2.1", "execution_mode": "local", "kind": "execution",
123
123
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
124
124
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
125
125
  "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": "2.2.0",
3
+ "version": "2.2.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",
@@ -1146,6 +1146,14 @@ def main() -> int:
1146
1146
  die(f"--track-title given {len(args.track_title)} times for {len(srt_tracks)} --srt file(s)",
1147
1147
  kind="input")
1148
1148
  args.srt = srt_tracks[0][0] if srt_tracks else None
1149
+ # 2.2.1: the extra --srt tracks (always inputs; the first may be written by --text or
1150
+ # --transcribe) are checked up front, every missing one named together, before any work.
1151
+ # Dry-run plans on files earlier steps have not written yet, as join.py does.
1152
+ missing_srt = [{"index": i, "path": p, "reason": "missing"}
1153
+ for i, (p, _l) in enumerate(srt_tracks) if i > 0 and not os.path.exists(p)]
1154
+ if missing_srt and not STATE.dry_run:
1155
+ die(f"{len(missing_srt)} SRT file(s) not found: " + ", ".join(m["path"] for m in missing_srt),
1156
+ kind="input", problems=missing_srt)
1149
1157
  srt_path = args.srt
1150
1158
  if args.transcribe:
1151
1159
  if not args.input:
package/scripts/render.py CHANGED
@@ -930,13 +930,30 @@ def main() -> int:
930
930
  f'{snap_spec.get("to")!r}', kind="input")
931
931
 
932
932
  # ---- clips
933
+ # 2.2.1: every clip source is checked before the first one is cut, and every problem is named
934
+ # in one refusal -- before, clip 7's missing file surfaced after clips 0-6 had been cut, and
935
+ # only the first of several missing sources was reported per run.
936
+ problems: List[Dict[str, Any]] = []
937
+ for i, c in enumerate(clips):
938
+ src = rel(c["src"])
939
+ if not os.path.exists(src): # under --dry-run too: a plan for a missing file is no plan
940
+ problems.append({"index": i, "path": src, "reason": "missing"})
941
+ elif os.path.isdir(src):
942
+ problems.append({"index": i, "path": src, "reason": "a directory, not a file"})
943
+ elif os.path.getsize(src) == 0:
944
+ problems.append({"index": i, "path": src, "reason": "empty (0 bytes)"})
945
+ if problems:
946
+ die(f"{len(problems)} of {len(clips)} clip sources unusable: "
947
+ + "; ".join(f"clip {p['index']}: {p['reason']}: {p['path']}" for p in problems),
948
+ kind="input", problems=problems,
949
+ hint="fix or remove these clips in the project; nothing was cut")
950
+ if not STATE.dry_run:
951
+ for c in clips:
952
+ probe(rel(c["src"])) # an unreadable source stops the run before any clip is cut
953
+
933
954
  parts: List[str] = []
934
955
  for i, c in enumerate(clips):
935
956
  src = rel(c["src"])
936
- if not os.path.exists(src):
937
- die(f"clip {i}: source not found: {src}") # under --dry-run too: a plan for a missing file is no plan
938
- if not STATE.dry_run:
939
- probe(src)
940
957
  needs_cut = c.get("in") is not None or c.get("out") is not None
941
958
  part = str(work / f"clip{i:02d}{mid}")
942
959
  if needs_cut:
@@ -37,6 +37,20 @@ def natural_key(name: str) -> list:
37
37
  return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", name)]
38
38
 
39
39
 
40
+ def _frames_after(directory: Path, pattern: str, index: int) -> list:
41
+ """Files in `directory` that match printf `pattern` with a number above `index`."""
42
+ m = re.search(r"%0?(\d*)d", pattern)
43
+ if not m:
44
+ return []
45
+ rx = re.compile("^" + re.escape(pattern[:m.start()]) + r"(\d+)" + re.escape(pattern[m.end():]) + "$")
46
+ found = []
47
+ for f in directory.iterdir():
48
+ hit = rx.match(f.name)
49
+ if hit and int(hit.group(1)) > index:
50
+ found.append((int(hit.group(1)), f))
51
+ return [f for _n, f in sorted(found)]
52
+
53
+
40
54
  def main() -> int:
41
55
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
42
56
  ap.add_argument("--dir", required=True, help="directory containing the frames")
@@ -79,6 +93,12 @@ def main() -> int:
79
93
  if not frames:
80
94
  die(f"first frame not found: {directory / (args.pattern % args.start_number)} (check --pattern / --start-number)")
81
95
  info(f"found {len(frames)} consecutive frames from index {args.start_number}")
96
+ # 2.2.1: frames past a gap in the numbering (one render or export step that failed) used
97
+ # to be dropped silently, giving a shorter video; name the gap and what was left out
98
+ later = _frames_after(directory, args.pattern, i)
99
+ if later:
100
+ info(f"WARNING: frame {args.pattern % i} is missing; {len(later)} later frame(s) "
101
+ f"(from {later[0].name}) are NOT in the sequence -- fill the gap or rename them")
82
102
 
83
103
  frame_meta = probe(str(frames[0]))
84
104
  if not frame_meta.get("video"):