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 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.12`) | any release |
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.12", "execution_mode": "local", "kind": "execution",
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.12",
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",
@@ -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 any single ffmpeg run that exceeds this many seconds and report kind=timeout (default {DEFAULT_TIMEOUT:.0f}, or FFMPEG_SKILL_TIMEOUT; 0 = no limit)")
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 file that already exists (without it a warning is printed today; from 2.0 an existing output is refused, and FFMPEG_SKILL_NO_OVERWRITE=1 opts into that now)")
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]))
@@ -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
- args.animate = args.animate or (bcap.get("animate", "none") if args.brand else "none")
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
@@ -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
- dur = (probe(part).get("duration") or 0.0) if not STATE.dry_run else 10.0
172
- fitted = str(work / f"clip{i:02d}_speed.mp4")
173
- sh("fit.py", part, "--duration", f"{dur / spd:.3f}", "-o", fitted)
174
- part = fitted
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]
@@ -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"):