ffmpeg-skill 1.7.1 → 1.7.2
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 +6 -1
- package/scripts/_contract.py +1 -0
- package/scripts/check.py +17 -4
- package/scripts/render.py +3 -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.7.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`1.7.2`) | 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.7.
|
|
86
|
+
"skill": {"id": "ffmpeg-skill", "version": "1.7.2", "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.7.
|
|
3
|
+
"version": "1.7.2",
|
|
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
|
@@ -385,6 +385,8 @@ plan), runs the tool with the planned argv, then the verify steps (probe; `check
|
|
|
385
385
|
for a `--platform` or a platform export preset), and reports `plan`, `tool`,
|
|
386
386
|
`tool_result` and `check`. Show the plan to the user, get the yes, execute:
|
|
387
387
|
one round trip instead of re-deriving the command.
|
|
388
|
+
`"export": {"preset": "reels", "normalize": true}` forwards `export.py --normalize`
|
|
389
|
+
so the rendered file meets the platform's loudness without a separate pass.
|
|
388
390
|
|
|
389
391
|
Stages: clips (cut, optional speed) → join (transition) → silence → fit →
|
|
390
392
|
captions → graphics → overlays → audio → loudness → export → check. Keys mirror the
|
|
@@ -413,7 +415,10 @@ check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|broadcast|podca
|
|
|
413
415
|
[--max-duration S] [--aspect 9:16] [--lufs -14] [--tp -1] [--max-mb N]
|
|
414
416
|
```
|
|
415
417
|
PASS/WARN/FAIL per check with the script that fixes it. Run it as the final
|
|
416
|
-
step before reporting a deliverable; fix FAILs, mention WARNs.
|
|
418
|
+
step before reporting a deliverable; fix FAILs, mention WARNs. Without
|
|
419
|
+
`--platform` the youtube spec is assumed and the judgement rows (duration,
|
|
420
|
+
aspect, fps, resolution, loudness, true peak) come back as WARN with a `notes`
|
|
421
|
+
line, not FAIL: name the platform when the file is a delivery for it.
|
|
417
422
|
|
|
418
423
|
### batch.py — same recipe over a folder, cached
|
|
419
424
|
```
|
package/scripts/_contract.py
CHANGED
|
@@ -379,6 +379,7 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
379
379
|
extra: Dict[str, Any] = {}
|
|
380
380
|
if name == "check":
|
|
381
381
|
extra = {"platform": {"type": "string"}, "ok": {"type": "boolean"}, "failed": {"type": "integer"}, "warnings": {"type": "integer"},
|
|
382
|
+
"notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named: youtube was assumed and judgement rows are WARN"},
|
|
382
383
|
"checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
|
|
383
384
|
elif name == "scenes":
|
|
384
385
|
extra = {"file": {"type": "string"}, "duration": {"type": "number"}, "scene_count": {"type": "integer"}, "scenes": {"type": "array"}, "audio_peaks": {"type": "array"}}
|
package/scripts/check.py
CHANGED
|
@@ -66,7 +66,7 @@ def aspect_name(w: int, h: int) -> str:
|
|
|
66
66
|
def main() -> int:
|
|
67
67
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
68
68
|
ap.add_argument("input")
|
|
69
|
-
ap.add_argument("--platform", choices=sorted(SPECS), default="youtube")
|
|
69
|
+
ap.add_argument("--platform", choices=sorted(SPECS), default=None, help="delivery spec to check against (default: youtube, with judgement rows reported as WARN because no platform was named)")
|
|
70
70
|
ap.add_argument("--max-duration", type=float, help="override max duration in seconds")
|
|
71
71
|
ap.add_argument("--aspect", help="override allowed aspect (e.g. 9:16 or 16:9,1:1)")
|
|
72
72
|
ap.add_argument("--lufs", type=float, help="override loudness target")
|
|
@@ -77,6 +77,11 @@ def main() -> int:
|
|
|
77
77
|
args = ap.parse_args()
|
|
78
78
|
apply_common(args)
|
|
79
79
|
|
|
80
|
+
# Eval 7: runs that only wanted the format rows got youtube's loudness / true-peak FAILs and
|
|
81
|
+
# spent a paragraph explaining why they left them alone. Without a named platform the
|
|
82
|
+
# judgement rows are advisory: WARN, not FAIL, and not counted as failed.
|
|
83
|
+
named = args.platform is not None
|
|
84
|
+
args.platform = args.platform or "youtube"
|
|
80
85
|
spec = dict(SPECS[args.platform])
|
|
81
86
|
if args.max_duration is not None:
|
|
82
87
|
spec["max_duration"] = args.max_duration
|
|
@@ -100,6 +105,8 @@ def main() -> int:
|
|
|
100
105
|
# (what is cut, what is cropped, how loud ambience gets) and need a decision.
|
|
101
106
|
# "fix" is the command that resolves it; "reason" (only on the FAILs a non-technical
|
|
102
107
|
# person would ask "so what?" about) is why it matters in plain terms, not the spec clause.
|
|
108
|
+
if status == "FAIL" and not named and (name in JUDGEMENT or name == "true peak"):
|
|
109
|
+
status = "WARN"
|
|
103
110
|
rows.append({"check": name, "status": status, "value": value, "expected": expect, "fix": fix,
|
|
104
111
|
"reason": reason if status != "PASS" else "",
|
|
105
112
|
"kind": "judgement" if name in JUDGEMENT else "format"})
|
|
@@ -179,9 +186,12 @@ def main() -> int:
|
|
|
179
186
|
|
|
180
187
|
failed = [r for r in rows if r["status"] == "FAIL"]
|
|
181
188
|
warned = [r for r in rows if r["status"] == "WARN"]
|
|
189
|
+
notes: List[str] = []
|
|
190
|
+
if not named:
|
|
191
|
+
notes.append("no --platform given: youtube's spec was assumed, so judgement rows (duration, aspect, fps, resolution, loudness, true peak) are WARN, not FAIL; name a platform to enforce them")
|
|
182
192
|
if not args.json:
|
|
183
193
|
width = max(len(r["check"]) for r in rows)
|
|
184
|
-
print(f"{args.input} — {args.platform}")
|
|
194
|
+
print(f"{args.input} — {args.platform}" + ("" if named else " (assumed)"))
|
|
185
195
|
for r in rows:
|
|
186
196
|
line = f" {r['status']:4s} {r['check']:{width}s} {r['value']} (expected {r['expected']})"
|
|
187
197
|
if r["status"] != "PASS" and r["kind"] == "judgement":
|
|
@@ -192,11 +202,14 @@ def main() -> int:
|
|
|
192
202
|
line += f" -> {r['fix']}"
|
|
193
203
|
print(line)
|
|
194
204
|
print(f" {len(rows)} checks, {len(failed)} failed, {len(warned)} warnings")
|
|
205
|
+
for n in notes:
|
|
206
|
+
print(f" note: {n}")
|
|
207
|
+
extra: Dict[str, Any] = {"notes": notes} if notes else {}
|
|
195
208
|
if failed:
|
|
196
209
|
die(f"{len(failed)} of {len(rows)} {args.platform} checks failed: {', '.join(r['check'] for r in failed)}",
|
|
197
210
|
kind="verification", output=None, dry_run=STATE.dry_run,
|
|
198
|
-
platform=args.platform, checks=rows, failed=len(failed), warnings=len(warned), ok=False)
|
|
199
|
-
emit(None, platform=args.platform, checks=rows, failed=len(failed), warnings=len(warned), ok=True)
|
|
211
|
+
platform=args.platform, checks=rows, failed=len(failed), warnings=len(warned), ok=False, **extra)
|
|
212
|
+
emit(None, platform=args.platform, checks=rows, failed=len(failed), warnings=len(warned), ok=True, **extra)
|
|
200
213
|
return 0
|
|
201
214
|
|
|
202
215
|
|
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"},
|
|
30
|
+
"export": {"preset": "reels", "normalize": true},
|
|
31
31
|
"check": {"platform": "reels"}
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -464,6 +464,8 @@ def main() -> int:
|
|
|
464
464
|
argv += ["--fit", ex["fit"]]
|
|
465
465
|
if ex.get("crf") is not None:
|
|
466
466
|
argv += ["--crf", str(ex["crf"])]
|
|
467
|
+
if ex.get("normalize"):
|
|
468
|
+
argv += ["--normalize"] # one export that meets the platform's loudness (export.py --normalize)
|
|
467
469
|
sh("export.py", *argv)
|
|
468
470
|
stages_done.append("export")
|
|
469
471
|
else:
|