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 +1 -1
- package/package.json +1 -1
- package/references/scripts.md +5 -1
- package/scripts/_common.py +26 -0
- package/scripts/_contract.py +3 -2
- package/scripts/export.py +3 -3
- package/scripts/fit.py +7 -5
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.
|
|
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",
|
package/references/scripts.md
CHANGED
|
@@ -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
|
```
|
package/scripts/_common.py
CHANGED
|
@@ -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."""
|
package/scripts/_contract.py
CHANGED
|
@@ -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
|
|
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(
|
|
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:
|