ffmpeg-skill 3.6.1 → 3.6.2
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 +36 -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 +43 -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/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/caption.py
CHANGED
|
@@ -43,9 +43,10 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
43
43
|
|
|
44
44
|
from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_units,
|
|
45
45
|
resolve as resolve_platform)
|
|
46
|
-
from _ass_overlay import EMOJI_SENTINEL, emoji_placeholder, ass_escape
|
|
46
|
+
from _ass_overlay import (EMOJI_SENTINEL, emoji_placeholder, ass_escape, ass_escape_markup,
|
|
47
|
+
strip_srt_markup, reinsert_srt_markup, carry_srt_markup, srt_alignment)
|
|
47
48
|
from _common.emit import refuse # noqa: E402
|
|
48
|
-
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, 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
|
|
49
|
+
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
|
|
49
50
|
# The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
|
|
50
51
|
# The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
|
|
51
52
|
# shares them). They stay caption.py's public names -- every caller and test that reached for
|
|
@@ -277,12 +278,12 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
|
|
|
277
278
|
margin_px = args.margin * play_h / 288.0
|
|
278
279
|
line_h = size_px * 1.2
|
|
279
280
|
box_px = size_px * scale
|
|
280
|
-
align = ALIGN[args.position]
|
|
281
281
|
out_cues = []
|
|
282
282
|
for start, end, text in cues:
|
|
283
283
|
lines = _cue_lines(text)
|
|
284
284
|
n = len(lines)
|
|
285
285
|
new_lines = []
|
|
286
|
+
align = srt_alignment(text) or ALIGN[args.position] # a cue's {\anN} moves its emoji too
|
|
286
287
|
for i, line in enumerate(lines):
|
|
287
288
|
if align in (7, 8, 9):
|
|
288
289
|
y_top = margin_px + i * line_h
|
|
@@ -290,7 +291,7 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
|
|
|
290
291
|
y_top = play_h / 2.0 - (n * line_h) / 2.0 + i * line_h
|
|
291
292
|
else:
|
|
292
293
|
y_top = play_h - margin_px - (n - i) * line_h
|
|
293
|
-
line_w = text_width_em(line, scale) * size_px
|
|
294
|
+
line_w = text_width_em(strip_srt_markup(line), scale) * size_px
|
|
294
295
|
if align in (1, 4, 7):
|
|
295
296
|
x0 = margin_px
|
|
296
297
|
elif align in (3, 6, 9):
|
|
@@ -300,7 +301,7 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
|
|
|
300
301
|
# libass lays an RTL line out right-to-left, so the LOGICAL prefix of a cluster
|
|
301
302
|
# occupies the RIGHT end of the rendered line. Measuring the prefix from the left
|
|
302
303
|
# edge put the PNG on top of the text, mirrored, on every Arabic/Hebrew cue (1.15.0).
|
|
303
|
-
rtl = detect_script(line) in BIDI_SCRIPTS
|
|
304
|
+
rtl = detect_script(strip_srt_markup(line)) in BIDI_SCRIPTS
|
|
304
305
|
rebuilt = ""
|
|
305
306
|
cursor = 0
|
|
306
307
|
for idx, cluster in emoji_clusters(line):
|
|
@@ -313,6 +314,7 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
|
|
|
313
314
|
rebuilt += line[cursor:idx + len(cluster)]
|
|
314
315
|
cursor = idx + len(cluster)
|
|
315
316
|
continue
|
|
317
|
+
prefix = strip_srt_markup(prefix) # a tag before the emoji takes no room
|
|
316
318
|
if rtl:
|
|
317
319
|
x = x0 + line_w - text_width_em(prefix + cluster, scale) * size_px
|
|
318
320
|
else:
|
|
@@ -390,9 +392,12 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
|
|
|
390
392
|
# greedy wrap would have given (`rebalanced`) and what 1.15's wrap would have
|
|
391
393
|
# given (`phrase_breaks`). Three wrap_text() calls re-ran the atomiser each time.
|
|
392
394
|
sliced_atoms: List[int] = []
|
|
393
|
-
|
|
395
|
+
# SRT markup (<i>, <font color>, {\an8}) is not text: the wrap measures and breaks
|
|
396
|
+
# the words a viewer reads, and the tags go back next to the characters they styled
|
|
397
|
+
plain = strip_srt_markup(text)
|
|
398
|
+
lines, greedy, measured = wrap_variants(plain, own_em, mode=wrap, lang=lang,
|
|
394
399
|
slice_overlong=True, sliced=sliced_atoms)
|
|
395
|
-
if lines != [l for l in
|
|
400
|
+
if lines != [l for l in plain.split("\n") if l.strip()]:
|
|
396
401
|
stats["wrapped"] += 1
|
|
397
402
|
if sliced_atoms:
|
|
398
403
|
# the escape hatch fired: a word or run with no break point the wrapper may use
|
|
@@ -407,19 +412,22 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
|
|
|
407
412
|
stats["rebalanced"] += 1
|
|
408
413
|
if wrap != "measured" and lines != measured:
|
|
409
414
|
stats["phrase_breaks"] += 1
|
|
415
|
+
marked = reinsert_srt_markup(text, lines) if plain != text else lines
|
|
410
416
|
if len(lines) > max_lines:
|
|
411
417
|
chunks = [lines[i:i + max_lines] for i in range(0, len(lines), max_lines)]
|
|
412
418
|
weights = [max(1.0, sum(len(l) for l in c)) for c in chunks]
|
|
413
419
|
total_w = sum(weights)
|
|
420
|
+
pieces = carry_srt_markup(["\n".join(marked[i:i + max_lines])
|
|
421
|
+
for i in range(0, len(marked), max_lines)])
|
|
414
422
|
t = start
|
|
415
|
-
for
|
|
423
|
+
for piece, weight in zip(pieces, weights):
|
|
416
424
|
seg = (end - start) * weight / total_w
|
|
417
|
-
staged.append((t, min(end, t + seg),
|
|
425
|
+
staged.append((t, min(end, t + seg), piece))
|
|
418
426
|
staged_sizes.append(own_size)
|
|
419
427
|
t += seg
|
|
420
428
|
stats["split"] += len(chunks) - 1
|
|
421
429
|
continue
|
|
422
|
-
text = "\n".join(
|
|
430
|
+
text = "\n".join(marked)
|
|
423
431
|
staged.append((start, end, text))
|
|
424
432
|
staged_sizes.append(own_size)
|
|
425
433
|
out: List[Tuple[float, float, str]] = []
|
|
@@ -572,7 +580,7 @@ def parse_ass_cues(path: str, stats: Optional[dict] = None) -> List[Tuple[float,
|
|
|
572
580
|
|
|
573
581
|
|
|
574
582
|
def cue_text_visible(text: str) -> bool:
|
|
575
|
-
return bool((text or "").strip())
|
|
583
|
+
return bool(strip_srt_markup(text or "").strip()) # `<i></i>` draws nothing
|
|
576
584
|
|
|
577
585
|
|
|
578
586
|
def count_visible_cues(cues, duration) -> Tuple[int, int]:
|
|
@@ -664,6 +672,17 @@ def _karaoke_word_durations(args, video: Optional[str], start: float, end: float
|
|
|
664
672
|
return durs
|
|
665
673
|
|
|
666
674
|
|
|
675
|
+
# an override block this file wrote (a converted SRT tag), not the escaped `\{` a user typed
|
|
676
|
+
_OVERRIDE_BLOCK_RE = re.compile(r"(?<!\\)\{\\[^{}]*\}")
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _karaoke_word(token: str) -> bool:
|
|
680
|
+
"""Does this space-separated Dialogue token hold something a viewer reads? An emoji
|
|
681
|
+
placeholder does not, and neither does a lone converted tag (`{\\i1}` from `<i> Hello`):
|
|
682
|
+
neither gets a share of the cue's time or a highlight of its own."""
|
|
683
|
+
return bool(_OVERRIDE_BLOCK_RE.sub("", token).strip(EMOJI_SENTINEL))
|
|
684
|
+
|
|
685
|
+
|
|
667
686
|
def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int, play_h: int, video: str = None) -> None:
|
|
668
687
|
"""Write a styled ASS file with optional animation and word-by-word highlight."""
|
|
669
688
|
def t(sec: float) -> str:
|
|
@@ -687,6 +706,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
687
706
|
word_active = ass_color(args.highlight_color)
|
|
688
707
|
word_upcoming = ass_color(args.upcoming_color)
|
|
689
708
|
word_bord = f"{args.outline * scale * (max(1.0, args.karaoke_scale) / 100.0):.2f}"
|
|
709
|
+
style_bord = f"{args.outline * scale:.2f}"
|
|
690
710
|
word_scale = int(round(args.karaoke_scale))
|
|
691
711
|
# --karaoke-style word emits one Dialogue per word carrying the WHOLE cue with explicit \N
|
|
692
712
|
# breaks (the layout's own fixed line list -- see max_line_em's docstring). WrapStyle: 2 tells
|
|
@@ -713,7 +733,9 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
713
733
|
# escapes for the braces, so 1.15 escapes them (ass_escape) rather than deleting them:
|
|
714
734
|
# a cue that says "use {curly} braces" is read out with its braces, and still cannot open
|
|
715
735
|
# an override block. Newlines become \N in the same pass.
|
|
716
|
-
|
|
736
|
+
# SRT markup is the exception (#X2): <i>/<b>/<u>/<s>, <font color> and {\anN} become the
|
|
737
|
+
# overrides ffmpeg's SRT reader gives them, so this route draws what the plain one draws
|
|
738
|
+
text = ass_escape_markup(text, colour=not args.karaoke)
|
|
717
739
|
fx = ""
|
|
718
740
|
if args.animate == "fade":
|
|
719
741
|
fx = "{\\fad(200,200)}"
|
|
@@ -740,7 +762,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
740
762
|
# the active word's larger \fscx/\fscy, which is the jump the issue is filed against.
|
|
741
763
|
line_words = [[w for w in seg.split(" ") if w] for seg in body.split("\\N")]
|
|
742
764
|
flat = [w for lw in line_words for w in lw]
|
|
743
|
-
real_idx = [i for i, w in enumerate(flat) if w
|
|
765
|
+
real_idx = [i for i, w in enumerate(flat) if _karaoke_word(w)]
|
|
744
766
|
if not real_idx:
|
|
745
767
|
# nothing to highlight (an all-emoji or empty cue) -- one plain event, same as
|
|
746
768
|
# today's karaoke-off rendering, rather than zero Dialogue lines for this cue
|
|
@@ -764,15 +786,18 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
764
786
|
for lw in line_words:
|
|
765
787
|
styled_line = []
|
|
766
788
|
for w in lw:
|
|
767
|
-
if not w
|
|
789
|
+
if not _karaoke_word(w):
|
|
768
790
|
styled_line.append(w)
|
|
769
791
|
else:
|
|
770
792
|
rank = real_idx.index(flat_pos)
|
|
771
793
|
if rank < j:
|
|
772
794
|
styled_line.append(f"{{\\c{word_past}}}{w}")
|
|
773
795
|
elif rank == j:
|
|
796
|
+
# put back what the active word changed, not {\r}: a reset would
|
|
797
|
+
# also drop the cue's own <i>/<b>/colour and a --fit-size \fs
|
|
774
798
|
styled_line.append(
|
|
775
|
-
f"{{\\c{word_active}\\fscx{word_scale}\\fscy{word_scale}\\bord{word_bord}}}{w}
|
|
799
|
+
f"{{\\c{word_active}\\fscx{word_scale}\\fscy{word_scale}\\bord{word_bord}}}{w}"
|
|
800
|
+
f"{{\\fscx100\\fscy100\\bord{style_bord}}}")
|
|
776
801
|
else:
|
|
777
802
|
styled_line.append(f"{{\\c{word_upcoming}}}{w}")
|
|
778
803
|
flat_pos += 1
|
|
@@ -789,7 +814,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
789
814
|
if args.karaoke:
|
|
790
815
|
# split each line into words and give every word an equal share of the cue (\k is in centiseconds)
|
|
791
816
|
segments = body.split("\\N")
|
|
792
|
-
words = [w for seg in segments for w in seg.split(" ") if w and w
|
|
817
|
+
words = [w for seg in segments for w in seg.split(" ") if w and _karaoke_word(w)]
|
|
793
818
|
durs = _karaoke_word_durations(args, video, start, end, len(words))
|
|
794
819
|
it = iter(durs)
|
|
795
820
|
out_segments = []
|
|
@@ -799,7 +824,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
799
824
|
# past the reserved gap without spending cue time on a glyph nobody sees (rendered
|
|
800
825
|
# and confirmed -- libass keeps the full gap inside a karaoke run).
|
|
801
826
|
out_segments.append(" ".join(
|
|
802
|
-
("{\\kf0}" + w) if not w
|
|
827
|
+
("{\\kf0}" + w) if not _karaoke_word(w) else f"{{\\kf{next(it)}}}{w}" for w in ws))
|
|
803
828
|
body = "\\N".join(out_segments)
|
|
804
829
|
if EMOJI_SENTINEL in body:
|
|
805
830
|
body = body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size)))
|
|
@@ -1223,7 +1248,7 @@ def main() -> int:
|
|
|
1223
1248
|
# viewer sees and would silently change the SRT this run writes, so the mux path is
|
|
1224
1249
|
# left exactly as 1.16 wrote it.
|
|
1225
1250
|
return
|
|
1226
|
-
fit = fit_size(cue_list, **fit_params())
|
|
1251
|
+
fit = fit_size([(s, e, strip_srt_markup(t)) for s, e, t in cue_list], **fit_params())
|
|
1227
1252
|
fit_stats["size_used"] = fit["size"]
|
|
1228
1253
|
fit_stats["size_source"] = "platform-frame" if planned_frame else "input"
|
|
1229
1254
|
fit_stats["shrunk"] = fit["shrunk"]
|
|
@@ -1774,7 +1799,7 @@ def main() -> int:
|
|
|
1774
1799
|
side_notes.append(square_pixels_note(meta["video"]))
|
|
1775
1800
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
1776
1801
|
chains, emoji_inputs = (emoji_filter_chain(emoji_plan or {}, "vsub", "vout", to_yuv=rgb_chain_to_bt709(meta),
|
|
1777
|
-
base_chain=layer_base_chain(meta))
|
|
1802
|
+
base_chain=layer_base_chain(meta), overlay_opts=overlay_format(meta))
|
|
1778
1803
|
if emoji_plan else ([], []))
|
|
1779
1804
|
if chains:
|
|
1780
1805
|
for spec in emoji_inputs:
|
package/scripts/check.py
CHANGED
|
@@ -525,7 +525,7 @@ def main() -> int:
|
|
|
525
525
|
# comes back at a level nobody checked.
|
|
526
526
|
ch = a.get("channels") or 0
|
|
527
527
|
row("channels", "PASS" if ch in (1, 2) else "WARN", f"{ch}ch", "1 (mono) or 2 (stereo)",
|
|
528
|
-
"audio.py --downmix (5.1/7.1 to stereo with the
|
|
528
|
+
"audio.py --downmix (5.1/7.1 to stereo with the ITU-R BS.775 weights) or audio.py --mono",
|
|
529
529
|
reason="podcast players downmix 5.1 unpredictably")
|
|
530
530
|
if args.platform == "podcast":
|
|
531
531
|
chapters = meta.get("chapters") or []
|
package/scripts/color.py
CHANGED
|
@@ -38,6 +38,7 @@ from _common.emit import note, warn
|
|
|
38
38
|
from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
|
|
39
39
|
report as alpha_report, source_alpha, tag_args, video_line)
|
|
40
40
|
from _common.decision import keeps_source_tags
|
|
41
|
+
from _common.color import bt2020_sdr_to_bt709_chain, hdr_to_sdr_chain, is_bt2020_sdr # noqa: E402,F401
|
|
41
42
|
|
|
42
43
|
# the curves of FFmpeg's CPU `tonemap` filter (every version from 5.1 to 9). bt2390 was listed
|
|
43
44
|
# until 2.24 but exists only in tonemap_opencl/libplacebo, so choosing it always failed.
|
|
@@ -198,47 +199,6 @@ def lut_legs(meta=None, out_fmt: str = "yuv420p") -> "tuple":
|
|
|
198
199
|
f"scale=out_color_matrix={matrix}:out_range=tv,format={out_fmt}")
|
|
199
200
|
|
|
200
201
|
|
|
201
|
-
def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str:
|
|
202
|
-
v = meta["video"]
|
|
203
|
-
trc = v.get("color_transfer") or "smpte2084"
|
|
204
|
-
prim = v.get("color_primaries") or "bt2020"
|
|
205
|
-
space = v.get("color_space") or "bt2020nc"
|
|
206
|
-
# zscale needs explicit input tags when the file lacks them
|
|
207
|
-
chain: List[str] = [
|
|
208
|
-
f"zscale=tin={trc}:pin={prim}:min={space}:rin={v.get('color_range') or 'tv'}:t=linear:npl={peak:g}",
|
|
209
|
-
"format=gbrpf32le",
|
|
210
|
-
"zscale=p=bt709",
|
|
211
|
-
f"tonemap=tonemap={tonemap}:desat={desat:g}",
|
|
212
|
-
"zscale=t=bt709:m=bt709:r=tv",
|
|
213
|
-
"format=yuv420p",
|
|
214
|
-
]
|
|
215
|
-
return ",".join(chain)
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
def bt2020_sdr_to_bt709_chain(meta: dict) -> str:
|
|
219
|
-
"""Gamut conversion only, for BT.2020 primaries on an SDR transfer (probe: `bt2020_or_hdr`
|
|
220
|
-
true, `hdr_signal` false). Such a file is already display-referred SDR: linearising it with
|
|
221
|
-
its own transfer, mapping the primaries to BT.709 and re-applying the BT.709 transfer keeps
|
|
222
|
-
white at white and grey at grey. The tone map the HDR chain adds would treat 100 % SDR white
|
|
223
|
-
as a fraction of a 1000-nit peak and darken the picture (white Y 235 -> 151, grey 126 -> 90)."""
|
|
224
|
-
v = meta["video"]
|
|
225
|
-
trc = v.get("color_transfer") or "bt709"
|
|
226
|
-
space = v.get("color_space") or "bt2020nc"
|
|
227
|
-
return ",".join([
|
|
228
|
-
f"zscale=tin={trc}:pin=bt2020:min={space}:rin={v.get('color_range') or 'tv'}:t=linear",
|
|
229
|
-
"format=gbrpf32le",
|
|
230
|
-
"zscale=p=bt709",
|
|
231
|
-
"zscale=t=bt709:m=bt709:r=tv",
|
|
232
|
-
"format=yuv420p",
|
|
233
|
-
])
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def is_bt2020_sdr(v: dict) -> bool:
|
|
237
|
-
"""BT.2020 primaries on a non-HDR transfer and no Dolby Vision: gamut-only --to-sdr."""
|
|
238
|
-
return bool(v.get("bt2020_or_hdr")) and not v.get("hdr_signal") and not v.get("dolby_vision") \
|
|
239
|
-
and v.get("color_transfer") not in ("smpte2084", "arib-std-b67")
|
|
240
|
-
|
|
241
|
-
|
|
242
202
|
# --retag targets: ffprobe's names (colorspace, primaries, transfer) and the ITU-T H.273 code points
|
|
243
203
|
# h264_metadata/hevc_metadata write into the VUI (matrix_coefficients, colour_primaries,
|
|
244
204
|
# transfer_characteristics).
|
|
@@ -436,7 +396,7 @@ def main() -> int:
|
|
|
436
396
|
"(luma 14-241, chroma 5-251) legal range after the 8-bit 4:2:0 conversion, then measure the written "
|
|
437
397
|
"file's out-of-range share (JSON legalize). --correct with no correction values clamps only")
|
|
438
398
|
ap.add_argument("--tonemap", choices=TONEMAPS, default="hable", help="tone-mapping curve (default hable)")
|
|
439
|
-
ap.add_argument("--peak", type=float, default=1000.0, help="source peak brightness in nits
|
|
399
|
+
ap.add_argument("--peak", type=float, default=1000.0, help="the PQ source's peak brightness in nits, the level the tone map takes to SDR white (default 1000; an HLG source peaks at 1000 by definition, so it does not apply there)")
|
|
440
400
|
ap.add_argument("--desat", type=float, default=0.0, help="tonemap desaturation strength (default 0)")
|
|
441
401
|
ap.add_argument("--lut-strength", type=float, default=1.0, help="blend LUT result with the original, 0..1 (default 1)")
|
|
442
402
|
ap.add_argument("--force", action="store_true", help="run --to-sdr even if the file is tagged neither HDR nor BT.2020 (tone-mapped as PQ; a BT.2020 SDR file never needs it)")
|
package/scripts/cut.py
CHANGED
|
@@ -62,7 +62,8 @@ from _common.runner import fail_if_classified
|
|
|
62
62
|
from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
|
|
63
63
|
from _common import require_tool
|
|
64
64
|
from _common.probe import TS_EXTS, signatures_match, stream_signature
|
|
65
|
-
from _common.decision import grid_at_or_after
|
|
65
|
+
from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, stream_starts
|
|
66
|
+
from _common.timeline import output_timecode_args
|
|
66
67
|
from _common import warn, record_fallback, refuse_existing_outputs, sibling_temp, source_codec_video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, place_output, refuse_output_is_input, fmt_secs, measure_frame_timing, dry_run_input_pending
|
|
67
68
|
|
|
68
69
|
# outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
|
|
@@ -352,6 +353,28 @@ def copy_presentation(t: float, key, edit_list: bool, fps) -> dict:
|
|
|
352
353
|
return {"start_snapped": abs(k_pts - t) > frame + 1e-6, "stored_preroll_seconds": None}
|
|
353
354
|
|
|
354
355
|
|
|
356
|
+
def first_frame_from(src: str, meta: dict, start: float, reencode: bool, edit_list: bool):
|
|
357
|
+
"""Seconds from the source picture's first frame to the frame a cut from `start` begins on,
|
|
358
|
+
for its start timecode: the first frame at or after `start` for a re-encode and an edit-listed
|
|
359
|
+
copy that hides its keyframe's pre-roll, the keyframe for any other copy (it shows the
|
|
360
|
+
pre-roll). None when where a copy lands cannot be read, and for an MXF source, whose demuxer
|
|
361
|
+
lands a seek on another keyframe than seek_keyframe's read (-ss 1.5: the one at 1.44 s, read as
|
|
362
|
+
the one at 0.96 s), so no label would be known to be right."""
|
|
363
|
+
v0, rate = stream_starts(meta)[0], frame_rate(meta)
|
|
364
|
+
if reencode:
|
|
365
|
+
# a start before the picture's first frame (a video that starts after the file's origin)
|
|
366
|
+
# begins on that first frame, not on a grid frame before it
|
|
367
|
+
return grid_at_or_after(max(start, v0), v0, rate) - v0
|
|
368
|
+
if "mxf" in str(meta.get("format") or "").split(","):
|
|
369
|
+
return None
|
|
370
|
+
key = seek_keyframe(src, start)
|
|
371
|
+
if key is None:
|
|
372
|
+
return None
|
|
373
|
+
if edit_list and key[0] <= start + 1e-6:
|
|
374
|
+
return None if key[2] is None else key[2] - v0
|
|
375
|
+
return key[0] - v0
|
|
376
|
+
|
|
377
|
+
|
|
355
378
|
def av_skew(out_meta: dict, edit_list_remedy: bool = False, expected: float = 0.0):
|
|
356
379
|
"""(seconds audio starts after video, warning note or None); None when either is missing.
|
|
357
380
|
A lossless cut can start its streams apart -- Core Media HEVC once gave 3.7 s of sound with no
|
|
@@ -891,6 +914,9 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
891
914
|
edit_list = (edit_list_ok or copy_t is not None) and os.path.splitext(dst)[1].lower() in EDIT_LIST_EXTS
|
|
892
915
|
cmd = (ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur if copy_t is None else copy_t:.6f}"]
|
|
893
916
|
+ copy_args(meta, dst) + ([] if edit_list else ["-avoid_negative_ts", "make_zero"]) + [dst])
|
|
917
|
+
if not audio_only and (meta.get("timecode") or {}).get("value"):
|
|
918
|
+
cmd[-1:-1] = output_timecode_args(meta, first_frame_from(src, meta, start, reencode, edit_list and copy_t is None),
|
|
919
|
+
delivered_rate(meta) if reencode else None)
|
|
894
920
|
proc = run(cmd, check=False)
|
|
895
921
|
if proc.returncode != 0:
|
|
896
922
|
if not reencode:
|
|
@@ -956,9 +982,6 @@ JOIN_CHUNK = 32
|
|
|
956
982
|
# plays as a click: measured on a 440 Hz tone, the step at the join was 14x the tone's own largest
|
|
957
983
|
# sample-to-sample change. A 20 ms fade is too short to hear as a dip and long enough to remove it.
|
|
958
984
|
DECLICK_S = 0.02
|
|
959
|
-
# how far before a segment the fallback seeks, so a start inside the B-frame reorder delay before a
|
|
960
|
-
# keyframe still decodes from the keyframe BEFORE it
|
|
961
|
-
SEEK_MARGIN = 1.0
|
|
962
985
|
# codecs whose configuration travels in the packets, so a part with no extradata is not missing any
|
|
963
986
|
_PARTS: dict = {}
|
|
964
987
|
|
|
@@ -1102,7 +1125,9 @@ def _join_chunk(src: str, segments: List[Tuple[float, float]], dst: str, meta: d
|
|
|
1102
1125
|
# the source's rate, stated: FFmpeg 7.0 encoded the trimmed, concatenated pictures at 25 fps
|
|
1103
1126
|
# and dropped frames to fit (98 frames for 117 at 30 fps)
|
|
1104
1127
|
rate_out = ["-r", rate_arg] if has_v and rate_arg and rate and "-r" not in codec else []
|
|
1105
|
-
|
|
1128
|
+
# a join's frames after the first range are not the source's next frames: no start timecode
|
|
1129
|
+
# labels them, so the output carries none (a single range's cut moves it to its first frame)
|
|
1130
|
+
cmd += ["-sn", "-dn"] + codec + rate_out + ([] if intermediate or not has_v else output_timecode_args(meta, None)) + [dst]
|
|
1106
1131
|
run(cmd)
|
|
1107
1132
|
return codec
|
|
1108
1133
|
|
|
@@ -1122,7 +1147,7 @@ def join_from_source(src: str, segments: List[Tuple[float, float]], dst: str, me
|
|
|
1122
1147
|
if len(segments) <= JOIN_CHUNK:
|
|
1123
1148
|
_join_chunk(src, segments, dst, meta, crf, preset, has_v, vend=vend, hold=hold)
|
|
1124
1149
|
return
|
|
1125
|
-
ext = os.path.splitext(dst)[1] or ".mp4"
|
|
1150
|
+
ext = os.path.splitext(dst)[1].lower() or ".mp4" # IMG_0001.MOV is a .mov
|
|
1126
1151
|
# Matroska chunks with PCM audio: no edit lists and no AAC priming to carry into the join (MP4
|
|
1127
1152
|
# chunks measured the video 23 ms late against the audio after a copy concat)
|
|
1128
1153
|
# the chunks' codec tag (hvc1), which a stream copy out of Matroska does not carry into an MP4
|
|
@@ -1163,7 +1188,8 @@ def join_from_source(src: str, segments: List[Tuple[float, float]], dst: str, me
|
|
|
1163
1188
|
audio = [] if not meta.get("audio") else (aac_args() if has_v else audio_codec_for(dst))
|
|
1164
1189
|
run(ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile]
|
|
1165
1190
|
+ (["-c:v", "copy"] + (tag if ext in (".mp4", ".mov", ".m4v") else []) if has_v else ["-vn"]) + audio
|
|
1166
|
-
+ (["-movflags", "+faststart"] if ext in (".mp4", ".mov", ".m4v") and has_v else [])
|
|
1191
|
+
+ (["-movflags", "+faststart"] if ext in (".mp4", ".mov", ".m4v") and has_v else [])
|
|
1192
|
+
+ (output_timecode_args(meta, None) if has_v else []) + [dst])
|
|
1167
1193
|
|
|
1168
1194
|
|
|
1169
1195
|
BEAT_RATE = 22050 # the decode rate the onset pass uses, matching scenes.py --beats
|
|
@@ -1333,7 +1359,7 @@ def run_smart(args, segments, output: str, meta: dict, forced: List[str], hold_n
|
|
|
1333
1359
|
# line can outgrow Windows' limit, and the full re-encode joins in chunks of this size
|
|
1334
1360
|
return fallback(f"{len(segments)} segments with sound: the smart join takes at most {JOIN_CHUNK}, the "
|
|
1335
1361
|
"full re-encode joins them in chunks", "segments")
|
|
1336
|
-
ext = os.path.splitext(output)[1] or ".mp4"
|
|
1362
|
+
ext = os.path.splitext(output)[1].lower() or ".mp4"
|
|
1337
1363
|
params = _smartcut.stream_params(args.input)
|
|
1338
1364
|
bad = _smartcut.ineligible(params, meta, ext)
|
|
1339
1365
|
if bad:
|
|
@@ -1510,7 +1536,7 @@ def main() -> int:
|
|
|
1510
1536
|
|
|
1511
1537
|
output = args.output or default_output(args.input, "cut")
|
|
1512
1538
|
refuse_output_is_input(output, args.input)
|
|
1513
|
-
ext = os.path.splitext(output)[1] or ".mp4"
|
|
1539
|
+
ext = os.path.splitext(output)[1].lower() or ".mp4" # an upper-case .MP4 is still faststart
|
|
1514
1540
|
|
|
1515
1541
|
# A segment that runs past the end of the video, with a join after it: whatever joins it (the
|
|
1516
1542
|
# concat demuxer or the concat filter) starts the next segment after its LONGER stream, so the
|
|
@@ -1693,6 +1719,8 @@ def main() -> int:
|
|
|
1693
1719
|
cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile, "-c", "copy"]
|
|
1694
1720
|
if ext in (".mp4", ".mov", ".m4v"):
|
|
1695
1721
|
cmd += ["-movflags", "+faststart"]
|
|
1722
|
+
if not is_audio_output(output):
|
|
1723
|
+
cmd += output_timecode_args(meta, None) # a join: no start timecode (_join_chunk)
|
|
1696
1724
|
cmd += [joined]
|
|
1697
1725
|
proc = run(cmd, check=False)
|
|
1698
1726
|
fail_if_classified(proc) # #415: a full disk is not a reason to re-cut every segment
|
package/scripts/fit.py
CHANGED
|
@@ -39,6 +39,8 @@ import sys
|
|
|
39
39
|
from fractions import Fraction
|
|
40
40
|
from typing import List
|
|
41
41
|
|
|
42
|
+
from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, stream_starts
|
|
43
|
+
from _common.timeline import exact_rate, output_timecode_args
|
|
42
44
|
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, aspect_ratio, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg, fmt_secs, frame_size, display_size, square_pixels, square_pixels_note, note
|
|
43
45
|
BLUR_DARKEN = 0.15 # how much --fit blur dims the blurred background copy (eq brightness)
|
|
44
46
|
|
|
@@ -136,6 +138,7 @@ def main() -> int:
|
|
|
136
138
|
pre_input: List[str] = []
|
|
137
139
|
post: List[str] = []
|
|
138
140
|
factor = 1.0
|
|
141
|
+
tc_first = None # seconds from the source's first frame to the output's, when a trim moves it
|
|
139
142
|
|
|
140
143
|
# ---- rotate / flip
|
|
141
144
|
if args.rotate == 90:
|
|
@@ -175,8 +178,18 @@ def main() -> int:
|
|
|
175
178
|
else:
|
|
176
179
|
if target < src_dur:
|
|
177
180
|
start = (src_dur - target) / 2 if args.from_center else 0.0
|
|
178
|
-
|
|
179
|
-
|
|
181
|
+
# seek SEEK_MARGIN early and drop the margin on the output side, as cut.py does:
|
|
182
|
+
# an input seek straight to `start` can land on a keyframe whose picture comes
|
|
183
|
+
# after it (a B-frame HEVC source) and lose the frames before it, so the first
|
|
184
|
+
# picture would not be the one the start timecode below names
|
|
185
|
+
m = min(SEEK_MARGIN, round(start, 3))
|
|
186
|
+
pre_input += ["-ss", f"{start - m:.3f}"]
|
|
187
|
+
post += (["-ss", f"{m:.3f}"] if m > 0 else []) + ["-t", f"{target:.3f}"]
|
|
188
|
+
# the output starts on the first frame at or after the seek (a seek before a
|
|
189
|
+
# picture that starts after the file's origin: on its first frame): its start
|
|
190
|
+
# timecode is that frame's, not the source's first frame's
|
|
191
|
+
v0, rate = stream_starts(meta)[0], frame_rate(meta)
|
|
192
|
+
tc_first = grid_at_or_after(max(round(start, 3), v0), v0, rate) - v0
|
|
180
193
|
else:
|
|
181
194
|
info(f"source ({src_dur:.2f}s) is already shorter than {target:.2f}s; trim does nothing")
|
|
182
195
|
|
|
@@ -213,6 +226,15 @@ def main() -> int:
|
|
|
213
226
|
vf.append(f"fps={args.fps:g}")
|
|
214
227
|
elif meta["video"].get("variable_frame_rate_suspected"):
|
|
215
228
|
info("source looks variable-frame-rate; conforming to constant fps automatically")
|
|
229
|
+
# the start timecode is counted at the rate the output is delivered at: a --fps that changes
|
|
230
|
+
# the rate relabels it (01:00:00:12 at 25 fps is 01:00:00:24 at 50) even with no trim, which
|
|
231
|
+
# is also how a render.py clip cut at its own rate reaches the project's frame.fps
|
|
232
|
+
out_rate = args.fps or delivered_rate(meta)
|
|
233
|
+
src_fps = meta["video"].get("fps")
|
|
234
|
+
rate_changed = bool(out_rate and src_fps and exact_rate(float(out_rate)) != exact_rate(float(src_fps)))
|
|
235
|
+
relabel = tc_first is not None or rate_changed
|
|
236
|
+
if relabel:
|
|
237
|
+
post += output_timecode_args(meta, tc_first or 0.0, out_rate)
|
|
216
238
|
|
|
217
239
|
output = args.output or default_output(args.input, "fit")
|
|
218
240
|
cmd = ffmpeg_base() + pre_input + ["-i", args.input]
|
|
@@ -240,7 +262,7 @@ def main() -> int:
|
|
|
240
262
|
run(cmd + [output])
|
|
241
263
|
dropped_streams = bool(meta.get("subtitle_streams") or meta.get("data_streams"))
|
|
242
264
|
else:
|
|
243
|
-
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
265
|
+
dropped_streams = run_keeping_subtitles(cmd, output, relabel_timecode=relabel)
|
|
244
266
|
|
|
245
267
|
result = probe(output, role="output")
|
|
246
268
|
msg = f"wrote {output} ({fmt_secs(result['duration'])}, {result['video']['width']}x{result['video']['height']})"
|
package/scripts/graphics.py
CHANGED
|
@@ -36,7 +36,7 @@ from typing import List, Optional, Tuple
|
|
|
36
36
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
37
37
|
from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
|
|
38
38
|
color_hex, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
|
|
39
|
-
ffmpeg_base, info, layer_base_chain, load_brand, parse_time, probe, rgb_chain_to_bt709, run, run_keeping_subtitles, video_args,
|
|
39
|
+
ffmpeg_base, info, layer_base_chain, load_brand, overlay_format, parse_time, probe, rgb_chain_to_bt709, run, run_keeping_subtitles, video_args,
|
|
40
40
|
drawtext_boxborderw, drawtext_center_align, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
|
|
41
41
|
LANGUAGE_NAMES, needs_shaping, detect_script, BIDI_SCRIPTS, font_family_of_file, font_family_for_script, has_emoji,
|
|
42
42
|
emoji_clusters, emoji_codepoint_name, char_script, emoji_filter_chain, emoji_asset_for, emoji_support, resolve_emoji_assets,
|
|
@@ -497,8 +497,9 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
497
497
|
h = max(3, int(base * 0.008))
|
|
498
498
|
fps = meta['video'].get('fps') or 30
|
|
499
499
|
fc.append(f"{layer_src(primary, 1.0, W, h)}[pb]")
|
|
500
|
-
if draws_chroma([bg], meta) or alpha_codec:
|
|
501
|
-
# a layer (overlay composites the alpha too; drawbox leaves it as it was
|
|
500
|
+
if draws_chroma([bg], meta) or alpha_codec or overlay_format(meta):
|
|
501
|
+
# a layer (overlay composites the alpha too; drawbox leaves it as it was, and takes a
|
|
502
|
+
# 10-bit picture to 8 bits)
|
|
502
503
|
fc.append(f"{layer_src(bg, 0.5, W, h, 0, (H - h) % 2)}[pbg]")
|
|
503
504
|
fc.append(f"[0:v][pbg]overlay=x=0:y={(H - h) // 2 * 2}:{en}:eof_action=pass[v1]")
|
|
504
505
|
else:
|
|
@@ -712,8 +713,9 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
712
713
|
# converts neither picture to the other's matrix. The text is drawn in BT.709 (drawn_chain).
|
|
713
714
|
# On an alpha line every box is a layer: overlay composites the box's alpha over the picture's,
|
|
714
715
|
# while drawbox paints the colour and leaves the alpha as it was (a box on a transparent canvas
|
|
715
|
-
# stayed invisible)
|
|
716
|
-
|
|
716
|
+
# stayed invisible). On a 10-bit picture too: drawbox is 8-bit only on 6.1, so FFmpeg took the
|
|
717
|
+
# whole picture to 8 bits around it (overlay_format)
|
|
718
|
+
as_layers = bool(alpha_codec) or bool(overlay_format(meta)) or any(draws_chroma([b[0]], meta) for b in boxes)
|
|
717
719
|
drawboxes = []
|
|
718
720
|
for colour, alpha, geo, x, y, w, h, enable, x_expr in boxes:
|
|
719
721
|
if as_layers:
|
|
@@ -743,6 +745,9 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
743
745
|
if fc:
|
|
744
746
|
fc = ["[0:v]format=yuva444p[ka0]"] + [c.replace("[0:v]", "[ka0]") for c in fc]
|
|
745
747
|
square = "format=yuva444p" + ("," + square if square else "")
|
|
748
|
+
elif overlay_format(meta):
|
|
749
|
+
# a 10-bit picture on a 10-bit line is composited at 10 bits (overlay's default is 8-bit)
|
|
750
|
+
fc = [c.replace(":eof_action=pass", ":eof_action=pass" + overlay_format(meta)) for c in fc]
|
|
746
751
|
finish = finish_chain(alpha_codec, args.alpha_mode, meta) if alpha_codec else ""
|
|
747
752
|
|
|
748
753
|
cmd = ffmpeg_base() + (decoder_args_for(meta, args.input) if alpha_codec else []) + ["-i", args.input]
|
|
@@ -762,7 +767,7 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
762
767
|
last = "vtxt"
|
|
763
768
|
eo, emoji_inputs = emoji_filter_chain({"overlays": emoji_overlays}, last, "vfinal", first_input=1,
|
|
764
769
|
to_yuv=rgb_chain_to_bt709(meta),
|
|
765
|
-
base_chain=layer_base_chain(meta))
|
|
770
|
+
base_chain=layer_base_chain(meta), overlay_opts=overlay_format(meta))
|
|
766
771
|
for spec in emoji_inputs:
|
|
767
772
|
cmd += spec
|
|
768
773
|
asset = spec[-1]
|
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")
|