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.
@@ -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 _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
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{args.height}:mode={args.waveform_mode}:rate={args.fps:g}:"
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{args.height}:mode={'separate' if args.split_channels else 'combined'}:fps={args.fps:g}"
73
- # showwaves/showspectrum paint the visualization on a transparent-black canvas; composite
74
- # it over an explicit solid background instead of assuming that canvas already matches
75
- # --background.
76
- 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"
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
- cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", vf, "-map", f"0:a:{args.audio_stream}"]
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", output]
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
- info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {args.style})")
92
- emit(output)
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
+ }