ffmpeg-skill 3.1.4 → 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,7 +34,8 @@ from collections import Counter
34
34
  from typing import Dict, List, Optional, Tuple
35
35
 
36
36
  from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool, run_analysis, decode_gray_frames, frame_flow
37
- from _common.emit import warn
37
+ from _common.emit import note, warn
38
+ from _common.probe import display_size, square_pixels, square_pixels_note
38
39
 
39
40
  MOTION_CENTRE_FPS = 2.0
40
41
  MOTION_CENTRE_W, MOTION_CENTRE_H = 64, 36
@@ -43,10 +44,11 @@ CROP_RE = re.compile(r"crop=(\d+):(\d+):(\d+):(\d+)")
43
44
 
44
45
 
45
46
  def detect(path: str, seconds: float, samples: int, limit: float, round_to: int, duration: float,
46
- failed: "Optional[List[Dict]]" = None) -> List[Tuple[int, int, int, int]]:
47
+ failed: "Optional[List[Dict]]" = None, square: str = "") -> List[Tuple[int, int, int, int]]:
47
48
  """Crop rectangles from `samples` windows spread over the file. A window ffmpeg cannot decode
48
49
  is skipped, and appended to `failed` as {start, seconds, error} when the caller passes a list,
49
- so the result can say how many of the readings it stands on."""
50
+ so the result can say how many of the readings it stands on. `square` is the source's
51
+ square_pixels() scale, run first so the rectangle is in the frame crop.py crops (#411)."""
50
52
  ffmpeg = require_tool("ffmpeg")
51
53
  per_window = max(0.5, seconds / max(1, samples))
52
54
  rects: List[Tuple[int, int, int, int]] = []
@@ -55,7 +57,7 @@ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int,
55
57
  start = 0.0 if duration <= 0 else (duration - per_window) * i / max(1, samples - 1) if samples > 1 else 0.0
56
58
  start = max(0.0, start)
57
59
  cmd = [ffmpeg, "-hide_banner", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{per_window:.3f}",
58
- "-vf", f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
60
+ "-vf", (square + "," if square else "") + f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
59
61
  proc = run_analysis(cmd, check=False)
60
62
  if proc.returncode != 0:
61
63
  # One window ffmpeg cannot decode (a damaged stretch) is skipped; the other windows
@@ -144,16 +146,18 @@ def main() -> int:
144
146
  meta = probe(args.input)
145
147
  if not meta.get("video"):
146
148
  die("input has no video stream")
147
- sw, sh = meta["video"]["width"], meta["video"]["height"]
148
149
  # cropdetect and the grey decode both see the frame after ffmpeg applies rotation, so the
149
150
  # rectangle and the motion points are in that frame. Reporting the stored size made a
150
- # 176x320 crop look taller than a 320x180 source.
151
- if meta["video"].get("rotation") in (90, -90, 270, -270):
152
- sw, sh = sh, sw
151
+ # 176x320 crop look taller than a 320x180 source. A non-square-pixel source is measured
152
+ # squared at its display aspect, the frame crop.py then crops (#411).
153
+ sw, sh = display_size(meta["video"])
154
+ if square_pixels_note(meta["video"]):
155
+ note(square_pixels_note(meta["video"]))
153
156
  duration = meta.get("duration") or 0.0
154
157
 
155
158
  failed: List[Dict] = []
156
- rects = detect(args.input, args.seconds, args.samples, args.limit, args.round_to, duration, failed)
159
+ rects = detect(args.input, args.seconds, args.samples, args.limit, args.round_to, duration, failed,
160
+ square_pixels(meta["video"]))
157
161
  # windows_failed: a window ffmpeg could not decode was skipped without a word, so a
158
162
  # crop measured on one window of five read as well-founded as one measured on all five
159
163
  result: Dict = {"file": args.input, "source_width": sw, "source_height": sh,
package/scripts/cut.py CHANGED
@@ -61,6 +61,7 @@ from typing import List, Tuple
61
61
  from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
62
62
  from _common import require_tool
63
63
  from _common.probe import TS_EXTS, signatures_match, stream_signature
64
+ from _common.decision import grid_at_or_after
64
65
  from _common import warn, 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
65
66
 
66
67
  # outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
@@ -1013,10 +1014,9 @@ def _fraction_rate(text: str) -> float:
1013
1014
  return float(num) / float(den or 1)
1014
1015
 
1015
1016
 
1016
- def _grid_at_or_after(t: float, v0: float, rate: float) -> float:
1017
- """The time of the first frame at or after `t` on a constant-rate grid starting at `v0`."""
1018
- import math
1019
- return v0 + math.ceil((t - v0) * rate - 1e-6) / rate
1017
+ # the first frame at or after t on the source's grid; shared with the tools that cut both streams
1018
+ # on one set of bounds (#409)
1019
+ _grid_at_or_after = grid_at_or_after
1020
1020
 
1021
1021
 
1022
1022
  def _join_chunk(src: str, segments: List[Tuple[float, float]], dst: str, meta: dict, crf: int, preset: str,
package/scripts/export.py CHANGED
@@ -38,9 +38,10 @@ import tempfile
38
38
  from pathlib import Path
39
39
  from typing import Dict, List
40
40
 
41
- from _common import STATE, run_analysis, add_common, apply_common, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, require_tool, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
41
+ from _common import STATE, run_analysis, add_common, apply_common, bt709_label_filter, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, require_tool, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
42
42
  from _common.decision import hw_preset_video, restate_last_swap
43
43
  from _common.emit import note, record_fallback, record_skip, warn
44
+ from _common.probe import square_pixels, square_pixels_note
44
45
  from check import SPECS as PLATFORMS, measure_loudness
45
46
  from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, resolve as resolve_platform
46
47
  # Frame and duration limit come from the one platform table (scripts/_platforms.py) rather than
@@ -358,6 +359,9 @@ def main() -> int:
358
359
  out_ext = Path(output).suffix.lstrip(".").lower()
359
360
 
360
361
  vf: List[str] = []
362
+ # A GIF is RGB: an untagged SDR source is labelled BT.709 (tags only) for palettegen's conversion,
363
+ # which read "unknown" as BT.601 (C03028 came out B31F28, #410); a tagged one is read by its tag
364
+ gif_label = bt709_label_filter(meta) if args.preset == "gif" else None
361
365
  if p["w"] and not args.no_scale:
362
366
  if p["h"]:
363
367
  if args.fit == "crop":
@@ -370,6 +374,11 @@ def main() -> int:
370
374
  vf.append("setsar=1")
371
375
  else:
372
376
  vf.append(f"scale={p['w']}:-2")
377
+ # sized in square pixels: a non-square-pixel master is squared at its display aspect
378
+ # first, or the preset's frame would squash it (#411)
379
+ if square_pixels(meta["video"]):
380
+ vf.insert(0, square_pixels(meta["video"]))
381
+ note(square_pixels_note(meta["video"]))
373
382
  if args.repair and args.preset != "copy":
374
383
  die("--repair is a remux: use it with --preset copy (a re-encoding preset already rebuilds every frame)")
375
384
  if args.max_mb is not None and args.preset == "gif":
@@ -387,7 +396,7 @@ def main() -> int:
387
396
  cmd += ["-map", "0:V:0"] + (["-map", "0:a:0"] if has_audio else [])
388
397
 
389
398
  if args.preset == "gif":
390
- chain = ",".join(["fps=12"] + vf) if vf else "fps=12"
399
+ chain = ",".join(([gif_label] if gif_label else []) + ["fps=12"] + vf)
391
400
  fc = f"[0:v]{chain},split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle"
392
401
  cmd += ["-filter_complex", fc, "-loop", "0", output]
393
402
  run(cmd)
package/scripts/fit.py CHANGED
@@ -39,7 +39,7 @@ import sys
39
39
  from fractions import Fraction
40
40
  from typing import List
41
41
 
42
- 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
42
+ 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
43
  BLUR_DARKEN = 0.15 # how much --fit blur dims the blurred background copy (eq brightness)
44
44
 
45
45
 
@@ -125,9 +125,8 @@ def main() -> int:
125
125
  if args.audio_stream and not audio_streams:
126
126
  die("--audio-stream needs an input with audio streams")
127
127
  src_dur = meta["duration"] or 0.0
128
- sw, sh = meta["video"]["width"], meta["video"]["height"]
129
- if meta["video"].get("rotation") in (90, -90, 270, -270):
130
- sw, sh = sh, sw
128
+ # the displayed frame: rotation applied and non-square pixels squared (#411)
129
+ sw, sh = display_size(meta["video"])
131
130
  if args.rotate in (90, 270):
132
131
  sw, sh = sh, sw
133
132
  has_audio = bool(meta.get("audio"))
@@ -204,6 +203,11 @@ def main() -> int:
204
203
  else:
205
204
  vf.append(pad_filters(out_w, out_h, args.pad_fill, args.pad_color, args.pad_blur, meta=meta))
206
205
  vf.append("setsar=1")
206
+ # a non-square-pixel source is made square at its display aspect before anything else
207
+ # (the size above is computed in that frame), so the fitted picture is not squashed
208
+ if square_pixels(meta["video"]):
209
+ vf.insert(0, square_pixels(meta["video"]))
210
+ note(square_pixels_note(meta["video"]))
207
211
 
208
212
  if args.fps:
209
213
  vf.append(f"fps={args.fps:g}")
package/scripts/freeze.py CHANGED
@@ -19,6 +19,7 @@ Examples:
19
19
  import argparse
20
20
  import sys
21
21
 
22
+ from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, stream_starts
22
23
  from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, MissingFpsError, time_arg, fmt_secs, run
23
24
 
24
25
 
@@ -55,14 +56,24 @@ def main() -> int:
55
56
  has_audio = bool(meta.get("audio"))
56
57
  output = args.output or default_output(args.input, "freeze")
57
58
 
58
- if args.mode == "extend":
59
+ # Both streams are cut at the same frame time and shifted by the same constants (#409): the
60
+ # head runs from the first frame, the tail from the first frame at or after --at. Rebasing each
61
+ # stream to its own start moved a sound that starts 0.3 s late 0.279 s early, and a picture that
62
+ # starts 0.1 s late 0.1 s early (147 frames for 150).
63
+ v0 = stream_starts(meta)[0]
64
+ rate = frame_rate(meta) or fps
65
+ cut_at = grid_at_or_after(at, v0, rate)
66
+ # no frame at or after --at (no --at holds at the clip's end): the insert tail would be empty
67
+ # and the hold lost, so hold the last frame as --mode extend does (#416 review)
68
+ vend = v0 + (meta["video"].get("duration") or dur - v0)
69
+ if args.mode == "extend" or cut_at >= vend - 0.5 / rate:
59
70
  # tpad's stop_duration clones the last frame for the given duration; no PTS surgery
60
71
  # needed since it only ever appends past the real end.
61
72
  vf = f"tpad=stop_mode=clone:stop_duration={args.hold:.3f}"
62
73
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
63
74
  if has_audio:
64
75
  cmd += ["-map", "0:a:0?", "-af", f"apad=pad_dur={args.hold:.3f}"]
65
- elif at == 0:
76
+ elif at == 0 or cut_at <= v0 + 1e-6:
66
77
  # A freeze at the very start has no preceding "head" segment to hold on to (the
67
78
  # split/trim/concat approach below needs a non-empty head, which trim=end=0 can't give
68
79
  # it -- ffmpeg fails filtering an empty stream). Symmetric to --mode extend at the other
@@ -73,22 +84,39 @@ def main() -> int:
73
84
  cmd += ["-map", "0:a:0?", "-af", f"adelay={int(args.hold * 1000)}:all=1"]
74
85
  else:
75
86
  # freeze N frames at `at` by holding on that one source frame for --hold seconds, then
76
- # resuming the rest of the clip: split the timeline at `at`, freeze-frame the first
77
- # part's last frame for --hold seconds via tpad, concat with the remainder.
87
+ # resuming the rest of the clip: split the timeline at `at` and hold the remainder's
88
+ # FIRST frame -- the frame shown at `at` -- for --hold seconds (a segment of its own, below).
89
+ # Through 3.1.3 the head's last frame was held, the frame before `at`: at a cut, the last
90
+ # frame of the previous shot (#411).
78
91
  n = max(1, round(args.hold * fps))
79
92
  # The video hold length is rounded to a whole number of frames (n / fps), but the audio
80
93
  # side used to pad by the raw --hold value -- up to half a frame duration off from what
81
94
  # the video actually holds for, a permanent A/V drift from this point on. Pad audio by
82
95
  # the same, frame-rounded duration the video actually gets.
83
96
  actual_hold = n / fps
84
- vf = (f"[0:v]split[a][b];[a]trim=end={at:.3f},setpts=PTS-STARTPTS[head];"
85
- f"[b]trim=start={at:.3f},setpts=PTS-STARTPTS[tail];"
86
- f"[head]tpad=stop_mode=clone:stop={n}[frozen];"
87
- f"[frozen][tail]concat=n=2:v=1:a=0[outv]")
97
+ head_v, head_a = av_trim(v0, cut_at, rate)
98
+ tail_v, tail_a = av_trim(cut_at, None, rate)
99
+ # The hold is its own segment: the frame at cut_at, cut on the input's clock and cloned to
100
+ # n frames there (tpad's stop clone), then shifted like the tail. From FFmpeg 7.0 setpts
101
+ # leaves its output's frame rate unknown and its frames' durations 0, so the earlier tpad
102
+ # start clone after av_trim's setpts stamped every clone 0 and the hold was dropped (a 3 s
103
+ # freeze came out 1.97 s on 7.1). Cloning before setpts and concatenating the hold before
104
+ # the tail reads the same on every version; the tail's first frame then shows n + 1 times,
105
+ # as tpad's start clone showed it.
106
+ # The held frame is the first frame at or after the cut, whatever its time: on a variable
107
+ # frame rate a nominal one-frame window around cut_at can hold no frame (frames at 0.033
108
+ # and 0.100, --at 0.06), and the hold was lost (#416 review). A second trim keeps that one
109
+ # frame; on a constant rate it is the frame at cut_at, as before.
110
+ half = 0.5 / rate
111
+ hold_v = (f"trim=start={cut_at - half:.6f},trim=end_frame=1,"
112
+ f"tpad=stop_mode=clone:stop={n - 1},setpts=PTS-STARTPTS")
113
+ vf = (f"[0:v]split=3[a][b][c];[a]{head_v}[head];"
114
+ f"[b]{hold_v}[hold];[c]{tail_v}[tail];"
115
+ f"[head][hold][tail]concat=n=3:v=1:a=0,{frame_durations(meta, rate)}[outv]")
88
116
  cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", vf, "-map", "[outv]"]
89
117
  if has_audio:
90
- af = (f"[0:a]asplit[aa][ab];[aa]atrim=end={at:.3f},asetpts=PTS-STARTPTS[ahead];"
91
- f"[ab]atrim=start={at:.3f},asetpts=PTS-STARTPTS[atail];"
118
+ af = (f"[0:a]asplit[aa][ab];[aa]{head_a}[ahead];"
119
+ f"[ab]{tail_a}[atail];"
92
120
  f"[ahead]apad=pad_dur={actual_hold:.6f}[afrozen];"
93
121
  f"[afrozen][atail]concat=n=2:v=0:a=1[outa]")
94
122
  cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", f"{vf};{af}", "-map", "[outv]", "-map", "[outa]"]
@@ -32,16 +32,17 @@ import sys
32
32
  from typing import List, Optional, Tuple
33
33
 
34
34
  from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
35
- from _common import (require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
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:
@@ -648,6 +683,10 @@ def main(force_route: Optional[str] = None) -> int:
648
683
  "overlays": len(emoji_overlays)}
649
684
  ass_path = args.write_ass or os.path.splitext(output)[0] + "_gfx.ass"
650
685
  require_filter("ass", "drawing this text through libass") # before the sidecar is written
686
+ # a file this run writes itself: never the input or the video, a missing directory or an
687
+ # existing file without --overwrite is refused before anything is written (#408)
688
+ guard_sidecar(ass_path, *[q for q in (args.input, getattr(args, "brand", None), getattr(args, "font_file", None))
689
+ if isinstance(q, str)], siblings=(output,))
651
690
  if STATE.dry_run:
652
691
  info(f"[dry-run] would write {ass_path} ({len(elements)} text elements)")
653
692
  else:
@@ -669,7 +708,7 @@ def main(force_route: Optional[str] = None) -> int:
669
708
  drawboxes.append(f"drawbox={geo}:color={ff_color(colour, alpha)}:t=fill:{enable}")
670
709
  filters = [("" if as_layers else drawboxes[int(f[len(BOX):])]) if f.startswith(BOX) else f for f in filters]
671
710
  filters = [f for f in filters if f]
672
- base_label = label_chain(meta, meta) if (layers or converted) else ""
711
+ base_label = layer_base_chain(meta) if (layers or converted) else ""
673
712
  text = (drawn_chain(",".join(filters), meta, re.findall(r"color=([0-9A-Za-z#@.]+)", ",".join(filters)),
674
713
  labelled=bool(base_label)) if filters else "")
675
714
  if layers:
@@ -682,10 +721,12 @@ def main(force_route: Optional[str] = None) -> int:
682
721
  fc = [c.replace(TEXT, text or "null") for c in fc]
683
722
  if fc and base_label:
684
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]
685
726
 
686
727
  cmd = ffmpeg_base() + ["-i", args.input]
687
728
  if route == "ass" or emoji_overlays:
688
- 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]"]
689
730
  last = "vout"
690
731
  if route == "ass":
691
732
  vf = f"ass=filename={escape_filter_path(ass_path)}"
@@ -698,7 +739,7 @@ def main(force_route: Optional[str] = None) -> int:
698
739
  last = "vtxt"
699
740
  eo, emoji_inputs = emoji_filter_chain({"overlays": emoji_overlays}, last, "vfinal", first_input=1,
700
741
  to_yuv=rgb_chain_to_bt709(meta),
701
- base_chain=label_chain(meta, meta) if rgb_chain_to_bt709(meta) else "")
742
+ base_chain=layer_base_chain(meta))
702
743
  for spec in emoji_inputs:
703
744
  cmd += spec
704
745
  asset = spec[-1]
@@ -712,7 +753,7 @@ def main(force_route: Optional[str] = None) -> int:
712
753
  elif fc:
713
754
  cmd += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", f"0:a:{args.audio_stream}?"]
714
755
  else:
715
- 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}?"]
716
757
  cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
717
758
  cmd += aac_args() if meta.get("audio") else ["-an"]
718
759
  dropped_streams = run_keeping_subtitles(cmd, output)