ffmpeg-skill 3.1.5 → 3.1.6
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/docs/contract.md +4 -3
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +3 -2
- package/references/gotchas.md +5 -1
- package/references/scripts.md +5 -4
- package/scripts/_common/__init__.py +7 -5
- package/scripts/_common/color.py +40 -1
- package/scripts/_common/decision.py +177 -10
- package/scripts/_common/emit.py +2 -0
- package/scripts/_common/probe.py +95 -2
- package/scripts/_common/runner.py +4 -1
- package/scripts/_common/timeline.py +5 -4
- package/scripts/_contract.py +2 -2
- package/scripts/broll.py +35 -14
- package/scripts/caption.py +10 -8
- package/scripts/color.py +37 -8
- package/scripts/crop.py +8 -3
- package/scripts/cropdetect.py +13 -9
- package/scripts/cut.py +4 -4
- package/scripts/export.py +11 -2
- package/scripts/fit.py +8 -4
- package/scripts/freeze.py +38 -10
- package/scripts/graphics.py +104 -67
- package/scripts/grid.py +49 -7
- package/scripts/insert.py +9 -1
- package/scripts/join.py +29 -19
- package/scripts/look.py +41 -12
- package/scripts/loop.py +45 -6
- package/scripts/multicam.py +16 -7
- package/scripts/overlay.py +68 -14
- package/scripts/proxy.py +8 -4
- package/scripts/redact.py +10 -5
- package/scripts/reverse.py +27 -2
- package/scripts/scenes.py +16 -5
- package/scripts/sequence.py +6 -2
- package/scripts/silence.py +61 -30
- package/scripts/speedramp.py +17 -5
- package/scripts/straighten.py +17 -5
- package/scripts/sync.py +8 -2
- package/scripts/waveform.py +8 -3
package/scripts/graphics.py
CHANGED
|
@@ -34,14 +34,15 @@ from typing import List, Optional, Tuple
|
|
|
34
34
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
35
35
|
from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
|
|
36
36
|
color_hex, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
|
|
37
|
-
ffmpeg_base, info,
|
|
37
|
+
ffmpeg_base, info, layer_base_chain, load_brand, parse_time, probe, rgb_chain_to_bt709, run, run_keeping_subtitles, video_args,
|
|
38
38
|
drawtext_boxborderw, drawtext_center_align, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
|
|
39
39
|
LANGUAGE_NAMES, needs_shaping, detect_script, BIDI_SCRIPTS, font_family_of_file, font_family_for_script, has_emoji,
|
|
40
40
|
emoji_clusters, emoji_codepoint_name, char_script, emoji_filter_chain, emoji_asset_for, emoji_support, resolve_emoji_assets,
|
|
41
41
|
EMOJI_ASSET_HINT, text_width_em, drawtext_shaping, wrap_text, WRAP_MODES,
|
|
42
42
|
SAFE_WIDTH_FRACTION, drawtext_draws_newline_glyph, is_multiline, _DRAWTEXT_PENDING)
|
|
43
|
-
from _common.emit import warn
|
|
43
|
+
from _common.emit import note, warn
|
|
44
44
|
from _common.fonts import font_choice, record_font_choice
|
|
45
|
+
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
45
46
|
from _ass_overlay import text_overlay_ass, EMOJI_SENTINEL
|
|
46
47
|
from _common import drawtext_fontfile # noqa: E402
|
|
47
48
|
|
|
@@ -170,9 +171,11 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
170
171
|
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
171
172
|
if args.audio_stream and not audio_streams:
|
|
172
173
|
die("--audio-stream needs an input with audio streams")
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
174
|
+
# the displayed frame: rotation applied, non-square pixels squared (square_pixels(), #411)
|
|
175
|
+
W, H = display_size(meta["video"])
|
|
176
|
+
square = square_pixels(meta["video"])
|
|
177
|
+
if square:
|
|
178
|
+
note(square_pixels_note(meta["video"]))
|
|
176
179
|
dur = meta.get("duration") or 0.0
|
|
177
180
|
# Per-edge margins. Without --margin/--platform every edge is the brand safe margin, exactly
|
|
178
181
|
# as before; --margin sets all four; --platform takes each edge from the destination's safe
|
|
@@ -373,9 +376,21 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
373
376
|
h1 = int(base * 0.055)
|
|
374
377
|
h2 = int(base * 0.038)
|
|
375
378
|
pad = int(base * 0.02)
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
+
tx_pad = int(base * 0.035)
|
|
380
|
+
# both lines wrap to the room between the side margins, less the text's own inset, and
|
|
381
|
+
# the bar widens (up to that room) to the widest line: through 3.1.3 the bar was a fixed
|
|
382
|
+
# 62 % of the short side and a long name ran past it and off the frame (#411)
|
|
383
|
+
room = W - m_left - m_right
|
|
384
|
+
lt_frac = max(0.1, min(1.0, (room - 2 * tx_pad) / float(W))) if W else SAFE_WIDTH_FRACTION
|
|
385
|
+
lt_name = wrapped(args.name, h1, lt_frac)
|
|
386
|
+
lt_title = wrapped(args.title, h2, lt_frac) if args.title else args.title
|
|
387
|
+
widest = max([text_width_em(ln) * h1 for ln in lt_name.split("\n")]
|
|
388
|
+
+ [text_width_em(ln) * h2 for ln in (lt_title or "").split("\n")])
|
|
389
|
+
name_h = block_h(lt_name, h1)
|
|
390
|
+
bar_h = name_h + (block_h(lt_title, h2) + pad if lt_title else 0) + pad * 2
|
|
391
|
+
# the 62 % floor never takes the bar past the right margin (#416 review: at a large --scale
|
|
392
|
+
# it did)
|
|
393
|
+
bar_w = max(1, min(room, max(int(base * 0.62), int(widest + 0.999) + 2 * tx_pad)))
|
|
379
394
|
y0 = H - m_bottom - bar_h
|
|
380
395
|
# slide in from the left over 0.4 s, slide out over 0.3 s (overlay evaluates x per frame)
|
|
381
396
|
x_expr = f"if(lt(t,{s:.3f}+0.4),-{bar_w}+({bar_w}+{m_left})*((t-{s:.3f})/0.4),if(gt(t,{e:.3f}-0.3),{m_left}-({bar_w}+{m_left})*(1-({e:.3f}-t)/0.3),{m_left}))"
|
|
@@ -383,12 +398,11 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
383
398
|
fc.append(f"{layer_src(primary, 1.0, int(base * 0.012), bar_h)}[acc]")
|
|
384
399
|
fc.append(f"[0:v][bar]overlay=x='{x_expr}':y={y0}:{en}:eof_action=pass[v1]")
|
|
385
400
|
fc.append(f"[v1][acc]overlay=x='{x_expr}':y={y0}:{en}:eof_action=pass[v2]")
|
|
386
|
-
tx_pad = int(base * 0.035)
|
|
387
401
|
tx = f"({x_expr})+{tx_pad}"
|
|
388
402
|
x_rest, x_off = m_left + tx_pad, -bar_w + tx_pad
|
|
389
403
|
draws = []
|
|
390
|
-
for text, fs, colour, ty in ((
|
|
391
|
-
(
|
|
404
|
+
for text, fs, colour, ty in ((lt_name, h1, text_c, y0 + pad),
|
|
405
|
+
(lt_title, h2, primary, y0 + pad + name_h + pad // 2)):
|
|
392
406
|
if not text:
|
|
393
407
|
continue
|
|
394
408
|
draws.append(f"drawtext={drawtext_text_opts(text)}:{fo}:fontsize={fs}:"
|
|
@@ -416,24 +430,33 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
416
430
|
die("title needs --title")
|
|
417
431
|
h1 = int(base * 0.11)
|
|
418
432
|
h2 = int(base * 0.045)
|
|
433
|
+
# the title and the subtitle wrap to the frame's safe width (#411: they never did)
|
|
434
|
+
title_t = wrapped(args.title, h1)
|
|
435
|
+
sub_t = wrapped(args.subtitle, h2) if args.subtitle else args.subtitle
|
|
419
436
|
fill_box(bg, 0.55, "x=0:y=0:w=iw:h=ih", 0, 0, W, H, en)
|
|
420
437
|
# the title is centred as a block, so the bar and the subtitle sit below its last line:
|
|
421
438
|
# `title_h` is the block's height, as the one-line layout has always taken h1; a further
|
|
422
439
|
# subtitle line hangs below the first, whose position the one-line layout fixes
|
|
423
|
-
title_h = block_h(
|
|
440
|
+
title_h = block_h(title_t, h1)
|
|
424
441
|
sub_y = title_h // 2 + int(base * 0.03)
|
|
425
|
-
sub_extra = block_h(
|
|
426
|
-
|
|
427
|
-
|
|
442
|
+
sub_extra = block_h(sub_t, h2) - h2 if sub_t else 0
|
|
443
|
+
tall = title_h + (sub_y - title_h // 2 + block_h(sub_t, h2) if sub_t else 0)
|
|
444
|
+
if tall > H:
|
|
445
|
+
# the wrap measures width only; a centred block taller than the frame loses its first
|
|
446
|
+
# and last lines off the edges (#416 review)
|
|
447
|
+
note(f"the wrapped title{' and subtitle' if sub_t else ''} are about {tall} px tall, taller than the "
|
|
448
|
+
f"{H} px frame: the lines past its edges are not drawn -- shorten the text or lower --scale")
|
|
449
|
+
add_text(title_t,
|
|
450
|
+
f"drawtext={drawtext_text_opts(title_t)}:{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}{drawtext_center_align()}:x=(w-text_w)/2:y=(h-text_h)/2-{h2 if sub_t else 0}:alpha='{fade_a}':{en}",
|
|
428
451
|
size=h1, color=text_c, font=ass_font_family(), align=5, x=W / 2,
|
|
429
|
-
y=H / 2 - (h2 if
|
|
452
|
+
y=H / 2 - (h2 if sub_t else 0), outline=max(1.0, h1 / 20.0),
|
|
430
453
|
outline_color="000000", start=s, end=e, fade=(300, 300))
|
|
431
454
|
acc_w, acc_h = int(base * 0.12), max(2, int(base * 0.006))
|
|
432
455
|
fill_box(primary, 1.0, f"x=(iw-{acc_w})/2:y=(ih)/2+{title_h // 2}:w={acc_w}:h={acc_h}",
|
|
433
456
|
(W - acc_w) // 2, H // 2 + title_h // 2, acc_w, acc_h, en)
|
|
434
|
-
if
|
|
435
|
-
add_text(
|
|
436
|
-
f"drawtext={drawtext_text_opts(
|
|
457
|
+
if sub_t:
|
|
458
|
+
add_text(sub_t,
|
|
459
|
+
f"drawtext={drawtext_text_opts(sub_t)}:{fo}:fontsize={h2}:fontcolor={ff_color(primary)}{drawtext_center_align()}:x=(w-text_w)/2:y=(h-text_h)/2+{sub_y + sub_extra // 2}:alpha='{fade_a}':{en}",
|
|
437
460
|
size=h2, color=primary, font=ass_font_family(), align=5, x=W / 2,
|
|
438
461
|
y=H / 2 + sub_y + sub_extra // 2, outline=max(1.0, h2 / 20.0),
|
|
439
462
|
outline_color="000000", start=s, end=e, fade=(300, 300))
|
|
@@ -444,6 +467,8 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
444
467
|
pos = args.position or ("bottom-left" if args.template == "chapter" else "top-right")
|
|
445
468
|
fs = int(base * (0.04 if args.template == "chapter" else 0.032))
|
|
446
469
|
padx, pady = int(fs * 0.6), int(fs * 0.35)
|
|
470
|
+
# the chip wraps to the room between the side margins, as the sticker's does (#411)
|
|
471
|
+
chip_t = wrapped(args.title, fs, chip_frac(m_left, m_right, padx))
|
|
447
472
|
xe = f"{m_left}" if "left" in pos else f"w-text_w-{m_right}"
|
|
448
473
|
ye = f"{m_top}" if "top" in pos else f"h-text_h-{m_bottom}"
|
|
449
474
|
box_color = ff_color(primary if args.template == "chapter" else bg, 0.9 if args.template == "chapter" else 0.7)
|
|
@@ -451,8 +476,8 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
451
476
|
box_hex = primary if args.template == "chapter" else bg
|
|
452
477
|
txt_hex = bg if args.template == "chapter" else text_c
|
|
453
478
|
align = (7 if "left" in pos else 9) if "top" in pos else (1 if "left" in pos else 3)
|
|
454
|
-
add_text(
|
|
455
|
-
f"drawtext={drawtext_text_opts(
|
|
479
|
+
add_text(chip_t,
|
|
480
|
+
f"drawtext={drawtext_text_opts(chip_t)}:{fo}:fontsize={fs}:fontcolor={txt_color}:x={xe}:y={ye}:box=1:boxcolor={box_color}:boxborderw={drawtext_boxborderw(pady, padx)}:alpha='{fade_a}':{en}",
|
|
456
481
|
size=fs, color=txt_hex, font=ass_font_family(), align=align,
|
|
457
482
|
x=(m_left if "left" in pos else W - m_right),
|
|
458
483
|
y=(m_top if "top" in pos else H - m_bottom),
|
|
@@ -587,52 +612,62 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
587
612
|
seen: List[str] = []
|
|
588
613
|
for el in elements:
|
|
589
614
|
el["box_px"] = int(round(el["size"] * scale_em))
|
|
590
|
-
|
|
591
|
-
clusters = emoji_clusters(line)
|
|
592
|
-
if not clusters or emoji_mode not in ("png",):
|
|
615
|
+
if not emoji_clusters(el["text"]) or emoji_mode not in ("png",):
|
|
593
616
|
continue
|
|
594
|
-
|
|
617
|
+
# a wrapped label is several lines: each is measured from its own start and sits one
|
|
618
|
+
# line pitch below the last (#416 review: they were measured as one line, on the first)
|
|
619
|
+
all_lines = el["text"].split("\n")
|
|
595
620
|
align = int(el.get("align", 7))
|
|
596
|
-
left = el["x"] if align in (7, 4, 1) else (
|
|
597
|
-
el["x"] - line_w / 2.0 if align in (8, 5, 2) else el["x"] - line_w)
|
|
598
621
|
line_h = el["size"] * 1.2
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
seen.append(name)
|
|
608
|
-
asset = emoji_asset_for(cluster, emoji_assets)
|
|
609
|
-
if not asset:
|
|
610
|
-
if name not in missing:
|
|
611
|
-
missing.append(name)
|
|
612
|
-
rebuilt += line[cursor:idx + len(cluster)]
|
|
613
|
-
cursor = idx + len(cluster)
|
|
622
|
+
block = len(all_lines) * line_h
|
|
623
|
+
block_top = el["y"] if align in (7, 8, 9) else (
|
|
624
|
+
el["y"] - block / 2.0 if align in (4, 5, 6) else el["y"] - block)
|
|
625
|
+
rebuilt_lines = []
|
|
626
|
+
for k, line in enumerate(all_lines):
|
|
627
|
+
clusters = emoji_clusters(line)
|
|
628
|
+
if not clusters:
|
|
629
|
+
rebuilt_lines.append(line)
|
|
614
630
|
continue
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
631
|
+
line_w = text_width_em(line, scale_em) * el["size"]
|
|
632
|
+
left = el["x"] if align in (7, 4, 1) else (
|
|
633
|
+
el["x"] - line_w / 2.0 if align in (8, 5, 2) else el["x"] - line_w)
|
|
634
|
+
y_top = block_top + k * line_h
|
|
635
|
+
# An RTL line is rendered right-to-left: measure the suffix, not the logical prefix.
|
|
636
|
+
rtl = detect_script(line) in BIDI_SCRIPTS
|
|
637
|
+
rebuilt, cursor = "", 0
|
|
638
|
+
for idx, cluster in clusters:
|
|
639
|
+
name = emoji_codepoint_name(cluster)
|
|
640
|
+
if name not in seen:
|
|
641
|
+
seen.append(name)
|
|
642
|
+
asset = emoji_asset_for(cluster, emoji_assets)
|
|
643
|
+
if not asset:
|
|
644
|
+
if name not in missing:
|
|
645
|
+
missing.append(name)
|
|
646
|
+
rebuilt += line[cursor:idx + len(cluster)]
|
|
647
|
+
cursor = idx + len(cluster)
|
|
648
|
+
continue
|
|
649
|
+
if rtl:
|
|
650
|
+
prefix_px = line_w - text_width_em(line[:idx] + cluster, scale_em) * el["size"]
|
|
651
|
+
else:
|
|
652
|
+
prefix_px = text_width_em(line[:idx], scale_em) * el["size"]
|
|
653
|
+
if el.get("x_expr"):
|
|
654
|
+
# the lower-third slides: the emoji rides the same expression the bar does
|
|
655
|
+
x = f"({el['x_expr']})+{prefix_px:.0f}"
|
|
656
|
+
else:
|
|
657
|
+
x = int(round(max(0.0, min(left + prefix_px, W - el["box_px"]))))
|
|
658
|
+
emoji_overlays.append({"asset": asset, "cluster": name, "x": x,
|
|
659
|
+
"y": int(round(max(0.0, min(y_top + (line_h - el["box_px"]) / 2.0,
|
|
660
|
+
H - el["box_px"])))),
|
|
661
|
+
"start": round(el["start"], 3), "end": round(el["end"], 3),
|
|
662
|
+
"box": el["box_px"],
|
|
663
|
+
# every template fades its text in and out over 0.3 s
|
|
664
|
+
# (fade_a above); the PNG rides the same envelope.
|
|
665
|
+
"fade_in": round(min(0.3, max(0.0, (el["end"] - el["start"]) / 2.0)), 3),
|
|
666
|
+
"fade_out": round(min(0.3, max(0.0, (el["end"] - el["start"]) / 2.0)), 3)})
|
|
667
|
+
rebuilt += line[cursor:idx] + EMOJI_SENTINEL
|
|
668
|
+
cursor = idx + len(cluster)
|
|
669
|
+
rebuilt_lines.append(rebuilt + line[cursor:])
|
|
670
|
+
el["text"] = "\n".join(rebuilt_lines)
|
|
636
671
|
# `or 60` would swallow --emoji-max 0, the one value meaning "none at all".
|
|
637
672
|
_max = 60 if args.emoji_max is None else int(args.emoji_max)
|
|
638
673
|
if len(emoji_overlays) > _max:
|
|
@@ -673,7 +708,7 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
673
708
|
drawboxes.append(f"drawbox={geo}:color={ff_color(colour, alpha)}:t=fill:{enable}")
|
|
674
709
|
filters = [("" if as_layers else drawboxes[int(f[len(BOX):])]) if f.startswith(BOX) else f for f in filters]
|
|
675
710
|
filters = [f for f in filters if f]
|
|
676
|
-
base_label =
|
|
711
|
+
base_label = layer_base_chain(meta) if (layers or converted) else ""
|
|
677
712
|
text = (drawn_chain(",".join(filters), meta, re.findall(r"color=([0-9A-Za-z#@.]+)", ",".join(filters)),
|
|
678
713
|
labelled=bool(base_label)) if filters else "")
|
|
679
714
|
if layers:
|
|
@@ -686,10 +721,12 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
686
721
|
fc = [c.replace(TEXT, text or "null") for c in fc]
|
|
687
722
|
if fc and base_label:
|
|
688
723
|
fc = [f"[0:v]{base_label}null[gbase]"] + [c.replace("[0:v]", "[gbase]") for c in fc]
|
|
724
|
+
if fc and square:
|
|
725
|
+
fc = [f"[0:v]{square}[gsq]"] + [c.replace("[0:v]", "[gsq]") for c in fc]
|
|
689
726
|
|
|
690
727
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
691
728
|
if route == "ass" or emoji_overlays:
|
|
692
|
-
chains = list(fc) if fc else [f"[0:v]{text or 'null'}[vout]"]
|
|
729
|
+
chains = list(fc) if fc else [f"[0:v]{(square + ',') if square else ''}{text or 'null'}[vout]"]
|
|
693
730
|
last = "vout"
|
|
694
731
|
if route == "ass":
|
|
695
732
|
vf = f"ass=filename={escape_filter_path(ass_path)}"
|
|
@@ -702,7 +739,7 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
702
739
|
last = "vtxt"
|
|
703
740
|
eo, emoji_inputs = emoji_filter_chain({"overlays": emoji_overlays}, last, "vfinal", first_input=1,
|
|
704
741
|
to_yuv=rgb_chain_to_bt709(meta),
|
|
705
|
-
base_chain=
|
|
742
|
+
base_chain=layer_base_chain(meta))
|
|
706
743
|
for spec in emoji_inputs:
|
|
707
744
|
cmd += spec
|
|
708
745
|
asset = spec[-1]
|
|
@@ -716,7 +753,7 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
716
753
|
elif fc:
|
|
717
754
|
cmd += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", f"0:a:{args.audio_stream}?"]
|
|
718
755
|
else:
|
|
719
|
-
cmd += ["-vf", text, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
|
|
756
|
+
cmd += ["-vf", (square + "," if square else "") + text, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
|
|
720
757
|
cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
|
|
721
758
|
cmd += aac_args() if meta.get("audio") else ["-an"]
|
|
722
759
|
dropped_streams = run_keeping_subtitles(cmd, output)
|
package/scripts/grid.py
CHANGED
|
@@ -26,15 +26,34 @@ import os
|
|
|
26
26
|
import sys
|
|
27
27
|
|
|
28
28
|
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, X264_PRESETS, fmt_secs, \
|
|
29
|
-
draws_chroma, drawn_chain, escape_drawtext, ffmpeg_base, info, label_chain, matrix_chain, probe, run, validate_color, video_args
|
|
29
|
+
as_limited, draws_chroma, drawn_chain, escape_drawtext, ffmpeg_base, info, label_chain, matrix_chain, range_chain, probe, run, validate_color, video_args
|
|
30
30
|
from _common.fonts import font_choice, record_font_choice
|
|
31
31
|
from _common import drawtext_fontfile # noqa: E402
|
|
32
32
|
from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
|
|
33
33
|
from _common.emit import note, record_fallback # noqa: E402
|
|
34
|
+
from _common.fonts import script_font_for_text # noqa: E402
|
|
35
|
+
from _common.probe import square_pixels # noqa: E402
|
|
36
|
+
from _common.wrap import text_width_em # noqa: E402
|
|
34
37
|
|
|
35
38
|
LABEL_MARGIN = 10
|
|
36
39
|
|
|
37
40
|
|
|
41
|
+
def _fit_label(stem: str, cell_width: int, font_size: int) -> str:
|
|
42
|
+
"""The label as drawn: `stem` when it fits the cell (less the margin and the box's border on
|
|
43
|
+
each side), else its head with an ellipsis. It is right-aligned, so a label wider than the
|
|
44
|
+
cell used to lose its start off the left edge and show only its tail (#411). The width is the
|
|
45
|
+
averaged advance table's estimate."""
|
|
46
|
+
room = (cell_width - 2 * LABEL_MARGIN - 8) / float(max(1, font_size))
|
|
47
|
+
if text_width_em(stem) <= room:
|
|
48
|
+
return stem
|
|
49
|
+
if room <= 1:
|
|
50
|
+
return stem[:1] # no room for an ellipsis: keep the start, as a shortened label does (#416 review)
|
|
51
|
+
head = stem
|
|
52
|
+
while head and text_width_em(head + "\u2026") > room:
|
|
53
|
+
head = head[:-1]
|
|
54
|
+
return (head.rstrip() + "\u2026") if head else stem[:1]
|
|
55
|
+
|
|
56
|
+
|
|
38
57
|
def main() -> int:
|
|
39
58
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
40
59
|
ap.add_argument("inputs", nargs="+", help="video clips, filled into the grid left-to-right, top-to-bottom")
|
|
@@ -69,6 +88,9 @@ def main() -> int:
|
|
|
69
88
|
n = args.cols * args.rows
|
|
70
89
|
if args.cols <= 0 or args.rows <= 0:
|
|
71
90
|
die(f"--cols/--rows must be > 0, got cols={args.cols} rows={args.rows}")
|
|
91
|
+
if n == 1:
|
|
92
|
+
# xstack takes two inputs or more: a 1x1 grid used to reach ffmpeg and fail there (exit 222)
|
|
93
|
+
die("--cols 1 --rows 1 is a single cell; a grid needs at least two (fit.py letterboxes one clip)")
|
|
72
94
|
if len(args.inputs) != n:
|
|
73
95
|
die(f"--cols {args.cols} --rows {args.rows} needs exactly {n} inputs, got {len(args.inputs)}")
|
|
74
96
|
if args.cell_width <= 0 or args.cell_height <= 0:
|
|
@@ -95,10 +117,26 @@ def main() -> int:
|
|
|
95
117
|
durations = [m.get("duration") or 0.0 for m in metas]
|
|
96
118
|
target_duration = max(durations) if args.pad else min(durations)
|
|
97
119
|
|
|
98
|
-
|
|
99
|
-
|
|
120
|
+
# each label is drawn with a font that covers its own script, as every other text-drawing tool
|
|
121
|
+
# picks one (through 3.1.3 a Japanese or Thai filename came out as tofu, #411); `font_used` is
|
|
122
|
+
# the first label's choice, and a grid whose labels needed different fonts says so in a note
|
|
123
|
+
stems = [os.path.splitext(os.path.basename(p))[0] for p in args.inputs]
|
|
124
|
+
font_explicit = args.font != ap.get_default("font")
|
|
125
|
+
label_fonts = []
|
|
126
|
+
for stem in (stems if args.label == "auto" else []):
|
|
127
|
+
_sc, script_file, script_family = script_font_for_text(stem, font=args.font, font_explicit=font_explicit)
|
|
128
|
+
label_fonts.append(font_choice(args.font, script_file=script_file, script_family=script_family))
|
|
129
|
+
font_used = label_fonts[0] if label_fonts else font_choice(args.font)
|
|
100
130
|
if args.label == "auto":
|
|
101
131
|
record_font_choice(font_used)
|
|
132
|
+
if len({f.get("file") for f in label_fonts}) > 1:
|
|
133
|
+
note("cell labels drawn with more than one font, by script: "
|
|
134
|
+
+ "; ".join(sorted({str(f.get("family") or f.get("file")) for f in label_fonts})))
|
|
135
|
+
labels = [_fit_label(stem, args.cell_width, args.font_size) for stem in stems]
|
|
136
|
+
cut = [stem for stem, lab in zip(stems, labels) if lab != stem]
|
|
137
|
+
if args.label == "auto" and cut:
|
|
138
|
+
note(f"{len(cut)} cell label(s) wider than the {args.cell_width} px cell at --font-size "
|
|
139
|
+
f"{args.font_size} were shortened from the end with an ellipsis")
|
|
102
140
|
output = args.output or default_output(args.inputs[0], "grid")
|
|
103
141
|
|
|
104
142
|
cmd = ffmpeg_base()
|
|
@@ -117,6 +155,8 @@ def main() -> int:
|
|
|
117
155
|
convert = [matrix_chain(m, None) for m in metas]
|
|
118
156
|
if any(convert) or chroma:
|
|
119
157
|
convert = [c or label_chain(m, None) for c, m in zip(convert, metas)]
|
|
158
|
+
# xstack mixes samples: a full-range cell beside a limited one is taken to limited range (#410)
|
|
159
|
+
convert = [c + range_chain(m, None) for c, m in zip(convert, metas)]
|
|
120
160
|
parts = []
|
|
121
161
|
for i, p in enumerate(args.inputs):
|
|
122
162
|
drawn = [ # (with this colour, with black)
|
|
@@ -129,17 +169,19 @@ def main() -> int:
|
|
|
129
169
|
elif not args.pad:
|
|
130
170
|
drawn.append((f"trim=duration={target_duration:.3f}",) * 2)
|
|
131
171
|
if args.label == "auto":
|
|
132
|
-
|
|
172
|
+
font_file = label_fonts[i]["file"]
|
|
133
173
|
font_opt = drawtext_fontfile(font_file) if font_file else f"font='{escape_drawtext(args.font)}'"
|
|
134
|
-
drawn.append((f"drawtext=text='{escape_drawtext(
|
|
174
|
+
drawn.append((f"drawtext=text='{escape_drawtext(labels[i])}':{font_opt}:fontsize={args.font_size}:"
|
|
135
175
|
f"fontcolor={args.font_color}:x=w-tw-{LABEL_MARGIN}:y=h-th-{LABEL_MARGIN}:"
|
|
136
176
|
f"box=1:boxcolor=black@0.5:boxborderw=4", "null"))
|
|
137
177
|
gap_r = args.gap if i % args.cols < args.cols - 1 else 0
|
|
138
178
|
gap_b = args.gap if i // args.cols < args.rows - 1 else 0
|
|
139
179
|
if chroma and (gap_r or gap_b):
|
|
140
180
|
drawn.append((f"pad=iw+{gap_r}:ih+{gap_b}:0:0:color={args.background}", f"pad=iw+{gap_r}:ih+{gap_b}:0:0:color=black"))
|
|
141
|
-
|
|
142
|
-
|
|
181
|
+
# a non-square-pixel clip is squared at its display aspect before it is fitted (#411)
|
|
182
|
+
sq = (square_pixels(metas[i]["video"]) + ",") if square_pixels(metas[i]["video"]) else ""
|
|
183
|
+
chain = f"fps={args.fps:g},{sq}scale={args.cell_width}:{args.cell_height}:force_original_aspect_ratio=decrease,"
|
|
184
|
+
chain += drawn_chain(",".join(c for c, _n in drawn), as_limited(metas[i]), colours, ",".join(n for _c, n in drawn),
|
|
143
185
|
line_meta=None, tag=f"c{i}", labelled=True)
|
|
144
186
|
parts.append(f"[{i}:v]{convert[i]}{chain}[v{i}]")
|
|
145
187
|
|
package/scripts/insert.py
CHANGED
|
@@ -29,6 +29,8 @@ import sys
|
|
|
29
29
|
|
|
30
30
|
from _common import still_input_args # noqa: E402
|
|
31
31
|
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
32
|
+
from _common.emit import note
|
|
33
|
+
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
32
34
|
|
|
33
35
|
|
|
34
36
|
def even(n: float) -> int:
|
|
@@ -69,7 +71,11 @@ def main() -> int:
|
|
|
69
71
|
if (meta.get("duration") or 0) > 0.5 or meta.get("audio"):
|
|
70
72
|
die(f"{args.input} is a video, not a still image; insert.py animates a still (Ken Burns). "
|
|
71
73
|
f"For a clip use broll.py (cutaway) or cut.py/join.py")
|
|
72
|
-
|
|
74
|
+
# the displayed picture: ffmpeg turns an EXIF-rotated still upright on decode (probe reads
|
|
75
|
+
# the Orientation off the decoded frame), and non-square pixels are squared (#411)
|
|
76
|
+
sw, sh = display_size(meta["video"])
|
|
77
|
+
if square_pixels(meta["video"]):
|
|
78
|
+
note(square_pixels_note(meta["video"]))
|
|
73
79
|
ratio = sw / sh
|
|
74
80
|
|
|
75
81
|
if args.width and args.height:
|
|
@@ -115,6 +121,8 @@ def main() -> int:
|
|
|
115
121
|
f"fps={args.fps:g}",
|
|
116
122
|
]
|
|
117
123
|
|
|
124
|
+
if square_pixels(meta["video"]):
|
|
125
|
+
vf.insert(0, square_pixels(meta["video"])) # the scale and crop above size the squared picture
|
|
118
126
|
output = args.output or default_output(args.input, "insert", "mp4")
|
|
119
127
|
cmd = ffmpeg_base() + ["-loop", "1", *still_input_args(args.input), "-i", args.input, "-t", f"{target:.3f}", "-vf", ",".join(vf)]
|
|
120
128
|
# the SDR line, whatever the image is; its RGB (PNG) or BT.601 (JPEG) pixels are converted to
|
package/scripts/join.py
CHANGED
|
@@ -42,8 +42,10 @@ import sys
|
|
|
42
42
|
from typing import Any, Dict, List, Optional, Tuple
|
|
43
43
|
|
|
44
44
|
from _common.probe import SILENT_PEAK_DBFS, signatures_match, stream_signature
|
|
45
|
-
from _common import STATE, AUDIO_MEDIA_EXT, concat_list_line, guard_sidecar, place_output, refuse, sibling_temp, draws_chroma, drawn_chain, label_chain, matrix_chain, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, measured_level_dbfs, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
|
|
46
|
-
from _common.emit import warn
|
|
45
|
+
from _common import STATE, AUDIO_MEDIA_EXT, concat_list_line, guard_sidecar, place_output, refuse, sibling_temp, as_limited, draws_chroma, drawn_chain, label_chain, matrix_chain, range_chain, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, measured_level_dbfs, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
|
|
46
|
+
from _common.emit import note, warn
|
|
47
|
+
from _common.decision import av_clip_length, av_trim, stream_starts
|
|
48
|
+
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
47
49
|
|
|
48
50
|
TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
|
|
49
51
|
"circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
|
|
@@ -103,16 +105,11 @@ def overlap_report(durs: List[float], expected: Optional[float]) -> Dict[str, An
|
|
|
103
105
|
|
|
104
106
|
def clip_length(meta: Dict[str, Any], fps: float) -> float:
|
|
105
107
|
"""The one length a clip holds in a join, for its picture and its sound alike: the
|
|
106
|
-
video stream's, or the
|
|
107
|
-
(narration is never cut; an AAC tail adds no frame). The container
|
|
108
|
-
a longer track) is only the fallback when a stream length is unknown
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if not v:
|
|
112
|
-
return meta.get("duration") or 0.0
|
|
113
|
-
if a and fps and a > v + 1.0 / fps:
|
|
114
|
-
return a
|
|
115
|
-
return v
|
|
108
|
+
video stream's, or up to where the sound ends when it runs more than a frame past the picture
|
|
109
|
+
(narration is never cut; an AAC tail adds no frame), counted from the first frame. The container
|
|
110
|
+
duration (audio priming, a longer track) is only the fallback when a stream length is unknown
|
|
111
|
+
(a pending input). Shared with reverse.py as av_clip_length."""
|
|
112
|
+
return av_clip_length(meta, fps)
|
|
116
113
|
|
|
117
114
|
|
|
118
115
|
def find_duplicates(paths: List[str]) -> List[Dict[str, Any]]:
|
|
@@ -655,9 +652,8 @@ def main() -> int:
|
|
|
655
652
|
if args.copy:
|
|
656
653
|
return join_copy(args, metas)
|
|
657
654
|
first = metas[0]["video"]
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
fw, fh = fh, fw
|
|
655
|
+
# the first clip's displayed frame: rotation applied, non-square pixels squared (#411)
|
|
656
|
+
fw, fh = display_size(first)
|
|
661
657
|
if args.width and args.height:
|
|
662
658
|
w, h = args.width, args.height
|
|
663
659
|
elif args.width:
|
|
@@ -721,13 +717,22 @@ def main() -> int:
|
|
|
721
717
|
chroma_pad = args.fit != "crop" and draws_chroma([args.pad_color], line)
|
|
722
718
|
if any(convert) or chroma_pad:
|
|
723
719
|
convert = [c or label_chain(m, line) for c, m in zip(convert, metas)]
|
|
720
|
+
# concat/xfade mix samples: a full-range clip (VP9/AV1 full range, 10-bit pc) beside a limited
|
|
721
|
+
# one is taken to limited range first, or it was shown with the other's range (#410)
|
|
722
|
+
convert = [c + range_chain(m, line) for c, m in zip(convert, metas)]
|
|
723
|
+
|
|
724
|
+
for p, m in zip(args.inputs, metas):
|
|
725
|
+
if square_pixels(m.get("video")):
|
|
726
|
+
note(square_pixels_note(m["video"], p))
|
|
724
727
|
|
|
725
728
|
def geo(i: int) -> str:
|
|
729
|
+
# every clip is squared at its display aspect before it is fitted (#411)
|
|
730
|
+
sq = (square_pixels(metas[i].get("video")) + ",") if square_pixels(metas[i].get("video")) else ""
|
|
726
731
|
if args.fit == "crop":
|
|
727
|
-
return f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
|
|
732
|
+
return f"{sq}scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
|
|
728
733
|
pad = f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color="
|
|
729
|
-
return (f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
|
|
730
|
-
+ drawn_chain(pad + args.pad_color, metas[i], [args.pad_color], pad + "black", line_meta=line,
|
|
734
|
+
return (f"{sq}scale={w}:{h}:force_original_aspect_ratio=decrease,"
|
|
735
|
+
+ drawn_chain(pad + args.pad_color, as_limited(metas[i]), [args.pad_color], pad + "black", line_meta=line,
|
|
731
736
|
tag=f"pad{i}", labelled=True))
|
|
732
737
|
# the audio-only join keeps the widest layout; the video join used to force stereo and
|
|
733
738
|
# silently dropped the centre/LFE of 5.1 material. A pending input's stub has no measured
|
|
@@ -741,7 +746,12 @@ def main() -> int:
|
|
|
741
746
|
info(f"channel counts differ ({', '.join(str(c) for c in chans)}); every clip becomes {layout}")
|
|
742
747
|
for i in range(n):
|
|
743
748
|
parts.append(f"[{i}:v]{convert[i]}{geo(i)},setsar=1,setpts=PTS-STARTPTS,fps={fps:g},format={pixfmt},settb=AVTB[v{i}]")
|
|
744
|
-
|
|
749
|
+
# the sound is shifted by the picture's start, not to its own (#409): #338 started each
|
|
750
|
+
# clip's picture at 0, and a per-stream PTS-STARTPTS then moved a sound that starts 0.3 s
|
|
751
|
+
# after its picture 0.3 s early. Sound before the first frame is dropped and a late sound
|
|
752
|
+
# is padded, as cut.py's re-cut does; a generated silent track starts at 0 already.
|
|
753
|
+
shift = (av_trim(stream_starts(metas[i])[0], None)[1] + ",") if metas[i].get("audio") else ""
|
|
754
|
+
parts.append(f"[{audio_src[i]}]{shift}aformat=sample_rates=48000:channel_layouts={layout},asetpts=PTS-STARTPTS[a{i}]")
|
|
745
755
|
|
|
746
756
|
# pad the shorter stream to the clip's length: the audio with silence, the picture by holding
|
|
747
757
|
# its last frame, then cut both there -- each clip boundary is then the same for both chains
|
package/scripts/look.py
CHANGED
|
@@ -22,6 +22,8 @@ from _common import STATE, require_tool, add_common, apply_common, default_font_
|
|
|
22
22
|
from _common import drawtext_fontfile # noqa: E402
|
|
23
23
|
from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
|
|
24
24
|
from _common.emit import note, record_fallback # noqa: E402
|
|
25
|
+
from _common.color import bt709_label_filter # noqa: E402
|
|
26
|
+
from _common.probe import display_size, square_pixels, square_pixels_note # noqa: E402
|
|
25
27
|
|
|
26
28
|
FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
|
|
27
29
|
|
|
@@ -41,6 +43,12 @@ def timecode_filter(font_prefix: str, offset: float = 0.0) -> str:
|
|
|
41
43
|
return f"drawtext=text='%{{pts\\:hms}}':{font_prefix}{FONT}"
|
|
42
44
|
|
|
43
45
|
|
|
46
|
+
def _half_height(half: int, size: "tuple") -> int:
|
|
47
|
+
"""The even height a `half`-wide picture of displayed `size` scales to (16:9 when unknown)."""
|
|
48
|
+
w, h = size
|
|
49
|
+
return max(2, int(round(half * (h / w if w and h else 9 / 16) / 2)) * 2)
|
|
50
|
+
|
|
51
|
+
|
|
44
52
|
def _png_size(png_path: str) -> "Optional[tuple]":
|
|
45
53
|
# run_analysis(): the same wall-clock ceiling and #234 UTF-8 decoding as every other
|
|
46
54
|
# ffprobe/ffmpeg measurement in this codebase, check=False since a probe failure here is
|
|
@@ -189,10 +197,7 @@ def main() -> int:
|
|
|
189
197
|
if args.safe:
|
|
190
198
|
z = PLATFORMS[args.safe]["safe"]
|
|
191
199
|
frame = PLATFORMS[args.safe]["frame"]
|
|
192
|
-
|
|
193
|
-
dw, dh = src.get("width") or 0, src.get("height") or 0
|
|
194
|
-
if src.get("rotation") in (90, -90, 270, -270):
|
|
195
|
-
dw, dh = dh, dw
|
|
200
|
+
dw, dh = display_size(meta["video"])
|
|
196
201
|
if dw and dh and abs(dw / dh - frame["w"] / frame["h"]) > 0.02:
|
|
197
202
|
info(f"--safe {args.safe}: this source is {dw}x{dh}, not {args.safe}'s "
|
|
198
203
|
f"{frame['w']}x{frame['h']} -- the zones are drawn as fractions of the frame you gave, "
|
|
@@ -221,11 +226,25 @@ def main() -> int:
|
|
|
221
226
|
tc = "," + tm.rstrip(",") + tc
|
|
222
227
|
info("HDR source: frames are tone-mapped to SDR for display")
|
|
223
228
|
outputs: List[str] = []
|
|
229
|
+
# An untagged SDR source is BT.709 (what players and every encoder line here take it as), but the
|
|
230
|
+
# conversion to the PNG's RGB reads "unknown" as BT.601: C03028 came out B31F28 (#410). Its
|
|
231
|
+
# frames are labelled first (tags only), so that conversion reads them as BT.709.
|
|
232
|
+
lab = bt709_label_filter(meta)
|
|
233
|
+
lab = lab + "," if lab else ""
|
|
234
|
+
# every picture is squared at its display aspect before it is scaled (#411): a DV or HDV
|
|
235
|
+
# frame's stored size is not the picture a player shows
|
|
236
|
+
square = square_pixels(meta["video"])
|
|
237
|
+
sq = square + "," if square else ""
|
|
238
|
+
if square:
|
|
239
|
+
note(square_pixels_note(meta["video"]))
|
|
224
240
|
|
|
225
241
|
if args.compare:
|
|
226
242
|
if not args.at:
|
|
227
243
|
die("--compare needs --at TIME")
|
|
228
|
-
probe(args.compare)
|
|
244
|
+
cmeta = probe(args.compare)
|
|
245
|
+
lab2 = bt709_label_filter(cmeta)
|
|
246
|
+
lab2 = lab2 + "," if lab2 else ""
|
|
247
|
+
csq = (square_pixels(cmeta.get("video")) + ",") if square_pixels(cmeta.get("video")) else ""
|
|
229
248
|
for idx, t in enumerate(args.at):
|
|
230
249
|
sec = time_arg(t, "--at", meta["video"].get("fps") if meta.get("video") else None)
|
|
231
250
|
if args.output and len(args.at) == 1:
|
|
@@ -238,8 +257,15 @@ def main() -> int:
|
|
|
238
257
|
half = args.width // 2
|
|
239
258
|
stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
|
|
240
259
|
tcs = tc.replace("," + timecode_filter(font_prefix), "") + stamp
|
|
241
|
-
|
|
242
|
-
|
|
260
|
+
# each picture keeps its own aspect inside a half-width box as tall as the taller of the
|
|
261
|
+
# two (letterboxed, not stretched): through 3.1.3 scale2ref forced the first to the
|
|
262
|
+
# second's size, so a 4:3 beside a 16:9 came out squashed (#411)
|
|
263
|
+
box_h = max(_half_height(half, display_size(meta["video"])),
|
|
264
|
+
_half_height(half, display_size(cmeta.get("video"))))
|
|
265
|
+
fit = (f"scale={half}:{box_h}:force_original_aspect_ratio=decrease,"
|
|
266
|
+
f"pad={half}:{box_h}:(ow-iw)/2:(oh-ih)/2:color=0x202020,setsar=1")
|
|
267
|
+
fc = (f"[0:v]{lab}{sq}{fit}{tcs}[a];[1:v]{lab2}{csq}{fit}{tcs}[b];"
|
|
268
|
+
f"[a][b]hstack=inputs=2[out]")
|
|
243
269
|
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,
|
|
244
270
|
"-filter_complex", fc, "-map", "[out]", "-frames:v", "1", "-update", "1", out]
|
|
245
271
|
run(cmd)
|
|
@@ -255,7 +281,7 @@ def main() -> int:
|
|
|
255
281
|
# several frames, or -o given as a stem/prefix without an image extension
|
|
256
282
|
out = os.path.join(outdir, f"{args.output and Path(args.output).stem or stem}_{sec:.3f}s.png")
|
|
257
283
|
stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
|
|
258
|
-
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", *still_input_args(args.input), "-i", args.input, "-vf", f"scale={args.width}:-2{tc.replace(',' + timecode_filter(font_prefix), '')}{stamp}", "-frames:v", "1", "-update", "1", out]
|
|
284
|
+
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", *still_input_args(args.input), "-i", args.input, "-vf", f"{lab}{sq}scale={args.width}:-2{tc.replace(',' + timecode_filter(font_prefix), '')}{stamp}", "-frames:v", "1", "-update", "1", out]
|
|
259
285
|
run(cmd)
|
|
260
286
|
outputs.append(out)
|
|
261
287
|
else:
|
|
@@ -278,11 +304,14 @@ def main() -> int:
|
|
|
278
304
|
# sample at the middle of each slice so the first/last tiles are not black lead-in/out frames
|
|
279
305
|
# with only a handful of frames a mid-slice seek skips past them all: start at 0 instead
|
|
280
306
|
seek = 0.0 if frames_avail < 4 else step / 2
|
|
281
|
-
#
|
|
282
|
-
|
|
283
|
-
|
|
307
|
+
# -copyts -start_at_zero keeps each frame's own timestamp through the seek, counted from
|
|
308
|
+
# the file's start, so the stamp is the frame's pts as it is. What an input -ss alone
|
|
309
|
+
# resets pts to differs by container: an MPEG-TS (which starts at 1.4 s) came out
|
|
310
|
+
# labelled one seek late when the seek was added back (#411)
|
|
311
|
+
tc_sheet = tc
|
|
312
|
+
vf = (f"select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,{step * 0.98:.6f})',{lab}{sq}scale={tile_w}:-2{tc_sheet},"
|
|
284
313
|
f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
|
|
285
|
-
cmd = ffmpeg_base() + ["-ss", f"{seek:.6f}", *still_input_args(args.input), "-i", args.input, "-vf", vf, "-frames:v", "1", "-update", "1", out]
|
|
314
|
+
cmd = ffmpeg_base() + ["-copyts", "-start_at_zero", "-ss", f"{seek:.6f}", *still_input_args(args.input), "-i", args.input, "-vf", vf, "-frames:v", "1", "-update", "1", out]
|
|
286
315
|
run(cmd)
|
|
287
316
|
outputs.append(out)
|
|
288
317
|
info(f"contact sheet: {n} frames every {step:.2f}s")
|