ffmpeg-skill 3.8.12 → 3.8.13

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/scripts/scenes.py CHANGED
@@ -110,6 +110,48 @@ def speech_music_ratio(samples: "List[float]", rate: int, step_s: float = SPEECH
110
110
  return [{"time": round(i * step_s, 2), "speech_music_ratio": round(z / med, 3)} for i, z in enumerate(zcrs)]
111
111
 
112
112
 
113
+ # A --sheet picks one frame a scene with select. One expression summing a term a scene was refused
114
+ # by FFmpeg 7.1 and later at 3,000 scenes ("Error while parsing expression", ENOMEM) and taken by
115
+ # 6.1. No expression here grows with the scene count: the scenes are dealt round-robin to branches
116
+ # of at most SHEET_SELECT_TERMS terms, each summed as a balanced tree (at most
117
+ # ceil(log2(SHEET_SELECT_TERMS)) = 6 nested parentheses), and the branches' frames are merged back in
118
+ # time order by interleave. Round-robin keeps the branches level, so interleave holds about one
119
+ # thumbnail a branch.
120
+ SHEET_SELECT_TERMS = 64
121
+
122
+
123
+ def _first_at(t: float) -> str:
124
+ """The select term for the first frame at or after `t`. The starts are presentation times, so
125
+ the frame is chosen by its time, not by start x fps as a frame number -- on a
126
+ variable-frame-rate source that number is another frame (#411)."""
127
+ a = f"{max(0.0, t - 0.0006):.4f}"
128
+ return f"gte(t\\,{a})*(isnan(prev_t)+lt(prev_t\\,{a}))"
129
+
130
+
131
+ def _balanced_sum(terms: List[str]) -> str:
132
+ if len(terms) == 1:
133
+ return terms[0]
134
+ mid = len(terms) // 2
135
+ return f"({_balanced_sum(terms[:mid])}+{_balanced_sum(terms[mid:])})"
136
+
137
+
138
+ def sheet_graph(starts: List[float], src: str, thumb: str, tile: str) -> str:
139
+ """The --sheet filter graph: exactly one frame a scene (the first at or after its start), each
140
+ made a thumbnail by `thumb`, laid out by `tile`, out as [sheet]."""
141
+ # no scene (a dry run whose input an earlier step has yet to write, a clip of under one scene):
142
+ # the sheet is its one tile, the first frame, as the 1x1 layout the caller sized for it
143
+ starts = starts or [0.0]
144
+ k = max(1, math.ceil(len(starts) / SHEET_SELECT_TERMS))
145
+ if k == 1:
146
+ return f"{src}select='{_balanced_sum([_first_at(t) for t in starts])}',{thumb},{tile}[sheet]"
147
+ parts = [f"{src}split={k}" + "".join(f"[s{j}]" for j in range(k))]
148
+ for j in range(k):
149
+ expr = _balanced_sum([_first_at(t) for t in starts[j::k]])
150
+ parts.append(f"[s{j}]select='{expr}',{thumb}[t{j}]")
151
+ parts.append("".join(f"[t{j}]" for j in range(k)) + f"interleave=nb_inputs={k},{tile}[sheet]")
152
+ return ";".join(parts)
153
+
154
+
113
155
  def main() -> int:
114
156
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
115
157
  ap.add_argument("input")
@@ -305,6 +347,7 @@ def main() -> int:
305
347
  rank_key = lambda sc: (-sc["audio_rms"], sc["start"])
306
348
  ranked = sorted(scenes, key=rank_key)[: args.highlights]
307
349
  picks: List[Tuple[float, float]] = []
350
+ empty: List[Tuple[float, float]] = []
308
351
  budget = args.target if args.target else None
309
352
  per = (budget / max(1, len(ranked))) if budget else args.max_scene
310
353
  for sc in ranked:
@@ -314,36 +357,44 @@ def main() -> int:
314
357
  if env and length < sc["duration"]:
315
358
  best, best_s = -1.0, sc["start"]
316
359
  win = max(1, int(length / step_s))
317
- lo, hi = int(sc["start"] / step_s), max(int(sc["start"] / step_s) + 1, int(sc["end"] / step_s) - win)
318
- for i in range(lo, hi + 1):
360
+ # A139: a window starts at the scene's start or on the step grid after it, and
361
+ # ends inside the scene. The grid point at or before the start (floor) was a
362
+ # candidate, so a pick opened up to one step (0.5 s) early, on the previous shot
363
+ latest = sc["end"] - length
364
+ starts = [sc["start"]] + [i * step_s for i in range(math.ceil(sc["start"] / step_s - 1e-9),
365
+ math.floor(latest / step_s + 1e-9) + 1)
366
+ if sc["start"] < i * step_s <= latest]
367
+ for t in starts:
368
+ i = int(t / step_s + 1e-9)
319
369
  val = sum(env[i:i + win])
320
370
  if val > best:
321
- best, best_s = val, i * step_s
322
- picks.append((round(best_s, 2), round(min(sc["end"], best_s + length), 2)))
371
+ best, best_s = val, t
372
+ # to the hundredth the result and the EDL are written in, inward: a start of 3.333
373
+ # written as 3.33 opened on the frame before the cut
374
+ pick_s = math.ceil(best_s * 100 - 1e-6) / 100
375
+ pick_e = math.floor(min(sc["end"], pick_s + length) * 100 + 1e-6) / 100
376
+ if pick_e <= pick_s:
377
+ # no hundredth fits inside it: written as 0.00-0.00 it was a range cut.py refuses
378
+ empty.append((sc["start"], sc["end"]))
379
+ continue
380
+ picks.append((pick_s, pick_e))
381
+ if empty and not picks:
382
+ die(f"--highlights found no range of at least 0.01 s, the precision the ranges are written in "
383
+ f"(each pick is at most {min(per, args.max_scene):g} s, or its scene is shorter)",
384
+ kind="input", hint="raise --target (or drop it), or lower --highlights")
385
+ for s, e in empty:
386
+ note(f"--highlights: the scene {s:.3f}-{e:.3f}s holds no range of at least 0.01 s and has no pick")
323
387
  picks.sort()
324
388
  result["highlights"] = [{"start": s, "end": e, "duration": round(e - s, 2)} for s, e in picks]
325
389
  result["highlights_total"] = round(sum(e - s for s, e in picks), 2)
326
390
  result["highlights_rank_by"] = args.rank_by
327
391
  info(f"proposed {len(picks)} highlight ranges totalling {result['highlights_total']:.1f}s")
328
- if args.edl:
329
- if not STATE.dry_run: # the contract says --edl is not written under --dry-run
330
- with open(args.edl, "w", encoding="utf-8") as fh:
331
- for s, e in picks:
332
- fh.write(f"{s:.2f}-{e:.2f}\n")
333
- info(f"wrote {args.edl}")
334
392
 
335
393
  if args.sheet:
336
394
  n = len(scenes)
337
395
  cols = min(4, max(1, n))
338
396
  rows = max(1, math.ceil(n / cols))
339
397
  tile_w = 1280 // cols // 2 * 2
340
- # exactly one frame per scene: the first frame at or after the scene start. The starts are
341
- # presentation times, so the frame is chosen by its time, not by start x fps as a frame
342
- # number -- on a variable-frame-rate source that number is another frame (#411)
343
- def first_at(t: float) -> str:
344
- a = f"{max(0.0, t - 0.0006):.4f}"
345
- return f"gte(t\\,{a})*(isnan(prev_t)+lt(prev_t\\,{a}))"
346
- expr = "+".join(first_at(sc["start"]) for sc in scenes)
347
398
  stamp = ""
348
399
  if not args.no_timecode and ffmpeg_filters() and "drawtext" not in ffmpeg_filters():
349
400
  # as look.py (#394): the sheet needs drawtext only for its stamps
@@ -360,13 +411,23 @@ def main() -> int:
360
411
  # a non-square-pixel source is squared at its display aspect (#411)
361
412
  lab = bt709_label_filter(meta)
362
413
  square = square_pixels(meta["video"])
363
- vf = (f"select='{expr}'," + (lab + "," if lab else "") + (square + "," if square else "")
364
- + f"scale={tile_w}:-2{stamp},"
365
- f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
366
- run(ffmpeg_base() + ["-i", args.input, "-map", f"0:{PROBED_VIDEO}", "-an", "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", "-update", "1", args.sheet])
414
+ thumb = (lab + "," if lab else "") + (square + "," if square else "") + f"scale={tile_w}:-2{stamp}"
415
+ graph = sheet_graph([sc["start"] for sc in scenes], f"[0:{PROBED_VIDEO}]", thumb,
416
+ f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
417
+ run(ffmpeg_base() + ["-i", args.input, "-filter_complex", graph, "-map", "[sheet]", "-an",
418
+ "-frames:v", "1", "-fps_mode", "vfr", "-update", "1", args.sheet])
367
419
  info(f"wrote {args.sheet}")
368
420
  result["sheet"] = args.sheet
369
421
 
422
+ # after the sheet, as silence.py writes its EDL after the cut (#462 A41): a --sheet refused as an
423
+ # existing file, or failed, left the EDL behind, and the corrected retry was then refused for it
424
+ if args.highlights and args.edl:
425
+ if not STATE.dry_run: # the contract says --edl is not written under --dry-run
426
+ with open(args.edl, "w", encoding="utf-8") as fh:
427
+ for s, e in picks:
428
+ fh.write(f"{s:.2f}-{e:.2f}\n")
429
+ info(f"wrote {args.edl}")
430
+
370
431
  if STATE.json: # --json-brief too: emit() is what adds the diagnostics (scenes_sheet_no_drawtext)
371
432
  emit(None, **result)
372
433
  else:
@@ -33,7 +33,7 @@ from _common.runner import parse_json, replace_checked
33
33
  from _common import guard_sidecar, refuse_existing_outputs, refuse_output_is_input, _check_output_path
34
34
  from _common.emit import refuse
35
35
  from _common import detect_silences as detect, STATE, add_common, apply_common, default_output, die, emit, info, is_audio_output, print_json, probe, X264_PRESETS, measured_level_dbfs, fmt_secs
36
- from _common.decision import frame_rate, stream_starts
36
+ from _common.decision import frame_rate, grid_at_or_after, stream_starts
37
37
  from _common.probe import PROBED_AUDIO
38
38
  from _common.emit import input_length
39
39
 
@@ -163,21 +163,47 @@ def speech_aware_silences(path: str, threshold: float, min_silence: float) -> "T
163
163
  return boundaries, breaths
164
164
 
165
165
 
166
+ # how close to the start or the end of the sound a silence's edge may sit and still be where the sound
167
+ # starts or ends: silencedetect closes a silence that runs to the end at its last decoded block (7.509
168
+ # for a sound probed as 7.500), and an AAC stream's stated start sits a priming frame off its samples
169
+ SOUND_EDGE = 0.05
170
+
171
+
172
+ def sound_span(meta: Dict[str, Any], duration: float) -> Tuple[float, float]:
173
+ """Where the sound starts and ends on the clock silencedetect reports on (the file's), for
174
+ inside_margin(): its start, and its own end (the file's length when that is not stated). The
175
+ end is stream_end()'s: a Matroska DURATION tag is already where the sound ends, and adding the
176
+ start to it put the end of a sound from 1 s to 6 s at 7 s."""
177
+ from _common.emit import file_origin, stream_end
178
+ a0 = stream_starts(meta)[1]
179
+ end = stream_end(meta, "audio")
180
+ return a0, (end - file_origin(meta)) if end is not None else duration
181
+
182
+
166
183
  def inside_margin(silences: List[Tuple[float, float]], duration: float, margin: float,
167
- lead: Optional[float] = None) -> List[Tuple[float, float]]:
184
+ lead: Optional[float] = None,
185
+ sound: Optional[Tuple[float, float]] = None) -> List[Tuple[float, float]]:
168
186
  """Each silence with a margin kept on both sides: the stretch actually removed. `margin` is
169
187
  kept after the speech that ends where the silence starts (--margin-tail), `lead` before the
170
188
  speech that starts where it ends (--margin-lead; the same as `margin` when not given). A
171
189
  silence too short to hold both removes nothing (it is dropped, never turned inside out).
172
190
 
191
+ A silence with no speech on one side keeps no margin on that side (#462 A40): one that starts
192
+ where the sound does (`sound`, from sound_span()) is removed from the file's start, and one
193
+ that runs to where the sound ends (or that silencedetect never closed, `inf`) to the file's end.
194
+ A margin there kept dead air as a piece of its own, with a jump cut after it; FFmpeg 6.1 closes
195
+ a silence at the end of the file, so the `inf` end alone never caught the last one.
196
+
173
197
  The margin is applied to the silences alone, before they are merged with filler spans (#414):
174
198
  a filler word's span is the word itself plus --filler-pad, and taking a 0.2 s margin off each
175
199
  side of it turned every word narrower than 2 x --margin inside out -- kept, and still counted
176
200
  as removed."""
177
201
  out: List[Tuple[float, float]] = []
178
202
  for s, e in silences:
179
- a = s + margin
180
- b = duration if e == float("inf") else min(duration, e - (margin if lead is None else lead))
203
+ opens = sound is not None and s <= sound[0] + SOUND_EDGE
204
+ closes = e == float("inf") or (sound is not None and e >= sound[1] - SOUND_EDGE)
205
+ a = 0.0 if opens else s + margin
206
+ b = duration if closes else min(duration, e - (margin if lead is None else lead))
181
207
  if b > a:
182
208
  out.append((a, b))
183
209
  return out
@@ -215,7 +241,10 @@ def rendered_ranges(keeps: List[Tuple[float, float]], meta: Dict[str, Any], dura
215
241
  v0 = stream_starts(meta)[0]
216
242
 
217
243
  def snap(t: float) -> float:
218
- return min(v0 + round((t - v0) * rate) / rate, duration)
244
+ # a video that starts after the sound puts frame times before 0 on its grid's extension: a
245
+ # bound there moves to the first one at or after 0, which is where the re-cut starts (#462 A43)
246
+ t = v0 + round((t - v0) * rate) / rate
247
+ return min(t if t >= 0 else grid_at_or_after(0.0, v0, rate), duration)
219
248
  return [(a, b) for a, b in ((snap(s), snap(e)) for s, e in keeps) if b > a]
220
249
 
221
250
 
@@ -307,8 +336,10 @@ def cut_levels(path: str, tm: Dict[str, Any], duration: float,
307
336
  out = []
308
337
  with tempfile.TemporaryDirectory(prefix="silence-levels-") as tmp:
309
338
  raw = os.path.join(tmp, "pcm.s16le")
339
+ # padded from the file's start, as decode_pcm_mono() pads it: the windows are read at
340
+ # t x rate, so a sound that starts late was read that far after each cut (#462 A42)
310
341
  proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path,
311
- "-map", f"0:{PROBED_AUDIO}", "-vn",
342
+ "-map", f"0:{PROBED_AUDIO}", "-vn", "-af", "aresample=async=1:first_pts=0",
312
343
  "-ac", str(channels), "-ar", str(CUT_LEVEL_RATE), "-f", "s16le", "-y", raw],
313
344
  check=False, text=False)
314
345
  if proc.returncode != 0 or not os.path.isfile(raw) or not os.path.getsize(raw):
@@ -624,8 +655,9 @@ def main() -> int:
624
655
  fil.add_argument("--filler-list", action="store_true",
625
656
  help="report what --filler would remove and write nothing")
626
657
  fil.add_argument("--max-cuts", type=int, default=400,
627
- help="refuse above this many removal ranges: the filter graph grows with them "
628
- "(default 400)")
658
+ help="refuse to render above this many removal ranges (default 400): that many cuts usually "
659
+ "means --threshold, --min-silence or the filler list is catching speech. --list and "
660
+ "--filler-list report them without the limit")
629
661
  ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
630
662
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
631
663
  add_common(ap)
@@ -686,6 +718,7 @@ def main() -> int:
686
718
  if not meta.get("audio"):
687
719
  die("input has no audio stream to analyse")
688
720
  duration = input_length(meta)
721
+ sound = sound_span(meta, duration)
689
722
  breaths: List[Tuple[float, float]] = []
690
723
  if args.speech_aware:
691
724
  silences, breaths = speech_aware_silences(args.input, args.threshold, args.min_silence)
@@ -717,10 +750,10 @@ def main() -> int:
717
750
  def margined(spans: List[Tuple[float, float]]) -> List[Tuple[float, float]]:
718
751
  """The stretch of each silence that is removed: its own tail for one after a sentence end."""
719
752
  if not by_sentence:
720
- return inside_margin(spans, duration, tail, lead)
753
+ return inside_margin(spans, duration, tail, lead, sound)
721
754
  out: List[Tuple[float, float]] = []
722
755
  for span, pos in zip(spans, positions):
723
- out += inside_margin([span], duration, sentence_tail if pos == "sentence_end" else tail, lead)
756
+ out += inside_margin([span], duration, sentence_tail if pos == "sentence_end" else tail, lead, sound)
724
757
  return out
725
758
  # One sorted, merged removal list through the graph the tool already has: filler removal IS
726
759
  # time-range removal, so it reuses keep_ranges() and the same select chain.
@@ -730,7 +763,9 @@ def main() -> int:
730
763
  keeps = keep_ranges(removals, duration, 0.0, args.min_keep)
731
764
  # what the render holds (on a picture, each bound on its nearest frame): the report, the EDL
732
765
  # and the time map all describe these, so a re-cut or a sum of `keep` is the rendered edit
733
- video_out = bool(meta.get("video")) and not (media_out and is_audio_output(media_out))
766
+ # from the -o given, under --list and --filler-list too (which write no media): a list for an
767
+ # audio -o reported frame-snapped ranges that the audio run does not cut (#462 A48)
768
+ video_out = bool(meta.get("video")) and not is_audio_output(args.output or "")
734
769
  held = rendered_ranges(keeps, meta, duration, video_out)
735
770
  kept = sum(e - s for s, e in held)
736
771
  removed = max(0.0, duration - kept)
@@ -787,10 +822,6 @@ def main() -> int:
787
822
  # an English-only transcript made on an assumption (asr.assumed_english_note): the
788
823
  # bridge already logged the warning; the result document carries it at the top level
789
824
  summary["notes"] = list(args._asr_notes)
790
- if len(keeps) > args.max_cuts:
791
- die(f"{len(keeps)} keep ranges is above --max-cuts {args.max_cuts}: the filter graph grows "
792
- "with every range and a graph this size is slow and fragile. Raise --max-cuts if you "
793
- "mean it, or use --min-silence/--filler-pad to merge the short ones.", kind="input")
794
825
  filler_only = args.filler and not args.speech_aware and not by_sentence
795
826
  if filler_only:
796
827
  info(f"--filler only (no --speech-aware): skipping generic silence detection, "
@@ -812,6 +843,14 @@ def main() -> int:
812
843
  info("hint: " + summary["hint"])
813
844
 
814
845
  tm = time_map(held, duration)
846
+ cuts = len(tm["removed"])
847
+ if cuts > args.max_cuts and not (args.list or args.filler_list):
848
+ # the removal ranges, as --max-cuts says, not the kept ones; a list renders nothing and is
849
+ # how the caller sees what the limit is about (#462 A49)
850
+ die(f"{cuts} removal ranges is above --max-cuts {args.max_cuts}: that many cuts usually means "
851
+ "--threshold, --min-silence or the filler list is catching speech. Check them with --list, then "
852
+ "raise --max-cuts if you mean it, or use --min-silence/--filler-pad to merge the short ones.",
853
+ kind="input")
815
854
  points: Any = False # False: not measured (a dry run over a pending input); None: the decode failed
816
855
  if not (STATE.dry_run and not os.path.exists(args.input)):
817
856
  points = cut_levels(args.input, tm, duration, list(filler_ranges))
@@ -841,7 +880,12 @@ def main() -> int:
841
880
  info(f"wrote {args.timemap}")
842
881
  summary["timemap"] = args.timemap
843
882
 
844
- if args.edl:
883
+ def finish_edl() -> None:
884
+ """Written with the time map, once the cut exists (or under --list): an EDL left by a
885
+ refused run (an existing -o, -o the input, nothing kept) or a failed encode described
886
+ media never made, and then blocked the corrected retry as an existing file (#462 A41)."""
887
+ if not args.edl:
888
+ return
845
889
  if not STATE.dry_run: # the EDL is an artifact like the cut itself: a plan writes nothing
846
890
  with open(args.edl, "w", encoding="utf-8") as fh:
847
891
  on_frames = bool(video_out and frame_rate(meta))
@@ -852,6 +896,7 @@ def main() -> int:
852
896
  info(f"wrote {args.edl}")
853
897
 
854
898
  if args.list or args.filler_list:
899
+ finish_edl()
855
900
  finish_time_map()
856
901
  if args.json:
857
902
  emit(None, **summary)
@@ -883,6 +928,7 @@ def main() -> int:
883
928
  _cut.join_from_source(args.input, held, output, meta, args.crf, args.preset, tmp)
884
929
  r = probe(output, role="output")
885
930
  info(f"wrote {output} ({fmt_secs(r['duration'])}, expected ~{kept:.3f}s)")
931
+ finish_edl()
886
932
  finish_time_map()
887
933
  emit(output, **summary)
888
934
  return 0
package/scripts/sync.py CHANGED
@@ -7,7 +7,9 @@ reduced to a loudness envelope, cross-correlated with an FFT implemented in
7
7
  Python (coarse, 20 ms), then refined by direct correlation at 1 ms.
8
8
 
9
9
  Offset semantics: a positive offset means the SECOND input starts LATER
10
- than the reference, i.e. `second` must be shifted earlier by that amount.
10
+ than the reference (it began recording after it), so its sound is delayed by
11
+ that amount (its head padded) to line up; a negative offset means it started
12
+ earlier, and its first -offset seconds are dropped.
11
13
 
12
14
  This aligns two AUDIO tracks to each other; it does not check or guarantee
13
15
  lip sync (mouth movement matching the audio). It assumes each recording's
@@ -161,9 +163,14 @@ def cross_correlate(ref: List[float], other: List[float], max_lag: int):
161
163
 
162
164
  scores.append((val, lag))
163
165
  if val > best_val:
164
- second = best_val
165
166
  best_val, best_lag = val, lag
166
- elif val > second and abs(lag - best_lag) > 5:
167
+ # The runner-up is the best other PEAK more than 5 lags from the winner (A126). The running
168
+ # "previous best" this used to take was usually the winner's own neighbour, one lag away and
169
+ # on the same slope, so an exact match read 0.59 and could not be told from a tie (0.50).
170
+ by_lag = dict((lag, val) for val, lag in scores)
171
+ for val, lag in scores:
172
+ if abs(lag - best_lag) > 5 and val > second and \
173
+ val >= by_lag.get(lag - 1, -float("inf")) and val >= by_lag.get(lag + 1, -float("inf")):
167
174
  second = val
168
175
  # confidence: peak similarity, penalised when a distant runner-up is nearly as good
169
176
  conf = max(0.0, min(1.0, best_val))
@@ -43,6 +43,7 @@ from _common import (add_common, apply_common, aac_args, default_output, die, dr
43
43
  fmt_secs, child_args, run_tool, measured_level_dbfs, sibling_temp)
44
44
  from _common.emit import absorb_stage_hw, note, warn
45
45
  from _common.decision import length_output_t
46
+ from _common.probe import AUDIO_MEDIA_EXT
46
47
 
47
48
  WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
48
49
 
@@ -50,7 +51,7 @@ WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
50
51
  def main() -> int:
51
52
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
52
53
  ap.add_argument("input")
53
- ap.add_argument("-o", "--output", help="output file (default: <name>_waveform.<ext>)")
54
+ ap.add_argument("-o", "--output", help="output file, a video (default: <name>_waveform.<ext>, .mp4 for an audio input; an audio extension is refused)")
54
55
  ap.add_argument("--style", choices=["waveform", "spectrum"], default="waveform", help="waveform (default) or spectrum visualization")
55
56
  ap.add_argument("--width", type=int, default=1920, help="output width in px, must be even (default 1920)")
56
57
  ap.add_argument("--height", type=int, default=1080, help="output height in px, must be even (default 1080)")
@@ -170,7 +171,16 @@ def main() -> int:
170
171
  die(f"{args.input} is silent (audio stream {args.audio_stream} peaks at {silent_peak:g} dBFS, at or below "
171
172
  f"--silence-threshold {args.silence_threshold:g}): the visualisation would be a flat line",
172
173
  kind="input", hint="check the audio, or pass --on-silent warn to render it anyway")
173
- output = args.output or default_output(args.input, "waveform")
174
+ # A137: the output always holds a picture, so an audio input's extension is not the default:
175
+ # talk.wav made talk_waveform.wav, which the WAV muxer refused (kind ffmpeg), and talk.m4a an
176
+ # H.264 video named .m4a
177
+ in_ext = os.path.splitext(args.input)[1].lower()
178
+ output = args.output or default_output(args.input, "waveform", "mp4" if in_ext in AUDIO_MEDIA_EXT else None)
179
+ # every audio-only extension this skill reads, not only those it writes (is_audio_output():
180
+ # .aif, .aiff, .caf and .wma are not there, and passed this to fail in ffmpeg, or in a dry run not at all)
181
+ if os.path.splitext(output)[1].lower() in AUDIO_MEDIA_EXT:
182
+ die(f"{output}: waveform.py writes a video, and {os.path.splitext(output)[1].lower()} holds sound only; "
183
+ "name a video file (.mp4, .mov, .mkv)", kind="input")
174
184
 
175
185
  # The visualisation's own band. Without --image/--position it fills the frame, which is what
176
186
  # every 1.15 command line did, so the graph below is byte-identical when no new flag is given.