ffmpeg-skill 3.8.9 → 3.8.11

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.
@@ -145,11 +145,14 @@ JA_PARTICLES = "はがをにでとのへもや" # a break AFTER one
145
145
  # text on each side of a candidate break -- putting them in the character string above turned
146
146
  # か, ら, ま, で, よ and り into one-character particles of their own, which none of them is.
147
147
  JA_PARTICLE_WORDS = ("から", "まで", "より")
148
- JA_SENTENCE_END = "。、!?」』)" # a break AFTER one of these is preferred
148
+ JA_SENTENCE_END = "。、!?」』),;:" # a break AFTER one of these is preferred (A54: ,;: too)
149
149
  # Characters that may never start a line: small kana, the prolonged sound mark, closing brackets
150
150
  # and the Japanese punctuation that hangs on the end of the line before it.
151
- JA_NO_LINE_START = "ぁぃぅぇぉっゃゅょァィゥェォッャュョーヽヾゝゞ、。!?)」』】〕》’”%"
152
- JA_NO_LINE_END = "(「『【〔《‘“" # ... and the ones that may never end a line
151
+ # A54 (#457): the fullwidth comma, semicolon, colon and full stop Chinese uses, and the closing
152
+ # brackets of both, are on the same table: a line that opens with ',' is the same fault as one
153
+ # that opens with '、'.
154
+ JA_NO_LINE_START = "ぁぃぅぇぉっゃゅょァィゥェォッャュョーヽヾゝゞ、。!?)」』】〕》’”%,;:.〉]}〗"
155
+ JA_NO_LINE_END = "(「『【〔《‘“〈[{〖" # ... and the ones that may never end a line
153
156
 
154
157
  # R4. Function words belong to the phrase that FOLLOWS them: an article or preposition begins the
155
158
  # noun phrase it governs, so a break before one is the good break (the word opens the next line
@@ -365,7 +368,7 @@ def _function_words(lang: "Optional[str]") -> "frozenset":
365
368
  """R4's table for this language. An unknown or absent language gets the union of the six sets:
366
369
  a token that appears in several of them is the same kind of word in each, which is why the
367
370
  rule is a penalty and not a refusal."""
368
- key = (lang or "").strip().lower().split("-")[0]
371
+ key = (lang or "").strip().lower().replace("_", "-").split("-")[0]
369
372
  if key in FUNCTION_WORDS:
370
373
  return frozenset(FUNCTION_WORDS[key])
371
374
  return _FUNCTION_WORDS_ANY
@@ -404,7 +407,7 @@ def break_penalty(prev_char: str, next_char: str, lang: "Optional[str]" = None,
404
407
  single-character table applies."""
405
408
  if not prev_char or not next_char:
406
409
  return PENALTY_NEUTRAL
407
- script = (lang or "").strip().lower().split("-")[0]
410
+ script = (lang or "").strip().lower().replace("_", "-").split("-")[0]
408
411
  if script not in ("ja", "zh"):
409
412
  # A kana on either side settles it: only Japanese has them, and char_script() reads a bare
410
413
  # Han character as Chinese, which used to switch the particle rules off for exactly the
@@ -494,14 +497,15 @@ def _gaps(lines: "List[str]", gaps: "Optional[List[bool]]") -> "List[bool]":
494
497
 
495
498
 
496
499
  def _fix_orphans(lines: "List[str]", max_em: float,
497
- gaps: "Optional[List[bool]]" = None) -> "List[str]":
500
+ gaps: "Optional[List[bool]]" = None, kinsoku: bool = False) -> "List[str]":
498
501
  """No last line that is a single stranded atom.
499
502
 
500
503
  Greedy wrapping leaves one character alone whenever the line before it filled exactly: eval 14
501
504
  produced a Thai cue ending in a lone `ล` and a Japanese one ending in a lone `行`. While the
502
505
  last line is one atom narrower than ORPHAN_MIN_EM, the last atom of the line above moves down
503
506
  onto it -- but only while the result still fits and the line above does not become an orphan
504
- itself, so a two-word cue is never made worse."""
507
+ itself, so a two-word cue is never made worse. `kinsoku` (phrase mode) moves a punctuation
508
+ mark down only with the character it closes (_kinsoku_atoms)."""
505
509
  lines = list(lines)
506
510
  gaps = _gaps(lines, gaps)
507
511
  for _ in range(len(lines)):
@@ -510,7 +514,7 @@ def _fix_orphans(lines: "List[str]", max_em: float,
510
514
  tail = _atoms(lines[-1])
511
515
  if len(tail) != 1 or text_width_em(lines[-1]) >= ORPHAN_MIN_EM:
512
516
  break
513
- prev = _atoms(lines[-2])
517
+ prev = _kinsoku_atoms(_atoms(lines[-2])) if kinsoku else _atoms(lines[-2])
514
518
  if len(prev) < 2:
515
519
  break
516
520
  moved, spaced = prev[-1]
@@ -532,14 +536,15 @@ def _fix_weak_lines(lines: "List[str]", max_em: float,
532
536
  1.15 only ever looked at the last line, so a stranded digit or kana in the middle of a
533
537
  three-line cue survived. Walking upward from the last line, while a line is weak the last atom
534
538
  of the line above moves down onto it -- with 1.15's two guards intact (the result must still
535
- fit, and the line above must not itself become weak), so the line count never changes."""
539
+ fit, and the line above must not itself become weak), so the line count never changes. A
540
+ punctuation mark moves down only with the character it closes (_kinsoku_atoms, A54)."""
536
541
  lines = list(lines)
537
542
  gaps = _gaps(lines, gaps)
538
543
  for i in range(len(lines) - 1, 0, -1):
539
544
  for _ in range(len(lines)):
540
545
  if not _is_weak_line(lines[i]):
541
546
  break
542
- prev = _atoms(lines[i - 1])
547
+ prev = _kinsoku_atoms(_atoms(lines[i - 1]))
543
548
  if len(prev) < 2:
544
549
  break
545
550
  moved, spaced = prev[-1]
@@ -633,9 +638,36 @@ def _rebalance_phrase(lines: "List[str]", max_em: float, lang: "Optional[str]",
633
638
  return out, moved
634
639
 
635
640
 
641
+ def _kinsoku_atoms(atoms: "List[Tuple[str, bool]]") -> "List[Tuple[str, bool]]":
642
+ """The atom list with each no-line-start character joined to the atom before it, and each
643
+ no-line-end character to the atom after it, where no space stands between them.
644
+
645
+ The phrase passes only ever choose among breaks that fit, so a forbidden break the greedy fill
646
+ had already taken stayed whenever no pass reconsidered it: a cue of three lines or more, or
647
+ the pieces of a cue split past --max-lines, opened a line with '、', ',' or '。' (A54, #457).
648
+ Joined, the fill carries the character before the punctuation onto the next line with it."""
649
+ split: "List[Tuple[str, bool]]" = []
650
+ for atom, spaced in atoms:
651
+ # the atomiser reads a run of CJK punctuation as one Latin word ('。('): part it where a
652
+ # closing mark meets an opening one, so each half can join its own neighbour
653
+ start = 0
654
+ for k in range(1, len(atom)):
655
+ if atom[k - 1] in JA_NO_LINE_START and atom[k] in JA_NO_LINE_END:
656
+ split.append((atom[start:k], spaced if start == 0 else False))
657
+ start = k
658
+ split.append((atom[start:], spaced if start == 0 else False))
659
+ out: "List[Tuple[str, bool]]" = []
660
+ for atom, spaced in split:
661
+ if out and not spaced and (atom[0] in JA_NO_LINE_START or out[-1][0][-1] in JA_NO_LINE_END):
662
+ out[-1] = (out[-1][0] + atom, out[-1][1])
663
+ else:
664
+ out.append((atom, spaced))
665
+ return out
666
+
667
+
636
668
  def _greedy_chunks(raw: str, max_em: float, *, slice_overlong: bool = False,
637
669
  sliced: "Optional[List[int]]" = None,
638
- gaps: "Optional[List[bool]]" = None) -> "List[str]":
670
+ gaps: "Optional[List[bool]]" = None, kinsoku: bool = False) -> "List[str]":
639
671
  """The greedy fill on its own: the line count every mode must keep.
640
672
 
641
673
  `slice_overlong` (off by default -- only caption.py's final burn-in pass turns it on, never
@@ -647,7 +679,10 @@ def _greedy_chunks(raw: str, max_em: float, *, slice_overlong: bool = False,
647
679
  `broken_inside_word` without re-deriving it from the output lines.
648
680
 
649
681
  `gaps`, when given, is filled with one flag per break: did a space stand there in `raw`? The
650
- passes in _balance() carry it, so a break they move keeps (or drops) exactly that space."""
682
+ passes in _balance() carry it, so a break they move keeps (or drops) exactly that space.
683
+
684
+ `kinsoku` (phrase mode) never breaks before a no-line-start or after a no-line-end character
685
+ (_kinsoku_atoms); `measured` keeps 1.15's fill."""
651
686
  current = ""
652
687
  current_gap = False # did a space stand before `current`'s first atom
653
688
  chunk: "List[str]" = []
@@ -671,7 +706,7 @@ def _greedy_chunks(raw: str, max_em: float, *, slice_overlong: bool = False,
671
706
  return pieces[-1], False
672
707
  return atom, spaced
673
708
 
674
- for atom, spaced in _atoms(raw):
709
+ for atom, spaced in (_kinsoku_atoms(_atoms(raw)) if kinsoku else _atoms(raw)):
675
710
  if not current:
676
711
  current, current_gap = start_line(atom, spaced)
677
712
  continue
@@ -699,7 +734,7 @@ def _balance(chunk: "List[str]", max_em: float, mode: str, lang: "Optional[str]"
699
734
  fixed = _fix_orphans(chunk, max_em, gaps)
700
735
  rebalanced = _rebalance(fixed, max_em, list(gaps) if gaps is not None else None)
701
736
  else:
702
- fixed = _fix_weak_lines(_fix_orphans(chunk, max_em, gaps), max_em, gaps)
737
+ fixed = _fix_weak_lines(_fix_orphans(chunk, max_em, gaps, kinsoku=True), max_em, gaps)
703
738
  later = list(gaps) if gaps is not None else None
704
739
  rebalanced, _moved = _rebalance_phrase(fixed, max_em, lang, later)
705
740
  rebalanced = _fix_weak_lines(rebalanced, max_em, later)
@@ -729,7 +764,8 @@ def wrap_text(text: str, max_em: float, *, balance: bool = True, mode: str = "ph
729
764
  if not raw.strip():
730
765
  continue
731
766
  gaps: "List[bool]" = []
732
- chunk = _greedy_chunks(raw, max_em, slice_overlong=slice_overlong, sliced=sliced, gaps=gaps)
767
+ chunk = _greedy_chunks(raw, max_em, slice_overlong=slice_overlong, sliced=sliced, gaps=gaps,
768
+ kinsoku=mode == "phrase")
733
769
  lines.extend(_balance(chunk, max_em, mode, lang, gaps) if balance else chunk)
734
770
  return lines or [text]
735
771
  def wrap_variants(text: str, max_em: float, *, mode: str = "phrase",
@@ -750,11 +786,17 @@ def wrap_variants(text: str, max_em: float, *, mode: str = "phrase",
750
786
  if not raw.strip():
751
787
  continue
752
788
  gaps: "List[bool]" = []
753
- chunk = _greedy_chunks(raw, max_em, slice_overlong=slice_overlong, sliced=sliced, gaps=gaps)
789
+ chunk = _greedy_chunks(raw, max_em, slice_overlong=slice_overlong, sliced=sliced, gaps=gaps,
790
+ kinsoku=mode == "phrase")
754
791
  greedy.extend(chunk)
755
792
  wrapped.extend(_balance(list(chunk), max_em, mode, lang, gaps))
756
- measured.extend(chunk if mode == "measured"
757
- else _balance(list(chunk), max_em, "measured", None, gaps))
793
+ if mode == "measured":
794
+ measured.extend(chunk)
795
+ else:
796
+ # 1.15's wrap fills without the kinsoku joins (A54), so it is filled on its own
797
+ plain_gaps: "List[bool]" = []
798
+ plain = _greedy_chunks(raw, max_em, slice_overlong=slice_overlong, gaps=plain_gaps)
799
+ measured.extend(_balance(plain, max_em, "measured", None, plain_gaps))
758
800
  if not greedy:
759
801
  greedy = [text]
760
802
  return (wrapped or [text], greedy, measured or [text])
@@ -489,7 +489,7 @@ DIAGNOSTIC_CODES = {
489
489
  "emoji_not_drawn": ("warnings", "caption.py / graphics.py --emoji auto: no glyph, colour path or PNG asset draws these emoji here (doctor fonts.emoji mode none), so they were left out of the drawn text; also graphics.py --text-render drawtext, which has no font fallback; {count, reason}"),
490
490
  "emoji_mono_drawn_in_colour": ("warnings", "caption.py / graphics.py --emoji mono on a build whose libass draws the emoji face in colour: nothing makes the glyph monochrome, so the result's emoji.mode is color (graphics.py --text-render drawtext draws none: mode none, emoji_not_drawn)"),
491
491
  "hdr10_metadata_dropped": ("warnings", "a PQ output measured without the source's HDR10 mastering-display and/or content-light metadata (an encoder with no option for it, e.g. prores_ks or libaom on FFmpeg before 7); {dropped, codec}"),
492
- "source_items_dropped": ("warnings", "the written file lacks a subtitle track, chapters, a cover picture or surround channels the first input carried (source_carry lists everything kept and dropped, tags included); not repeated for a subtitle track the tool already reports through dropped_non_av_streams; {dropped}"),
492
+ "source_items_dropped": ("warnings", "the written file lacks a subtitle track, chapters, a cover picture, surround channels, attached files or a second video stream the first input carried (source_carry lists everything kept and dropped, tags included); not repeated for a subtitle track the tool already reports through dropped_non_av_streams; {dropped}"),
493
493
  "audio_tracks_dropped": ("warnings", "the source has more audio tracks than the output kept (the tools write one; cut.py also reports dropped_audio_streams). Not compared when two or more inputs have sound (grid --audio-from, a mix): which source the output carries is the tool's choice; {source, kept, dropped}"),
494
494
  "grid_no_drawtext": ("fallbacks", "grid.py --label auto on a build without the drawtext filter: the cells were not labelled, as with --label none"),
495
495
  "scenes_sheet_no_drawtext": ("fallbacks", "scenes.py --sheet on a build without the drawtext filter: the sheet was drawn without timecode stamps, as with --no-timecode"),
@@ -689,7 +689,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
689
689
  "levels": {"type": "object", "description": "--analyze only, on an input with a video stream: picture levels sampled at 2 fps over the first 20 s, on an 8-bit scale inferred from the values measured (a 10-bit or deeper picture whose luma never passes code 255 is read as 8-bit and left unscaled) {scale, y_min, y_max, y_avg, y_low_p5, y_high_p95, saturation_avg, looks_like_log, note}; {error} when no frame was analysed. Measured under --dry-run too"}},
690
690
  "additionalProperties": True}
691
691
  base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
692
- "dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "the ffmpeg command lines this run planned or ran to write its output (render.py and waveform.py: their stages' too); a measurement pass is listed only where the tool records it (e.g. loudness.py pass 1, check.py, stabilize.py pass 1), and batch.py lists none of its steps' (each results row's step documents carry their own)"},
692
+ "dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "the ffmpeg command lines this run planned or ran to write its output (render.py and waveform.py: their stages' too), shell-quoted; a line that names a file the tool wrote for the run and removed when it exited (such as a concat list, an ffmetadata file, a drawtext text file, a two-pass log or a stage's intermediate) cannot be run again as printed; a measurement pass is listed only where the tool records it (e.g. loudness.py pass 1, check.py, stabilize.py pass 1), and batch.py lists none of its steps' (each results row's step documents carry their own)"},
693
693
  "probe": {"type": "object", "description": "probe of the output when a file was written"},
694
694
  "output_identity": {"type": "object", "description": "2.24: {size, mtime_ns} of the file this run wrote, absent under --dry-run; pass it to check.py --expect-identity SIZE:MTIME_NS so the check grades this file and not whatever later sits at the path"},
695
695
  "encoder": {"type": "string", "description": "the video encoder the last ffmpeg command used (after any GPU->CPU fallback; under --dry-run the planned one; render.py and waveform.py: their stages' commands included), `copy` for a stream copy; absent when no command encoded video"},
@@ -698,7 +698,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
698
698
  "verified": {"type": "boolean", "description": "true only when the artifact was written, probed, and every self-check the tool ran (verification) met its target; false under --dry-run"},
699
699
  "verification": {"type": "array", "items": {"type": "object", "properties": {"step": {"type": "string"}, "ok": {"type": "boolean"}, "stage": {"type": "string"}, "property": {"type": "string"}, "expected": {}, "measured": {}, "delta": {}, "tolerance": {}, "provenance": {"enum": ["specification", "user_specified", "independent_measurement", "input_premeasurement"]}, "attribution": {"enum": ["artifact", "reference", "instrument", "provenance", "unresolved"]}}}, "description": "what the tool itself verified: probe, plus loudness (loudness.py, export platform presets; ok false with reason not_measured when it could not be read), check (render), report.py's sections (loudness, compliance, contact_sheet) and batch.py's items (item). render.py also lists every stage's own steps beyond its artifact probe, tagged with `stage` (a cached stage's as recorded), so its `verified` is the conjunction of every stage's. Since 2.18 a step may carry the property it measured with expected, measured, delta, tolerance and the provenance of the expected value; cut, join and export add a `streams` step comparing the output's video/audio streams with the input's; a step with ok false carries `attribution` (unresolved until a tool knows the cause)"},
700
700
  "unverified": {"type": "array", "items": {"type": "string"}, "description": "2.18: the properties of the output this tool did not measure (the unverified range), e.g. caption's rendered_placement; `verified` covers the measured properties only"},
701
- "source_carry": {"type": "object", "description": "what the first input carried besides its picture and sound, compared with the written file: {kept: [...], dropped: [...]} over subtitles, chapters, cover_art, audio_channels (a surround source only), creation_time, audio_language, timecode (kept only when the written start timecode is the label of the frame the output starts on: the source's own, or moved there by a tool that moved the picture's start; a join of several ranges writes none). Present only when the source had at least one of them; absent under --dry-run, for a still or GIF output, and when two or more inputs have a picture or sound. A report, not a check: `verified` does not depend on it. A dropped subtitles/chapters/cover_art/audio_channels also records a source_items_dropped warning"},
701
+ "source_carry": {"type": "object", "description": "what the first input carried besides its picture and sound, compared with the written file: {kept: [...], dropped: [...]} over subtitles, chapters, cover_art, audio_channels (a surround source only), attachments (Matroska attached files, such as a subtitle track's fonts), video_streams (a source with more than one playable picture), creation_time, audio_language, timecode (kept only when the written start timecode is the label of the frame the output starts on: the source's own, or moved there by a tool that moved the picture's start; a join of several ranges writes none). Present only when the source had at least one of them; absent under --dry-run, for a still or GIF output, and when two or more inputs have a picture or sound (render.py: a project whose clips are one file, with no music, replacement, effects track or video layer, is compared with that file). A report, not a check: `verified` does not depend on it. A dropped subtitles/chapters/cover_art/audio_channels/attachments/video_streams also records a source_items_dropped warning"},
702
702
  "diagnostics": DIAGNOSTICS_SCHEMA}
703
703
  extra: Dict[str, Any] = {}
704
704
  if name == "check":
@@ -22,6 +22,35 @@ from _common.emit import note
22
22
  from _common import STATE, add_common, apply_common, die, draws_chroma, emit, ffmpeg_base, info, parse_time, probe, rgb_chain_to_bt709, run, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs
23
23
 
24
24
 
25
+ def gradient_source(w: int, h: int, fps: float, c0: str, c1: str, angle: float) -> str:
26
+ """The `gradients` source for a `w`x`h` gradient at `angle` degrees (0 = left to right, 90 =
27
+ top to bottom) that runs from the frame's first corner along that direction to its last: c0
28
+ on the corner the direction starts from, c1 on the one opposite.
29
+
30
+ `gradients` takes its two points inside its own frame: a coordinate below -1 is refused and
31
+ one outside 0..w-1 / 0..h-1 is replaced by a seeded random one. The points were (0, 0) and the
32
+ width along the angle, so every angle outside 0-90 failed as kind ffmpeg (135 put x1 at -45:
33
+ A169), and inside it the far point was past the frame (x1 = w at 0, y1 = w at 90) and drawn
34
+ from the seed instead. A line of any direction that crosses the whole frame fits inside a
35
+ square canvas as wide as the frame's diagonal, so the gradient is drawn there, through the
36
+ centre, over the frame's extent along the direction (w|cos| + h|sin|), and the frame is cropped
37
+ from the middle."""
38
+ rad = math.radians(angle)
39
+ c, s = math.cos(rad), math.sin(rad)
40
+ side = 2 * math.ceil((math.hypot(w, h) + 4) / 2) # even, so the centred crop is whole pixels
41
+ half = (w * abs(c) + h * abs(s)) / 2
42
+ mid = side / 2
43
+ x0, y0, x1, y1 = (round(mid - half * c), round(mid - half * s), round(mid + half * c), round(mid + half * s))
44
+ # gradients defaults to seed=-1 (a random seed picked fresh each run) and speed=0.01 (a
45
+ # slow rotation applied every frame), so without pinning both, this "static" background
46
+ # was neither reproducible between runs nor actually static across its own duration --
47
+ # violating the bit_exact/deterministic contract _contract.py declares for this tool.
48
+ # speed's own valid range bottoms out at 1e-05 (0 is refused), so that's the closest to
49
+ # motionless the filter allows.
50
+ return (f"gradients=size={side}x{side}:rate={fps:g}:c0={c0}:c1={c1}:x0={x0}:y0={y0}:x1={x1}:y1={y1}"
51
+ f":seed=0:speed=1e-05,crop={w}:{h}:{(side - w) // 2}:{(side - h) // 2}")
52
+
53
+
25
54
  def main() -> int:
26
55
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
27
56
  ap.add_argument("-o", "--output", required=True, help="output file")
@@ -66,16 +95,7 @@ def main() -> int:
66
95
  die(f"--gradient needs two colours as C1:C2, got '{args.gradient}'")
67
96
  validate_color(c0, "--gradient")
68
97
  validate_color(c1, "--gradient")
69
- rad = math.radians(args.angle)
70
- x1 = round(args.width * math.cos(rad))
71
- y1 = round(args.width * math.sin(rad))
72
- # gradients defaults to seed=-1 (a random seed picked fresh each run) and speed=0.01 (a
73
- # slow rotation applied every frame), so without pinning both, this "static" background
74
- # was neither reproducible between runs nor actually static across its own duration --
75
- # violating the bit_exact/deterministic contract _contract.py declares for this tool.
76
- # speed's own valid range bottoms out at 1e-05 (0 is refused), so that's the closest to
77
- # motionless the filter allows.
78
- src_filter = f"gradients=size={args.width}x{args.height}:rate={args.fps:g}:c0={c0}:c1={c1}:x0=0:y0=0:x1={x1}:y1={y1}:seed=0:speed=1e-05"
98
+ src_filter = gradient_source(args.width, args.height, args.fps, c0, c1, args.angle)
79
99
  colours = [c0, c1]
80
100
  else:
81
101
  validate_color(args.color, "--color")
package/scripts/broll.py CHANGED
@@ -105,9 +105,15 @@ def main() -> int:
105
105
  dur_b = input_length(meta_b)
106
106
  if dur_b and start_b + length > dur_b + 0.01 and not STATE.dry_run:
107
107
  die(f"cutaway {i + 1}: {path} has only {dur_b - start_b:.3f}s from {start_b:g}s, {length:g}s asked for")
108
- if cutaways and at < cutaways[-1]["at"] + cutaways[-1]["length"]:
109
- die(f"cutaway {i + 1} at {at:g}s overlaps the previous one (ends {cutaways[-1]['at'] + cutaways[-1]['length']:g}s)")
110
108
  cutaways.append({"path": path, "at": at, "length": length, "from": start_b, "has_audio": bool(meta_b.get("audio")), "meta": meta_b})
109
+ # overlap is a property of the windows in time, not of the order they were given in: compared
110
+ # against the one given before it, a later window listed first was called overlapping (A179)
111
+ order = sorted(range(len(cutaways)), key=lambda k: cutaways[k]["at"])
112
+ for prev, k in zip(order, order[1:]):
113
+ end_prev = cutaways[prev]["at"] + cutaways[prev]["length"]
114
+ if cutaways[k]["at"] < end_prev:
115
+ die(f"cutaway {k + 1} at {cutaways[k]['at']:g}s overlaps cutaway {prev + 1} "
116
+ f"({cutaways[prev]['at']:g}s to {end_prev:g}s)")
111
117
  if args.audio != "a" and not all(c["has_audio"] for c in cutaways):
112
118
  die("--audio b/mix needs audio on every B-roll clip")
113
119