ffmpeg-skill 1.15.1 → 1.16.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 +5 -3
- package/SKILL.md +8 -7
- package/docs/contract.md +24 -9
- package/package.json +1 -1
- package/references/scripts.md +121 -8
- package/scripts/_common/__init__.py +21 -4
- package/scripts/_common/decision.py +106 -1
- package/scripts/_common/probe.py +92 -2
- package/scripts/_common/text.py +535 -0
- package/scripts/_contract.py +19 -4
- package/scripts/caption.py +252 -200
- package/scripts/check.py +19 -0
- package/scripts/graphics.py +47 -8
- package/scripts/metadata.py +130 -5
- package/scripts/render.py +33 -6
- package/scripts/scenes.py +3 -61
- package/scripts/silence.py +3 -25
- package/scripts/waveform.py +199 -12
- package/templates/audiogram.json +31 -0
package/scripts/waveform.py
CHANGED
|
@@ -18,11 +18,26 @@ Examples:
|
|
|
18
18
|
python3 waveform.py podcast.wav -o waveform.mp4
|
|
19
19
|
python3 waveform.py track.wav --style spectrum --width 1920 --height 1080 -o spectrum.mp4
|
|
20
20
|
python3 waveform.py interview.mp4 --split-channels --color cyan|magenta
|
|
21
|
+
|
|
22
|
+
An *audiogram* is the same render over a picture: --image cover.png puts a
|
|
23
|
+
still behind the visualisation, --platform sizes the frame for a
|
|
24
|
+
destination, --srt burns the captions on afterwards (by running caption.py,
|
|
25
|
+
not by re-implementing the subtitle path) and --title draws one static
|
|
26
|
+
label through graphics.py. Nothing is ever fetched and no cover art is ever
|
|
27
|
+
invented: give an image or a colour.
|
|
28
|
+
|
|
29
|
+
python3 waveform.py ep.m4a --image cover.png --platform reels --position strip -o ep.mp4
|
|
30
|
+
python3 waveform.py ep.m4a --image cover.png --srt ep.srt --title "Episode 12" -o ep.mp4
|
|
21
31
|
"""
|
|
22
32
|
import argparse
|
|
33
|
+
import os
|
|
34
|
+
import subprocess
|
|
23
35
|
import sys
|
|
24
36
|
|
|
25
|
-
from
|
|
37
|
+
from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
|
|
38
|
+
from _common import (add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base,
|
|
39
|
+
info, load_brand, pad_filters, probe, run, STATE, validate_color, video_args, X264_PRESETS,
|
|
40
|
+
fmt_secs)
|
|
26
41
|
|
|
27
42
|
WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
|
|
28
43
|
|
|
@@ -41,12 +56,65 @@ def main() -> int:
|
|
|
41
56
|
ap.add_argument("--split-channels", action="store_true", help="draw each channel in its own lane instead of summing to one")
|
|
42
57
|
ap.add_argument("--audio-stream", type=int, default=0,
|
|
43
58
|
help="which audio stream of the input to render, 0-based in file order (default 0)")
|
|
59
|
+
ag = ap.add_argument_group("audiogram (1.16)",
|
|
60
|
+
"The visualisation over a picture, for an episode that has no video. The image is a "
|
|
61
|
+
"local file you give: this skill has no network access and never invents cover art.")
|
|
62
|
+
ag.add_argument("--image", help="still image or brand plate to put behind the visualisation (a local file; scaled to cover the frame and centre-cropped)")
|
|
63
|
+
ag.add_argument("--image-fit", choices=["cover", "contain", "blur"], default="cover",
|
|
64
|
+
help="how a still of the wrong aspect fills the frame: cover (default, crop the overflow), contain (bars in --background), blur (bars are a blurred copy, as fit.py)")
|
|
65
|
+
ag.add_argument("--position", choices=["bottom", "centre", "center", "top", "strip"], default="strip",
|
|
66
|
+
help="where the visualisation sits over the plate (default strip: a band of --vis-height along the bottom, the podcast-audiogram convention)")
|
|
67
|
+
ag.add_argument("--vis-height", type=float, default=0.35,
|
|
68
|
+
help="height of the visualisation band as a fraction of the frame (default 0.35)")
|
|
69
|
+
ag.add_argument("--opacity", type=float, default=1.0, help="visualisation alpha over the plate, 0..1 (default 1)")
|
|
70
|
+
ag.add_argument("--platform", choices=PLATFORM_CHOICES, default=None,
|
|
71
|
+
help="take the frame size and fps from this destination instead of --width/--height/--fps")
|
|
72
|
+
ag.add_argument("--srt", help="burn these captions into the render afterwards, by running caption.py (never re-implemented here)")
|
|
73
|
+
ag.add_argument("--text", help="plain cue file to burn, same as caption.py --text")
|
|
74
|
+
ag.add_argument("--title", help="one static label drawn over the plate, through graphics.py's sticker template")
|
|
75
|
+
ag.add_argument("--brand", help="brand.json: --color / --background / font defaults")
|
|
44
76
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
45
77
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
46
78
|
add_common(ap)
|
|
47
79
|
args = ap.parse_args()
|
|
48
80
|
apply_common(args)
|
|
49
81
|
|
|
82
|
+
args.platform = resolve_platform(args.platform)
|
|
83
|
+
if args.platform:
|
|
84
|
+
frame = PLATFORMS[args.platform].get("frame")
|
|
85
|
+
if not frame:
|
|
86
|
+
die(f"--platform {args.platform}: that destination has no frame (it is an audio spec); "
|
|
87
|
+
"give --width/--height, or name a destination that has a picture", kind="input")
|
|
88
|
+
args.width, args.height = frame["w"], frame["h"]
|
|
89
|
+
args.fps = float(PLATFORMS[args.platform].get("fps") or args.fps)
|
|
90
|
+
info(f"--platform {args.platform}: {args.width}x{args.height} @ {args.fps:g}fps")
|
|
91
|
+
brand = load_brand(args.brand) if args.brand else None
|
|
92
|
+
if brand:
|
|
93
|
+
bc = brand.get("colors") or {}
|
|
94
|
+
if args.color == "lime" and bc.get("primary"):
|
|
95
|
+
args.color = bc["primary"]
|
|
96
|
+
if args.background == "black" and bc.get("background"):
|
|
97
|
+
args.background = bc["background"]
|
|
98
|
+
if args.image:
|
|
99
|
+
if "://" in args.image:
|
|
100
|
+
die("--image must be a readable local file; this skill has no network access, so save "
|
|
101
|
+
"the image locally and pass its path", kind="input")
|
|
102
|
+
if not os.path.exists(args.image):
|
|
103
|
+
die(f"--image file not found: {args.image}", kind="input")
|
|
104
|
+
if not STATE.dry_run:
|
|
105
|
+
plate = probe(args.image, role="input")
|
|
106
|
+
if not (plate.get("video") or {}).get("width"):
|
|
107
|
+
die(f"--image is not an image ffmpeg can decode: {args.image}", kind="input")
|
|
108
|
+
if args.background != "black":
|
|
109
|
+
die("--image and a non-default --background exclude each other: the plate is either "
|
|
110
|
+
"the picture or the colour", kind="input")
|
|
111
|
+
if not 0.0 <= args.opacity <= 1.0:
|
|
112
|
+
die(f"--opacity must be between 0 and 1, got {args.opacity:g}")
|
|
113
|
+
if not 0.0 < args.vis_height <= 1.0:
|
|
114
|
+
die(f"--vis-height must be between 0 and 1, got {args.vis_height:g}")
|
|
115
|
+
if args.srt and args.text:
|
|
116
|
+
die("--srt and --text exclude each other (both name the cues to burn)")
|
|
117
|
+
|
|
50
118
|
if args.width <= 0 or args.height <= 0:
|
|
51
119
|
die(f"--width/--height must be > 0, got width={args.width} height={args.height}")
|
|
52
120
|
if args.width % 2 or args.height % 2:
|
|
@@ -65,17 +133,58 @@ def main() -> int:
|
|
|
65
133
|
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
66
134
|
output = args.output or default_output(args.input, "waveform")
|
|
67
135
|
|
|
136
|
+
# The visualisation's own band. Without --image/--position it fills the frame, which is what
|
|
137
|
+
# every 1.15 command line did, so the graph below is byte-identical when no new flag is given.
|
|
138
|
+
vis_h = args.height
|
|
139
|
+
vis_y = 0
|
|
140
|
+
if args.image:
|
|
141
|
+
vis_h = max(2, int(round(args.height * args.vis_height)) // 2 * 2)
|
|
142
|
+
pos = "centre" if args.position == "center" else args.position
|
|
143
|
+
vis_y = {"top": 0, "centre": (args.height - vis_h) // 2,
|
|
144
|
+
"bottom": args.height - vis_h, "strip": args.height - vis_h}[pos]
|
|
145
|
+
|
|
68
146
|
if args.style == "waveform":
|
|
69
|
-
vf = (f"showwaves=s={args.width}x{
|
|
147
|
+
vf = (f"showwaves=s={args.width}x{vis_h}:mode={args.waveform_mode}:rate={args.fps:g}:"
|
|
70
148
|
f"split_channels={1 if args.split_channels else 0}:colors={args.color}")
|
|
71
149
|
else:
|
|
72
|
-
vf = f"showspectrum=s={args.width}x{
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
150
|
+
vf = f"showspectrum=s={args.width}x{vis_h}:mode={'separate' if args.split_channels else 'combined'}:fps={args.fps:g}"
|
|
151
|
+
if args.image:
|
|
152
|
+
# One filter_complex, one encode: the still gets a timeline with -loop 1, is scaled to the
|
|
153
|
+
# frame by --image-fit, and the visualisation is overlaid on it at --opacity.
|
|
154
|
+
if args.image_fit == "cover":
|
|
155
|
+
plate = (f"scale={args.width}:{args.height}:force_original_aspect_ratio=increase,"
|
|
156
|
+
f"crop={args.width}:{args.height},setsar=1")
|
|
157
|
+
else:
|
|
158
|
+
plate = pad_filters(args.width, args.height,
|
|
159
|
+
"blur" if args.image_fit == "blur" else "color",
|
|
160
|
+
args.background, 20)
|
|
161
|
+
graph = f"[1:v]{plate},format=rgba[plate];[0:a:{args.audio_stream}]{vf},format=rgba"
|
|
162
|
+
if args.opacity < 1.0:
|
|
163
|
+
graph += f",colorchannelmixer=aa={args.opacity:g}"
|
|
164
|
+
graph += f"[vis];[plate][vis]overlay=x=(W-w)/2:y={vis_y}:format=auto[v]"
|
|
165
|
+
vf = graph
|
|
166
|
+
else:
|
|
167
|
+
# showwaves/showspectrum paint the visualization on a transparent-black canvas; composite
|
|
168
|
+
# it over an explicit solid background instead of assuming that canvas already matches
|
|
169
|
+
# --background.
|
|
170
|
+
vf = f"color=c={args.background}:s={args.width}x{args.height}:r={args.fps:g}[bg];[0:a:{args.audio_stream}]{vf}[vis];[bg][vis]overlay=format=auto"
|
|
77
171
|
|
|
78
|
-
|
|
172
|
+
render = output
|
|
173
|
+
if args.title or args.srt or args.text:
|
|
174
|
+
# the visualisation is rendered first and the label/captions are drawn on it by the tools
|
|
175
|
+
# that own those code paths, so neither is re-implemented here
|
|
176
|
+
stem, ext = os.path.splitext(output)
|
|
177
|
+
render = stem + "_vis" + ext
|
|
178
|
+
cmd = ffmpeg_base() + ["-i", args.input]
|
|
179
|
+
if args.image:
|
|
180
|
+
# -framerate before -loop: a looped still defaults to 25 fps, and overlay takes its rate
|
|
181
|
+
# from the FIRST input -- so without this the file came out 25 fps however loud --fps or
|
|
182
|
+
# --platform said otherwise (the colour-plate path never had the bug: `color=` carries r=).
|
|
183
|
+
cmd += ["-framerate", f"{args.fps:g}", "-loop", "1", "-i", args.image]
|
|
184
|
+
cmd += ["-filter_complex", vf]
|
|
185
|
+
if args.image:
|
|
186
|
+
cmd += ["-map", "[v]"]
|
|
187
|
+
cmd += ["-map", f"0:a:{args.audio_stream}"]
|
|
79
188
|
cmd += video_args(None, args.crf, args.preset) # the one encoder line, so --codec / --quality reach it (review 7)
|
|
80
189
|
cmd += aac_args()
|
|
81
190
|
# -shortest alone is not enough on FFmpeg 5.x: showwaves keeps emitting frames after the
|
|
@@ -83,15 +192,93 @@ def main() -> int:
|
|
|
83
192
|
# at the source's own duration when probe knows it.
|
|
84
193
|
if meta.get("duration"):
|
|
85
194
|
cmd += ["-t", f"{float(meta['duration']):.3f}"]
|
|
86
|
-
cmd += ["-shortest",
|
|
195
|
+
cmd += ["-shortest", render]
|
|
87
196
|
run(cmd)
|
|
88
197
|
|
|
198
|
+
stages = ["waveform"]
|
|
199
|
+
current = render
|
|
200
|
+
if args.title:
|
|
201
|
+
stem, ext = os.path.splitext(output)
|
|
202
|
+
titled = stem + "_titled" + ext if (args.srt or args.text) else output
|
|
203
|
+
_child("graphics.py", [current, "--template", "sticker", "--text", args.title,
|
|
204
|
+
"--position", "top-left", "-o", titled]
|
|
205
|
+
+ (["--brand", args.brand] if args.brand else []))
|
|
206
|
+
stages.append("title")
|
|
207
|
+
current = titled
|
|
208
|
+
if args.srt or args.text:
|
|
209
|
+
_child("caption.py", [current] + (["--srt", args.srt] if args.srt else ["--text", args.text])
|
|
210
|
+
+ (["--platform", args.platform] if args.platform else []) + ["-o", output])
|
|
211
|
+
stages.append("captions")
|
|
212
|
+
current = output
|
|
213
|
+
if current != output and not STATE.dry_run:
|
|
214
|
+
os.replace(current, output)
|
|
215
|
+
current = output
|
|
216
|
+
# the intermediates exist only to keep each tool's own code path the only one there is
|
|
217
|
+
for temp in (render, os.path.splitext(output)[0] + "_titled" + os.path.splitext(output)[1]):
|
|
218
|
+
if temp != output and os.path.exists(temp) and not STATE.dry_run:
|
|
219
|
+
os.remove(temp)
|
|
220
|
+
|
|
89
221
|
result = probe(output, role="output")
|
|
90
|
-
v = result["video"]
|
|
91
|
-
|
|
92
|
-
|
|
222
|
+
v = result["video"] or {}
|
|
223
|
+
notes: "list" = []
|
|
224
|
+
if args.image and args.platform and args.srt:
|
|
225
|
+
safe_px = int(round(PLATFORMS[args.platform]["safe"]["bottom"] * args.height))
|
|
226
|
+
overlap = (vis_h + safe_px) - args.height
|
|
227
|
+
if args.position in ("strip", "bottom") and overlap > 0:
|
|
228
|
+
notes.append(f"the visualisation band and {args.platform}'s bottom safe zone overlap by "
|
|
229
|
+
f"{overlap}px: the captions or the app's own UI will sit over the waveform "
|
|
230
|
+
f"(lower --vis-height to {max(0.05, (args.height - safe_px) / args.height):.2f})")
|
|
231
|
+
duration_ok = True
|
|
232
|
+
if not STATE.dry_run and meta.get("duration") and result.get("duration"):
|
|
233
|
+
duration_ok = abs(float(result["duration"]) - float(meta["duration"])) <= 0.05
|
|
234
|
+
if not duration_ok:
|
|
235
|
+
notes.append("the render is " + fmt_secs(result.get("duration")) + " against "
|
|
236
|
+
+ fmt_secs(meta.get("duration")) + " of audio")
|
|
237
|
+
size_ok = (v.get("width"), v.get("height")) == (args.width, args.height) or STATE.dry_run
|
|
238
|
+
# the rate the run announced is the rate the file must carry: the audiogram path builds the
|
|
239
|
+
# plate as a second input, so getting this wrong is silent (see the -framerate above)
|
|
240
|
+
fps_ok = True
|
|
241
|
+
if not STATE.dry_run and v.get("fps"):
|
|
242
|
+
fps_ok = abs(float(v["fps"]) - float(args.fps)) <= 0.01
|
|
243
|
+
if not fps_ok:
|
|
244
|
+
notes.append(f"the render is {float(v['fps']):g} fps against the {args.fps:g} fps asked for")
|
|
245
|
+
# reported on every run, not only an audiogram one: a caller that keys on `audiogram.background`
|
|
246
|
+
# should not have to guess whether the key exists (`"color"` is the plain-waveform answer).
|
|
247
|
+
extra = {"audiogram": {
|
|
248
|
+
"style": args.style,
|
|
249
|
+
"background": "image" if args.image else "color",
|
|
250
|
+
"image": args.image,
|
|
251
|
+
"position": args.position if args.image else None,
|
|
252
|
+
"vis_height": args.vis_height if args.image else 1.0,
|
|
253
|
+
"platform": args.platform,
|
|
254
|
+
"captions": (args.srt or args.text) if (args.srt or args.text) else None,
|
|
255
|
+
"title": args.title,
|
|
256
|
+
"stages": stages,
|
|
257
|
+
# a dry run rendered nothing, so there is nothing to have verified -- the common
|
|
258
|
+
# top-level `verified` says false for the same run and these two must not disagree
|
|
259
|
+
"verified": False if STATE.dry_run else bool(duration_ok and size_ok and fps_ok),
|
|
260
|
+
}}
|
|
261
|
+
if notes:
|
|
262
|
+
extra["notes"] = notes
|
|
263
|
+
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v.get('width')}x{v.get('height')}, {args.style}"
|
|
264
|
+
+ (", audiogram" if args.image else "") + ")")
|
|
265
|
+
emit(output, **extra)
|
|
93
266
|
return 0
|
|
94
267
|
|
|
95
268
|
|
|
269
|
+
def _child(script_name: str, argv: "list") -> None:
|
|
270
|
+
"""Run one of this skill's own tools as a second process, so the code path it owns (the ASS
|
|
271
|
+
generator, the drawtext template) stays the only one there is."""
|
|
272
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
273
|
+
cmd = [sys.executable, os.path.join(here, script_name)] + [str(a) for a in argv]
|
|
274
|
+
if STATE.dry_run:
|
|
275
|
+
cmd.append("--dry-run")
|
|
276
|
+
info("-> " + " ".join(os.path.basename(c) if c.endswith(".py") else str(c) for c in cmd[1:]))
|
|
277
|
+
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
278
|
+
encoding="utf-8", errors="replace")
|
|
279
|
+
if proc.returncode != 0:
|
|
280
|
+
die(f"{script_name} failed:\n{(proc.stderr or proc.stdout).strip()[-800:]}", kind="ffmpeg")
|
|
281
|
+
|
|
282
|
+
|
|
96
283
|
if __name__ == "__main__":
|
|
97
284
|
sys.exit(main())
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"template": "audiogram",
|
|
3
|
+
"output": "$OUTPUT",
|
|
4
|
+
"clips": [
|
|
5
|
+
{
|
|
6
|
+
"src": "$INPUT"
|
|
7
|
+
}
|
|
8
|
+
],
|
|
9
|
+
"audiogram": {
|
|
10
|
+
"image": "$IMAGE",
|
|
11
|
+
"style": "waveform",
|
|
12
|
+
"position": "strip"
|
|
13
|
+
},
|
|
14
|
+
"captions": {
|
|
15
|
+
"text": "$CUES",
|
|
16
|
+
"size": 20,
|
|
17
|
+
"position": "bottom",
|
|
18
|
+
"margin": 14
|
|
19
|
+
},
|
|
20
|
+
"loudness": {
|
|
21
|
+
"lufs": -14,
|
|
22
|
+
"tp": -1
|
|
23
|
+
},
|
|
24
|
+
"export": {
|
|
25
|
+
"preset": "youtube",
|
|
26
|
+
"normalize": true
|
|
27
|
+
},
|
|
28
|
+
"check": {
|
|
29
|
+
"platform": "youtube"
|
|
30
|
+
}
|
|
31
|
+
}
|