ffmpeg-skill 3.8.8 → 3.8.10
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 +13 -3
- package/package.json +1 -1
- package/references/gotchas.md +7 -4
- package/references/scripts.md +35 -14
- package/scripts/_ass_overlay.py +7 -4
- package/scripts/_common/asr.py +12 -1
- package/scripts/_common/drawtext.py +11 -0
- package/scripts/_common/emit.py +3 -1
- package/scripts/_common/emoji.py +33 -6
- package/scripts/_common/fonts.py +42 -5
- package/scripts/_common/probe.py +27 -7
- package/scripts/_common/runner.py +133 -5
- package/scripts/_common/wrap.py +60 -18
- package/scripts/_contract.py +14 -9
- package/scripts/caption.py +260 -72
- package/scripts/color.py +36 -0
- package/scripts/cropdetect.py +24 -6
- package/scripts/cut.py +13 -5
- package/scripts/export.py +3 -2
- package/scripts/graphics.py +31 -12
- package/scripts/join.py +20 -2
- package/scripts/loudness.py +2 -1
- package/scripts/metadata.py +11 -1
- package/scripts/overlay.py +11 -6
- package/scripts/probe.py +8 -0
- package/scripts/render.py +21 -3
- package/scripts/silence.py +27 -3
- package/scripts/stabilize.py +6 -2
- package/scripts/sync.py +8 -3
- package/scripts/waveform.py +13 -6
package/scripts/caption.py
CHANGED
|
@@ -46,6 +46,7 @@ from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_unit
|
|
|
46
46
|
from _ass_overlay import (EMOJI_SENTINEL, emoji_placeholder, ass_escape, ass_escape_markup,
|
|
47
47
|
strip_srt_markup, reinsert_srt_markup, carry_srt_markup, srt_alignment)
|
|
48
48
|
from _common.emit import refuse # noqa: E402
|
|
49
|
+
from _common.emoji import emoji_missing_asset_note, emoji_mono_on_colour_note
|
|
49
50
|
from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, label_chain, layer_base_chain, load_brand, overlay_format, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
|
|
50
51
|
# The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
|
|
51
52
|
# The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
|
|
@@ -103,6 +104,32 @@ def cue_times_match(line: str) -> "Optional[re.Match[str]]":
|
|
|
103
104
|
# an SRT timing line: the times alone, the cue text on the lines after it
|
|
104
105
|
DROP_FRAME_RE = re.compile(r"^\s*\d+:\d{2}:\d{2};\d+")
|
|
105
106
|
SRT_TIMING_RE = re.compile(r"^\s*\d+:\d{2}:\d{2}[,.]\d+\s*-->\s*\d+:\d{2}:\d{2}[,.]\d+\s*$")
|
|
107
|
+
# the same line as WebVTT may write it, without the hours: `00:01.000 --> 00:02.000`
|
|
108
|
+
TIMING_ONLY_RE = re.compile(r"^\s*(?:\d+:)?\d{1,2}:\d{2}[,.]\d+\s*-->\s*(?:\d+:)?\d{1,2}:\d{2}[,.]\d+\s*$")
|
|
109
|
+
ASS_SECTIONS = ("[script info]", "[v4+ styles]", "[v4 styles]", "[events]")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def refuse_not_a_cue_file(path: str, lines: List[str]) -> None:
|
|
113
|
+
"""--text given a file in another format (#404): read as a cue file, a WebVTT header, an ASS
|
|
114
|
+
section or a JSON document was burned in line by line, auto-timed 3 s apart. Named by what the
|
|
115
|
+
file starts with; the SRT timing-line check below catches a file with no such header."""
|
|
116
|
+
head = next((ln.strip() for ln in lines if ln.strip()), "")
|
|
117
|
+
cue_shape = "A cue file has one cue per line: `START-END text` (or text alone, timed --auto-seconds apart)"
|
|
118
|
+
if head.upper().startswith("WEBVTT"):
|
|
119
|
+
die(f"--text: {path} is a WebVTT subtitle file (it starts with {head[:20]!r}); pass it as --srt, which reads "
|
|
120
|
+
f"WebVTT (render.py: --srt, not --cues). {cue_shape}", kind="input")
|
|
121
|
+
if head.lower() in ASS_SECTIONS or head.startswith("Dialogue:"):
|
|
122
|
+
die(f"--text: {path} is an ASS/SSA subtitle file (it starts with {head[:20]!r}); pass it as --ass, which burns "
|
|
123
|
+
f"it with the styles inside it (in a render.py project, the captions stage's \"ass\" key). {cue_shape}",
|
|
124
|
+
kind="input")
|
|
125
|
+
if head[:1] in "{[":
|
|
126
|
+
try:
|
|
127
|
+
doc = json.loads("\n".join(lines))
|
|
128
|
+
except ValueError:
|
|
129
|
+
doc = None
|
|
130
|
+
if isinstance(doc, dict) or (isinstance(doc, list) and doc and isinstance(doc[0], (dict, list))):
|
|
131
|
+
die(f"--text: {path} is a JSON document, not a cue file. {cue_shape}. A whisper JSON is a transcript: "
|
|
132
|
+
"caption.py --transcribe writes the cues from the audio", kind="input")
|
|
106
133
|
|
|
107
134
|
|
|
108
135
|
def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[float] = None) -> List[Tuple[float, float, str]]:
|
|
@@ -111,7 +138,12 @@ def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[fl
|
|
|
111
138
|
# split at "\n" only: splitlines() also breaks a cue at U+2028, U+0085, \v and \f, which
|
|
112
139
|
# made the rest of the line an auto-timed cue of its own
|
|
113
140
|
lines = read_text_or_die(path, "--text").split("\n")
|
|
141
|
+
refuse_not_a_cue_file(path, lines)
|
|
114
142
|
for n, raw in enumerate(lines, 1):
|
|
143
|
+
if TIMING_ONLY_RE.match(raw) and not SRT_TIMING_RE.match(raw):
|
|
144
|
+
# WebVTT's short form (no hours), in a file without the WEBVTT header
|
|
145
|
+
die(f"--text: {path} is a subtitle file, not a cue file (line {n} is a timing line with no text: "
|
|
146
|
+
f"'{raw.strip()}'); pass it as --srt (render.py: --srt, not --cues)", kind="input")
|
|
115
147
|
if SRT_TIMING_RE.match(raw):
|
|
116
148
|
# a cue file puts the text on the line with its times; this line has the times alone, so
|
|
117
149
|
# the file is an SRT. Read as a cue file, its numbers and timing lines would be burned in
|
|
@@ -257,6 +289,9 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
|
|
|
257
289
|
die(f"--emoji mono: this ffmpeg draws no emoji glyph through libass ({support['detail']}), so "
|
|
258
290
|
f"{len(clusters_all)} emoji would come out as empty boxes -- pass --emoji-assets DIR for colour, "
|
|
259
291
|
"or --emoji none to leave them out of the drawn text", kind="input")
|
|
292
|
+
if want == "mono" and support.get("libass_color"):
|
|
293
|
+
mode = "color"
|
|
294
|
+
warn(emoji_mono_on_colour_note(support), "emoji_mono_drawn_in_colour")
|
|
260
295
|
plan = {"mode": mode, "count": len(clusters_all),
|
|
261
296
|
"clusters": sorted({emoji_codepoint_name(cl) for cl in clusters_all}),
|
|
262
297
|
"assets": assets, "missing": [], "overlays": []}
|
|
@@ -286,12 +321,15 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
|
|
|
286
321
|
scale = float(getattr(args, "emoji_scale", 1.0) or 1.0)
|
|
287
322
|
# --animate moves the TEXT (\fad/\fscx in the ASS); the PNG has to move with it, or the emoji
|
|
288
323
|
# pops in against a line that is still fading up. These match the \fad values below.
|
|
289
|
-
fade_in, fade_out =
|
|
290
|
-
"slide": (0.15, 0.15)}.get(getattr(args, "animate", None) or "none", (0.0, 0.0))
|
|
324
|
+
fade_in, fade_out = (ms / 1000.0 for ms in ANIMATE_FADE_MS.get(getattr(args, "animate", None) or "none", (0, 0)))
|
|
291
325
|
size_px = args.size * play_h / 288.0
|
|
292
326
|
margin_px = args.margin * play_h / 288.0
|
|
293
327
|
line_h = size_px * 1.2
|
|
294
328
|
box_px = size_px * scale
|
|
329
|
+
# A62 (#457): libass lays a line out between the Style's MarginL and MarginR (margins_x, the
|
|
330
|
+
# horizontal safe zone), not --margin, which is the vertical distance: a left-aligned PNG was
|
|
331
|
+
# drawn over the first words and a right-aligned one left of its gap
|
|
332
|
+
margin_l, margin_r = margins_x(args, play_w)
|
|
295
333
|
out_cues = []
|
|
296
334
|
for start, end, text in cues:
|
|
297
335
|
lines = _cue_lines(text)
|
|
@@ -307,11 +345,11 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
|
|
|
307
345
|
y_top = play_h - margin_px - (n - i) * line_h
|
|
308
346
|
line_w = text_width_em(strip_srt_markup(line), scale) * size_px
|
|
309
347
|
if align in (1, 4, 7):
|
|
310
|
-
x0 =
|
|
348
|
+
x0 = float(margin_l)
|
|
311
349
|
elif align in (3, 6, 9):
|
|
312
|
-
x0 = play_w -
|
|
350
|
+
x0 = play_w - margin_r - line_w
|
|
313
351
|
else:
|
|
314
|
-
x0 = (play_w - line_w) / 2.0
|
|
352
|
+
x0 = margin_l + (play_w - margin_l - margin_r - line_w) / 2.0
|
|
315
353
|
# libass lays an RTL line out right-to-left, so the LOGICAL prefix of a cluster
|
|
316
354
|
# occupies the RIGHT end of the rendered line. Measuring the prefix from the left
|
|
317
355
|
# edge put the PNG on top of the text, mirrored, on every Arabic/Hebrew cue (1.15.0).
|
|
@@ -354,8 +392,7 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
|
|
|
354
392
|
"--emoji-max raises it); ffmpeg's filter graph and the per-frame cost both grow "
|
|
355
393
|
"linearly -- split the job, or use --emoji none", kind="input")
|
|
356
394
|
if plan["missing"]:
|
|
357
|
-
warn("
|
|
358
|
-
" -- those clusters are drawn by the text font instead", "emoji_assets_missing", missing=list(plan["missing"]))
|
|
395
|
+
warn(emoji_missing_asset_note(plan["missing"], support), "emoji_assets_missing", missing=list(plan["missing"]))
|
|
359
396
|
plan["box_px"] = int(round(box_px))
|
|
360
397
|
return out_cues, plan
|
|
361
398
|
|
|
@@ -527,23 +564,31 @@ def safe_width_fraction(args, play_w: Optional[int]) -> float:
|
|
|
527
564
|
return max(0.05, (play_w - left - right) / float(play_w))
|
|
528
565
|
|
|
529
566
|
|
|
567
|
+
def line_fraction(args, play_w: Optional[int]) -> float:
|
|
568
|
+
"""The width fraction one line is wrapped to: safe_width_fraction, narrowed under
|
|
569
|
+
--karaoke-style word by --karaoke-scale.
|
|
570
|
+
|
|
571
|
+
--karaoke-style word scales its active word up by --karaoke-scale and freezes the line break
|
|
572
|
+
(WrapStyle: 2 in write_ass) so libass can never re-wrap around that scale-up. The budget is
|
|
573
|
+
narrowed by the same factor so the longest wrapped line -- computed at the UNscaled width --
|
|
574
|
+
still fits once its active word is drawn --karaoke-scale%% larger; without this, a line that
|
|
575
|
+
exactly filled the safe width would overflow it the moment its emphasised word appears. The
|
|
576
|
+
size fit measures against the same budget, or a cue it reported as fitting was split by the
|
|
577
|
+
layout after it (A58, #457).
|
|
578
|
+
"""
|
|
579
|
+
frac = safe_width_fraction(args, play_w)
|
|
580
|
+
if getattr(args, "karaoke", False) and getattr(args, "karaoke_style", "sweep") == "word":
|
|
581
|
+
frac = frac / max(1.0, getattr(args, "karaoke_scale", 112) / 100.0)
|
|
582
|
+
return frac
|
|
583
|
+
|
|
584
|
+
|
|
530
585
|
def max_line_em(args, play_w: Optional[int], play_h: Optional[int]) -> Optional[float]:
|
|
531
586
|
"""How many em fit on one caption line at the chosen size, or None without video geometry.
|
|
532
587
|
|
|
533
588
|
--size is in ASS points against a 288-line script (what libass's force_style uses), so the
|
|
534
|
-
rendered pixel size is size * play_h / 288.
|
|
535
|
-
|
|
536
|
-
--karaoke-style word scales its active word up by --karaoke-scale and freezes the line break
|
|
537
|
-
(WrapStyle: 2 in write_ass) so libass can never re-wrap around that scale-up. The budget here
|
|
538
|
-
is narrowed by the same factor so the longest wrapped line -- computed at the UNscaled width --
|
|
539
|
-
still fits once its active word is drawn --karaoke-scale%% larger; without this, a line that
|
|
540
|
-
exactly filled the safe width would overflow it the moment its emphasised word appears.
|
|
589
|
+
rendered pixel size is size * play_h / 288; the width is line_fraction's.
|
|
541
590
|
"""
|
|
542
|
-
|
|
543
|
-
safe_fraction=safe_width_fraction(args, play_w))
|
|
544
|
-
if em is not None and getattr(args, "karaoke", False) and getattr(args, "karaoke_style", "sweep") == "word":
|
|
545
|
-
em = em / max(1.0, getattr(args, "karaoke_scale", 112) / 100.0)
|
|
546
|
-
return em
|
|
591
|
+
return line_em_for_size(args.size, play_w, play_h, safe_fraction=line_fraction(args, play_w))
|
|
547
592
|
|
|
548
593
|
|
|
549
594
|
def parse_ass_dialogue(path: str) -> str:
|
|
@@ -658,35 +703,139 @@ def shift_ass_text(source: str, offset: float) -> Tuple[str, int, int]:
|
|
|
658
703
|
|
|
659
704
|
|
|
660
705
|
def word_durations_from_timings(words: List[Tuple[float, float, str]], start: float, end: float,
|
|
661
|
-
n_words: int) -> Optional[List[int]]:
|
|
662
|
-
"""
|
|
706
|
+
n_words: int) -> Optional[Tuple[int, List[int]]]:
|
|
707
|
+
"""(lead, centiseconds per word) for one cue from real word timings; None when they don't
|
|
708
|
+
cover it. `lead` is the silence between the cue's start and its first word: the highlight
|
|
709
|
+
waits for it rather than starting at the cue and running ahead of the speech by that much
|
|
710
|
+
(A59, #457)."""
|
|
663
711
|
inside = [w for w in words if w[1] > start + 0.01 and w[0] < end - 0.01]
|
|
664
712
|
if len(inside) != n_words or n_words <= 0:
|
|
665
713
|
return None
|
|
666
714
|
total_cs = max(1, int(round((end - start) * 100)))
|
|
667
|
-
|
|
715
|
+
first = min(end, max(start, inside[0][0]))
|
|
716
|
+
lead = int(round((first - start) * 100))
|
|
717
|
+
bounds = [first] + [max(first, min(end, w[1])) for w in inside]
|
|
668
718
|
out = [max(1, int(round((bounds[i + 1] - bounds[i]) * 100))) for i in range(n_words)]
|
|
669
|
-
out[-1] += total_cs - sum(out)
|
|
719
|
+
out[-1] += total_cs - lead - sum(out)
|
|
670
720
|
if out[-1] < 1:
|
|
671
721
|
return None
|
|
672
|
-
return out
|
|
722
|
+
return lead, out
|
|
673
723
|
|
|
674
724
|
|
|
675
|
-
def _karaoke_word_durations(args, video: Optional[str], start: float, end: float,
|
|
676
|
-
|
|
725
|
+
def _karaoke_word_durations(args, video: Optional[str], start: float, end: float,
|
|
726
|
+
n_words: int) -> Tuple[int, List[int]]:
|
|
727
|
+
"""(lead, centiseconds per word) for one cue, shared by --karaoke-style sweep and word.
|
|
677
728
|
|
|
678
729
|
Real word timings from the transcript beat both the energy estimate and the even split --
|
|
679
|
-
they are what the speaker actually did, not a proxy for it.
|
|
730
|
+
they are what the speaker actually did, not a proxy for it. Only they carry a lead (the
|
|
731
|
+
silence before the first word); the other two share the whole cue out from its start.
|
|
680
732
|
"""
|
|
681
733
|
dur_cs = max(1, int(round((end - start) * 100)))
|
|
682
|
-
|
|
683
|
-
if
|
|
684
|
-
|
|
685
|
-
|
|
734
|
+
timed = word_durations_from_timings(getattr(args, "_word_timings", None) or [], start, end, n_words)
|
|
735
|
+
if timed is not None:
|
|
736
|
+
return timed
|
|
737
|
+
if getattr(args, "karaoke_timing", "even") == "energy" and video:
|
|
738
|
+
return 0, word_durations_from_audio(video, start, end, n_words, getattr(args, "audio_stream", 0))
|
|
739
|
+
per = max(1, dur_cs // max(1, n_words))
|
|
740
|
+
return 0, [per] * n_words
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def shift_words(words: List[Tuple[float, float, str]], offset: float) -> List[Tuple[float, float, str]]:
|
|
744
|
+
"""Word timings moved by --offset, as the cues they belong to are (#505): the offset corrects a
|
|
745
|
+
transcript that runs early or late against the sound, and its word timings come from the same
|
|
746
|
+
transcript."""
|
|
747
|
+
if not offset:
|
|
748
|
+
return list(words or [])
|
|
749
|
+
return [(s + offset, e + offset, w) for s, e, w in (words or [])]
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
# A `{...}` that ffmpeg's SRT reader passes on to libass as text, where it is an override block and
|
|
753
|
+
# draws nothing: not `{\...}` (ffmpeg removes those itself), not a MicroDVD `{y:i}` style code, not
|
|
754
|
+
# the `\{` escape
|
|
755
|
+
SRT_HIDDEN_BRACES_RE = re.compile(r"(?<!\\)\{(?!\\)(?![CcFfoPSsYy]:)[^{}]*\}")
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
# --animate's envelope in ms (fade in, fade out), and the slide's rise: one table for the ASS
|
|
759
|
+
# below and the emoji PNGs that ride it (plan_emoji)
|
|
760
|
+
ANIMATE_FADE_MS = {"fade": (200, 200), "pop": (80, 120), "slide": (150, 150)}
|
|
761
|
+
SLIDE_MS = 250
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def line_anchor(align: int, play_w: int, play_h: int, margin: int, margin_l: int, margin_r: int) -> Tuple[int, int]:
|
|
765
|
+
"""The point libass anchors a line of alignment `align` (numpad) at, from the Style's margins:
|
|
766
|
+
what a `\\move` has to end on so that --animate slide lands where the line is drawn without it
|
|
767
|
+
(A56, #457: the slide always ended at the bottom centre)."""
|
|
768
|
+
if align in (1, 4, 7):
|
|
769
|
+
x = margin_l
|
|
770
|
+
elif align in (3, 6, 9):
|
|
771
|
+
x = play_w - margin_r
|
|
772
|
+
else:
|
|
773
|
+
x = margin_l + (play_w - margin_l - margin_r) // 2
|
|
774
|
+
if align in (7, 8, 9):
|
|
775
|
+
y = margin
|
|
776
|
+
elif align in (4, 5, 6):
|
|
777
|
+
y = play_h // 2
|
|
778
|
+
else:
|
|
779
|
+
y = play_h - margin
|
|
780
|
+
return x, y
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def animate_fx(kind: str, o: int, e: int, dur: int, anchor: Tuple[int, int], rise: int) -> str:
|
|
784
|
+
"""The --animate override for an event shown from `o` to `e` ms into a cue `dur` ms long.
|
|
785
|
+
|
|
786
|
+
A whole cue (o == 0, e == dur) gets the envelope it always had. --karaoke-style word draws a
|
|
787
|
+
cue as one event per word: each gets the part of the cue's one envelope that falls inside it,
|
|
788
|
+
started where the event before left it, so the fade-out comes at the cue's end and not at the
|
|
789
|
+
end of the first word, and a slide or a pop does not restart or jump at a word (A57, #457)."""
|
|
790
|
+
if kind not in ANIMATE_FADE_MS:
|
|
791
|
+
return ""
|
|
792
|
+
fin, fout = ANIMATE_FADE_MS[kind]
|
|
793
|
+
x, y2 = anchor
|
|
794
|
+
y1 = y2 + rise
|
|
795
|
+
whole = o == 0 and e == dur
|
|
796
|
+
if whole:
|
|
797
|
+
fade = "\\fad(%d,%d)" % (fin, fout)
|
|
798
|
+
else:
|
|
799
|
+
out_at = dur - fout
|
|
800
|
+
|
|
801
|
+
def alpha(t: float) -> int:
|
|
802
|
+
a = 0.0
|
|
803
|
+
if t < fin:
|
|
804
|
+
a = 255.0 * (1.0 - t / float(fin))
|
|
805
|
+
if t > out_at and fout:
|
|
806
|
+
a = max(a, 255.0 * (t - out_at) / float(fout))
|
|
807
|
+
return int(round(min(255.0, max(0.0, a))))
|
|
808
|
+
if o >= fin and e <= out_at:
|
|
809
|
+
fade = ""
|
|
810
|
+
else:
|
|
811
|
+
a1 = alpha(o)
|
|
812
|
+
t2 = max(0, fin - o)
|
|
813
|
+
t3 = max(t2, out_at - o)
|
|
814
|
+
fade = "\\fade(%d,%d,255,0,%d,%d,%d)" % (a1, a1 if o > out_at else 0, t2, t3, max(t3, dur - o))
|
|
815
|
+
if kind == "fade":
|
|
816
|
+
tags = fade
|
|
817
|
+
elif kind == "pop":
|
|
818
|
+
if o == 0:
|
|
819
|
+
scale = "\\fscx60\\fscy60\\t(0,120,\\fscx110\\fscy110)\\t(120,200,\\fscx100\\fscy100)"
|
|
820
|
+
elif o < 120:
|
|
821
|
+
v = int(round(60 + 50 * o / 120.0))
|
|
822
|
+
scale = ("\\fscx%d\\fscy%d\\t(0,%d,\\fscx110\\fscy110)\\t(%d,%d,\\fscx100\\fscy100)"
|
|
823
|
+
% (v, v, 120 - o, 120 - o, 200 - o))
|
|
824
|
+
elif o < 200:
|
|
825
|
+
v = int(round(110 - 10 * (o - 120) / 80.0))
|
|
826
|
+
scale = "\\fscx%d\\fscy%d\\t(0,%d,\\fscx100\\fscy100)" % (v, v, 200 - o)
|
|
827
|
+
else:
|
|
828
|
+
scale = ""
|
|
829
|
+
tags = fade + scale
|
|
830
|
+
else:
|
|
831
|
+
if o == 0:
|
|
832
|
+
move = "\\move(%d,%d,%d,%d,0,%d)" % (x, y1, x, y2, SLIDE_MS)
|
|
833
|
+
elif o < SLIDE_MS:
|
|
834
|
+
move = "\\move(%d,%d,%d,%d,0,%d)" % (x, int(round(y1 + (y2 - y1) * o / float(SLIDE_MS))), x, y2, SLIDE_MS - o)
|
|
686
835
|
else:
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
return
|
|
836
|
+
move = "\\pos(%d,%d)" % (x, y2)
|
|
837
|
+
tags = fade + move
|
|
838
|
+
return "{" + tags + "}" if tags else ""
|
|
690
839
|
|
|
691
840
|
|
|
692
841
|
# an override block this file wrote (a converted SRT tag), not the escaped `\{` a user typed
|
|
@@ -717,7 +866,8 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
717
866
|
primary = ass_color(args.highlight_color if args.karaoke else args.color)
|
|
718
867
|
secondary = ass_color(args.color)
|
|
719
868
|
outline = ass_color(args.outline_color)
|
|
720
|
-
|
|
869
|
+
back_alpha = 0x80 # the --box colour is half transparent; an emoji placeholder restores it
|
|
870
|
+
back = ass_color(args.outline_color, back_alpha)
|
|
721
871
|
word_style = args.karaoke and getattr(args, "karaoke_style", "sweep") == "word"
|
|
722
872
|
word_past = ass_color(args.color)
|
|
723
873
|
word_active = ass_color(args.highlight_color)
|
|
@@ -752,14 +902,12 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
752
902
|
# an override block. Newlines become \N in the same pass.
|
|
753
903
|
# SRT markup is the exception (#X2): <i>/<b>/<u>/<s>, <font color> and {\anN} become the
|
|
754
904
|
# overrides ffmpeg's SRT reader gives them, so this route draws what the plain one draws
|
|
905
|
+
# the line's anchor for --animate slide: the cue's own {\anN} first, as libass reads it
|
|
906
|
+
anchor = line_anchor(srt_alignment(text) or ALIGN[args.position], play_w, play_h, margin, margin_l, margin_r)
|
|
755
907
|
text = ass_escape_markup(text, colour=not args.karaoke)
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
elif args.animate == "pop":
|
|
760
|
-
fx = "{\\fad(80,120)\\fscx60\\fscy60\\t(0,120,\\fscx110\\fscy110)\\t(120,200,\\fscx100\\fscy100)}"
|
|
761
|
-
elif args.animate == "slide":
|
|
762
|
-
fx = "{\\fad(150,150)\\move(%d,%d,%d,%d,0,250)}" % (play_w // 2, play_h - margin + int(30 * scale), play_w // 2, play_h - margin)
|
|
908
|
+
cue_ms = max(1, int(round((end - start) * 100))) * 10
|
|
909
|
+
fx = animate_fx(args.animate, 0, cue_ms, cue_ms, anchor, int(30 * scale))
|
|
910
|
+
size_fx = ""
|
|
763
911
|
body = text
|
|
764
912
|
# --fit-size-scope cue: one size per cue, as a leading {\fsN} override. Opt-in only --
|
|
765
913
|
# see fit_size()'s docstring for why a size that changes cue to cue is not the default.
|
|
@@ -769,7 +917,8 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
769
917
|
own_sizes = getattr(args, "_fit_sizes_out", None) or []
|
|
770
918
|
own = own_sizes[cue_index] if cue_index < len(own_sizes) else None
|
|
771
919
|
if own and own != args.size:
|
|
772
|
-
|
|
920
|
+
size_fx = "{\\fs%d}" % int(round(own * scale))
|
|
921
|
+
fx += size_fx
|
|
773
922
|
if args.karaoke and word_style:
|
|
774
923
|
# One Dialogue event per WORD, each carrying the whole cue -- \k/\kf can only
|
|
775
924
|
# interpolate colour, so scaling/emboldening the active word (and a third "upcoming"
|
|
@@ -785,18 +934,21 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
785
934
|
# today's karaoke-off rendering, rather than zero Dialogue lines for this cue
|
|
786
935
|
out_body = body
|
|
787
936
|
if EMOJI_SENTINEL in out_body:
|
|
788
|
-
out_body = out_body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size)))
|
|
937
|
+
out_body = out_body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size), back_alpha))
|
|
789
938
|
lines.append(f"Dialogue: 0,{t(start)},{t(end)},Default,,0,0,0,,{fx}{out_body}")
|
|
790
939
|
continue
|
|
791
940
|
n_words = len(real_idx)
|
|
792
|
-
durs = _karaoke_word_durations(args, video, start, end, n_words)
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
941
|
+
lead, durs = _karaoke_word_durations(args, video, start, end, n_words)
|
|
942
|
+
# (first cs, cs, active word): the silence before the first word is an event of its
|
|
943
|
+
# own in which no word is active yet (A59)
|
|
944
|
+
events, acc = ([(0, lead, -1)] if lead > 0 else []), lead
|
|
945
|
+
for j, d in enumerate(durs):
|
|
946
|
+
events.append((acc, d, j))
|
|
796
947
|
acc += d
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
948
|
+
span_ms = acc * 10 # the --animate envelope spans the events, first to last
|
|
949
|
+
for ev_cs, ev_len, j in events:
|
|
950
|
+
w_start = start + ev_cs / 100.0
|
|
951
|
+
w_end = start + (ev_cs + ev_len) / 100.0
|
|
800
952
|
# colour/scale each real word by its rank against the active one (j); an
|
|
801
953
|
# emoji-placeholder token is never active and just keeps its plain text
|
|
802
954
|
styled, flat_pos = [], 0
|
|
@@ -821,18 +973,19 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
821
973
|
styled.append(" ".join(styled_line))
|
|
822
974
|
out_body = "\\N".join(styled)
|
|
823
975
|
if EMOJI_SENTINEL in out_body:
|
|
824
|
-
out_body = out_body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size)))
|
|
825
|
-
# the size/fit override (\fsN) belongs on every event; the
|
|
826
|
-
#
|
|
827
|
-
#
|
|
828
|
-
event_fx =
|
|
976
|
+
out_body = out_body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size), back_alpha))
|
|
977
|
+
# the size/fit override (\fsN) belongs on every event; --animate is the cue's one
|
|
978
|
+
# envelope, and each event takes the part of it that falls inside the event: the
|
|
979
|
+
# fade-out at the cue's end, the slide and the pop carried on, not restarted (A57)
|
|
980
|
+
event_fx = animate_fx(args.animate, ev_cs * 10, (ev_cs + ev_len) * 10, span_ms, anchor,
|
|
981
|
+
int(30 * scale)) + size_fx
|
|
829
982
|
lines.append(f"Dialogue: 0,{t(w_start)},{t(w_end)},Default,,0,0,0,,{event_fx}{out_body}")
|
|
830
983
|
continue
|
|
831
984
|
if args.karaoke:
|
|
832
985
|
# split each line into words and give every word an equal share of the cue (\k is in centiseconds)
|
|
833
986
|
segments = body.split("\\N")
|
|
834
987
|
words = [w for seg in segments for w in seg.split(" ") if w and _karaoke_word(w)]
|
|
835
|
-
durs = _karaoke_word_durations(args, video, start, end, len(words))
|
|
988
|
+
lead, durs = _karaoke_word_durations(args, video, start, end, len(words))
|
|
836
989
|
it = iter(durs)
|
|
837
990
|
out_segments = []
|
|
838
991
|
for seg in segments:
|
|
@@ -843,8 +996,12 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
843
996
|
out_segments.append(" ".join(
|
|
844
997
|
("{\\kf0}" + w) if not _karaoke_word(w) else f"{{\\kf{next(it)}}}{w}" for w in ws))
|
|
845
998
|
body = "\\N".join(out_segments)
|
|
999
|
+
if lead > 0:
|
|
1000
|
+
# the silence before the first word: an empty \k syllable, so the fill starts
|
|
1001
|
+
# with the speech and not at the cue's start (A59)
|
|
1002
|
+
body = "{\\k%d}" % lead + body
|
|
846
1003
|
if EMOJI_SENTINEL in body:
|
|
847
|
-
body = body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size)))
|
|
1004
|
+
body = body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size), back_alpha))
|
|
848
1005
|
lines.append(f"Dialogue: 0,{t(start)},{t(end)},Default,,0,0,0,,{fx}{body}")
|
|
849
1006
|
with open(path, "w", encoding="utf-8-sig") as fh:
|
|
850
1007
|
fh.write("\n".join(header + lines) + "\n")
|
|
@@ -1180,6 +1337,13 @@ def main() -> int:
|
|
|
1180
1337
|
"ASS carries burn-only styling with no soft-subtitle equivalent; use --mode burn for an ASS file")
|
|
1181
1338
|
if args.animate != "none" or args.karaoke:
|
|
1182
1339
|
die("--animate/--karaoke render pixels into the picture and require --mode burn")
|
|
1340
|
+
if args.max_lines != 2 or args.wrap != "phrase":
|
|
1341
|
+
# A55: a soft track is wrapped by the player at the size it draws; this run does not wrap
|
|
1342
|
+
# or split it, so a layout flag would be accepted and do nothing
|
|
1343
|
+
asked = f"--max-lines {args.max_lines}" if args.max_lines != 2 else f"--wrap {args.wrap}"
|
|
1344
|
+
die(f"{asked} lays out burned captions; --mode mux adds the cues as they are and the player wraps "
|
|
1345
|
+
"a soft subtitle at the size it draws it", kind="input",
|
|
1346
|
+
hint=f"drop {asked.split()[0]}, or use --mode burn")
|
|
1183
1347
|
|
|
1184
1348
|
args.offset = signed_time_arg(str(args.offset), "--offset")
|
|
1185
1349
|
if args.max_lines < 1:
|
|
@@ -1255,7 +1419,7 @@ def main() -> int:
|
|
|
1255
1419
|
return dict(size=fit_stats["size_requested"], min_size=args._fit_floor,
|
|
1256
1420
|
max_lines=args.max_lines, play_w=play_w, play_h=play_h,
|
|
1257
1421
|
mode=args.wrap, lang=args.language, scope=args.fit_size_scope,
|
|
1258
|
-
safe_fraction=
|
|
1422
|
+
safe_fraction=line_fraction(args, play_w))
|
|
1259
1423
|
|
|
1260
1424
|
def fit_the_size(cue_list):
|
|
1261
1425
|
"""Shrink --size until every cue fits --max-lines, BEFORE the cue is split.
|
|
@@ -1297,7 +1461,7 @@ def main() -> int:
|
|
|
1297
1461
|
# budget and then drawing some cues large put lines off the side of the frame.
|
|
1298
1462
|
args._fit_cue_em = [line_em_for_size(fit["per_cue"].get(i, fit["size"]),
|
|
1299
1463
|
play_w, play_h,
|
|
1300
|
-
safe_fraction=
|
|
1464
|
+
safe_fraction=line_fraction(args, play_w))
|
|
1301
1465
|
for i in range(len(cue_list))]
|
|
1302
1466
|
args._fit_cue_size = [fit["per_cue"].get(i, fit["size"]) for i in range(len(cue_list))]
|
|
1303
1467
|
|
|
@@ -1306,7 +1470,9 @@ def main() -> int:
|
|
|
1306
1470
|
--offset -- the one place every cue source goes through, so an SRT, a cue file and a
|
|
1307
1471
|
transcript all come out equally readable."""
|
|
1308
1472
|
fit_the_size(cue_list)
|
|
1309
|
-
out
|
|
1473
|
+
# A55 (#457): a soft subtitle is laid out by the player, at a size this run does not
|
|
1474
|
+
# know; wrapping it at the burn-in size split a cue on a portrait video into three
|
|
1475
|
+
out, stats = layout_cues(cue_list, max_em=None if args.mode == "mux" else max_line_em(args, play_w, play_h),
|
|
1310
1476
|
max_lines=args.max_lines, min_duration=args.min_duration,
|
|
1311
1477
|
offset=args.offset, wrap=args.wrap, lang=args.language,
|
|
1312
1478
|
per_cue_em=getattr(args, "_fit_cue_em", None),
|
|
@@ -1326,15 +1492,24 @@ def main() -> int:
|
|
|
1326
1492
|
for token in (args.srt or []):
|
|
1327
1493
|
path_part, lang_part = split_srt_lang(token)
|
|
1328
1494
|
srt_tracks.append((path_part, lang_part))
|
|
1329
|
-
|
|
1495
|
+
# --language tags the track only where there is a track to tag (A71, #457): a burn reads it as
|
|
1496
|
+
# the script and wrap hint, and refusing it as an --srt suffix named a flag nobody had given
|
|
1497
|
+
tagged_by_language = False
|
|
1498
|
+
if args.mode == "mux" and len(srt_tracks) == 1 and srt_tracks[0][1] is None and args.language:
|
|
1330
1499
|
srt_tracks[0] = (srt_tracks[0][0], args.language)
|
|
1500
|
+
tagged_by_language = True
|
|
1501
|
+
if args.mode == "mux" and args.language and not srt_tracks and not LANG_TOKEN_RE.match(args.language):
|
|
1502
|
+
# the --text/--transcribe track is tagged with it too, and was tagged unchecked
|
|
1503
|
+
die(f"--language: '{args.language}' is not a language code (two or three letters, optionally with a "
|
|
1504
|
+
"region, e.g. en, ja, pt-BR)", kind="input")
|
|
1331
1505
|
if srt_tracks and args.mode != "mux" and len(srt_tracks) > 1:
|
|
1332
1506
|
die("burning renders pixels; only one language can be in the picture -- burn one and mux "
|
|
1333
1507
|
"the rest (caption.py OUT --mode mux --srt en.srt:en --srt ja.srt:ja)", kind="input")
|
|
1334
1508
|
seen_langs = [lang for _p, lang in srt_tracks if lang]
|
|
1335
1509
|
for lang in seen_langs:
|
|
1336
1510
|
if not LANG_TOKEN_RE.match(lang):
|
|
1337
|
-
die(f"--srt: '{lang}' is not a language code (two or
|
|
1511
|
+
die(f"{'--language' if tagged_by_language else '--srt'}: '{lang}' is not a language code (two or "
|
|
1512
|
+
"three letters, optionally with a "
|
|
1338
1513
|
"region, e.g. en, ja, pt-BR)", kind="input")
|
|
1339
1514
|
if len(set(seen_langs)) != len(seen_langs):
|
|
1340
1515
|
dup = sorted({l for l in seen_langs if seen_langs.count(l) > 1})
|
|
@@ -1430,7 +1605,8 @@ def main() -> int:
|
|
|
1430
1605
|
cues = asr_run.cues
|
|
1431
1606
|
args._asr = dict(asr_run.facts)
|
|
1432
1607
|
args._asr_notes = list(asr_run.notes)
|
|
1433
|
-
args._word_timings = whisper_word_timings(srt_path) or sorted(
|
|
1608
|
+
args._word_timings = shift_words(whisper_word_timings(srt_path) or sorted(
|
|
1609
|
+
(w["start"], w["end"], w["word"]) for w in asr_run.words), args.offset)
|
|
1434
1610
|
cues, changed = lay_out(cues)
|
|
1435
1611
|
if changed:
|
|
1436
1612
|
write_srt(cues, srt_path)
|
|
@@ -1595,11 +1771,11 @@ def main() -> int:
|
|
|
1595
1771
|
side_notes.append(f"subtitle track {n} is {copy}, a copy of {os.path.basename(path)} with its timings "
|
|
1596
1772
|
"rewritten as hh:mm:ss,mmm, which ffmpeg reads as this tool does")
|
|
1597
1773
|
added[n] = (copy, lang)
|
|
1598
|
-
if len(added) > 1 and
|
|
1599
|
-
# --
|
|
1600
|
-
#
|
|
1601
|
-
side_notes.append(f"--
|
|
1602
|
-
"added track(s) keep the
|
|
1774
|
+
if len(added) > 1 and caption_stats.get("extended"):
|
|
1775
|
+
# --min-duration lengthens the first --srt's cues; the others keep their own durations,
|
|
1776
|
+
# and the result says so (no track is wrapped or split in a mux, A55)
|
|
1777
|
+
side_notes.append(f"--min-duration lengthened cues of the first --srt only: the other {len(added) - 1} "
|
|
1778
|
+
"added track(s) keep the cue lengths of their own files")
|
|
1603
1779
|
titles = list(args.track_title or [])
|
|
1604
1780
|
mp4_family = Path(output).suffix.lower() in (".mp4", ".m4v", ".mov")
|
|
1605
1781
|
dropped_titles: List[str] = []
|
|
@@ -1739,7 +1915,7 @@ def main() -> int:
|
|
|
1739
1915
|
elif font_file or not args.fonts_dir:
|
|
1740
1916
|
# the family libass is given is the sanitised one (ass_font_name): that is what it resolves
|
|
1741
1917
|
font_used = record_font_choice(font_choice(ass_font_name(args.font), script_file=font_file, script_family=font_family,
|
|
1742
|
-
renderer="libass"))
|
|
1918
|
+
renderer="libass", bold=bool(args.bold)))
|
|
1743
1919
|
else:
|
|
1744
1920
|
font_used = {"asked": ass_font_name(args.font), "file": None, "family": ass_font_name(args.font), "how": "fonts_dir",
|
|
1745
1921
|
"fonts_dir": args.fonts_dir, "substituted": False}
|
|
@@ -1767,6 +1943,15 @@ def main() -> int:
|
|
|
1767
1943
|
brand if args.brand else None)
|
|
1768
1944
|
# the PNG route and --emoji none both change the drawn text, so they need the generated ASS
|
|
1769
1945
|
force_ass = bool(emoji_plan and (emoji_plan.get("overlays") or emoji_plan.get("mode") == "none"))
|
|
1946
|
+
if (not args.ass and not force_ass and args.animate == "none" and not args.karaoke
|
|
1947
|
+
and any(SRT_HIDDEN_BRACES_RE.search(t) for _s, _e, t in src_cues)):
|
|
1948
|
+
# A68 (#457): ffmpeg's SRT reader hands `{curly}` to libass as an override block, which
|
|
1949
|
+
# draws nothing of it, while the generated ASS draws the braces and their text; the cues
|
|
1950
|
+
# go the generated-ASS way, so the burned words are the words given
|
|
1951
|
+
force_ass = True
|
|
1952
|
+
info("cue text has braces that ffmpeg's SRT reader would hide; drawing the cues through the generated ASS")
|
|
1953
|
+
side_notes.append("the cues hold text in braces, which a plain SRT burn does not draw ({...} is an override "
|
|
1954
|
+
"block to libass), so they were drawn through the generated ASS, braces included")
|
|
1770
1955
|
if emoji_plan and emoji_plan.get("box_px"):
|
|
1771
1956
|
args._emoji_box_px = emoji_plan["box_px"]
|
|
1772
1957
|
|
|
@@ -1778,7 +1963,8 @@ def main() -> int:
|
|
|
1778
1963
|
planned_cues if planned_cues is not None else parse_srt(srt_path)))
|
|
1779
1964
|
if args.karaoke and not getattr(args, "_word_timings", None):
|
|
1780
1965
|
# Use the cue source's sidecar, even when layout rewrites the caller's SRT.
|
|
1781
|
-
args._word_timings = whisper_word_timings(
|
|
1966
|
+
args._word_timings = shift_words(whisper_word_timings(
|
|
1967
|
+
args.srt if args.srt and not (args.text or args.transcribe) else srt_path), args.offset)
|
|
1782
1968
|
ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
|
|
1783
1969
|
claim([(ass_path, "ASS")])
|
|
1784
1970
|
w, h = display_size(meta["video"])
|
|
@@ -1934,7 +2120,9 @@ def main() -> int:
|
|
|
1934
2120
|
notes.append("emoji rendered monochrome (no colour path on this ffmpeg; "
|
|
1935
2121
|
"--emoji-assets DIR for colour)")
|
|
1936
2122
|
if emoji_plan["mode"] == "none":
|
|
1937
|
-
|
|
2123
|
+
# --emoji none was asked for only when it was: an auto run that can draw none says that
|
|
2124
|
+
notes.append("emoji stripped from the drawn text (--emoji none)" if getattr(args, "emoji", "auto") == "none" else
|
|
2125
|
+
"emoji left out of the drawn text: nothing here draws them (--emoji auto; the emoji_not_drawn warning says why)")
|
|
1938
2126
|
if emoji_plan["missing"]:
|
|
1939
2127
|
notes.append("no PNG asset for " + ", ".join(emoji_plan["missing"]))
|
|
1940
2128
|
if notes:
|
package/scripts/color.py
CHANGED
|
@@ -420,6 +420,41 @@ def legalize_report(standard: str, output: str) -> "tuple":
|
|
|
420
420
|
"worst_frame": worst, "tolerance_pct": LEGALIZE_TOLERANCE_PCT}])
|
|
421
421
|
|
|
422
422
|
|
|
423
|
+
def refuse_bad_cube(path: str) -> None:
|
|
424
|
+
"""--lut given a .cube that holds no complete 3D LUT (#404): lut3d failed on it with "Invalid
|
|
425
|
+
data found when processing input", which reads as an unreadable video and got that hint. Only
|
|
426
|
+
.cube is checked (lut3d's other formats are left to it), by what lut3d itself needs: a
|
|
427
|
+
LUT_3D_SIZE line and size^3 rows that start with three numbers."""
|
|
428
|
+
if os.path.splitext(path)[1].lower() != ".cube":
|
|
429
|
+
return
|
|
430
|
+
size = rows = 0
|
|
431
|
+
one_d = False
|
|
432
|
+
try:
|
|
433
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
434
|
+
for line in fh:
|
|
435
|
+
words = line.split()
|
|
436
|
+
if not words or words[0].startswith("#"):
|
|
437
|
+
continue
|
|
438
|
+
if words[0] == "LUT_3D_SIZE" and len(words) > 1 and words[1].isdigit():
|
|
439
|
+
size = int(words[1])
|
|
440
|
+
elif words[0] == "LUT_1D_SIZE":
|
|
441
|
+
one_d = True
|
|
442
|
+
elif len(words) >= 3:
|
|
443
|
+
try:
|
|
444
|
+
[float(w) for w in words[:3]]
|
|
445
|
+
rows += 1
|
|
446
|
+
except ValueError:
|
|
447
|
+
pass
|
|
448
|
+
except OSError as e:
|
|
449
|
+
die(f"--lut: cannot read {path}: {e.strerror}", kind="input")
|
|
450
|
+
if size < 2 or rows < size ** 3:
|
|
451
|
+
what = ("it is a 1D LUT (LUT_1D_SIZE), and --lut applies a 3D one" if one_d and size < 2
|
|
452
|
+
else "it has no LUT_3D_SIZE line" if size < 2
|
|
453
|
+
else f"it has {rows} rows of three numbers, and a LUT_3D_SIZE {size} table has {size ** 3}")
|
|
454
|
+
die(f"--lut {path} is not a complete 3D .cube LUT: {what}", kind="input",
|
|
455
|
+
hint="a .cube file is plain text with a LUT_3D_SIZE line and size^3 rows of three numbers; export it again from the grading tool")
|
|
456
|
+
|
|
457
|
+
|
|
423
458
|
def main() -> int:
|
|
424
459
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
425
460
|
ap.add_argument("input")
|
|
@@ -565,6 +600,7 @@ def main() -> int:
|
|
|
565
600
|
f"tagged BT.709 (sweep F10). Run --to-sdr first (or chain it), or --force if the LUT expects HDR input")
|
|
566
601
|
if not os.path.exists(args.lut):
|
|
567
602
|
die(f"LUT not found: {args.lut}")
|
|
603
|
+
refuse_bad_cube(args.lut)
|
|
568
604
|
if not (0.0 <= args.lut_strength <= 1.0):
|
|
569
605
|
die(f"--lut-strength {args.lut_strength:g} is outside 0..1")
|
|
570
606
|
lut = f"lut3d=file={escape_filter_path(args.lut)}:interp=tetrahedral"
|