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/caption.py
CHANGED
|
@@ -56,7 +56,7 @@ from _common import asr as _asr
|
|
|
56
56
|
from _common.asr import ENGINE_CHOICES
|
|
57
57
|
from _common.emit import warn
|
|
58
58
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
59
|
-
from _common.runner import subtitle_codec_for, subtitle_container_holds
|
|
59
|
+
from _common.runner import carried_stream_maps, subtitle_codec_for, subtitle_container_holds
|
|
60
60
|
from _common.fonts import font_choice, record_font_choice
|
|
61
61
|
from _common import (ASR_INSTALL_HINT, die_no_engine, parse_srt, transcribe, whisper_word_timings,
|
|
62
62
|
write_srt)
|
|
@@ -321,12 +321,15 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
|
|
|
321
321
|
scale = float(getattr(args, "emoji_scale", 1.0) or 1.0)
|
|
322
322
|
# --animate moves the TEXT (\fad/\fscx in the ASS); the PNG has to move with it, or the emoji
|
|
323
323
|
# pops in against a line that is still fading up. These match the \fad values below.
|
|
324
|
-
fade_in, fade_out =
|
|
325
|
-
"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)))
|
|
326
325
|
size_px = args.size * play_h / 288.0
|
|
327
326
|
margin_px = args.margin * play_h / 288.0
|
|
328
327
|
line_h = size_px * 1.2
|
|
329
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)
|
|
330
333
|
out_cues = []
|
|
331
334
|
for start, end, text in cues:
|
|
332
335
|
lines = _cue_lines(text)
|
|
@@ -342,11 +345,11 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
|
|
|
342
345
|
y_top = play_h - margin_px - (n - i) * line_h
|
|
343
346
|
line_w = text_width_em(strip_srt_markup(line), scale) * size_px
|
|
344
347
|
if align in (1, 4, 7):
|
|
345
|
-
x0 =
|
|
348
|
+
x0 = float(margin_l)
|
|
346
349
|
elif align in (3, 6, 9):
|
|
347
|
-
x0 = play_w -
|
|
350
|
+
x0 = play_w - margin_r - line_w
|
|
348
351
|
else:
|
|
349
|
-
x0 = (play_w - line_w) / 2.0
|
|
352
|
+
x0 = margin_l + (play_w - margin_l - margin_r - line_w) / 2.0
|
|
350
353
|
# libass lays an RTL line out right-to-left, so the LOGICAL prefix of a cluster
|
|
351
354
|
# occupies the RIGHT end of the rendered line. Measuring the prefix from the left
|
|
352
355
|
# edge put the PNG on top of the text, mirrored, on every Arabic/Hebrew cue (1.15.0).
|
|
@@ -561,23 +564,31 @@ def safe_width_fraction(args, play_w: Optional[int]) -> float:
|
|
|
561
564
|
return max(0.05, (play_w - left - right) / float(play_w))
|
|
562
565
|
|
|
563
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
|
+
|
|
564
585
|
def max_line_em(args, play_w: Optional[int], play_h: Optional[int]) -> Optional[float]:
|
|
565
586
|
"""How many em fit on one caption line at the chosen size, or None without video geometry.
|
|
566
587
|
|
|
567
588
|
--size is in ASS points against a 288-line script (what libass's force_style uses), so the
|
|
568
|
-
rendered pixel size is size * play_h / 288.
|
|
569
|
-
|
|
570
|
-
--karaoke-style word scales its active word up by --karaoke-scale and freezes the line break
|
|
571
|
-
(WrapStyle: 2 in write_ass) so libass can never re-wrap around that scale-up. The budget here
|
|
572
|
-
is narrowed by the same factor so the longest wrapped line -- computed at the UNscaled width --
|
|
573
|
-
still fits once its active word is drawn --karaoke-scale%% larger; without this, a line that
|
|
574
|
-
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.
|
|
575
590
|
"""
|
|
576
|
-
|
|
577
|
-
safe_fraction=safe_width_fraction(args, play_w))
|
|
578
|
-
if em is not None and getattr(args, "karaoke", False) and getattr(args, "karaoke_style", "sweep") == "word":
|
|
579
|
-
em = em / max(1.0, getattr(args, "karaoke_scale", 112) / 100.0)
|
|
580
|
-
return em
|
|
591
|
+
return line_em_for_size(args.size, play_w, play_h, safe_fraction=line_fraction(args, play_w))
|
|
581
592
|
|
|
582
593
|
|
|
583
594
|
def parse_ass_dialogue(path: str) -> str:
|
|
@@ -692,35 +703,139 @@ def shift_ass_text(source: str, offset: float) -> Tuple[str, int, int]:
|
|
|
692
703
|
|
|
693
704
|
|
|
694
705
|
def word_durations_from_timings(words: List[Tuple[float, float, str]], start: float, end: float,
|
|
695
|
-
n_words: int) -> Optional[List[int]]:
|
|
696
|
-
"""
|
|
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)."""
|
|
697
711
|
inside = [w for w in words if w[1] > start + 0.01 and w[0] < end - 0.01]
|
|
698
712
|
if len(inside) != n_words or n_words <= 0:
|
|
699
713
|
return None
|
|
700
714
|
total_cs = max(1, int(round((end - start) * 100)))
|
|
701
|
-
|
|
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]
|
|
702
718
|
out = [max(1, int(round((bounds[i + 1] - bounds[i]) * 100))) for i in range(n_words)]
|
|
703
|
-
out[-1] += total_cs - sum(out)
|
|
719
|
+
out[-1] += total_cs - lead - sum(out)
|
|
704
720
|
if out[-1] < 1:
|
|
705
721
|
return None
|
|
706
|
-
return out
|
|
722
|
+
return lead, out
|
|
707
723
|
|
|
708
724
|
|
|
709
|
-
def _karaoke_word_durations(args, video: Optional[str], start: float, end: float,
|
|
710
|
-
|
|
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.
|
|
711
728
|
|
|
712
729
|
Real word timings from the transcript beat both the energy estimate and the even split --
|
|
713
|
-
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.
|
|
714
732
|
"""
|
|
715
733
|
dur_cs = max(1, int(round((end - start) * 100)))
|
|
716
|
-
|
|
717
|
-
if
|
|
718
|
-
|
|
719
|
-
|
|
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)
|
|
720
835
|
else:
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
return
|
|
836
|
+
move = "\\pos(%d,%d)" % (x, y2)
|
|
837
|
+
tags = fade + move
|
|
838
|
+
return "{" + tags + "}" if tags else ""
|
|
724
839
|
|
|
725
840
|
|
|
726
841
|
# an override block this file wrote (a converted SRT tag), not the escaped `\{` a user typed
|
|
@@ -751,7 +866,8 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
751
866
|
primary = ass_color(args.highlight_color if args.karaoke else args.color)
|
|
752
867
|
secondary = ass_color(args.color)
|
|
753
868
|
outline = ass_color(args.outline_color)
|
|
754
|
-
|
|
869
|
+
back_alpha = 0x80 # the --box colour is half transparent; an emoji placeholder restores it
|
|
870
|
+
back = ass_color(args.outline_color, back_alpha)
|
|
755
871
|
word_style = args.karaoke and getattr(args, "karaoke_style", "sweep") == "word"
|
|
756
872
|
word_past = ass_color(args.color)
|
|
757
873
|
word_active = ass_color(args.highlight_color)
|
|
@@ -786,14 +902,12 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
786
902
|
# an override block. Newlines become \N in the same pass.
|
|
787
903
|
# SRT markup is the exception (#X2): <i>/<b>/<u>/<s>, <font color> and {\anN} become the
|
|
788
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)
|
|
789
907
|
text = ass_escape_markup(text, colour=not args.karaoke)
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
elif args.animate == "pop":
|
|
794
|
-
fx = "{\\fad(80,120)\\fscx60\\fscy60\\t(0,120,\\fscx110\\fscy110)\\t(120,200,\\fscx100\\fscy100)}"
|
|
795
|
-
elif args.animate == "slide":
|
|
796
|
-
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 = ""
|
|
797
911
|
body = text
|
|
798
912
|
# --fit-size-scope cue: one size per cue, as a leading {\fsN} override. Opt-in only --
|
|
799
913
|
# see fit_size()'s docstring for why a size that changes cue to cue is not the default.
|
|
@@ -803,7 +917,8 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
803
917
|
own_sizes = getattr(args, "_fit_sizes_out", None) or []
|
|
804
918
|
own = own_sizes[cue_index] if cue_index < len(own_sizes) else None
|
|
805
919
|
if own and own != args.size:
|
|
806
|
-
|
|
920
|
+
size_fx = "{\\fs%d}" % int(round(own * scale))
|
|
921
|
+
fx += size_fx
|
|
807
922
|
if args.karaoke and word_style:
|
|
808
923
|
# One Dialogue event per WORD, each carrying the whole cue -- \k/\kf can only
|
|
809
924
|
# interpolate colour, so scaling/emboldening the active word (and a third "upcoming"
|
|
@@ -819,18 +934,21 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
819
934
|
# today's karaoke-off rendering, rather than zero Dialogue lines for this cue
|
|
820
935
|
out_body = body
|
|
821
936
|
if EMOJI_SENTINEL in out_body:
|
|
822
|
-
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))
|
|
823
938
|
lines.append(f"Dialogue: 0,{t(start)},{t(end)},Default,,0,0,0,,{fx}{out_body}")
|
|
824
939
|
continue
|
|
825
940
|
n_words = len(real_idx)
|
|
826
|
-
durs = _karaoke_word_durations(args, video, start, end, n_words)
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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))
|
|
830
947
|
acc += d
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
|
834
952
|
# colour/scale each real word by its rank against the active one (j); an
|
|
835
953
|
# emoji-placeholder token is never active and just keeps its plain text
|
|
836
954
|
styled, flat_pos = [], 0
|
|
@@ -855,18 +973,19 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
855
973
|
styled.append(" ".join(styled_line))
|
|
856
974
|
out_body = "\\N".join(styled)
|
|
857
975
|
if EMOJI_SENTINEL in out_body:
|
|
858
|
-
out_body = out_body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size)))
|
|
859
|
-
# the size/fit override (\fsN) belongs on every event; the
|
|
860
|
-
#
|
|
861
|
-
#
|
|
862
|
-
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
|
|
863
982
|
lines.append(f"Dialogue: 0,{t(w_start)},{t(w_end)},Default,,0,0,0,,{event_fx}{out_body}")
|
|
864
983
|
continue
|
|
865
984
|
if args.karaoke:
|
|
866
985
|
# split each line into words and give every word an equal share of the cue (\k is in centiseconds)
|
|
867
986
|
segments = body.split("\\N")
|
|
868
987
|
words = [w for seg in segments for w in seg.split(" ") if w and _karaoke_word(w)]
|
|
869
|
-
durs = _karaoke_word_durations(args, video, start, end, len(words))
|
|
988
|
+
lead, durs = _karaoke_word_durations(args, video, start, end, len(words))
|
|
870
989
|
it = iter(durs)
|
|
871
990
|
out_segments = []
|
|
872
991
|
for seg in segments:
|
|
@@ -877,8 +996,12 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
877
996
|
out_segments.append(" ".join(
|
|
878
997
|
("{\\kf0}" + w) if not _karaoke_word(w) else f"{{\\kf{next(it)}}}{w}" for w in ws))
|
|
879
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
|
|
880
1003
|
if EMOJI_SENTINEL in body:
|
|
881
|
-
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))
|
|
882
1005
|
lines.append(f"Dialogue: 0,{t(start)},{t(end)},Default,,0,0,0,,{fx}{body}")
|
|
883
1006
|
with open(path, "w", encoding="utf-8-sig") as fh:
|
|
884
1007
|
fh.write("\n".join(header + lines) + "\n")
|
|
@@ -1214,6 +1337,13 @@ def main() -> int:
|
|
|
1214
1337
|
"ASS carries burn-only styling with no soft-subtitle equivalent; use --mode burn for an ASS file")
|
|
1215
1338
|
if args.animate != "none" or args.karaoke:
|
|
1216
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")
|
|
1217
1347
|
|
|
1218
1348
|
args.offset = signed_time_arg(str(args.offset), "--offset")
|
|
1219
1349
|
if args.max_lines < 1:
|
|
@@ -1289,7 +1419,7 @@ def main() -> int:
|
|
|
1289
1419
|
return dict(size=fit_stats["size_requested"], min_size=args._fit_floor,
|
|
1290
1420
|
max_lines=args.max_lines, play_w=play_w, play_h=play_h,
|
|
1291
1421
|
mode=args.wrap, lang=args.language, scope=args.fit_size_scope,
|
|
1292
|
-
safe_fraction=
|
|
1422
|
+
safe_fraction=line_fraction(args, play_w))
|
|
1293
1423
|
|
|
1294
1424
|
def fit_the_size(cue_list):
|
|
1295
1425
|
"""Shrink --size until every cue fits --max-lines, BEFORE the cue is split.
|
|
@@ -1331,7 +1461,7 @@ def main() -> int:
|
|
|
1331
1461
|
# budget and then drawing some cues large put lines off the side of the frame.
|
|
1332
1462
|
args._fit_cue_em = [line_em_for_size(fit["per_cue"].get(i, fit["size"]),
|
|
1333
1463
|
play_w, play_h,
|
|
1334
|
-
safe_fraction=
|
|
1464
|
+
safe_fraction=line_fraction(args, play_w))
|
|
1335
1465
|
for i in range(len(cue_list))]
|
|
1336
1466
|
args._fit_cue_size = [fit["per_cue"].get(i, fit["size"]) for i in range(len(cue_list))]
|
|
1337
1467
|
|
|
@@ -1340,7 +1470,9 @@ def main() -> int:
|
|
|
1340
1470
|
--offset -- the one place every cue source goes through, so an SRT, a cue file and a
|
|
1341
1471
|
transcript all come out equally readable."""
|
|
1342
1472
|
fit_the_size(cue_list)
|
|
1343
|
-
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),
|
|
1344
1476
|
max_lines=args.max_lines, min_duration=args.min_duration,
|
|
1345
1477
|
offset=args.offset, wrap=args.wrap, lang=args.language,
|
|
1346
1478
|
per_cue_em=getattr(args, "_fit_cue_em", None),
|
|
@@ -1360,15 +1492,24 @@ def main() -> int:
|
|
|
1360
1492
|
for token in (args.srt or []):
|
|
1361
1493
|
path_part, lang_part = split_srt_lang(token)
|
|
1362
1494
|
srt_tracks.append((path_part, lang_part))
|
|
1363
|
-
|
|
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:
|
|
1364
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")
|
|
1365
1505
|
if srt_tracks and args.mode != "mux" and len(srt_tracks) > 1:
|
|
1366
1506
|
die("burning renders pixels; only one language can be in the picture -- burn one and mux "
|
|
1367
1507
|
"the rest (caption.py OUT --mode mux --srt en.srt:en --srt ja.srt:ja)", kind="input")
|
|
1368
1508
|
seen_langs = [lang for _p, lang in srt_tracks if lang]
|
|
1369
1509
|
for lang in seen_langs:
|
|
1370
1510
|
if not LANG_TOKEN_RE.match(lang):
|
|
1371
|
-
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 "
|
|
1372
1513
|
"region, e.g. en, ja, pt-BR)", kind="input")
|
|
1373
1514
|
if len(set(seen_langs)) != len(seen_langs):
|
|
1374
1515
|
dup = sorted({l for l in seen_langs if seen_langs.count(l) > 1})
|
|
@@ -1464,7 +1605,8 @@ def main() -> int:
|
|
|
1464
1605
|
cues = asr_run.cues
|
|
1465
1606
|
args._asr = dict(asr_run.facts)
|
|
1466
1607
|
args._asr_notes = list(asr_run.notes)
|
|
1467
|
-
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)
|
|
1468
1610
|
cues, changed = lay_out(cues)
|
|
1469
1611
|
if changed:
|
|
1470
1612
|
write_srt(cues, srt_path)
|
|
@@ -1629,11 +1771,11 @@ def main() -> int:
|
|
|
1629
1771
|
side_notes.append(f"subtitle track {n} is {copy}, a copy of {os.path.basename(path)} with its timings "
|
|
1630
1772
|
"rewritten as hh:mm:ss,mmm, which ffmpeg reads as this tool does")
|
|
1631
1773
|
added[n] = (copy, lang)
|
|
1632
|
-
if len(added) > 1 and
|
|
1633
|
-
# --
|
|
1634
|
-
#
|
|
1635
|
-
side_notes.append(f"--
|
|
1636
|
-
"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")
|
|
1637
1779
|
titles = list(args.track_title or [])
|
|
1638
1780
|
mp4_family = Path(output).suffix.lower() in (".mp4", ".m4v", ".mov")
|
|
1639
1781
|
dropped_titles: List[str] = []
|
|
@@ -1645,7 +1787,7 @@ def main() -> int:
|
|
|
1645
1787
|
if meta.get("audio"):
|
|
1646
1788
|
maps += ["-map", f"0:a:{args.audio_stream}"]
|
|
1647
1789
|
if existing_subs:
|
|
1648
|
-
maps += ["-map", "0:s?"]
|
|
1790
|
+
maps += ["-map", "0:s?"] + carried_stream_maps(args.input, output, chapters=False) # their fonts (A256)
|
|
1649
1791
|
for n in range(len(added)):
|
|
1650
1792
|
maps += ["-map", f"{n + 1}:0"]
|
|
1651
1793
|
cmd += maps + ["-c:v", "copy"] + (["-c:a", "copy"] if meta.get("audio") else [])
|
|
@@ -1773,7 +1915,7 @@ def main() -> int:
|
|
|
1773
1915
|
elif font_file or not args.fonts_dir:
|
|
1774
1916
|
# the family libass is given is the sanitised one (ass_font_name): that is what it resolves
|
|
1775
1917
|
font_used = record_font_choice(font_choice(ass_font_name(args.font), script_file=font_file, script_family=font_family,
|
|
1776
|
-
renderer="libass"))
|
|
1918
|
+
renderer="libass", bold=bool(args.bold)))
|
|
1777
1919
|
else:
|
|
1778
1920
|
font_used = {"asked": ass_font_name(args.font), "file": None, "family": ass_font_name(args.font), "how": "fonts_dir",
|
|
1779
1921
|
"fonts_dir": args.fonts_dir, "substituted": False}
|
|
@@ -1801,6 +1943,15 @@ def main() -> int:
|
|
|
1801
1943
|
brand if args.brand else None)
|
|
1802
1944
|
# the PNG route and --emoji none both change the drawn text, so they need the generated ASS
|
|
1803
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")
|
|
1804
1955
|
if emoji_plan and emoji_plan.get("box_px"):
|
|
1805
1956
|
args._emoji_box_px = emoji_plan["box_px"]
|
|
1806
1957
|
|
|
@@ -1812,7 +1963,8 @@ def main() -> int:
|
|
|
1812
1963
|
planned_cues if planned_cues is not None else parse_srt(srt_path)))
|
|
1813
1964
|
if args.karaoke and not getattr(args, "_word_timings", None):
|
|
1814
1965
|
# Use the cue source's sidecar, even when layout rewrites the caller's SRT.
|
|
1815
|
-
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)
|
|
1816
1968
|
ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
|
|
1817
1969
|
claim([(ass_path, "ASS")])
|
|
1818
1970
|
w, h = display_size(meta["video"])
|
package/scripts/color.py
CHANGED
|
@@ -32,7 +32,7 @@ import os
|
|
|
32
32
|
import sys
|
|
33
33
|
from typing import List, Optional
|
|
34
34
|
|
|
35
|
-
from _common.runner import fail_if_classified
|
|
35
|
+
from _common.runner import carried_stream_maps, fail_if_classified
|
|
36
36
|
from _common import STATE, add_common, analyze_levels, LEGAL_RANGE_CHOICES, legal_limits, measure_signal_range, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, ffmpeg_bsfs, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
|
|
37
37
|
from _common.emit import note, warn
|
|
38
38
|
from _common.color import is_rgb, rgb_to_bt709_filter
|
|
@@ -329,7 +329,8 @@ def retag(args: argparse.Namespace, meta: dict, has_audio: bool, src_alpha=lambd
|
|
|
329
329
|
ext = os.path.splitext(output)[1].lower()
|
|
330
330
|
tags = ["-colorspace", names[0], "-color_primaries", names[1], "-color_trc", names[2]]
|
|
331
331
|
bsf = retag_bsf(meta, args.retag)
|
|
332
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0"
|
|
332
|
+
cmd = (ffmpeg_base() + ["-i", args.input, "-map", "0"] + carried_stream_maps(args.input, output, attachments=False)
|
|
333
|
+
+ ["-c", "copy"] + bsf + tags)
|
|
333
334
|
if ext in (".mp4", ".mov", ".m4v"):
|
|
334
335
|
cmd += ["-movflags", "+faststart"]
|
|
335
336
|
cmd.append(output)
|
|
@@ -357,8 +358,9 @@ def retag(args: argparse.Namespace, meta: dict, has_audio: bool, src_alpha=lambd
|
|
|
357
358
|
info("re-encoding video/audio with the new tags (subtitles/data streams kept)")
|
|
358
359
|
label = ["-vf", f"setparams=color_primaries={names[1]}:color_trc={names[2]}:colorspace={names[0]}"]
|
|
359
360
|
enc = x264_args(args.crf, args.preset, keep_bt709=False) + (aac_args() if has_audio else [])
|
|
360
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?",
|
|
361
|
-
"-map", "0:s?", "-map", "0:d?"] +
|
|
361
|
+
cmd = (ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?",
|
|
362
|
+
"-map", "0:s?", "-map", "0:d?"] + carried_stream_maps(args.input, output) + label + enc
|
|
363
|
+
+ ["-c:s", "copy", "-c:d", "copy", output])
|
|
362
364
|
proc2 = run(cmd, check=False)
|
|
363
365
|
fail_if_classified(proc2)
|
|
364
366
|
if proc2.returncode != 0:
|
|
@@ -552,7 +554,8 @@ def main() -> int:
|
|
|
552
554
|
die("--strip-dovi only applies to HEVC (Dolby Vision) streams")
|
|
553
555
|
if not v.get("dolby_vision"):
|
|
554
556
|
warn("no Dolby Vision metadata detected; removing unregistered SEI anyway", "dovi_not_detected")
|
|
555
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0"
|
|
557
|
+
cmd = (ffmpeg_base() + ["-i", args.input, "-map", "0"] + carried_stream_maps(args.input, output, attachments=False)
|
|
558
|
+
+ ["-c", "copy", "-bsf:v", "filter_units=remove_types=62", "-tag:v", "hvc1"])
|
|
556
559
|
if os.path.splitext(output)[1].lower() in (".mp4", ".mov", ".m4v"):
|
|
557
560
|
cmd += ["-movflags", "+faststart"]
|
|
558
561
|
cmd.append(output)
|
package/scripts/crop.py
CHANGED
|
@@ -62,7 +62,9 @@ def main() -> int:
|
|
|
62
62
|
has_audio = bool(meta.get("audio"))
|
|
63
63
|
|
|
64
64
|
output = args.output or default_output(args.input, "crop")
|
|
65
|
-
|
|
65
|
+
# exact=1: without it crop rounds an odd --x/--y down to the chroma grid of a 4:2:0 frame,
|
|
66
|
+
# and the rectangle written was one pixel left of / above the one asked for (A170's sibling)
|
|
67
|
+
vf = [f"crop={args.width}:{args.height}:{args.x}:{args.y}:exact=1", "setsar=1"]
|
|
66
68
|
if square_pixels(meta["video"]):
|
|
67
69
|
vf.insert(0, square_pixels(meta["video"]))
|
|
68
70
|
note(square_pixels_note(meta["video"]))
|
package/scripts/export.py
CHANGED
|
@@ -46,7 +46,7 @@ import tempfile
|
|
|
46
46
|
from pathlib import Path
|
|
47
47
|
from typing import Dict, List, Optional
|
|
48
48
|
|
|
49
|
-
from _common.runner import child_exit_code, fail_if_classified, kept_clause, mark_delivered
|
|
49
|
+
from _common.runner import carried_stream_maps, child_exit_code, fail_if_classified, kept_clause, mark_delivered
|
|
50
50
|
from _common import STATE, run_analysis, add_common, apply_common, bt709_label_filter, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, require_tool, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
|
|
51
51
|
from _common.decision import hw_preset_video, restate_last_swap
|
|
52
52
|
from _common.emit import note, record_fallback, record_skip, warn
|
|
@@ -321,15 +321,19 @@ _subtitle_codec = subtitle_codec_for
|
|
|
321
321
|
|
|
322
322
|
def _data_streams(path: str) -> List[Dict[str, object]]:
|
|
323
323
|
"""The data streams of a file ({index, codec}), without its attachments (probe()'s
|
|
324
|
-
data_streams counts both, and -map 0:d maps only the data streams)
|
|
324
|
+
data_streams counts both, and -map 0:d maps only the data streams) and without an MP4/MOV
|
|
325
|
+
chapter track, which the muxer writes again from the chapters (carried_stream_maps, A173)."""
|
|
325
326
|
proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "d", "-show_entries",
|
|
326
|
-
"stream=codec_name,codec_tag_string", "-of", "json", path], quiet=True)
|
|
327
|
+
"stream=codec_name,codec_tag_string:chapter=id", "-of", "json", path], quiet=True)
|
|
327
328
|
try:
|
|
328
|
-
|
|
329
|
+
doc = json.loads(proc.stdout or "{}")
|
|
329
330
|
except ValueError:
|
|
330
331
|
# an unread list would map nothing and report nothing dropped
|
|
331
332
|
die(f"ffprobe's data-stream list for {path} did not parse", kind="ffmpeg")
|
|
332
|
-
|
|
333
|
+
streams = doc.get("streams") or []
|
|
334
|
+
chapter_track = bool(doc.get("chapters"))
|
|
335
|
+
return [{"index": n, "codec": s.get("codec_name") or s.get("codec_tag_string")} for n, s in enumerate(streams)
|
|
336
|
+
if not (chapter_track and str(s.get("codec_tag_string") or "").lower() == "text")]
|
|
333
337
|
|
|
334
338
|
|
|
335
339
|
def _run_keeping_non_av(cmd: List[str], output: str, sub_codecs: List[str], data: int) -> List[str]:
|
|
@@ -338,10 +342,13 @@ def _run_keeping_non_av(cmd: List[str], output: str, sub_codecs: List[str], data
|
|
|
338
342
|
subtitles + data, subtitles alone, neither; each retry is a `non_av_streams_dropped` fallback
|
|
339
343
|
with the attempt's error. `cmd` already maps the picture and the sound and has no output.
|
|
340
344
|
Returns the stream types dropped ([] when everything mapped was written)."""
|
|
345
|
+
src = next((str(cmd[i + 1]) for i, a in enumerate(cmd[:-1]) if a == "-i"), None)
|
|
341
346
|
sub_args: List[str] = ["-map", "0:s"] if sub_codecs else []
|
|
342
347
|
for n, codec in enumerate(sub_codecs):
|
|
343
348
|
sub_args += [f"-c:s:{n}", codec]
|
|
344
|
-
|
|
349
|
+
if sub_codecs:
|
|
350
|
+
sub_args += carried_stream_maps(src, output, chapters=False) # the fonts the subtitles are drawn with (A256)
|
|
351
|
+
data_args = (["-map", "0:d"] + carried_stream_maps(src, output, attachments=False) + ["-c:d", "copy"]) if data else []
|
|
345
352
|
rungs = [(sub_args + data_args, [])]
|
|
346
353
|
if sub_codecs and data:
|
|
347
354
|
rungs.append((sub_args, ["data"]))
|
|
@@ -389,7 +396,9 @@ def _non_av_report(subs: List[Dict], sub_codecs: List[str], data: List[Dict], dr
|
|
|
389
396
|
written_data = _data_streams(output)
|
|
390
397
|
for kind, streams, written in (("subtitle", subs, written_subs), ("data", data, written_data)):
|
|
391
398
|
for n, s in enumerate(streams):
|
|
392
|
-
row
|
|
399
|
+
# a data row keeps its n-th-of-type index in the source: the chapter text track is left
|
|
400
|
+
# out of the list, so a timecode track after it is data stream 1, not 0
|
|
401
|
+
row = {"type": kind, "index": s.get("index", n) if kind == "data" else n, "codec": s.get("codec")}
|
|
393
402
|
if kind == "subtitle":
|
|
394
403
|
row["language"] = s.get("language")
|
|
395
404
|
if STATE.dry_run and kind not in dropped:
|
package/scripts/freeze.py
CHANGED
|
@@ -67,7 +67,8 @@ def main() -> int:
|
|
|
67
67
|
# no frame at or after --at (no --at holds at the clip's end): the insert tail would be empty
|
|
68
68
|
# and the hold lost, so hold the last frame as --mode extend does (#416 review)
|
|
69
69
|
vend = v0 + (meta["video"].get("duration") or dur - v0)
|
|
70
|
-
|
|
70
|
+
holds_end = args.mode == "extend" or cut_at >= vend - 0.5 / rate
|
|
71
|
+
if holds_end:
|
|
71
72
|
# tpad's stop_duration clones the last frame for the given duration; no PTS surgery
|
|
72
73
|
# needed since it only ever appends past the real end.
|
|
73
74
|
vf = f"tpad=stop_mode=clone:stop_duration={args.hold:.3f}"
|
|
@@ -127,10 +128,12 @@ def main() -> int:
|
|
|
127
128
|
cmd += aac_args()
|
|
128
129
|
else:
|
|
129
130
|
cmd += ["-an"]
|
|
130
|
-
if
|
|
131
|
+
if not holds_end:
|
|
131
132
|
# the picture gains `hold` seconds at `at`; a stream-copied subtitle track would keep its
|
|
132
133
|
# old timestamps and every cue after the freeze would fire early (same reasoning as
|
|
133
|
-
# fit.py --method speed). Drop it rather than ship captions that lie.
|
|
134
|
+
# fit.py --method speed). Drop it rather than ship captions that lie. A hold of the last
|
|
135
|
+
# frame (the default with no --at, or an --at at the end) moves nothing, so the track is
|
|
136
|
+
# kept as --mode extend keeps it (A180)
|
|
134
137
|
run(cmd + [output])
|
|
135
138
|
dropped_streams = bool(meta.get("subtitle_streams") or meta.get("data_streams"))
|
|
136
139
|
else:
|