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/README.md +1 -1
- package/docs/contract.md +5 -4
- package/package.json +1 -1
- package/references/scripts.md +54 -20
- package/scripts/_common/decision.py +27 -5
- package/scripts/_common/emit.py +38 -10
- package/scripts/_common/probe.py +34 -6
- package/scripts/_common/runner.py +50 -4
- package/scripts/_contract.py +2 -2
- package/scripts/_smartcut.py +23 -5
- package/scripts/audio.py +86 -28
- package/scripts/check.py +2 -2
- package/scripts/cut.py +49 -16
- package/scripts/export.py +3 -3
- package/scripts/loop.py +2 -2
- package/scripts/loudness.py +20 -7
- package/scripts/multicam.py +55 -14
- package/scripts/scenes.py +82 -21
- package/scripts/silence.py +62 -16
- package/scripts/sync.py +10 -3
- package/scripts/waveform.py +12 -2
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
|
-
|
|
318
|
-
|
|
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,
|
|
322
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
run(ffmpeg_base() + ["-i", args.input, "-
|
|
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:
|
package/scripts/silence.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
|
628
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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))
|
package/scripts/waveform.py
CHANGED
|
@@ -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
|
-
|
|
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.
|