ffmpeg-skill 1.1.1 → 1.2.0

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
@@ -128,7 +128,7 @@ If a request needs an FFmpeg feature none of the 40 scripts expose, say so and n
128
128
  | "cut from 1:20 to 2:05", "trim the first 10 seconds" | `cut.py input.mp4 --start 1:20 --end 2:05` |
129
129
  | "keep only these parts", "remove the middle" | `cut.py input.mp4 --segments 0-1:00,1:30-2:00` |
130
130
  | "make it exactly 60 seconds", "fit it in 30s" | `fit.py input.mp4 --duration 60` (speed) or `--method trim` |
131
- | "make it vertical / for TikTok / 9:16", "square for Instagram" | `fit.py input.mp4 --aspect 9:16 --fit pad` (or `--fit crop`) |
131
+ | "make it vertical / for TikTok / 9:16", "square for Instagram" | `fit.py input.mp4 --aspect 9:16 --fit pad` (or `--fit crop`); add `--pad-fill blur` for the blurred-background bars phone editors produce |
132
132
  | "resize to a specific height, width follows" | `fit.py input.mp4 --height 1080` (or `--width`, or both for an exact frame) |
133
133
  | "crop to this exact box/rectangle" (known x/y/width/height, not an aspect ratio) | `crop.py input.mp4 --x 100 --y 0 --width 1080 --height 1920` |
134
134
  | "are there black bars on this?", "what's the crop rectangle to remove the letterboxing" | `cropdetect.py input.mp4` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 40 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",
@@ -58,7 +58,7 @@ the result was "lossless stream copy" or "re-encoded".
58
58
  ### fit.py — target duration and/or aspect, rotate/flip
59
59
  ```
60
60
  fit.py INPUT [--duration T --method speed|trim [--from-center] [--max-speed 4]]
61
- [--aspect 16:9|9:16|1:1|4:5|W:H --fit pad|crop [--width W] [--height H] [--pad-color black]]
61
+ [--aspect 16:9|9:16|1:1|4:5|W:H --fit pad|crop [--width W] [--height H] [--pad-color black] [--pad-fill color|blur [--pad-blur 20]]]
62
62
  [--rotate 90|180|270] [--flip h|v] [--fps N] [-o OUT]
63
63
  ```
64
64
  `speed` retimes video and audio together (pitch-preserving `atempo`); it
@@ -73,6 +73,10 @@ is separate from the rotation *metadata* fit.py already reads to size a
73
73
  source correctly); `--flip h|v` mirrors the picture; both can combine, rotate
74
74
  first. `--fps` forces a constant frame rate; VFR sources are conformed
75
75
  automatically even without it.
76
+ `--pad-fill blur` fills the letterbox/pillarbox bars with a blurred, scaled-to-cover copy
77
+ of the frame (the look every phone editor gives landscape footage posted as a Short/Reel)
78
+ instead of the solid `--pad-color`; `--pad-blur` is the blur radius. `export.py --fit pad`
79
+ takes the same two flags.
76
80
 
77
81
  ### crop.py — crop to an exact pixel rectangle
78
82
  ```
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
  import json
10
10
  import os
11
11
  import platform
12
+ import argparse
12
13
  import re
13
14
  import shutil
14
15
  import subprocess
@@ -106,6 +107,31 @@ def drawtext_boxborderw(vertical: int, horizontal: int) -> str:
106
107
  return str(max(vertical, horizontal))
107
108
 
108
109
 
110
+ def pad_filters(out_w: int, out_h: int, fill: str, color: str, blur: int) -> str:
111
+ """The letterbox/pillarbox step shared by fit.py and export.py, as one -vf segment.
112
+
113
+ fill="color": scale to fit, then pad with a solid colour (the historical behaviour).
114
+ fill="blur": the bars are a blurred, scaled-to-cover copy of the same frame -- what every
115
+ phone editor's "make it vertical" does with landscape footage (#139). Built as a small
116
+ graph inside the -vf chain: split, one branch scaled to cover and cropped to the frame
117
+ then boxblur'ed, the other scaled to fit, overlaid centred. Only `filter:boxblur` is
118
+ needed beyond the usual scale/pad set, and that is already required by redact.py."""
119
+ if fill == "blur":
120
+ radius = max(1, int(blur))
121
+ return (f"split[__fitfg][__fitbg];"
122
+ f"[__fitbg]scale={out_w}:{out_h}:force_original_aspect_ratio=increase,crop={out_w}:{out_h},"
123
+ f"boxblur={radius}:2[__fitbgb];"
124
+ f"[__fitfg]scale={out_w}:{out_h}:force_original_aspect_ratio=decrease[__fitfgs];"
125
+ f"[__fitbgb][__fitfgs]overlay=(W-w)/2:(H-h)/2:format=auto")
126
+ return f"scale={out_w}:{out_h}:force_original_aspect_ratio=decrease,pad={out_w}:{out_h}:(ow-iw)/2:(oh-ih)/2:color={color}"
127
+
128
+
129
+ def add_pad_fill_args(parser: "argparse.ArgumentParser") -> None:
130
+ parser.add_argument("--pad-fill", choices=["color", "blur"], default="color",
131
+ help="what fills the letterbox/pillarbox bars under --fit pad: a solid --pad-color (default) or a blurred, scaled-up copy of the frame")
132
+ parser.add_argument("--pad-blur", type=int, default=20, help="blur radius in pixels for --pad-fill blur (default 20)")
133
+
134
+
109
135
  def die(msg: str, code: int = 1, kind: str = "input") -> "None":
110
136
  """Exit with a message. Under --json also print a machine-readable failure document
111
137
  (status: failed) on stdout so callers get the same shape as a success; exit codes are unchanged."""
@@ -74,7 +74,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
74
74
  required=FF, optional=[{"capability": X264, "when": "re-encode: --accurate, VFR source, or a keyframe farther than --tolerance"}, HDR_X265, {"capability": AAC, "when": "re-encode of a video container"}] + AUDIO_OUT,
75
75
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
76
76
  "fit": dict(role="execution", inputs=["video asset"], outputs=["video artifact at the requested duration / aspect / fps"],
77
- required=FF + [X264, AAC], optional=[HDR_X265, {"capability": "filter:minterpolate", "when": "--smooth interpolate"}],
77
+ required=FF + [X264, AAC], optional=[HDR_X265, {"capability": "filter:minterpolate", "when": "--smooth interpolate"},
78
+ {"capability": "filter:boxblur", "when": "--pad-fill blur"}],
78
79
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
79
80
  "crop": dict(role="execution", inputs=["video asset"], outputs=["video artifact cropped to the given pixel rectangle"],
80
81
  required=FF + [X264, AAC], optional=[HDR_X265],
@@ -174,7 +175,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
174
175
  "export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
175
176
  required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "preset youtube / youtube4k / reels / x / h265 (prores uses pcm_s16le, copy stream-copies, gif has no audio)"},
176
177
  {"capability": X265, "when": "preset h265"}, {"capability": "encoder:prores_ks", "when": "preset prores"},
177
- {"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}],
178
+ {"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}, {"capability": "filter:boxblur", "when": "--pad-fill blur"}],
178
179
  video_required=True, audio_only=False, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
179
180
  "check": dict(role="verification", inputs=["media artifact"], outputs=["compliance rows JSON on stdout (no file)"],
180
181
  required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "loudness rows (default)"}, {"capability": "filter:loudnorm", "when": "loudness rows (default)"}],
package/scripts/export.py CHANGED
@@ -26,8 +26,7 @@ import sys
26
26
  from pathlib import Path
27
27
  from typing import Dict, List
28
28
 
29
- from _common import STATE, add_common, apply_common, bt709_tag_args, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run, validate_color
30
-
29
+ from _common import STATE, add_common, apply_common, bt709_tag_args, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run, validate_color, pad_filters, add_pad_fill_args
31
30
  PRESETS: Dict[str, Dict] = {
32
31
  "youtube": {"w": 1920, "h": 1080, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "1080p H.264, AAC 192k"},
33
32
  "youtube4k": {"w": 3840, "h": 2160, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "2160p H.264, AAC 192k"},
@@ -48,6 +47,7 @@ def main() -> int:
48
47
  ap.add_argument("--preset", choices=sorted(PRESETS), help="delivery preset")
49
48
  ap.add_argument("--fit", choices=["pad", "crop"], default="pad", help="how to reach the preset frame when aspect differs (default pad)")
50
49
  ap.add_argument("--pad-color", default="black")
50
+ add_pad_fill_args(ap)
51
51
  ap.add_argument("--no-scale", action="store_true", help="keep source resolution even for platform presets")
52
52
  ap.add_argument("--allow-long", action="store_true", help="do not trim to the platform's max duration")
53
53
  ap.add_argument("--crf", type=int, help="override CRF")
@@ -80,7 +80,7 @@ def main() -> int:
80
80
  if args.fit == "crop":
81
81
  vf += [f"scale={p['w']}:{p['h']}:force_original_aspect_ratio=increase", f"crop={p['w']}:{p['h']}"]
82
82
  else:
83
- vf += [f"scale={p['w']}:{p['h']}:force_original_aspect_ratio=decrease", f"pad={p['w']}:{p['h']}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}"]
83
+ vf.append(pad_filters(p["w"], p["h"], args.pad_fill, args.pad_color, args.pad_blur))
84
84
  vf.append("setsar=1")
85
85
  else:
86
86
  vf.append(f"scale={p['w']}:-2")
package/scripts/fit.py CHANGED
@@ -4,7 +4,7 @@
4
4
  Duration: --duration N with --method speed (retime video+audio, pitch-preserving
5
5
  via atempo chaining) or --method trim (keep the first N seconds, or a centred
6
6
  window with --from-center). Aspect: --aspect 16:9|9:16|1:1|4:5|W:H with
7
- --fit pad (letterbox/pillarbox with --pad-color, default black) or --fit crop.
7
+ --fit pad (letterbox/pillarbox with --pad-color, default black, or --pad-fill blur\nfor a blurred copy of the frame behind the picture) or --fit crop.
8
8
  --width and/or --height set the output size: give one and the other follows
9
9
  the aspect (source aspect if --aspect is not also given); give both for an
10
10
  exact frame. --rotate 90|180|270 (clockwise) and --flip h|v apply a new
@@ -24,6 +24,7 @@ Examples:
24
24
  python3 fit.py input.mp4 --duration 60 # speed up/down to exactly 60s
25
25
  python3 fit.py input.mp4 --duration 30 --method trim
26
26
  python3 fit.py input.mp4 --aspect 9:16 --fit pad --width 1080
27
+ python3 fit.py input.mp4 --aspect 9:16 --fit pad --pad-fill blur # the phone-editor look: blurred frame behind the bars
27
28
  python3 fit.py input.mp4 --aspect 1:1 --fit crop --duration 15
28
29
  python3 fit.py input.mp4 --aspect 9:16 --fit crop --crop-x 1 # keep the right edge (e.g. product held stage-right)
29
30
  python3 fit.py input.mp4 --height 1080 # width follows the source aspect
@@ -37,8 +38,7 @@ import sys
37
38
  from fractions import Fraction
38
39
  from typing import List
39
40
 
40
- from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args
41
-
41
+ from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args
42
42
  ASPECT_PRESETS = {"16:9": Fraction(16, 9), "9:16": Fraction(9, 16), "1:1": Fraction(1, 1), "4:5": Fraction(4, 5), "4:3": Fraction(4, 3), "21:9": Fraction(21, 9)}
43
43
 
44
44
 
@@ -93,6 +93,7 @@ def main() -> int:
93
93
  a.add_argument("--width", type=int, help="output width in px (default: keep source width or the width implied by the aspect); with --height also given, both are used directly")
94
94
  a.add_argument("--height", type=int, help="output height in px (default: keep source height or the height implied by the aspect); with --width also given, both are used directly")
95
95
  a.add_argument("--pad-color", default="black", help="pad colour, e.g. black, white, 0x101010 (default black)")
96
+ add_pad_fill_args(a)
96
97
  a.add_argument("--crop-x", type=float, default=0.5, help="with --fit crop, horizontal anchor 0=left, 0.5=centre (default), 1=right")
97
98
  a.add_argument("--crop-y", type=float, default=0.5, help="with --fit crop, vertical anchor 0=top, 0.5=centre (default), 1=bottom")
98
99
  r = ap.add_argument_group("rotate / flip")
@@ -115,6 +116,8 @@ def main() -> int:
115
116
  if not 0.0 <= args.crop_y <= 1.0:
116
117
  die(f"--crop-y must be 0..1, got {args.crop_y}")
117
118
  validate_color(args.pad_color, "--pad-color")
119
+ if args.pad_blur <= 0:
120
+ die(f"--pad-blur must be > 0, got {args.pad_blur}")
118
121
 
119
122
  meta = probe(args.input)
120
123
  if not meta.get("video"):
@@ -212,8 +215,7 @@ def main() -> int:
212
215
  vf.append(f"scale={out_w}:{out_h}:force_original_aspect_ratio=increase")
213
216
  vf.append(f"crop={out_w}:{out_h}:(in_w-out_w)*{args.crop_x:g}:(in_h-out_h)*{args.crop_y:g}")
214
217
  else:
215
- vf.append(f"scale={out_w}:{out_h}:force_original_aspect_ratio=decrease")
216
- vf.append(f"pad={out_w}:{out_h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}")
218
+ vf.append(pad_filters(out_w, out_h, args.pad_fill, args.pad_color, args.pad_blur))
217
219
  vf.append("setsar=1")
218
220
 
219
221
  if args.fps: