ffmpeg-skill 1.4.12 → 1.4.13
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/SKILL.md +1 -0
- package/docs/contract.md +2 -2
- package/package.json +1 -1
- package/scripts/_common.py +4 -4
- package/scripts/caption.py +3 -1
- package/scripts/multicam.py +2 -0
- package/scripts/render.py +5 -4
- package/scripts/reverse.py +3 -0
- package/scripts/sync.py +2 -0
package/SKILL.md
CHANGED
|
@@ -278,6 +278,7 @@ Every script prints `{"status": "failed", "error": {"kind": input | ffmpeg | out
|
|
|
278
278
|
-40 LUFS or below is room tone, wind or nothing; raising it 25 dB raises the
|
|
279
279
|
noise, not the content. Leave the level, say so, and offer music or narration.
|
|
280
280
|
- Captions burned before a crop/resize: text lands off-frame. Frame changes first, then text.
|
|
281
|
+
- Captions burned at an intermediate size and then upscaled by `export.py` come out soft (a 1280x720 source fit to 9:16 is 406x720 until export scales it to 1080x1920). Fit to the delivery size first (`fit.py --width 1080 --height 1920`), then caption, then export.
|
|
281
282
|
- Anything chained by hand through three re-encodes: use `render.py` so the plan is one file and the user can change one number.
|
|
282
283
|
- `--fit crop` to reach 9:16 from 16:9 throws away 70 % of the width: a wide shot loses people at the edges. Check the sheet; pad (bars), `--crop-x`/`--crop-y` toward the subject, or a reframe is often the honest answer — a silent centre crop is a guess, not a decision.
|
|
283
284
|
- Conforming 60 fps to 30 halves the motion samples: fine for a talking head, visibly choppy for sports, gaming, drone pans. Keep 60 when the platform allows it.
|
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.4.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`1.4.13`) | 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.4.
|
|
86
|
+
"skill": {"id": "ffmpeg-skill", "version": "1.4.13", "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.4.
|
|
3
|
+
"version": "1.4.13",
|
|
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/_common.py
CHANGED
|
@@ -258,9 +258,9 @@ def add_common(ap: "argparse.ArgumentParser") -> None:
|
|
|
258
258
|
g.add_argument("--fast", action="store_true", help="preview quality: x264 preset veryfast (overrides --preset) for quick iterations")
|
|
259
259
|
if "--timeout" not in ap._option_string_actions: # verify.py defines its own per-step --timeout; apply_common reads either
|
|
260
260
|
g.add_argument("--timeout", type=float, default=None, metavar="SECONDS",
|
|
261
|
-
help=f"kill
|
|
261
|
+
help=f"kill an ffmpeg run past this many seconds, kind=timeout (default {DEFAULT_TIMEOUT:.0f}; 0 = no limit)")
|
|
262
262
|
g.add_argument("--overwrite", action="store_true",
|
|
263
|
-
help="allow replacing an output
|
|
263
|
+
help="allow replacing an existing output (warned today, refused from 2.0)")
|
|
264
264
|
|
|
265
265
|
|
|
266
266
|
def apply_common(args: "argparse.Namespace") -> None:
|
|
@@ -840,7 +840,7 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
|
|
|
840
840
|
|
|
841
841
|
|
|
842
842
|
def shell_quote(s: str) -> str:
|
|
843
|
-
if not s or any(ch in s for ch in " \t\\\"';|&<>()[]{}$*?"):
|
|
843
|
+
if not s or any(ch in s for ch in " \t\n\r\\\"';|&<>()[]{}$*?"):
|
|
844
844
|
return "'" + s.replace("'", "'\\''") + "'"
|
|
845
845
|
return s
|
|
846
846
|
|
|
@@ -1466,7 +1466,7 @@ def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
|
|
|
1466
1466
|
# signalstats reports in the source bit depth; normalise everything to an 8-bit scale
|
|
1467
1467
|
scale = 1.0
|
|
1468
1468
|
if ymax > 255 or yavg > 255:
|
|
1469
|
-
scale = 1 / 4.0 if ymax <= 1023 else 1 / 16.0
|
|
1469
|
+
scale = 1 / 4.0 if ymax <= 1023 else (1 / 16.0 if ymax <= 4095 else 1 / 256.0) # 10 / 12 / 16-bit
|
|
1470
1470
|
ymin, ymax, yavg, sat = ymin * scale, ymax * scale, yavg * scale, sat * scale
|
|
1471
1471
|
# 5th/95th percentile of per-frame lows/highs is more robust than the absolute min/max
|
|
1472
1472
|
lows = sorted(x * scale for x in (vals.get("YLOW") or vals.get("YMIN") or [0]))
|
package/scripts/caption.py
CHANGED
|
@@ -450,7 +450,9 @@ def main() -> int:
|
|
|
450
450
|
args.outline_color = color_hex(args.outline_color or bc.get("outline", "000000"))
|
|
451
451
|
args.outline = args.outline if args.outline is not None else (float(bcap.get("outline", 2)) if args.brand else 2.0)
|
|
452
452
|
args.position = args.position or (bcap.get("position", "bottom") if args.brand else "bottom")
|
|
453
|
-
|
|
453
|
+
# a brand's caption.animate is a burn-in default; over --mode mux (soft subtitles) it used
|
|
454
|
+
# to be applied anyway and then refused as "animation is burn only" -- ignore it there
|
|
455
|
+
args.animate = args.animate or (bcap.get("animate", "none") if args.brand and args.mode != "mux" else "none")
|
|
454
456
|
args.highlight_color = color_hex(args.highlight_color or bc.get("primary", "FFD200"))
|
|
455
457
|
if args.brand and bcap.get("bold") and not args.bold:
|
|
456
458
|
args.bold = True
|
package/scripts/multicam.py
CHANGED
|
@@ -73,6 +73,8 @@ def main() -> int:
|
|
|
73
73
|
add_common(ap)
|
|
74
74
|
args = ap.parse_args()
|
|
75
75
|
apply_common(args)
|
|
76
|
+
if args.analyze_seconds > 900:
|
|
77
|
+
die(f"--analyze-seconds {args.analyze_seconds:g}: the window is decoded into memory; 900 s is the ceiling")
|
|
76
78
|
if args.fps is not None and args.fps <= 0:
|
|
77
79
|
die(f"--fps must be positive, got {args.fps:g}")
|
|
78
80
|
|
package/scripts/render.py
CHANGED
|
@@ -168,10 +168,11 @@ def main() -> int:
|
|
|
168
168
|
spd = float(c["speed"])
|
|
169
169
|
if not (spd > 0) or spd != spd or spd == float("inf"):
|
|
170
170
|
die(f"clip {i}: speed must be a positive number, got {c['speed']!r}")
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
171
|
+
if abs(spd - 1.0) > 1e-6: # speed 1.0 used to cost a full re-encode for nothing
|
|
172
|
+
dur = (probe(part).get("duration") or 0.0) if not STATE.dry_run else 10.0
|
|
173
|
+
fitted = str(work / f"clip{i:02d}_speed.mp4")
|
|
174
|
+
sh("fit.py", part, "--duration", f"{dur / spd:.3f}", "-o", fitted)
|
|
175
|
+
part = fitted
|
|
175
176
|
parts.append(part)
|
|
176
177
|
stages_done.append("clips")
|
|
177
178
|
current = parts[0]
|
package/scripts/reverse.py
CHANGED
|
@@ -34,6 +34,9 @@ def main() -> int:
|
|
|
34
34
|
has_audio = bool(meta.get("audio")) and not args.no_audio
|
|
35
35
|
|
|
36
36
|
output = args.output or default_output(args.input, "reverse")
|
|
37
|
+
if (meta.get("duration") or 0) > 60:
|
|
38
|
+
info(f"warning: reverse buffers every decoded frame in memory; {meta['duration']:.0f}s of "
|
|
39
|
+
f"{meta['video']['width']}x{meta['video']['height']} can exhaust RAM -- cut the part to reverse first (cut.py)")
|
|
37
40
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", "reverse"]
|
|
38
41
|
if has_audio:
|
|
39
42
|
cmd += ["-af", "areverse"]
|
package/scripts/sync.py
CHANGED
|
@@ -213,6 +213,8 @@ def main() -> int:
|
|
|
213
213
|
add_common(ap)
|
|
214
214
|
args = ap.parse_args()
|
|
215
215
|
apply_common(args)
|
|
216
|
+
if args.analyze_seconds > 900:
|
|
217
|
+
die(f"--analyze-seconds {args.analyze_seconds:g}: the window is decoded into memory; 900 s is the ceiling")
|
|
216
218
|
|
|
217
219
|
for p in (args.reference, args.second):
|
|
218
220
|
if not probe(p).get("audio"):
|