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.
- package/README.md +2 -2
- package/docs/contract.md +13 -7
- package/package.json +1 -1
- package/references/gotchas.md +2 -1
- package/references/scripts.md +37 -14
- package/scripts/_ass_overlay.py +7 -4
- package/scripts/_common/decision.py +19 -0
- package/scripts/_common/emit.py +8 -5
- package/scripts/_common/emoji.py +5 -1
- package/scripts/_common/fonts.py +42 -5
- package/scripts/_common/probe.py +5 -0
- package/scripts/_common/runner.py +42 -1
- package/scripts/_common/wrap.py +60 -18
- package/scripts/_contract.py +3 -3
- package/scripts/background.py +30 -10
- package/scripts/broll.py +8 -2
- package/scripts/caption.py +223 -71
- package/scripts/color.py +8 -5
- package/scripts/crop.py +3 -1
- package/scripts/export.py +16 -7
- package/scripts/freeze.py +6 -3
- package/scripts/graphics.py +3 -1
- package/scripts/grid.py +4 -2
- package/scripts/loop.py +9 -5
- package/scripts/metadata.py +4 -0
- package/scripts/redact.py +14 -2
- package/scripts/render.py +64 -2
- package/scripts/reverse.py +20 -7
- package/scripts/straighten.py +14 -3
package/scripts/_common/wrap.py
CHANGED
|
@@ -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 = "
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
757
|
-
|
|
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])
|
package/scripts/_contract.py
CHANGED
|
@@ -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
|
|
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":
|
package/scripts/background.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|