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.
- 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 +9 -7
- package/scripts/_common/color.py +40 -1
- package/scripts/_common/decision.py +199 -14
- package/scripts/_common/emit.py +12 -1
- package/scripts/_common/probe.py +98 -4
- package/scripts/_common/runner.py +38 -3
- package/scripts/_common/timeline.py +5 -4
- package/scripts/_contract.py +2 -2
- package/scripts/broll.py +35 -14
- package/scripts/caption.py +49 -22
- 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 +109 -68
- package/scripts/grid.py +49 -7
- package/scripts/insert.py +11 -2
- package/scripts/join.py +29 -19
- package/scripts/look.py +44 -14
- package/scripts/loop.py +45 -6
- package/scripts/multicam.py +16 -7
- package/scripts/overlay.py +70 -15
- package/scripts/proxy.py +8 -4
- package/scripts/redact.py +10 -5
- package/scripts/render.py +8 -0
- 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/verify.py +7 -2
- package/scripts/waveform.py +22 -10
package/scripts/cropdetect.py
CHANGED
|
@@ -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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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([
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
77
|
-
#
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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]
|
|
91
|
-
f"[ab]
|
|
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]"]
|
package/scripts/graphics.py
CHANGED
|
@@ -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,
|
|
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:
|
|
@@ -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 =
|
|
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=
|
|
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)
|