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.
@@ -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, label_chain, load_brand, parse_time, probe, rgb_chain_to_bt709, run, run_keeping_subtitles, video_args,
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
- W, H = meta["video"]["width"], meta["video"]["height"]
174
- if meta["video"].get("rotation") in (90, -90, 270, -270):
175
- W, H = H, W
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
- name_h = block_h(args.name, h1)
377
- bar_h = name_h + (block_h(args.title, h2) + pad if args.title else 0) + pad * 2
378
- bar_w = int(base * 0.62)
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 ((args.name, h1, text_c, y0 + pad),
391
- (args.title, h2, primary, y0 + pad + name_h + pad // 2)):
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(args.title, h1)
440
+ title_h = block_h(title_t, h1)
424
441
  sub_y = title_h // 2 + int(base * 0.03)
425
- sub_extra = block_h(args.subtitle, h2) - h2 if args.subtitle else 0
426
- add_text(args.title,
427
- f"drawtext={drawtext_text_opts(args.title)}:{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}{drawtext_center_align()}:x=(w-text_w)/2:y=(h-text_h)/2-{h2 if args.subtitle else 0}:alpha='{fade_a}':{en}",
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 args.subtitle else 0), outline=max(1.0, h1 / 20.0),
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 args.subtitle:
435
- add_text(args.subtitle,
436
- f"drawtext={drawtext_text_opts(args.subtitle)}:{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}",
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(args.title,
455
- f"drawtext={drawtext_text_opts(args.title)}:{fo}:fontsize={fs}:fontcolor={txt_color}:x={xe}:y={ye}:box=1:boxcolor={box_color}:boxborderw={drawtext_boxborderw(pady, padx)}:alpha='{fade_a}':{en}",
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
- line = el["text"]
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
- line_w = text_width_em(line, scale_em) * el["size"]
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
- y_top = el["y"] if align in (7, 8, 9) else (
600
- el["y"] - line_h / 2.0 if align in (4, 5, 6) else el["y"] - line_h)
601
- # An RTL line is rendered right-to-left: measure the suffix, not the logical prefix.
602
- rtl = detect_script(line) in BIDI_SCRIPTS
603
- rebuilt, cursor = "", 0
604
- for idx, cluster in clusters:
605
- name = emoji_codepoint_name(cluster)
606
- if name not in seen:
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
- if rtl:
616
- prefix_px = line_w - text_width_em(line[:idx] + cluster, scale_em) * el["size"]
617
- else:
618
- prefix_px = text_width_em(line[:idx], scale_em) * el["size"]
619
- if el.get("x_expr"):
620
- # the lower-third slides: the emoji rides the same expression the bar does
621
- x = f"({el['x_expr']})+{prefix_px:.0f}"
622
- else:
623
- x = int(round(max(0.0, min(left + prefix_px, W - el["box_px"]))))
624
- emoji_overlays.append({"asset": asset, "cluster": name, "x": x,
625
- "y": int(round(max(0.0, min(y_top + (line_h - el["box_px"]) / 2.0,
626
- H - el["box_px"])))),
627
- "start": round(el["start"], 3), "end": round(el["end"], 3),
628
- "box": el["box_px"],
629
- # every template fades its text in and out over 0.3 s
630
- # (fade_a above); the PNG rides the same envelope.
631
- "fade_in": round(min(0.3, max(0.0, (el["end"] - el["start"]) / 2.0)), 3),
632
- "fade_out": round(min(0.3, max(0.0, (el["end"] - el["start"]) / 2.0)), 3)})
633
- rebuilt += line[cursor:idx] + EMOJI_SENTINEL
634
- cursor = idx + len(cluster)
635
- el["text"] = rebuilt + line[cursor:]
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 = label_chain(meta, meta) if (layers or converted) else ""
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=label_chain(meta, meta) if rgb_chain_to_bt709(meta) else "")
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
- font_used = font_choice(args.font) # the label font, reported as `font_used` when labels are drawn
99
- font_file = font_used["file"]
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
- stem = os.path.splitext(os.path.basename(p))[0]
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(stem)}':{font_opt}:fontsize={args.font_size}:"
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
- chain = f"fps={args.fps:g},scale={args.cell_width}:{args.cell_height}:force_original_aspect_ratio=decrease,"
142
- chain += drawn_chain(",".join(c for c, _n in drawn), metas[i], colours, ",".join(n for _c, n in drawn),
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
- sw, sh = meta["video"]["width"], meta["video"]["height"]
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 audio stream's when the sound runs more than a frame past the picture
107
- (narration is never cut; an AAC tail adds no frame). The container duration (audio priming,
108
- a longer track) is only the fallback when a stream length is unknown (a pending input)."""
109
- v = (meta.get("video") or {}).get("duration")
110
- a = (meta.get("audio") or {}).get("duration")
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
- fw, fh = first["width"], first["height"]
659
- if first.get("rotation") in (90, -90, 270, -270):
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
- parts.append(f"[{audio_src[i]}]aformat=sample_rates=48000:channel_layouts={layout},asetpts=PTS-STARTPTS[a{i}]")
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
- src = meta["video"]
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
- fc = (f"[0:v]scale={half}:-2{tcs}[a];[1:v]scale={half}:-2{tcs}[b];"
242
- f"[a][b]scale2ref=w=iw:h=ih[a2][b2];[a2][b2]hstack=inputs=2[out]")
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
- # the seek resets pts to 0, so the burned-in timecode gets the seek added back
282
- tc_sheet = tc.replace(timecode_filter(font_prefix), timecode_filter(font_prefix, seek)) if seek else tc
283
- vf = (f"select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,{step * 0.98:.6f})',scale={tile_w}:-2{tc_sheet},"
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")