ffmpeg-skill 1.1.0 → 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/README.md +4 -3
- package/SKILL.md +1 -1
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +56 -0
- package/references/scripts.md +5 -1
- package/scripts/_common.py +80 -2
- package/scripts/_contract.py +3 -2
- package/scripts/export.py +4 -5
- package/scripts/fit.py +7 -5
- package/scripts/graphics.py +2 -2
- package/scripts/scenes.py +19 -9
- package/scripts/waveform.py +5 -0
package/README.md
CHANGED
|
@@ -312,12 +312,13 @@ npx ffmpeg-skill doctor --json # available / missing / missing_optional / unkn
|
|
|
312
312
|
|
|
313
313
|
## FFmpeg compatibility
|
|
314
314
|
|
|
315
|
-
The tools need FFmpeg 5.0 or later and Python 3.9 or later (standard library only). What CI actually exercises on every pull request is FFmpeg 6.1 (Ubuntu), 8.x (macOS) and 9.x (Windows),
|
|
315
|
+
The tools need FFmpeg 5.0 or later and Python 3.9 or later (standard library only). What CI actually exercises on every pull request is FFmpeg 5.1.1 (static build), 6.1 (Ubuntu apt), 7.1 (Debian trixie apt), 8.x (macOS Homebrew) and 9.x (Windows gyan.dev), on Python 3.9 and 3.13 (the two ends of the supported range). The capability parser has been run against the listings of these builds:
|
|
316
316
|
|
|
317
317
|
| FFmpeg | `-filters` row layout | Source |
|
|
318
318
|
|---|---|---|
|
|
319
|
+
| 5.1.1 | three flag characters, same as 6.x | johnvansickle.com static build on the Linux CI runner |
|
|
319
320
|
| 6.1.1 | three flag characters: `..C acompressor A->A` | Ubuntu 24.04 apt, captured |
|
|
320
|
-
| 7.x | same as 6.x |
|
|
321
|
+
| 7.1.x | same as 6.x | Debian trixie apt in a CI container (plus a constructed fixture in tests/) |
|
|
321
322
|
| 8.1.2 | two flag characters: `TS aap AA->A`, three-character legend, `------` separator | Homebrew on the macOS CI runner, captured |
|
|
322
323
|
| 9.0.1 | same as 8.x, CRLF | gyan.dev build on the Windows CI runner, captured |
|
|
323
324
|
|
|
@@ -403,7 +404,7 @@ python3 evals/run.py --list # agent eval prompts (see evals/)
|
|
|
403
404
|
node bin/install.js --dir /tmp/skills # try the installer without touching ~/.claude
|
|
404
405
|
```
|
|
405
406
|
|
|
406
|
-
CI (`.github/workflows/ci.yml`) runs on every pull request and on pushes to `main`, on Ubuntu (FFmpeg 6.1), macOS (Homebrew FFmpeg 8.x) and Windows (gyan.dev FFmpeg 9.x), and uploads each runner's FFmpeg listings as an artifact.
|
|
407
|
+
CI (`.github/workflows/ci.yml`) runs on every pull request and on pushes to `main`, on Ubuntu (FFmpeg 6.1, Python 3.9 and 3.13), macOS (Homebrew FFmpeg 8.x) and Windows (gyan.dev FFmpeg 9.x), plus two Linux jobs on FFmpeg 5.1.1 (static build) and 7.1 (Debian trixie container), and uploads each runner's FFmpeg listings as an artifact.
|
|
407
408
|
|
|
408
409
|
`tests/test_contract.py` runs on all three OSes, but a handful of its tests build a fake `ffmpeg` as a `#!/bin/sh` script on a PATH shim to force specific FFmpeg 6/7/8/9 fixture layouts through `doctor`'s parser — that technique isn't portable to Windows, so `test_dry_run_never_runs_ffmpeg_and_writes_nothing` and the whole `DoctorDetectionTests` class (fixture-driven layout parsing) are individually `skipIf`'d there and show as `skipped`, not silently absent, in the Windows job's log. Everything else — contract schema, `reencodes_*`, `doctor.tools`, MCP derivation, and every tool exercised through the contract, including `cut.py`'s provenance fields — runs against the real Windows `ffmpeg` on every PR. See [references/ci-platform-pitfalls.md](references/ci-platform-pitfalls.md) for this and other per-OS behaviour differences already diagnosed, before spending a CI cycle re-diagnosing a platform-only failure.
|
|
409
410
|
|
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",
|
|
@@ -109,3 +109,59 @@ When a test needs to special-case a platform, prefer gating with
|
|
|
109
109
|
mechanism, and write the skip/relaxation reason as a full sentence
|
|
110
110
|
explaining the underlying platform behaviour — not just "flaky on
|
|
111
111
|
Windows" — so a future reader doesn't have to re-derive it from the CI log.
|
|
112
|
+
|
|
113
|
+
## FFmpeg 5.x (the 5.1.1 static-build CI job)
|
|
114
|
+
|
|
115
|
+
Found the day the job was added (#146); 6.1+ behaves the same on all three OSes, so none of
|
|
116
|
+
these had ever shown up before.
|
|
117
|
+
|
|
118
|
+
- **`scdet=...:sc_pass=1` passes only frames whose score exceeds the threshold.** On 5.x every
|
|
119
|
+
truly static frame (score exactly 0: a title card, colour bars) is dropped before the next
|
|
120
|
+
filter and the frame numbers are re-counted without them. `scenes.py` used it with
|
|
121
|
+
`threshold=0` expecting every frame through, so a 4 s smptebars scene made the cuts on both
|
|
122
|
+
sides of it disappear from its neighbourhood test. 6.1+ passes every frame regardless.
|
|
123
|
+
Dropped the option; scores are also indexed by frame number now, missing frames counting as 0.
|
|
124
|
+
- **`drawtext` `boxborderw=v|h` (and the four-value form) is 6.1+.** 5.x and 6.0 fail the whole
|
|
125
|
+
filter with "Error setting option boxborderw to value 9|16". `_common.drawtext_boxborderw()`
|
|
126
|
+
emits the larger single value on older builds (`_common.ffmpeg_version()` parses
|
|
127
|
+
`ffmpeg -version` once; it is the only place the tools branch on a version string).
|
|
128
|
+
- **`showwaves` keeps emitting frames after the audio ends, `-shortest` notwithstanding.** A
|
|
129
|
+
12 s source came out 14.08 s on 5.1.1. `waveform.py` now also passes `-t <source duration>`.
|
|
130
|
+
- **`-display_rotation` is 6.0+.** Only the test fixture builder used it (to make a rotated
|
|
131
|
+
phone-style clip); on 5.x it writes the stream's `rotate` tag instead, which every probe here
|
|
132
|
+
reads identically.
|
|
133
|
+
- **John Van Sickle's 7.0.2 static build has no `drawtext`** (built with freetype, yet the
|
|
134
|
+
filter is absent), and BtbN no longer publishes 7.x; the 7.1 job therefore runs in a Debian
|
|
135
|
+
trixie container (apt ffmpeg 7.1.5 with libass, freetype and zimg).
|
|
136
|
+
|
|
137
|
+
## FFmpeg 7.1+ (the debian-trixie container CI job)
|
|
138
|
+
|
|
139
|
+
- **Output `-colorspace bt709` is no longer just a tag: on an untagged source it converts.**
|
|
140
|
+
7.1 added colourspace negotiation to libavfilter, and the CLI feeds the encoder's
|
|
141
|
+
`-colorspace/-color_primaries/-color_trc` into the graph's output constraints. A source
|
|
142
|
+
whose bitstream carries no colour tags (`color_space=unknown` -- test sources, screen
|
|
143
|
+
recordings, many cameras) then *differs* from the requested BT.709, so ffmpeg auto-inserts
|
|
144
|
+
a real matrix conversion (swscale guesses bt601 for "unknown"): every SDR re-encode through
|
|
145
|
+
`x264_args()` shifted the picture, and a `--lut-strength 0` no-op grade came back ~24 dB
|
|
146
|
+
PSNR from its source. 5.x/6.x wrote the same options as tags only (47 dB, no conversion).
|
|
147
|
+
The 7.0.2 static build does *not* show it; 7.1.1 (conda-forge) reproduces it locally, so
|
|
148
|
+
that is the build to use when the trixie job goes red on a colour test.
|
|
149
|
+
Fix: `_common.bt709_tag_args()` writes the tags through the encoder's own VUI parameters
|
|
150
|
+
(`-x264-params colorprim=...:transfer=...:colormatrix=...`, x265 likewise) from 7.1 on,
|
|
151
|
+
which libavfilter never sees; older builds keep the output options, since those were the
|
|
152
|
+
only way to get an mp4 `colr` atom there. A decoder-side `-colorspace bt709 ... -i` override
|
|
153
|
+
was tried first and rejected: it also tags a `-c copy` output of an untagged source (export
|
|
154
|
+
copy must stay a real copy), and it exposed a separate, pre-existing `--correct` bug (below).
|
|
155
|
+
Verified on 5.1.1, 6.1.1, 7.1.1 and 8.1.2. 8.x has the same encode-time conversion; it
|
|
156
|
+
went unnoticed there because 8.x's `psnr` filter *also* negotiates colourspace and undid it
|
|
157
|
+
before measuring (40 dB for re-matrixed pixels), and it then flagged the fixed, byte-clean
|
|
158
|
+
output as 26 dB instead. The tests' `_psnr` helper now pins identical colour tags on both
|
|
159
|
+
inputs so every build reports the same number for the same two files.
|
|
160
|
+
- **The conda-forge 7.1.1 build deadlocks on `tpad` + `adelay`/`apad` (pad.py) and ignores
|
|
161
|
+
SIGTERM.** Debian's 7.1.5 in CI does not. Run pad tests against CI, not that build, and note
|
|
162
|
+
that the tools have no subprocess timeout to get an agent out of such a hang.
|
|
163
|
+
- **Not a 7.1 issue, found while chasing it: `color --correct` desaturates a bt709-*tagged*
|
|
164
|
+
source by ~8 % at identity settings on 6.1 and 7.1 alike** (113.6 → 103.9 saturation_avg):
|
|
165
|
+
the RGB stages (exposure/colortemperature/colorbalance) make swscale go yuv→rgb with the
|
|
166
|
+
frame's bt709 matrix and back with its bt601 default. The identity test only ever used an
|
|
167
|
+
untagged source, where both legs pick bt601 and cancel out. Tracked separately.
|
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,13 +9,14 @@ 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
|
|
15
16
|
import sys
|
|
16
17
|
from fractions import Fraction
|
|
17
18
|
from pathlib import Path
|
|
18
|
-
from typing import Any, Dict, List, Optional, Sequence
|
|
19
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
19
20
|
|
|
20
21
|
# Every script prints paths, help text and reports that may contain non-ASCII (Japanese examples,
|
|
21
22
|
# arrows). On Windows the console streams default to a legacy code page and raise
|
|
@@ -74,6 +75,63 @@ ERROR_CODE = {
|
|
|
74
75
|
ERROR_RETRYABLE = False
|
|
75
76
|
|
|
76
77
|
|
|
78
|
+
_FFMPEG_VERSION: "Optional[Tuple[int, int]]" = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def ffmpeg_version() -> "Tuple[int, int]":
|
|
82
|
+
"""(major, minor) of the FFmpeg build on PATH, parsed once from `ffprobe -version`; (0, 0)
|
|
83
|
+
when it cannot be read. ffprobe rather than ffmpeg because --dry-run promises never to run
|
|
84
|
+
ffmpeg (docs/contract.md: ffmpeg_execution "none") while ffprobe always may, and the two
|
|
85
|
+
ship from the same build. Used only to pick between two spellings of an option where FFmpeg
|
|
86
|
+
changed behaviour between releases (the tools otherwise never branch on the version: doctor's
|
|
87
|
+
capability listing is the source of truth for what a build can do)."""
|
|
88
|
+
global _FFMPEG_VERSION
|
|
89
|
+
if _FFMPEG_VERSION is None:
|
|
90
|
+
_FFMPEG_VERSION = (0, 0)
|
|
91
|
+
try:
|
|
92
|
+
out = subprocess.run(["ffprobe", "-version"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True).stdout
|
|
93
|
+
m = re.search(r"ffprobe version\s+n?(\d+)\.(\d+)", out)
|
|
94
|
+
if m:
|
|
95
|
+
_FFMPEG_VERSION = (int(m.group(1)), int(m.group(2)))
|
|
96
|
+
except OSError:
|
|
97
|
+
pass
|
|
98
|
+
return _FFMPEG_VERSION
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def drawtext_boxborderw(vertical: int, horizontal: int) -> str:
|
|
102
|
+
"""drawtext's per-side `boxborderw=top|right|bottom|left` (and the two-value `v|h` form)
|
|
103
|
+
arrived in FFmpeg 6.1; 5.x and 6.0 reject the `|` with "Error setting option boxborderw"
|
|
104
|
+
(found by the FFmpeg 5.1.1 CI job, #146). Older builds get the larger single value."""
|
|
105
|
+
if ffmpeg_version() >= (6, 1):
|
|
106
|
+
return f"{vertical}|{horizontal}"
|
|
107
|
+
return str(max(vertical, horizontal))
|
|
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
|
+
|
|
77
135
|
def die(msg: str, code: int = 1, kind: str = "input") -> "None":
|
|
78
136
|
"""Exit with a message. Under --json also print a machine-readable failure document
|
|
79
137
|
(status: failed) on stdout so callers get the same shape as a success; exit codes are unchanged."""
|
|
@@ -698,10 +756,30 @@ def cfr_args(meta: Optional[Dict[str, Any]], fps: Optional[float] = None) -> Lis
|
|
|
698
756
|
return ["-fps_mode", "cfr", "-r", f"{rate:g}"]
|
|
699
757
|
|
|
700
758
|
|
|
759
|
+
def bt709_tag_args(encoder: str = "libx264") -> List[str]:
|
|
760
|
+
"""Tag an SDR output as BT.709 without touching its pixels.
|
|
761
|
+
|
|
762
|
+
Up to FFmpeg 7.0 the output options -colorspace/-color_primaries/-color_trc were tags only.
|
|
763
|
+
7.1 added colourspace negotiation to libavfilter and feeds those options into the graph's
|
|
764
|
+
output constraints, so on a source whose bitstream carries no colour tags (test sources,
|
|
765
|
+
screen recordings, many cameras) the CLI now auto-inserts a *real* matrix conversion (its
|
|
766
|
+
guess for "unknown" is bt601) into every SDR re-encode: a --lut-strength 0 no-op grade
|
|
767
|
+
came back ~24 dB PSNR from its source on 7.1. From 7.1 on, the tags therefore go through
|
|
768
|
+
the encoder's own VUI parameters instead, which libavfilter never sees; a source that is
|
|
769
|
+
genuinely tagged bt601/bt2020 is left alone either way (it keeps its own tags on the old
|
|
770
|
+
path, and the encoder VUI is a label, not a conversion, on the new one).
|
|
771
|
+
"""
|
|
772
|
+
if ffmpeg_version() < (7, 1):
|
|
773
|
+
return ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
|
|
774
|
+
if encoder == "libx265":
|
|
775
|
+
return ["-x265-params", "colorprim=bt709:transfer=bt709:colormatrix=bt709"]
|
|
776
|
+
return ["-x264-params", "colorprim=bt709:transfer=bt709:colormatrix=bt709"]
|
|
777
|
+
|
|
778
|
+
|
|
701
779
|
def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True) -> List[str]:
|
|
702
780
|
args = ["-c:v", "libx264", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p", "-movflags", "+faststart"]
|
|
703
781
|
if keep_bt709:
|
|
704
|
-
args +=
|
|
782
|
+
args += bt709_tag_args("libx264")
|
|
705
783
|
return args
|
|
706
784
|
|
|
707
785
|
|
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, 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"},
|
|
@@ -39,7 +38,6 @@ PRESETS: Dict[str, Dict] = {
|
|
|
39
38
|
"copy": {"w": None, "h": None, "ext": None, "video": ["-c:v", "copy"], "audio": ["-c:a", "copy"], "max": None, "desc": "stream copy, no re-encode (source codecs/container/colour tags unchanged)"},
|
|
40
39
|
}
|
|
41
40
|
|
|
42
|
-
BT709 = ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
|
|
43
41
|
|
|
44
42
|
|
|
45
43
|
def main() -> int:
|
|
@@ -49,6 +47,7 @@ def main() -> int:
|
|
|
49
47
|
ap.add_argument("--preset", choices=sorted(PRESETS), help="delivery preset")
|
|
50
48
|
ap.add_argument("--fit", choices=["pad", "crop"], default="pad", help="how to reach the preset frame when aspect differs (default pad)")
|
|
51
49
|
ap.add_argument("--pad-color", default="black")
|
|
50
|
+
add_pad_fill_args(ap)
|
|
52
51
|
ap.add_argument("--no-scale", action="store_true", help="keep source resolution even for platform presets")
|
|
53
52
|
ap.add_argument("--allow-long", action="store_true", help="do not trim to the platform's max duration")
|
|
54
53
|
ap.add_argument("--crf", type=int, help="override CRF")
|
|
@@ -81,7 +80,7 @@ def main() -> int:
|
|
|
81
80
|
if args.fit == "crop":
|
|
82
81
|
vf += [f"scale={p['w']}:{p['h']}:force_original_aspect_ratio=increase", f"crop={p['w']}:{p['h']}"]
|
|
83
82
|
else:
|
|
84
|
-
vf
|
|
83
|
+
vf.append(pad_filters(p["w"], p["h"], args.pad_fill, args.pad_color, args.pad_blur))
|
|
85
84
|
vf.append("setsar=1")
|
|
86
85
|
else:
|
|
87
86
|
vf.append(f"scale={p['w']}:-2")
|
|
@@ -110,7 +109,7 @@ def main() -> int:
|
|
|
110
109
|
if "-r" not in video:
|
|
111
110
|
cmd += cfr_args(meta)
|
|
112
111
|
if args.preset not in ("prores",):
|
|
113
|
-
cmd +=
|
|
112
|
+
cmd += bt709_tag_args(video[video.index("-c:v") + 1])
|
|
114
113
|
if out_ext == "mp4":
|
|
115
114
|
cmd += ["-movflags", "+faststart"]
|
|
116
115
|
cmd += (p["audio"] if has_audio else ["-an"])
|
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:
|
package/scripts/graphics.py
CHANGED
|
@@ -21,7 +21,7 @@ import argparse
|
|
|
21
21
|
import sys
|
|
22
22
|
from typing import List, Optional
|
|
23
23
|
|
|
24
|
-
from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args
|
|
24
|
+
from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw
|
|
25
25
|
|
|
26
26
|
TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug"]
|
|
27
27
|
|
|
@@ -139,7 +139,7 @@ def main() -> int:
|
|
|
139
139
|
ye = f"{margin}" if "top" in pos else f"h-text_h-{margin}"
|
|
140
140
|
box_color = ff_color(primary if args.template == "chapter" else bg, 0.9 if args.template == "chapter" else 0.7)
|
|
141
141
|
txt_color = ff_color(bg if args.template == "chapter" else text_c)
|
|
142
|
-
filters.append(f"drawtext=text='{escape_drawtext(args.title)}':{fo}:fontsize={fs}:fontcolor={txt_color}:x={xe}:y={ye}:box=1:boxcolor={box_color}:boxborderw={pady
|
|
142
|
+
filters.append(f"drawtext=text='{escape_drawtext(args.title)}':{fo}:fontsize={fs}:fontcolor={txt_color}:x={xe}:y={ye}:box=1:boxcolor={box_color}:boxborderw={drawtext_boxborderw(pady, padx)}:alpha='{fade_a}':{en}")
|
|
143
143
|
|
|
144
144
|
elif args.template == "progress":
|
|
145
145
|
h = max(3, int(base * 0.008))
|
package/scripts/scenes.py
CHANGED
|
@@ -39,25 +39,35 @@ def detect_scenes(path: str, threshold: float, min_len: float, duration: float,
|
|
|
39
39
|
equal recall compared with the raw scdet threshold."""
|
|
40
40
|
ffmpeg = require_tool("ffmpeg")
|
|
41
41
|
proc = subprocess.run([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-an", "-vf",
|
|
42
|
-
"scale=320:-2,scdet=threshold=0
|
|
42
|
+
"scale=320:-2,scdet=threshold=0,metadata=print:file=-", "-f", "null", "-"],
|
|
43
43
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
# No `sc_pass=1` on scdet: on FFmpeg 5.x that option means "pass only the frames whose
|
|
45
|
+
# score exceeds the threshold", so every truly static frame (score exactly 0 -- a title
|
|
46
|
+
# card, colour bars) is dropped before metadata=print and the frame numbers are re-counted
|
|
47
|
+
# without them. The +-12-frame neighbourhood around a real cut then fills with the moving
|
|
48
|
+
# segment's scores instead of the still one's zeros, the cut fails the ratio test, and a
|
|
49
|
+
# 4 s smptebars scene made the cuts on both sides of it disappear (found by the 5.1.1 CI
|
|
50
|
+
# job, #146). 6.1+ passes every frame either way. Scores are still indexed by frame number
|
|
51
|
+
# and any frame the filter did not report counts as 0, so a build that drops frames again
|
|
52
|
+
# cannot shift the neighbourhood.
|
|
53
|
+
by_frame: Dict[int, Tuple[float, float]] = {}
|
|
54
|
+
cur = None
|
|
47
55
|
for line in proc.stdout.splitlines():
|
|
48
56
|
m = SCORE_RE.match(line)
|
|
49
57
|
if m:
|
|
50
|
-
|
|
58
|
+
cur = (int(m.group(1)), float(m.group(2)))
|
|
51
59
|
continue
|
|
52
|
-
if line.startswith("lavfi.scd.score=") and
|
|
60
|
+
if line.startswith("lavfi.scd.score=") and cur is not None:
|
|
53
61
|
try:
|
|
54
|
-
|
|
55
|
-
scores.append(float(line.split("=", 1)[1]))
|
|
62
|
+
by_frame[cur[0]] = (cur[1], float(line.split("=", 1)[1]))
|
|
56
63
|
except ValueError:
|
|
57
64
|
pass
|
|
58
65
|
cuts = [0.0]
|
|
59
|
-
if not
|
|
66
|
+
if not by_frame:
|
|
60
67
|
return cuts
|
|
68
|
+
n_frames = max(by_frame) + 1
|
|
69
|
+
times: List[float] = [by_frame[i][0] if i in by_frame else -1.0 for i in range(n_frames)]
|
|
70
|
+
scores: List[float] = [by_frame[i][1] if i in by_frame else 0.0 for i in range(n_frames)]
|
|
61
71
|
w = 12
|
|
62
72
|
for i, sc in enumerate(scores):
|
|
63
73
|
if sc < threshold:
|
package/scripts/waveform.py
CHANGED
|
@@ -78,6 +78,11 @@ def main() -> int:
|
|
|
78
78
|
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", vf, "-map", f"0:a:{args.audio_stream}"]
|
|
79
79
|
cmd += ["-c:v", "libx264", "-preset", args.preset, "-crf", str(args.crf), "-pix_fmt", "yuv420p", "-movflags", "+faststart"]
|
|
80
80
|
cmd += aac_args()
|
|
81
|
+
# -shortest alone is not enough on FFmpeg 5.x: showwaves keeps emitting frames after the
|
|
82
|
+
# audio ends (a 12 s source came out 14.08 s on 5.1.1, #146), so the output is also capped
|
|
83
|
+
# at the source's own duration when probe knows it.
|
|
84
|
+
if meta.get("duration"):
|
|
85
|
+
cmd += ["-t", f"{float(meta['duration']):.3f}"]
|
|
81
86
|
cmd += ["-shortest", output]
|
|
82
87
|
run(cmd)
|
|
83
88
|
|