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 +2 -2
- package/package.json +1 -1
- package/scripts/caption.py +8 -0
- package/scripts/render.py +21 -4
- package/scripts/sequence.py +20 -0
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.
|
|
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.
|
|
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.
|
|
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",
|
package/scripts/caption.py
CHANGED
|
@@ -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:
|
package/scripts/sequence.py
CHANGED
|
@@ -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"):
|