ffmpeg-skill 3.6.1 → 3.6.3
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 +7 -5
- package/SKILL.md +1 -0
- package/docs/contract.md +37 -8
- package/mcp/server.py +28 -13
- package/package.json +1 -1
- package/references/gotchas.md +2 -1
- package/references/scripts.md +40 -14
- package/scripts/_ass_overlay.py +234 -1
- package/scripts/_common/__init__.py +4 -4
- package/scripts/_common/asr.py +3 -0
- package/scripts/_common/color.py +55 -0
- package/scripts/_common/decision.py +80 -0
- package/scripts/_common/emit.py +8 -0
- package/scripts/_common/emoji.py +76 -9
- package/scripts/_common/probe.py +2 -1
- package/scripts/_common/runner.py +148 -7
- package/scripts/_common/timeline.py +39 -0
- package/scripts/_common/wrap.py +92 -42
- package/scripts/_contract.py +46 -15
- package/scripts/audio.py +9 -3
- package/scripts/batch.py +149 -11
- package/scripts/broll.py +2 -2
- package/scripts/caption.py +44 -19
- package/scripts/check.py +1 -1
- package/scripts/color.py +2 -42
- package/scripts/cut.py +37 -9
- package/scripts/fit.py +25 -3
- package/scripts/graphics.py +11 -6
- package/scripts/join.py +135 -14
- package/scripts/look.py +31 -12
- package/scripts/multicam.py +6 -1
- package/scripts/overlay.py +20 -7
- package/scripts/redact.py +2 -2
- package/scripts/render.py +30 -23
- package/scripts/silence.py +6 -3
- package/scripts/speedramp.py +6 -2
- package/scripts/sync.py +28 -4
package/scripts/join.py
CHANGED
|
@@ -36,9 +36,11 @@ audio stream is never silent.
|
|
|
36
36
|
"""
|
|
37
37
|
import argparse
|
|
38
38
|
import json
|
|
39
|
+
import math
|
|
39
40
|
import os
|
|
40
41
|
import tempfile
|
|
41
42
|
import sys
|
|
43
|
+
from fractions import Fraction
|
|
42
44
|
from typing import Any, Dict, List, Optional, Tuple
|
|
43
45
|
|
|
44
46
|
from _common.probe import SILENT_PEAK_DBFS, signatures_match, stream_signature
|
|
@@ -46,6 +48,7 @@ from _common import STATE, AUDIO_MEDIA_EXT, concat_list_line, guard_sidecar, pla
|
|
|
46
48
|
from _common.emit import note, warn
|
|
47
49
|
from _common.decision import av_clip_length, av_trim, stream_starts
|
|
48
50
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
51
|
+
from _common.timeline import exact_rate
|
|
49
52
|
|
|
50
53
|
TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
|
|
51
54
|
"circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
|
|
@@ -112,6 +115,81 @@ def clip_length(meta: Dict[str, Any], fps: float) -> float:
|
|
|
112
115
|
return av_clip_length(meta, fps)
|
|
113
116
|
|
|
114
117
|
|
|
118
|
+
# the video join's sound rate (every clip is resampled to it before the trims)
|
|
119
|
+
JOIN_AUDIO_RATE = 48000
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def output_rate(fps: float, first: Dict[str, Any], asked: bool) -> Fraction:
|
|
123
|
+
"""The join's frame rate as an exact fraction: the first clip's own r_frame_rate/avg_frame_rate
|
|
124
|
+
when `fps` is that clip's (30000/1001, not 29.97), else the NTSC fraction or the plain one
|
|
125
|
+
`fps` names (timeline.exact_rate). Every clip length, offset and sound length is counted in
|
|
126
|
+
frames of this rate, so the graph's fps filter runs at the same fraction."""
|
|
127
|
+
if not asked:
|
|
128
|
+
for key in ("r_frame_rate", "avg_frame_rate"):
|
|
129
|
+
try:
|
|
130
|
+
q = Fraction(str(first.get(key) or ""))
|
|
131
|
+
except (ValueError, ZeroDivisionError):
|
|
132
|
+
continue
|
|
133
|
+
# the declared rate also wins when the average was taken over a length that leaves out
|
|
134
|
+
# the last frame's duration (#475: 5 frames in 0.1333 s read 37.5 fps, and the whole
|
|
135
|
+
# join ran at 75/2)
|
|
136
|
+
if 0 < q <= 240 and (abs(float(q) - fps) < 1e-3 or short_last_frame(first, q)):
|
|
137
|
+
return q
|
|
138
|
+
return exact_rate(fps)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def short_last_frame(video: Optional[Dict[str, Any]], q: Fraction) -> bool:
|
|
142
|
+
"""Whether a picture's duration is its frame count less one frame at rate `q`: a container
|
|
143
|
+
that stores no duration for the last frame (MOV written with -fps_mode passthrough, seen on
|
|
144
|
+
FFmpeg 7.1) reads 5 frames as 0.1333 s at 30 fps (#475)."""
|
|
145
|
+
v = video or {}
|
|
146
|
+
nb, dur = v.get("nb_frames"), v.get("duration")
|
|
147
|
+
if not nb or nb < 2 or not dur or q <= 0:
|
|
148
|
+
return False
|
|
149
|
+
step = float(1 / q)
|
|
150
|
+
return abs(dur - (nb - 1) * step) < 0.25 * step and abs(dur - nb * step) > 0.25 * step
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def with_whole_picture(meta: Dict[str, Any], q: Fraction) -> Dict[str, Any]:
|
|
154
|
+
"""`meta` with its picture counted as every frame it holds when its duration leaves out the
|
|
155
|
+
last one (short_last_frame), so the clip-length rule does not read the sound as running a
|
|
156
|
+
frame past the picture and hold the clip longer (#475). Other clips are returned as they are."""
|
|
157
|
+
v = meta.get("video") or {}
|
|
158
|
+
# each clip at its OWN declared rate: at the join's rate a complete 30 fps clip of 5 frames
|
|
159
|
+
# (5/30 s) also equals 4 frames at --fps 24 and would be stretched to 5/24 s
|
|
160
|
+
try:
|
|
161
|
+
own = Fraction(str(v.get("r_frame_rate") or ""))
|
|
162
|
+
except (ValueError, ZeroDivisionError):
|
|
163
|
+
own = q
|
|
164
|
+
if not 0 < own <= 240:
|
|
165
|
+
own = q
|
|
166
|
+
if not short_last_frame(v, own):
|
|
167
|
+
return meta
|
|
168
|
+
return dict(meta, video=dict(v, duration=float(v["nb_frames"] / own)))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def clip_frames(meta: Dict[str, Any], length: float, q: Fraction) -> int:
|
|
172
|
+
"""How many frames of rate `q` a clip of `length` seconds (clip_length) holds in the join: its
|
|
173
|
+
picture's frames, or, when the clip lasts as long as its sound, enough frames to cover that
|
|
174
|
+
sound (narration is never cut; a hundredth of a frame is rounding, not sound). A pending
|
|
175
|
+
dry-run stub's 0 s stays 0; a measured clip holds at least one frame (#455: a length in
|
|
176
|
+
milliseconds rounded up started every next clip late, and the errors added up)."""
|
|
177
|
+
exact = length * q.numerator / q.denominator
|
|
178
|
+
vlen = (meta.get("video") or {}).get("duration") or 0.0
|
|
179
|
+
frames = math.ceil(exact - 0.01) if vlen and length > vlen else round(exact)
|
|
180
|
+
return frames if meta.get("dry_run") else max(1, frames)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def frame_time(frames: int, q: Fraction) -> str:
|
|
184
|
+
"""The time of frame `frames` at rate `q` in seconds, rounded down to the microsecond: never
|
|
185
|
+
after that frame's own timestamp, so a filter that starts on the first frame at or after it
|
|
186
|
+
(xfade's offset) starts on that frame and not the next (#468: 2.566667 written 2.567 started
|
|
187
|
+
the transition a frame late). Trailing zeros are dropped (0.5, not 0.500000)."""
|
|
188
|
+
us = (frames * q.denominator * 1_000_000) // q.numerator
|
|
189
|
+
text = f"{us // 1_000_000}.{us % 1_000_000:06d}".rstrip("0").rstrip(".")
|
|
190
|
+
return text or "0"
|
|
191
|
+
|
|
192
|
+
|
|
115
193
|
def find_duplicates(paths: List[str]) -> List[Dict[str, Any]]:
|
|
116
194
|
"""Every path (compared by its resolved absolute form) listed more than once, with the 0-based
|
|
117
195
|
list positions it appears at. A warning, not a refusal: repeating a clip can be intentional."""
|
|
@@ -543,6 +621,10 @@ def main() -> int:
|
|
|
543
621
|
apply_common(args)
|
|
544
622
|
if args.fps is not None and args.fps <= 0:
|
|
545
623
|
die(f"--fps must be positive, got {args.fps:g}")
|
|
624
|
+
# nan and inf are refused by the parser (finite_float); a negative length was taken as the
|
|
625
|
+
# one-frame minimum and the join succeeded with a transition nobody asked for
|
|
626
|
+
if args.duration < 0:
|
|
627
|
+
die(f"--duration must be 0 or more seconds, got {args.duration:g}")
|
|
546
628
|
|
|
547
629
|
if args.list:
|
|
548
630
|
if args.inputs:
|
|
@@ -666,6 +748,9 @@ def main() -> int:
|
|
|
666
748
|
w, h = fw, fh
|
|
667
749
|
fps = args.fps or first.get("fps") or 30.0
|
|
668
750
|
fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
|
|
751
|
+
# the exact rate every length below is counted in (30000/1001, not 29.97)
|
|
752
|
+
rate_q = output_rate(fps, first, bool(args.fps))
|
|
753
|
+
fps = float(rate_q)
|
|
669
754
|
w, h = w - (w % 2), h - (h % 2)
|
|
670
755
|
note_short_segments(args.inputs, metas, MIN_SEGMENT_FRAMES / fps, f"{MIN_SEGMENT_FRAMES} frames at {fps:g} fps")
|
|
671
756
|
durs = [m.get("duration") or 0.0 for m in metas]
|
|
@@ -675,11 +760,37 @@ def main() -> int:
|
|
|
675
760
|
# its picture and its sound together. The plain cut used to take the raw streams, and a sound
|
|
676
761
|
# past the picture (a music bed padded to the container, even a 20 ms AAC tail) left a hole in
|
|
677
762
|
# the video there.
|
|
678
|
-
|
|
763
|
+
# Each length is a whole number of frames of the output rate, and each sound length the
|
|
764
|
+
# samples those frames last (#455): a length in milliseconds rounded 5 frames at 30 fps
|
|
765
|
+
# (0.166667 s) up to 0.167 s, the concat filter started the next clip after it, and 60 such
|
|
766
|
+
# clips left one-frame holes from about 8 s on. Sample counts come from the running frame
|
|
767
|
+
# total, so the sound never drifts from the picture by more than one sample.
|
|
768
|
+
lmetas = [with_whole_picture(m, rate_q) for m in metas]
|
|
769
|
+
frames = [clip_frames(m, clip_length(m, fps), rate_q) for m in lmetas]
|
|
770
|
+
# the transition is a whole number of frames too, so xfade's offsets (and acrossfade's
|
|
771
|
+
# overlap) land on frames of the output rate (#468). A transition is at least one frame:
|
|
772
|
+
# --duration 0 used to build acrossfade=ns=0, which FFmpeg refuses
|
|
773
|
+
tframes = max(1, round(d * fps)) if args.transition != "none" else 0
|
|
774
|
+
d = tframes / fps
|
|
775
|
+
lens = [f / fps for f in frames]
|
|
679
776
|
place_silent(lens, d)
|
|
680
|
-
for p,
|
|
681
|
-
if d and
|
|
682
|
-
die(f"{p} is only {
|
|
777
|
+
for p, nf in zip(args.inputs, frames):
|
|
778
|
+
if d and nf <= tframes * 2 and not STATE.dry_run:
|
|
779
|
+
die(f"{p} is only {nf / fps:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
|
|
780
|
+
# every sample boundary is a frame position of the OUTPUT rounded once: clip i spans output
|
|
781
|
+
# frames [start_i, start_i + frames_i) and overlaps the clip before it over [start_i,
|
|
782
|
+
# start_i + tframes), so its samples and each overlap's are differences of rounded
|
|
783
|
+
# cumulative positions and their sum telescopes to the output's own length. Rounding each
|
|
784
|
+
# overlap on its own (14414.4 samples -> 14414 at 30000/1001 and 9 frames) left the sound
|
|
785
|
+
# 0.4 samples longer per transition.
|
|
786
|
+
def at(frame: int) -> int:
|
|
787
|
+
return round(frame * JOIN_AUDIO_RATE / rate_q)
|
|
788
|
+
starts, start = [], 0
|
|
789
|
+
for i, nf in enumerate(frames):
|
|
790
|
+
starts.append(start)
|
|
791
|
+
start += nf - tframes
|
|
792
|
+
samples = [at(s0 + nf) - at(s0) for s0, nf in zip(starts, frames)]
|
|
793
|
+
overlaps = [at(s0 + tframes) - at(s0) for s0 in starts]
|
|
683
794
|
|
|
684
795
|
cmd = ffmpeg_base()
|
|
685
796
|
extra_inputs: List[str] = []
|
|
@@ -745,7 +856,10 @@ def main() -> int:
|
|
|
745
856
|
if len(set(chans)) > 1:
|
|
746
857
|
info(f"channel counts differ ({', '.join(str(c) for c in chans)}); every clip becomes {layout}")
|
|
747
858
|
for i in range(n):
|
|
748
|
-
|
|
859
|
+
# the fps filter's time base is one frame (1/rate), so every timestamp downstream is a
|
|
860
|
+
# frame number and an offset in seconds is rounded to a whole frame, never a microsecond
|
|
861
|
+
# off it on any FFmpeg (AVTB left xfade to compare microseconds)
|
|
862
|
+
parts.append(f"[{i}:v]{convert[i]}{geo(i)},setsar=1,setpts=PTS-STARTPTS,fps={rate_q.numerator}/{rate_q.denominator},format={pixfmt}[v{i}]")
|
|
749
863
|
# the sound is shifted by the picture's start, not to its own (#409): #338 started each
|
|
750
864
|
# clip's picture at 0, and a per-stream PTS-STARTPTS then moved a sound that starts 0.3 s
|
|
751
865
|
# after its picture 0.3 s early. Sound before the first frame is dropped and a late sound
|
|
@@ -755,23 +869,29 @@ def main() -> int:
|
|
|
755
869
|
|
|
756
870
|
# pad the shorter stream to the clip's length: the audio with silence, the picture by holding
|
|
757
871
|
# its last frame, then cut both there -- each clip boundary is then the same for both chains
|
|
872
|
+
# -- by frame and by sample, never by rounded seconds (#455)
|
|
758
873
|
for i in range(n):
|
|
759
|
-
vlen = (
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
874
|
+
vlen = (lmetas[i].get("video") or {}).get("duration") or lens[i]
|
|
875
|
+
have = round(vlen * fps)
|
|
876
|
+
# one frame more than the gap is held, and the trim cuts at the clip's last frame
|
|
877
|
+
hold = f"tpad=stop_mode=clone:stop={frames[i] - have + 1}," if frames[i] > have else ""
|
|
878
|
+
parts.append(f"[v{i}]{hold}trim=end_frame={frames[i]}[vp{i}]")
|
|
879
|
+
parts.append(f"[a{i}]apad=whole_len={samples[i]},atrim=end_sample={samples[i]}[ap{i}]")
|
|
763
880
|
if args.transition == "none":
|
|
764
881
|
chain = "".join(f"[vp{i}][ap{i}]" for i in range(n))
|
|
765
882
|
parts.append(f"{chain}concat=n={n}:v=1:a=1[vout][aout]")
|
|
766
883
|
else:
|
|
767
884
|
vprev, aprev = "vp0", "ap0"
|
|
768
|
-
|
|
885
|
+
start = 0
|
|
769
886
|
for i in range(1, n):
|
|
770
|
-
|
|
887
|
+
# clip i starts on frame `start` of the output; xfade starts on the first frame at or
|
|
888
|
+
# after its offset, so the offset is that frame's time rounded down (#468)
|
|
889
|
+
start += frames[i - 1] - tframes
|
|
771
890
|
vout = f"vx{i}" if i < n - 1 else "vout"
|
|
772
891
|
aout = f"ax{i}" if i < n - 1 else "aout"
|
|
773
|
-
parts.append(f"[{vprev}][vp{i}]xfade=transition={args.transition}:duration={
|
|
774
|
-
|
|
892
|
+
parts.append(f"[{vprev}][vp{i}]xfade=transition={args.transition}:duration={frame_time(tframes, rate_q)}"
|
|
893
|
+
f":offset={frame_time(start, rate_q)}[{vout}]")
|
|
894
|
+
parts.append(f"[{aprev}][ap{i}]acrossfade=ns={overlaps[i]}:c1=tri:c2=tri[{aout}]")
|
|
775
895
|
vprev, aprev = vout, aout
|
|
776
896
|
|
|
777
897
|
# A pending input's stub is unmeasured (0x0, 0 fps, 0 s -- #77 keeps it honest rather than
|
|
@@ -784,7 +904,7 @@ def main() -> int:
|
|
|
784
904
|
"by its measured length")
|
|
785
905
|
elif not d and any(m.get("dry_run") for m in metas):
|
|
786
906
|
# the plain cut trims/holds every clip to clip_length too (2.4.1), so a pending clip's
|
|
787
|
-
# planned trim=
|
|
907
|
+
# planned trim=end_frame=0 is the stub's placeholder, not the cut
|
|
788
908
|
STATE_NOTES.append("the planned trim lengths count each pending input as 0 s long: a real run trims "
|
|
789
909
|
"or holds it to its measured length")
|
|
790
910
|
output = args.output or default_output(args.inputs[0], "joined", "mp4")
|
|
@@ -818,6 +938,7 @@ def main() -> int:
|
|
|
818
938
|
info(f"wrote {output} ({fmt_secs(measured)}, {expected_text(expected)}, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
|
|
819
939
|
overlap = overlap_report(lens, expected)
|
|
820
940
|
emit(output, mode="video", **overlap, **reported(), clips=n, transition=args.transition, expected_duration=expected,
|
|
941
|
+
frame_rate=f"{rate_q.numerator}/{rate_q.denominator}", clip_frames=frames, transition_frames=tframes,
|
|
821
942
|
verification=verification, expect_streams={"video": 1, "audio": 1}, stream_reference=metas,
|
|
822
943
|
audio_substituted=substituted,
|
|
823
944
|
dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
|
package/scripts/look.py
CHANGED
|
@@ -23,7 +23,7 @@ from _common import STATE, require_tool, add_common, apply_common, default_font_
|
|
|
23
23
|
from _common import drawtext_fontfile # noqa: E402
|
|
24
24
|
from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
|
|
25
25
|
from _common.emit import note, record_fallback # noqa: E402
|
|
26
|
-
from _common.color import bt709_label_filter # noqa: E402
|
|
26
|
+
from _common.color import bt709_label_filter, bt2020_sdr_to_bt709_chain, hdr_to_sdr_chain, is_bt2020_sdr # noqa: E402
|
|
27
27
|
from _common.probe import display_size, square_pixels, square_pixels_note # noqa: E402
|
|
28
28
|
|
|
29
29
|
# --guides: broadcast safe areas as outlines, in fractions of the frame (a tile as much as a frame).
|
|
@@ -177,6 +177,23 @@ def measure_ink(png_path: str, *, box=None, margin_frac: float = 0.03, threshold
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
|
|
180
|
+
def display_conversion(meta: dict, what: str = "source") -> str:
|
|
181
|
+
"""The filter that shows `meta`'s frames on an SDR BT.709 display, or "" for a picture that is
|
|
182
|
+
already one. HDR is tone-mapped so the agent judges representative colours, not raw HLG/PQ."""
|
|
183
|
+
v = meta.get("video") or {}
|
|
184
|
+
if not v.get("bt2020_or_hdr"):
|
|
185
|
+
return ""
|
|
186
|
+
# color.py --to-sdr's own chains at its defaults (hable, a 1000-nit PQ peak), so the sheet
|
|
187
|
+
# shows what that conversion gives. Through 3.6.0 this tone map linearised at npl=1000 and
|
|
188
|
+
# a sheet's white came out at about Y 151 of 235; a BT.2020 SDR source was tone-mapped
|
|
189
|
+
# too, where color.py only converts its gamut.
|
|
190
|
+
if is_bt2020_sdr(v):
|
|
191
|
+
info(f"BT.2020 SDR {what}: frames are converted to BT.709 for display")
|
|
192
|
+
return bt2020_sdr_to_bt709_chain(meta)
|
|
193
|
+
info(f"HDR {what}: frames are tone-mapped to SDR for display")
|
|
194
|
+
return hdr_to_sdr_chain({"video": dict(v, color_transfer=v.get("color_transfer") or "arib-std-b67")}, "hable")
|
|
195
|
+
|
|
196
|
+
|
|
180
197
|
def main() -> int:
|
|
181
198
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
182
199
|
ap.add_argument("input")
|
|
@@ -264,13 +281,10 @@ def main() -> int:
|
|
|
264
281
|
boxes, guides = guide_boxes(list(dict.fromkeys(names)))
|
|
265
282
|
safe_filter += "," + ",".join(boxes)
|
|
266
283
|
tc = safe_filter + tc
|
|
267
|
-
#
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
"format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable,zscale=t=bt709:m=bt709:r=tv,format=yuv420p,")
|
|
272
|
-
tc = "," + tm.rstrip(",") + tc
|
|
273
|
-
info("HDR source: frames are tone-mapped to SDR for display")
|
|
284
|
+
marks = tc # the boxes and the stamp, without the display conversion (each --compare input has its own)
|
|
285
|
+
tm = display_conversion(meta)
|
|
286
|
+
if tm:
|
|
287
|
+
tc = "," + tm + tc
|
|
274
288
|
outputs: List[str] = []
|
|
275
289
|
# An untagged SDR source is BT.709 (what players and every encoder line here take it as), but the
|
|
276
290
|
# conversion to the PNG's RGB reads "unknown" as BT.601: C03028 came out B31F28 (#410). Its
|
|
@@ -293,6 +307,9 @@ def main() -> int:
|
|
|
293
307
|
cw, ch = display_size(cmeta.get("video") or {})
|
|
294
308
|
if cw and ch and cw / ch < 4 / 3 + 0.01:
|
|
295
309
|
die(f"--guides 4:3 draws the 4:3 centre cut of a wider frame; the --compare clip is {cw}x{ch}")
|
|
310
|
+
# the second picture is converted for display by its own tags: through 3.6.1 the first
|
|
311
|
+
# input's tone map ran on both, so an SDR clip beside a PQ one was read as PQ
|
|
312
|
+
tm2 = display_conversion(cmeta, "--compare clip")
|
|
296
313
|
lab2 = bt709_label_filter(cmeta)
|
|
297
314
|
lab2 = lab2 + "," if lab2 else ""
|
|
298
315
|
csq = (square_pixels(cmeta.get("video")) + ",") if square_pixels(cmeta.get("video")) else ""
|
|
@@ -310,15 +327,17 @@ def main() -> int:
|
|
|
310
327
|
# the tone map and the --safe / --guides boxes go on each picture as scaled, before the
|
|
311
328
|
# pad: drawn after it, a picture letterboxed in the box got guides from the padded
|
|
312
329
|
# height (a 2:1 clip beside a 16:9 one); the timecode stamp is drawn on the box
|
|
313
|
-
pre =
|
|
330
|
+
pre = marks.replace("," + timecode_filter(font_prefix), "")
|
|
314
331
|
# each picture keeps its own aspect inside a half-width box as tall as the taller of the
|
|
315
332
|
# two (letterboxed, not stretched): through 3.1.3 scale2ref forced the first to the
|
|
316
333
|
# second's size, so a 4:3 beside a 16:9 came out squashed (#411)
|
|
317
334
|
box_h = max(_half_height(half, display_size(meta["video"])),
|
|
318
335
|
_half_height(half, display_size(cmeta.get("video"))))
|
|
319
|
-
fit
|
|
320
|
-
|
|
321
|
-
|
|
336
|
+
def fit(conv):
|
|
337
|
+
conv = "," + conv if conv else ""
|
|
338
|
+
return (f"scale={half}:{box_h}:force_original_aspect_ratio=decrease{conv}{pre},"
|
|
339
|
+
f"pad={half}:{box_h}:(ow-iw)/2:(oh-ih)/2:color=0x202020,setsar=1{stamp}")
|
|
340
|
+
fc = (f"[0:v]{lab}{sq}{fit(tm)}[a];[1:v]{lab2}{csq}{fit(tm2)}[b];"
|
|
322
341
|
f"[a][b]hstack=inputs=2[out]")
|
|
323
342
|
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", *still_input_args(args.input), "-i", args.input, "-ss", f"{sec:.3f}", *still_input_args(args.compare), "-i", args.compare,
|
|
324
343
|
"-filter_complex", fc, "-map", "[out]", "-frames:v", "1", "-update", "1", out]
|
package/scripts/multicam.py
CHANGED
|
@@ -28,12 +28,13 @@ Examples:
|
|
|
28
28
|
import argparse
|
|
29
29
|
import json
|
|
30
30
|
import math
|
|
31
|
+
import os
|
|
31
32
|
import sys
|
|
32
33
|
from typing import Dict, List, Tuple
|
|
33
34
|
|
|
34
35
|
from _common import guard_sidecar, label_chain, matrix_chain, range_chain, video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, x264_args, X264_PRESETS, fmt_secs, decode_pcm_mono, rms_envelope, STATE
|
|
35
36
|
from _common.emit import note, record_fallback, warn
|
|
36
|
-
from _common.decision import av_trim
|
|
37
|
+
from _common.decision import av_trim, rebase_project_paths
|
|
37
38
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
38
39
|
from sync import measure_offset
|
|
39
40
|
|
|
@@ -335,6 +336,10 @@ def main() -> int:
|
|
|
335
336
|
# a replacement: the combined output below is still written as usual.
|
|
336
337
|
project = {"output": default_output(args.inputs[0], "multicam_project", "mp4"),
|
|
337
338
|
"clips": project_clips}
|
|
339
|
+
# the inputs are named as given, relative to the working directory, and render.py reads a
|
|
340
|
+
# project's relative paths against the project file's directory: written elsewhere
|
|
341
|
+
# (--write-project out/cut.json), they named files that are not there
|
|
342
|
+
project = rebase_project_paths(project, os.getcwd())
|
|
338
343
|
if not STATE.dry_run:
|
|
339
344
|
with open(args.write_project, "w", encoding="utf-8") as fh:
|
|
340
345
|
fh.write(json.dumps(project, indent=2) + "\n")
|
package/scripts/overlay.py
CHANGED
|
@@ -31,11 +31,11 @@ with libvpx-vp9, so its transparency reaches the composite (FFmpeg's own VP9 dec
|
|
|
31
31
|
import argparse
|
|
32
32
|
import re
|
|
33
33
|
import sys
|
|
34
|
-
from typing import List, Optional
|
|
34
|
+
from typing import Any, Dict, List, Optional
|
|
35
35
|
|
|
36
36
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
37
37
|
from _common import still_input_args # noqa: E402
|
|
38
|
-
from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, layer_base_chain, matrix_chain, range_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
|
|
38
|
+
from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, layer_base_chain, matrix_chain, overlay_format, range_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
|
|
39
39
|
from _common.emit import note, warn
|
|
40
40
|
from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
|
|
41
41
|
has_alpha_plane, report as alpha_report, source_alpha, tag_args, video_line)
|
|
@@ -428,7 +428,9 @@ def main() -> int:
|
|
|
428
428
|
if end is not None and end > args.fade:
|
|
429
429
|
chain.append(f"fade=t=out:st={end - args.fade:.3f}:d={args.fade:g}:alpha=1")
|
|
430
430
|
x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
|
|
431
|
-
|
|
431
|
+
# a 10-bit main on a 10-bit line is composited at 10 bits in YUV: format=auto with an RGBA
|
|
432
|
+
# still took the whole picture through 8-bit RGB (overlay_format)
|
|
433
|
+
ov = f"overlay={x}:{y}" + (overlay_format(meta) or ":format=auto")
|
|
432
434
|
if enable:
|
|
433
435
|
ov += f":enable='{enable}'"
|
|
434
436
|
# -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
|
|
@@ -480,13 +482,18 @@ def main() -> int:
|
|
|
480
482
|
elif args.scale:
|
|
481
483
|
chain.append(f"scale={args.scale}:-2")
|
|
482
484
|
# an alpha line composites at 4:4:4: the layer is not taken through 4:2:0 on the way
|
|
483
|
-
|
|
485
|
+
# a 10-bit layer over a main overlay_format() composites at 10 bits stays 10-bit
|
|
486
|
+
deep = bool(overlay_format(meta)) and (pip_meta["video"].get("bit_depth") or 8) > 8
|
|
487
|
+
chain.append("format=yuva444p" if alpha_codec else "format=yuva420p10le" if deep else "format=yuva420p")
|
|
484
488
|
if args.chromakey:
|
|
485
489
|
chain.append(f"chromakey={args.chromakey}:{args.chromakey_similarity:g}:{args.chromakey_blend:g}")
|
|
486
490
|
if args.opacity < 1:
|
|
487
|
-
|
|
491
|
+
# the alpha is scaled in YUV: colorchannelmixer takes RGB only, so FFmpeg converted the
|
|
492
|
+
# layer to RGB and overlay=format=auto then composited the whole main picture in RGB,
|
|
493
|
+
# which came back BT.601 under the BT.709 tag (#333's defect, on this path)
|
|
494
|
+
chain.append(f"lut=y=val:u=val:v=val:a=val*{args.opacity:g}")
|
|
488
495
|
x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
|
|
489
|
-
ov = f"overlay={x}:{y}:format=auto"
|
|
496
|
+
ov = f"overlay={x}:{y}" + (overlay_format(meta) or ":format=auto")
|
|
490
497
|
if enable:
|
|
491
498
|
ov += f":enable='{enable}'"
|
|
492
499
|
# a VP9 layer stored with an alpha layer is decoded with libvpx-vp9: FFmpeg's own decoder
|
|
@@ -501,7 +508,13 @@ def main() -> int:
|
|
|
501
508
|
# converted to BT.709 YUV with its alpha plane: the 4:4:4 the conversion ends in had none,
|
|
502
509
|
# and the layer covered the picture as an opaque rectangle
|
|
503
510
|
pip = pip.replace("format=yuv444p", "format=yuva444p")
|
|
504
|
-
|
|
511
|
+
# an untagged picture is labelled beside a converted one, and beside a tagged one too: FFmpeg
|
|
512
|
+
# 8.0's overlay negotiates one colorspace for both inputs, and a BT.709-tagged main under an
|
|
513
|
+
# untagged PiP was converted to the PiP's "unknown" (BT.601) matrix, away from the PiP too
|
|
514
|
+
# (C03028 at 80/110/192 came back 88/103/192 on 8.0.1). Two untagged pictures agree as they are.
|
|
515
|
+
def tagged(m: Dict[str, Any]) -> bool:
|
|
516
|
+
return ((m.get("video") or {}).get("color_space") or "unknown") not in ("unknown", "unspecified")
|
|
517
|
+
if base or pip or tagged(meta) != tagged(pip_meta):
|
|
505
518
|
base, pip = base or label_chain(meta, meta), pip or label_chain(pip_meta, meta)
|
|
506
519
|
# overlay mixes samples: a full-range picture is taken to limited range (#410)
|
|
507
520
|
base, pip = base + range_chain(meta, meta), pip + range_chain(pip_meta, meta)
|
package/scripts/redact.py
CHANGED
|
@@ -27,7 +27,7 @@ Examples:
|
|
|
27
27
|
import argparse
|
|
28
28
|
import sys
|
|
29
29
|
|
|
30
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
|
|
30
|
+
from _common import add_common, overlay_format, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
|
|
31
31
|
from _common.emit import note
|
|
32
32
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
33
33
|
|
|
@@ -110,7 +110,7 @@ def main() -> int:
|
|
|
110
110
|
region = f"{crop},boxblur={radius}:{radius}:{chroma}:{radius}"
|
|
111
111
|
else:
|
|
112
112
|
region = f"{crop},scale={max(1, args.width // args.block_size)}:{max(1, args.height // args.block_size)}:flags=neighbor,scale={args.width}:{args.height}:flags=neighbor"
|
|
113
|
-
fc = f"[0:v]{square + ',' if square else ''}split=2[base][region];[region]{region}[patched];[base][patched]overlay={args.x}:{args.y}[out]"
|
|
113
|
+
fc = f"[0:v]{square + ',' if square else ''}split=2[base][region];[region]{region}[patched];[base][patched]overlay={args.x}:{args.y}{overlay_format(meta)}[out]"
|
|
114
114
|
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", fc, "-map", "[out]"]
|
|
115
115
|
if has_audio:
|
|
116
116
|
cmd += ["-map", f"0:a:{args.audio_stream}?"]
|
package/scripts/render.py
CHANGED
|
@@ -74,9 +74,10 @@ from typing import Any, Dict, List, Optional, Sequence
|
|
|
74
74
|
|
|
75
75
|
from export import PRESETS, PLATFORM_OF
|
|
76
76
|
from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
|
|
77
|
-
from _common import guard_sidecar, STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, X264_PRESETS
|
|
77
|
+
from _common import guard_sidecar, STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, option_args, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, X264_PRESETS
|
|
78
78
|
from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args, child_exit_code, found_exe, kept_clause, kept_deliverables, mark_delivered, remember_written
|
|
79
79
|
from _common.emit import absorb_stage, audit, record_fallback, record_skip, refuse, warn
|
|
80
|
+
from _common.decision import rebase_project_paths
|
|
80
81
|
import subprocess
|
|
81
82
|
from _contract import CONTRACT_VERSION
|
|
82
83
|
from batch import file_key
|
|
@@ -362,7 +363,7 @@ def render_pack(names: List[str], args) -> int:
|
|
|
362
363
|
("--chapters", args.chapters), ("--image", args.image),
|
|
363
364
|
("--intermediate-preset", args.intermediate_preset)):
|
|
364
365
|
if value:
|
|
365
|
-
argv +=
|
|
366
|
+
argv += option_args(flag, value)
|
|
366
367
|
info(f"→ pack: {name}")
|
|
367
368
|
proc = run_tool(argv + child_args() + ["--json"])
|
|
368
369
|
try:
|
|
@@ -1260,6 +1261,12 @@ def main() -> int:
|
|
|
1260
1261
|
emit(args.write_project, template=args.template, stages=[], check=None)
|
|
1261
1262
|
return 0
|
|
1262
1263
|
|
|
1264
|
+
# every file the project names is read against the project file's directory (a template's,
|
|
1265
|
+
# the working directory): the same keys batch.py and multicam.py rebase on a copy they write
|
|
1266
|
+
# elsewhere, including the two rel() below never saw (audiogram.image, overlays[].font_file
|
|
1267
|
+
# were read against the working directory)
|
|
1268
|
+
proj = rebase_project_paths(proj, base)
|
|
1269
|
+
|
|
1263
1270
|
def rel(p: Any) -> str:
|
|
1264
1271
|
p = str(p)
|
|
1265
1272
|
return p if os.path.isabs(p) else str(base / p)
|
|
@@ -1407,17 +1414,17 @@ def main() -> int:
|
|
|
1407
1414
|
if needs_cut:
|
|
1408
1415
|
argv: List[Any] = [src, "-o", part, "--accurate"]
|
|
1409
1416
|
if c.get("in") is not None:
|
|
1410
|
-
argv +=
|
|
1417
|
+
argv += option_args("--start", c["in"])
|
|
1411
1418
|
if c.get("out") is not None:
|
|
1412
|
-
argv +=
|
|
1419
|
+
argv += option_args("--end", c["out"])
|
|
1413
1420
|
clip_snap = dict(snap_spec)
|
|
1414
1421
|
clip_snap.update(c.get("snap") or {})
|
|
1415
1422
|
if str(clip_snap.get("to") or "none") == "beats":
|
|
1416
1423
|
argv += ["--snap", "beats"]
|
|
1417
1424
|
if clip_snap.get("tolerance") is not None:
|
|
1418
|
-
argv +=
|
|
1425
|
+
argv += option_args("--snap-tolerance", clip_snap["tolerance"])
|
|
1419
1426
|
if clip_snap.get("min_confidence") is not None:
|
|
1420
|
-
argv +=
|
|
1427
|
+
argv += option_args("--min-confidence", clip_snap["min_confidence"])
|
|
1421
1428
|
if clip_snap.get("source"):
|
|
1422
1429
|
argv += ["--snap-source", rel(clip_snap["source"])]
|
|
1423
1430
|
sh("cut.py", *argv, stage="clips")
|
|
@@ -1464,7 +1471,7 @@ def main() -> int:
|
|
|
1464
1471
|
# against the project file, as every other path here and as the output guard
|
|
1465
1472
|
# checks it; it used to reach waveform.py as written, against the cwd
|
|
1466
1473
|
value = rel(value)
|
|
1467
|
-
argv +=
|
|
1474
|
+
argv += option_args(flag, value)
|
|
1468
1475
|
argv += brand_args
|
|
1469
1476
|
sh("waveform.py", *argv, stage="audiogram")
|
|
1470
1477
|
current = nxt
|
|
@@ -1480,11 +1487,11 @@ def main() -> int:
|
|
|
1480
1487
|
current = str(work / f"joined{mid}")
|
|
1481
1488
|
argv = list(parts) + ["-o", current, "--transition", trans.get("type", "fade"), "--duration", str(trans.get("duration", 0.5))]
|
|
1482
1489
|
if frame.get("width"):
|
|
1483
|
-
argv +=
|
|
1490
|
+
argv += option_args("--width", frame["width"])
|
|
1484
1491
|
if frame.get("height"):
|
|
1485
|
-
argv +=
|
|
1492
|
+
argv += option_args("--height", frame["height"])
|
|
1486
1493
|
if frame.get("fps"):
|
|
1487
|
-
argv +=
|
|
1494
|
+
argv += option_args("--fps", frame["fps"])
|
|
1488
1495
|
sh("join.py", *argv, stage="join")
|
|
1489
1496
|
stages_done.append("join")
|
|
1490
1497
|
if args.stop_after == "join":
|
|
@@ -1499,7 +1506,7 @@ def main() -> int:
|
|
|
1499
1506
|
for k, flag in (("threshold", "--threshold"), ("min_silence", "--min-silence"), ("margin", "--margin"),
|
|
1500
1507
|
("margin_lead", "--margin-lead"), ("margin_tail", "--margin-tail")):
|
|
1501
1508
|
if sil.get(k) is not None:
|
|
1502
|
-
argv +=
|
|
1509
|
+
argv += option_args(flag, sil[k])
|
|
1503
1510
|
sh("silence.py", *argv, stage="silence")
|
|
1504
1511
|
current = nxt
|
|
1505
1512
|
stages_done.append("silence")
|
|
@@ -1536,7 +1543,7 @@ def main() -> int:
|
|
|
1536
1543
|
argv = [current, "-o", nxt]
|
|
1537
1544
|
for k, flag in (("duration", "--duration"), ("method", "--method"), ("aspect", "--aspect"), ("fit", "--fit"), ("width", "--width"), ("height", "--height"), ("fps", "--fps"), ("smooth", "--smooth")):
|
|
1538
1545
|
if fit.get(k) is not None:
|
|
1539
|
-
argv +=
|
|
1546
|
+
argv += option_args(flag, fit[k])
|
|
1540
1547
|
sh("fit.py", *argv, stage="fit")
|
|
1541
1548
|
current = nxt
|
|
1542
1549
|
stages_done.append("fit")
|
|
@@ -1560,7 +1567,7 @@ def main() -> int:
|
|
|
1560
1567
|
for k, flag in (("font", "--font"), ("size", "--size"), ("color", "--color"), ("position", "--position"), ("margin", "--margin"), ("animate", "--animate"), ("highlight_color", "--highlight-color"), ("outline", "--outline"), ("lang", "--lang"), ("offset", "--offset"), ("max_lines", "--max-lines"), ("min_duration", "--min-duration"),
|
|
1561
1568
|
("fit_size", "--fit-size"), ("min_size", "--min-size"), ("fit_size_scope", "--fit-size-scope")):
|
|
1562
1569
|
if cap.get(k) is not None:
|
|
1563
|
-
argv +=
|
|
1570
|
+
argv += option_args(flag, cap[k])
|
|
1564
1571
|
for k, flag in (("karaoke", "--karaoke"), ("bold", "--bold"), ("box", "--box")):
|
|
1565
1572
|
if cap.get(k):
|
|
1566
1573
|
argv.append(flag)
|
|
@@ -1578,11 +1585,11 @@ def main() -> int:
|
|
|
1578
1585
|
nxt = str(work / f"graphics{i:02d}{mid}")
|
|
1579
1586
|
if not g.get("template"):
|
|
1580
1587
|
die(f"graphics[{i}] needs a template")
|
|
1581
|
-
argv = [current, "-o", nxt
|
|
1588
|
+
argv = [current, "-o", nxt] + option_args("--template", g["template"])
|
|
1582
1589
|
for k, flag in (("name", "--name"), ("title", "--title"), ("subtitle", "--subtitle"), ("start", "--start"), ("end", "--end"), ("position", "--position"), ("from", "--from"), ("scale", "--scale"), ("primary", "--primary"), ("text_color", "--text-color"), ("lang", "--lang"),
|
|
1583
1590
|
("text", "--text"), ("top", "--top"), ("bottom", "--bottom"), ("duration", "--duration"), ("margin", "--margin"), ("platform", "--platform")):
|
|
1584
1591
|
if g.get(k) is not None:
|
|
1585
|
-
argv +=
|
|
1592
|
+
argv += option_args(flag, g[k])
|
|
1586
1593
|
# the entry's own "platform" is the more specific statement than the template's destination
|
|
1587
1594
|
sh("graphics.py", *(argv + brand_args + ([] if g.get("platform") else platform_args)), stage="graphics")
|
|
1588
1595
|
current = nxt
|
|
@@ -1601,7 +1608,7 @@ def main() -> int:
|
|
|
1601
1608
|
elif ov.get("image"):
|
|
1602
1609
|
argv += ["--image", rel(ov["image"])]
|
|
1603
1610
|
elif ov.get("text"):
|
|
1604
|
-
argv +=
|
|
1611
|
+
argv += option_args("--text", ov["text"])
|
|
1605
1612
|
elif ov.get("video"):
|
|
1606
1613
|
# #326: sized and placed here, after fit, against the frame that ships
|
|
1607
1614
|
argv += ["--video", rel(ov["video"])]
|
|
@@ -1611,7 +1618,7 @@ def main() -> int:
|
|
|
1611
1618
|
("chromakey", "--chromakey"), ("chromakey_similarity", "--chromakey-similarity"), ("chromakey_blend", "--chromakey-blend")):
|
|
1612
1619
|
if ov.get(k) is not None:
|
|
1613
1620
|
# font_file is a path: the same one project_input_files() guards
|
|
1614
|
-
argv +=
|
|
1621
|
+
argv += option_args(flag, rel(ov[k]) if k == "font_file" and ov[k] else ov[k])
|
|
1615
1622
|
if ov.get("box"):
|
|
1616
1623
|
argv.append("--box")
|
|
1617
1624
|
# review 12: the overlay stage was the one stage that never heard which destination this
|
|
@@ -1647,10 +1654,10 @@ def main() -> int:
|
|
|
1647
1654
|
argv += [flag, rel(au[k])]
|
|
1648
1655
|
for k, flag in (("music_volume", "--music-volume"), ("effects_volume", "--effects-volume"), ("fade_in", "--fade-in"), ("fade_out", "--fade-out"), ("music_fade_out", "--music-fade-out"), ("gain", "--gain"), ("duck_amount", "--duck-amount"), ("duck_threshold", "--duck-threshold"), ("duck_attack", "--duck-attack"), ("duck_release", "--duck-release"), ("stereo_widen", "--stereo-widen")):
|
|
1649
1656
|
if au.get(k) is not None:
|
|
1650
|
-
argv +=
|
|
1657
|
+
argv += option_args(flag, au[k])
|
|
1651
1658
|
# "voice": true is the medium chain; "voice": "light"|"medium"|"strong" names one
|
|
1652
1659
|
if au.get("voice") is not None and au.get("voice") is not False:
|
|
1653
|
-
argv += ["--voice"]
|
|
1660
|
+
argv += ["--voice"] if au["voice"] is True else option_args("--voice", au["voice"])
|
|
1654
1661
|
for k, flag in (("denoise", "--denoise"), ("duck", "--duck"), ("music_loop", "--music-loop"), ("stereo", "--stereo"), ("mono", "--mono"), ("downmix", "--downmix")):
|
|
1655
1662
|
if au.get(k):
|
|
1656
1663
|
argv.append(flag)
|
|
@@ -1672,9 +1679,9 @@ def main() -> int:
|
|
|
1672
1679
|
nxt = str(work / f"loudnorm{mid}")
|
|
1673
1680
|
argv = [current, "-o", nxt]
|
|
1674
1681
|
if ld.get("lufs") is not None:
|
|
1675
|
-
argv +=
|
|
1682
|
+
argv += option_args("--lufs", ld["lufs"])
|
|
1676
1683
|
if ld.get("tp") is not None:
|
|
1677
|
-
argv +=
|
|
1684
|
+
argv += option_args("--tp", ld["tp"])
|
|
1678
1685
|
sh("loudness.py", *argv, stage="loudness")
|
|
1679
1686
|
current = nxt
|
|
1680
1687
|
stages_done.append("loudness")
|
|
@@ -1687,9 +1694,9 @@ def main() -> int:
|
|
|
1687
1694
|
if ex and ex.get("preset"):
|
|
1688
1695
|
argv = [current, "--preset", ex["preset"], "-o", output]
|
|
1689
1696
|
if ex.get("fit"):
|
|
1690
|
-
argv +=
|
|
1697
|
+
argv += option_args("--fit", ex["fit"])
|
|
1691
1698
|
if ex.get("crf") is not None:
|
|
1692
|
-
argv +=
|
|
1699
|
+
argv += option_args("--crf", ex["crf"])
|
|
1693
1700
|
normalize = ex.get("normalize")
|
|
1694
1701
|
if normalize is None and ex["preset"] in PLATFORM_OF and not proj.get("loudness"):
|
|
1695
1702
|
# eval 8: a reels project without the key rendered fully, failed the loudness check and
|
package/scripts/silence.py
CHANGED
|
@@ -82,14 +82,17 @@ def silence_positions(silences: List[Tuple[float, float]], words: "list",
|
|
|
82
82
|
of the word after (on demos/footage/ai_talk_en.mp4 three word starts sit 0.45 to 0.5 s before
|
|
83
83
|
the sound does), and a word's midpoint stays on its own side of the pause either way. A
|
|
84
84
|
silence with no word before it is `inside`."""
|
|
85
|
+
import bisect
|
|
85
86
|
timed = sorted(words, key=lambda w: w["start"] + w["end"])
|
|
87
|
+
mids = [(w["start"] + w["end"]) / 2 for w in timed]
|
|
86
88
|
out: List[str] = []
|
|
87
89
|
for s, e in silences:
|
|
88
90
|
if e == float("inf"):
|
|
89
91
|
e = max(s, duration) if duration is not None else s
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
# the last word whose midpoint is before the silence's: a binary search, not a scan of the
|
|
93
|
+
# whole transcript per silence (an hour of speech has thousands of each)
|
|
94
|
+
k = bisect.bisect_left(mids, (s + e) / 2)
|
|
95
|
+
out.append("sentence_end" if k and ends_sentence(timed[k - 1]["word"]) else "inside")
|
|
93
96
|
return out
|
|
94
97
|
|
|
95
98
|
|