ffmpeg-skill 3.1.5 → 3.1.7
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 +11 -7
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +3 -2
- package/references/gotchas.md +5 -1
- package/references/scripts.md +12 -6
- package/scripts/_common/__init__.py +7 -5
- package/scripts/_common/asr.py +49 -10
- package/scripts/_common/color.py +40 -1
- package/scripts/_common/decision.py +177 -10
- package/scripts/_common/emit.py +2 -0
- package/scripts/_common/fonts.py +26 -0
- package/scripts/_common/probe.py +115 -5
- package/scripts/_common/runner.py +38 -1
- package/scripts/_common/timeline.py +123 -27
- package/scripts/_contract.py +34 -3
- package/scripts/broll.py +35 -14
- package/scripts/caption.py +105 -19
- 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 +16 -15
- package/scripts/fit.py +8 -4
- package/scripts/freeze.py +38 -10
- package/scripts/graphics.py +104 -67
- package/scripts/grid.py +49 -7
- package/scripts/insert.py +9 -1
- package/scripts/join.py +29 -19
- package/scripts/look.py +41 -12
- package/scripts/loop.py +45 -6
- package/scripts/multicam.py +16 -7
- package/scripts/overlay.py +68 -14
- package/scripts/proxy.py +8 -4
- package/scripts/redact.py +10 -5
- package/scripts/reverse.py +27 -2
- package/scripts/scenes.py +16 -5
- package/scripts/sequence.py +6 -2
- package/scripts/silence.py +91 -35
- package/scripts/speedramp.py +17 -5
- package/scripts/straighten.py +17 -5
- package/scripts/sync.py +8 -2
- package/scripts/waveform.py +8 -3
package/scripts/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,11 @@ 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
|
|
45
|
+
from _common.runner import subtitle_codec_for
|
|
44
46
|
from check import SPECS as PLATFORMS, measure_loudness
|
|
45
47
|
from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, resolve as resolve_platform
|
|
46
48
|
# Frame and duration limit come from the one platform table (scripts/_platforms.py) rather than
|
|
@@ -141,19 +143,10 @@ def _loudness_unmeasured(extra: Dict[str, object], notes: List[str], platform: s
|
|
|
141
143
|
|
|
142
144
|
# Text subtitle codecs ffmpeg can convert to the container's own text track: mov_text is the one
|
|
143
145
|
# text track MP4/MOV hold, Matroska holds SubRip/ASS/WebVTT as they are but not mov_text.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
def _subtitle_codec(codec: str, out_ext: str) -> str:
|
|
149
|
-
"""The -c:s for one source subtitle stream: copy when the output container holds it as it is,
|
|
150
|
-
else the container's own text format (a bitmap track is tried as a copy; a container that
|
|
151
|
-
refuses it is the fallback below)."""
|
|
152
|
-
if out_ext in _MP4_FAMILY and codec in _TEXT_SUBTITLES and codec != "mov_text":
|
|
153
|
-
return "mov_text"
|
|
154
|
-
if out_ext == "mkv" and codec == "mov_text":
|
|
155
|
-
return "srt"
|
|
156
|
-
return "copy"
|
|
146
|
+
# the -c:s for one source subtitle stream (#380), shared with caption.py --mode mux (#414): copy
|
|
147
|
+
# when the output container holds it as it is, else the container's own text format (a bitmap
|
|
148
|
+
# track is tried as a copy; a container that refuses it is the fallback below)
|
|
149
|
+
_subtitle_codec = subtitle_codec_for
|
|
157
150
|
|
|
158
151
|
|
|
159
152
|
def _data_streams(path: str) -> List[Dict[str, object]]:
|
|
@@ -358,6 +351,9 @@ def main() -> int:
|
|
|
358
351
|
out_ext = Path(output).suffix.lstrip(".").lower()
|
|
359
352
|
|
|
360
353
|
vf: List[str] = []
|
|
354
|
+
# A GIF is RGB: an untagged SDR source is labelled BT.709 (tags only) for palettegen's conversion,
|
|
355
|
+
# which read "unknown" as BT.601 (C03028 came out B31F28, #410); a tagged one is read by its tag
|
|
356
|
+
gif_label = bt709_label_filter(meta) if args.preset == "gif" else None
|
|
361
357
|
if p["w"] and not args.no_scale:
|
|
362
358
|
if p["h"]:
|
|
363
359
|
if args.fit == "crop":
|
|
@@ -370,6 +366,11 @@ def main() -> int:
|
|
|
370
366
|
vf.append("setsar=1")
|
|
371
367
|
else:
|
|
372
368
|
vf.append(f"scale={p['w']}:-2")
|
|
369
|
+
# sized in square pixels: a non-square-pixel master is squared at its display aspect
|
|
370
|
+
# first, or the preset's frame would squash it (#411)
|
|
371
|
+
if square_pixels(meta["video"]):
|
|
372
|
+
vf.insert(0, square_pixels(meta["video"]))
|
|
373
|
+
note(square_pixels_note(meta["video"]))
|
|
373
374
|
if args.repair and args.preset != "copy":
|
|
374
375
|
die("--repair is a remux: use it with --preset copy (a re-encoding preset already rebuilds every frame)")
|
|
375
376
|
if args.max_mb is not None and args.preset == "gif":
|
|
@@ -387,7 +388,7 @@ def main() -> int:
|
|
|
387
388
|
cmd += ["-map", "0:V:0"] + (["-map", "0:a:0"] if has_audio else [])
|
|
388
389
|
|
|
389
390
|
if args.preset == "gif":
|
|
390
|
-
chain = ",".join([
|
|
391
|
+
chain = ",".join(([gif_label] if gif_label else []) + ["fps=12"] + vf)
|
|
391
392
|
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
393
|
cmd += ["-filter_complex", fc, "-loop", "0", output]
|
|
393
394
|
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
|
@@ -34,14 +34,15 @@ from typing import List, Optional, Tuple
|
|
|
34
34
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
35
35
|
from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
|
|
36
36
|
color_hex, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
|
|
37
|
-
ffmpeg_base, info,
|
|
37
|
+
ffmpeg_base, info, layer_base_chain, load_brand, parse_time, probe, rgb_chain_to_bt709, run, run_keeping_subtitles, video_args,
|
|
38
38
|
drawtext_boxborderw, drawtext_center_align, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
|
|
39
39
|
LANGUAGE_NAMES, needs_shaping, detect_script, BIDI_SCRIPTS, font_family_of_file, font_family_for_script, has_emoji,
|
|
40
40
|
emoji_clusters, emoji_codepoint_name, char_script, emoji_filter_chain, emoji_asset_for, emoji_support, resolve_emoji_assets,
|
|
41
41
|
EMOJI_ASSET_HINT, text_width_em, drawtext_shaping, wrap_text, WRAP_MODES,
|
|
42
42
|
SAFE_WIDTH_FRACTION, drawtext_draws_newline_glyph, is_multiline, _DRAWTEXT_PENDING)
|
|
43
|
-
from _common.emit import warn
|
|
43
|
+
from _common.emit import note, warn
|
|
44
44
|
from _common.fonts import font_choice, record_font_choice
|
|
45
|
+
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
45
46
|
from _ass_overlay import text_overlay_ass, EMOJI_SENTINEL
|
|
46
47
|
from _common import drawtext_fontfile # noqa: E402
|
|
47
48
|
|
|
@@ -170,9 +171,11 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
170
171
|
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
171
172
|
if args.audio_stream and not audio_streams:
|
|
172
173
|
die("--audio-stream needs an input with audio streams")
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
174
|
+
# the displayed frame: rotation applied, non-square pixels squared (square_pixels(), #411)
|
|
175
|
+
W, H = display_size(meta["video"])
|
|
176
|
+
square = square_pixels(meta["video"])
|
|
177
|
+
if square:
|
|
178
|
+
note(square_pixels_note(meta["video"]))
|
|
176
179
|
dur = meta.get("duration") or 0.0
|
|
177
180
|
# Per-edge margins. Without --margin/--platform every edge is the brand safe margin, exactly
|
|
178
181
|
# as before; --margin sets all four; --platform takes each edge from the destination's safe
|
|
@@ -373,9 +376,21 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
373
376
|
h1 = int(base * 0.055)
|
|
374
377
|
h2 = int(base * 0.038)
|
|
375
378
|
pad = int(base * 0.02)
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
+
tx_pad = int(base * 0.035)
|
|
380
|
+
# both lines wrap to the room between the side margins, less the text's own inset, and
|
|
381
|
+
# the bar widens (up to that room) to the widest line: through 3.1.3 the bar was a fixed
|
|
382
|
+
# 62 % of the short side and a long name ran past it and off the frame (#411)
|
|
383
|
+
room = W - m_left - m_right
|
|
384
|
+
lt_frac = max(0.1, min(1.0, (room - 2 * tx_pad) / float(W))) if W else SAFE_WIDTH_FRACTION
|
|
385
|
+
lt_name = wrapped(args.name, h1, lt_frac)
|
|
386
|
+
lt_title = wrapped(args.title, h2, lt_frac) if args.title else args.title
|
|
387
|
+
widest = max([text_width_em(ln) * h1 for ln in lt_name.split("\n")]
|
|
388
|
+
+ [text_width_em(ln) * h2 for ln in (lt_title or "").split("\n")])
|
|
389
|
+
name_h = block_h(lt_name, h1)
|
|
390
|
+
bar_h = name_h + (block_h(lt_title, h2) + pad if lt_title else 0) + pad * 2
|
|
391
|
+
# the 62 % floor never takes the bar past the right margin (#416 review: at a large --scale
|
|
392
|
+
# it did)
|
|
393
|
+
bar_w = max(1, min(room, max(int(base * 0.62), int(widest + 0.999) + 2 * tx_pad)))
|
|
379
394
|
y0 = H - m_bottom - bar_h
|
|
380
395
|
# slide in from the left over 0.4 s, slide out over 0.3 s (overlay evaluates x per frame)
|
|
381
396
|
x_expr = f"if(lt(t,{s:.3f}+0.4),-{bar_w}+({bar_w}+{m_left})*((t-{s:.3f})/0.4),if(gt(t,{e:.3f}-0.3),{m_left}-({bar_w}+{m_left})*(1-({e:.3f}-t)/0.3),{m_left}))"
|
|
@@ -383,12 +398,11 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
383
398
|
fc.append(f"{layer_src(primary, 1.0, int(base * 0.012), bar_h)}[acc]")
|
|
384
399
|
fc.append(f"[0:v][bar]overlay=x='{x_expr}':y={y0}:{en}:eof_action=pass[v1]")
|
|
385
400
|
fc.append(f"[v1][acc]overlay=x='{x_expr}':y={y0}:{en}:eof_action=pass[v2]")
|
|
386
|
-
tx_pad = int(base * 0.035)
|
|
387
401
|
tx = f"({x_expr})+{tx_pad}"
|
|
388
402
|
x_rest, x_off = m_left + tx_pad, -bar_w + tx_pad
|
|
389
403
|
draws = []
|
|
390
|
-
for text, fs, colour, ty in ((
|
|
391
|
-
(
|
|
404
|
+
for text, fs, colour, ty in ((lt_name, h1, text_c, y0 + pad),
|
|
405
|
+
(lt_title, h2, primary, y0 + pad + name_h + pad // 2)):
|
|
392
406
|
if not text:
|
|
393
407
|
continue
|
|
394
408
|
draws.append(f"drawtext={drawtext_text_opts(text)}:{fo}:fontsize={fs}:"
|
|
@@ -416,24 +430,33 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
416
430
|
die("title needs --title")
|
|
417
431
|
h1 = int(base * 0.11)
|
|
418
432
|
h2 = int(base * 0.045)
|
|
433
|
+
# the title and the subtitle wrap to the frame's safe width (#411: they never did)
|
|
434
|
+
title_t = wrapped(args.title, h1)
|
|
435
|
+
sub_t = wrapped(args.subtitle, h2) if args.subtitle else args.subtitle
|
|
419
436
|
fill_box(bg, 0.55, "x=0:y=0:w=iw:h=ih", 0, 0, W, H, en)
|
|
420
437
|
# the title is centred as a block, so the bar and the subtitle sit below its last line:
|
|
421
438
|
# `title_h` is the block's height, as the one-line layout has always taken h1; a further
|
|
422
439
|
# subtitle line hangs below the first, whose position the one-line layout fixes
|
|
423
|
-
title_h = block_h(
|
|
440
|
+
title_h = block_h(title_t, h1)
|
|
424
441
|
sub_y = title_h // 2 + int(base * 0.03)
|
|
425
|
-
sub_extra = block_h(
|
|
426
|
-
|
|
427
|
-
|
|
442
|
+
sub_extra = block_h(sub_t, h2) - h2 if sub_t else 0
|
|
443
|
+
tall = title_h + (sub_y - title_h // 2 + block_h(sub_t, h2) if sub_t else 0)
|
|
444
|
+
if tall > H:
|
|
445
|
+
# the wrap measures width only; a centred block taller than the frame loses its first
|
|
446
|
+
# and last lines off the edges (#416 review)
|
|
447
|
+
note(f"the wrapped title{' and subtitle' if sub_t else ''} are about {tall} px tall, taller than the "
|
|
448
|
+
f"{H} px frame: the lines past its edges are not drawn -- shorten the text or lower --scale")
|
|
449
|
+
add_text(title_t,
|
|
450
|
+
f"drawtext={drawtext_text_opts(title_t)}:{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}{drawtext_center_align()}:x=(w-text_w)/2:y=(h-text_h)/2-{h2 if sub_t else 0}:alpha='{fade_a}':{en}",
|
|
428
451
|
size=h1, color=text_c, font=ass_font_family(), align=5, x=W / 2,
|
|
429
|
-
y=H / 2 - (h2 if
|
|
452
|
+
y=H / 2 - (h2 if sub_t else 0), outline=max(1.0, h1 / 20.0),
|
|
430
453
|
outline_color="000000", start=s, end=e, fade=(300, 300))
|
|
431
454
|
acc_w, acc_h = int(base * 0.12), max(2, int(base * 0.006))
|
|
432
455
|
fill_box(primary, 1.0, f"x=(iw-{acc_w})/2:y=(ih)/2+{title_h // 2}:w={acc_w}:h={acc_h}",
|
|
433
456
|
(W - acc_w) // 2, H // 2 + title_h // 2, acc_w, acc_h, en)
|
|
434
|
-
if
|
|
435
|
-
add_text(
|
|
436
|
-
f"drawtext={drawtext_text_opts(
|
|
457
|
+
if sub_t:
|
|
458
|
+
add_text(sub_t,
|
|
459
|
+
f"drawtext={drawtext_text_opts(sub_t)}:{fo}:fontsize={h2}:fontcolor={ff_color(primary)}{drawtext_center_align()}:x=(w-text_w)/2:y=(h-text_h)/2+{sub_y + sub_extra // 2}:alpha='{fade_a}':{en}",
|
|
437
460
|
size=h2, color=primary, font=ass_font_family(), align=5, x=W / 2,
|
|
438
461
|
y=H / 2 + sub_y + sub_extra // 2, outline=max(1.0, h2 / 20.0),
|
|
439
462
|
outline_color="000000", start=s, end=e, fade=(300, 300))
|
|
@@ -444,6 +467,8 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
444
467
|
pos = args.position or ("bottom-left" if args.template == "chapter" else "top-right")
|
|
445
468
|
fs = int(base * (0.04 if args.template == "chapter" else 0.032))
|
|
446
469
|
padx, pady = int(fs * 0.6), int(fs * 0.35)
|
|
470
|
+
# the chip wraps to the room between the side margins, as the sticker's does (#411)
|
|
471
|
+
chip_t = wrapped(args.title, fs, chip_frac(m_left, m_right, padx))
|
|
447
472
|
xe = f"{m_left}" if "left" in pos else f"w-text_w-{m_right}"
|
|
448
473
|
ye = f"{m_top}" if "top" in pos else f"h-text_h-{m_bottom}"
|
|
449
474
|
box_color = ff_color(primary if args.template == "chapter" else bg, 0.9 if args.template == "chapter" else 0.7)
|
|
@@ -451,8 +476,8 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
451
476
|
box_hex = primary if args.template == "chapter" else bg
|
|
452
477
|
txt_hex = bg if args.template == "chapter" else text_c
|
|
453
478
|
align = (7 if "left" in pos else 9) if "top" in pos else (1 if "left" in pos else 3)
|
|
454
|
-
add_text(
|
|
455
|
-
f"drawtext={drawtext_text_opts(
|
|
479
|
+
add_text(chip_t,
|
|
480
|
+
f"drawtext={drawtext_text_opts(chip_t)}:{fo}:fontsize={fs}:fontcolor={txt_color}:x={xe}:y={ye}:box=1:boxcolor={box_color}:boxborderw={drawtext_boxborderw(pady, padx)}:alpha='{fade_a}':{en}",
|
|
456
481
|
size=fs, color=txt_hex, font=ass_font_family(), align=align,
|
|
457
482
|
x=(m_left if "left" in pos else W - m_right),
|
|
458
483
|
y=(m_top if "top" in pos else H - m_bottom),
|
|
@@ -587,52 +612,62 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
587
612
|
seen: List[str] = []
|
|
588
613
|
for el in elements:
|
|
589
614
|
el["box_px"] = int(round(el["size"] * scale_em))
|
|
590
|
-
|
|
591
|
-
clusters = emoji_clusters(line)
|
|
592
|
-
if not clusters or emoji_mode not in ("png",):
|
|
615
|
+
if not emoji_clusters(el["text"]) or emoji_mode not in ("png",):
|
|
593
616
|
continue
|
|
594
|
-
|
|
617
|
+
# a wrapped label is several lines: each is measured from its own start and sits one
|
|
618
|
+
# line pitch below the last (#416 review: they were measured as one line, on the first)
|
|
619
|
+
all_lines = el["text"].split("\n")
|
|
595
620
|
align = int(el.get("align", 7))
|
|
596
|
-
left = el["x"] if align in (7, 4, 1) else (
|
|
597
|
-
el["x"] - line_w / 2.0 if align in (8, 5, 2) else el["x"] - line_w)
|
|
598
621
|
line_h = el["size"] * 1.2
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
seen.append(name)
|
|
608
|
-
asset = emoji_asset_for(cluster, emoji_assets)
|
|
609
|
-
if not asset:
|
|
610
|
-
if name not in missing:
|
|
611
|
-
missing.append(name)
|
|
612
|
-
rebuilt += line[cursor:idx + len(cluster)]
|
|
613
|
-
cursor = idx + len(cluster)
|
|
622
|
+
block = len(all_lines) * line_h
|
|
623
|
+
block_top = el["y"] if align in (7, 8, 9) else (
|
|
624
|
+
el["y"] - block / 2.0 if align in (4, 5, 6) else el["y"] - block)
|
|
625
|
+
rebuilt_lines = []
|
|
626
|
+
for k, line in enumerate(all_lines):
|
|
627
|
+
clusters = emoji_clusters(line)
|
|
628
|
+
if not clusters:
|
|
629
|
+
rebuilt_lines.append(line)
|
|
614
630
|
continue
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
631
|
+
line_w = text_width_em(line, scale_em) * el["size"]
|
|
632
|
+
left = el["x"] if align in (7, 4, 1) else (
|
|
633
|
+
el["x"] - line_w / 2.0 if align in (8, 5, 2) else el["x"] - line_w)
|
|
634
|
+
y_top = block_top + k * line_h
|
|
635
|
+
# An RTL line is rendered right-to-left: measure the suffix, not the logical prefix.
|
|
636
|
+
rtl = detect_script(line) in BIDI_SCRIPTS
|
|
637
|
+
rebuilt, cursor = "", 0
|
|
638
|
+
for idx, cluster in clusters:
|
|
639
|
+
name = emoji_codepoint_name(cluster)
|
|
640
|
+
if name not in seen:
|
|
641
|
+
seen.append(name)
|
|
642
|
+
asset = emoji_asset_for(cluster, emoji_assets)
|
|
643
|
+
if not asset:
|
|
644
|
+
if name not in missing:
|
|
645
|
+
missing.append(name)
|
|
646
|
+
rebuilt += line[cursor:idx + len(cluster)]
|
|
647
|
+
cursor = idx + len(cluster)
|
|
648
|
+
continue
|
|
649
|
+
if rtl:
|
|
650
|
+
prefix_px = line_w - text_width_em(line[:idx] + cluster, scale_em) * el["size"]
|
|
651
|
+
else:
|
|
652
|
+
prefix_px = text_width_em(line[:idx], scale_em) * el["size"]
|
|
653
|
+
if el.get("x_expr"):
|
|
654
|
+
# the lower-third slides: the emoji rides the same expression the bar does
|
|
655
|
+
x = f"({el['x_expr']})+{prefix_px:.0f}"
|
|
656
|
+
else:
|
|
657
|
+
x = int(round(max(0.0, min(left + prefix_px, W - el["box_px"]))))
|
|
658
|
+
emoji_overlays.append({"asset": asset, "cluster": name, "x": x,
|
|
659
|
+
"y": int(round(max(0.0, min(y_top + (line_h - el["box_px"]) / 2.0,
|
|
660
|
+
H - el["box_px"])))),
|
|
661
|
+
"start": round(el["start"], 3), "end": round(el["end"], 3),
|
|
662
|
+
"box": el["box_px"],
|
|
663
|
+
# every template fades its text in and out over 0.3 s
|
|
664
|
+
# (fade_a above); the PNG rides the same envelope.
|
|
665
|
+
"fade_in": round(min(0.3, max(0.0, (el["end"] - el["start"]) / 2.0)), 3),
|
|
666
|
+
"fade_out": round(min(0.3, max(0.0, (el["end"] - el["start"]) / 2.0)), 3)})
|
|
667
|
+
rebuilt += line[cursor:idx] + EMOJI_SENTINEL
|
|
668
|
+
cursor = idx + len(cluster)
|
|
669
|
+
rebuilt_lines.append(rebuilt + line[cursor:])
|
|
670
|
+
el["text"] = "\n".join(rebuilt_lines)
|
|
636
671
|
# `or 60` would swallow --emoji-max 0, the one value meaning "none at all".
|
|
637
672
|
_max = 60 if args.emoji_max is None else int(args.emoji_max)
|
|
638
673
|
if len(emoji_overlays) > _max:
|
|
@@ -673,7 +708,7 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
673
708
|
drawboxes.append(f"drawbox={geo}:color={ff_color(colour, alpha)}:t=fill:{enable}")
|
|
674
709
|
filters = [("" if as_layers else drawboxes[int(f[len(BOX):])]) if f.startswith(BOX) else f for f in filters]
|
|
675
710
|
filters = [f for f in filters if f]
|
|
676
|
-
base_label =
|
|
711
|
+
base_label = layer_base_chain(meta) if (layers or converted) else ""
|
|
677
712
|
text = (drawn_chain(",".join(filters), meta, re.findall(r"color=([0-9A-Za-z#@.]+)", ",".join(filters)),
|
|
678
713
|
labelled=bool(base_label)) if filters else "")
|
|
679
714
|
if layers:
|
|
@@ -686,10 +721,12 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
686
721
|
fc = [c.replace(TEXT, text or "null") for c in fc]
|
|
687
722
|
if fc and base_label:
|
|
688
723
|
fc = [f"[0:v]{base_label}null[gbase]"] + [c.replace("[0:v]", "[gbase]") for c in fc]
|
|
724
|
+
if fc and square:
|
|
725
|
+
fc = [f"[0:v]{square}[gsq]"] + [c.replace("[0:v]", "[gsq]") for c in fc]
|
|
689
726
|
|
|
690
727
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
691
728
|
if route == "ass" or emoji_overlays:
|
|
692
|
-
chains = list(fc) if fc else [f"[0:v]{text or 'null'}[vout]"]
|
|
729
|
+
chains = list(fc) if fc else [f"[0:v]{(square + ',') if square else ''}{text or 'null'}[vout]"]
|
|
693
730
|
last = "vout"
|
|
694
731
|
if route == "ass":
|
|
695
732
|
vf = f"ass=filename={escape_filter_path(ass_path)}"
|
|
@@ -702,7 +739,7 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
702
739
|
last = "vtxt"
|
|
703
740
|
eo, emoji_inputs = emoji_filter_chain({"overlays": emoji_overlays}, last, "vfinal", first_input=1,
|
|
704
741
|
to_yuv=rgb_chain_to_bt709(meta),
|
|
705
|
-
base_chain=
|
|
742
|
+
base_chain=layer_base_chain(meta))
|
|
706
743
|
for spec in emoji_inputs:
|
|
707
744
|
cmd += spec
|
|
708
745
|
asset = spec[-1]
|
|
@@ -716,7 +753,7 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
716
753
|
elif fc:
|
|
717
754
|
cmd += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", f"0:a:{args.audio_stream}?"]
|
|
718
755
|
else:
|
|
719
|
-
cmd += ["-vf", text, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
|
|
756
|
+
cmd += ["-vf", (square + "," if square else "") + text, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
|
|
720
757
|
cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
|
|
721
758
|
cmd += aac_args() if meta.get("audio") else ["-an"]
|
|
722
759
|
dropped_streams = run_keeping_subtitles(cmd, output)
|
package/scripts/grid.py
CHANGED
|
@@ -26,15 +26,34 @@ import os
|
|
|
26
26
|
import sys
|
|
27
27
|
|
|
28
28
|
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, X264_PRESETS, fmt_secs, \
|
|
29
|
-
draws_chroma, drawn_chain, escape_drawtext, ffmpeg_base, info, label_chain, matrix_chain, probe, run, validate_color, video_args
|
|
29
|
+
as_limited, draws_chroma, drawn_chain, escape_drawtext, ffmpeg_base, info, label_chain, matrix_chain, range_chain, probe, run, validate_color, video_args
|
|
30
30
|
from _common.fonts import font_choice, record_font_choice
|
|
31
31
|
from _common import drawtext_fontfile # noqa: E402
|
|
32
32
|
from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
|
|
33
33
|
from _common.emit import note, record_fallback # noqa: E402
|
|
34
|
+
from _common.fonts import script_font_for_text # noqa: E402
|
|
35
|
+
from _common.probe import square_pixels # noqa: E402
|
|
36
|
+
from _common.wrap import text_width_em # noqa: E402
|
|
34
37
|
|
|
35
38
|
LABEL_MARGIN = 10
|
|
36
39
|
|
|
37
40
|
|
|
41
|
+
def _fit_label(stem: str, cell_width: int, font_size: int) -> str:
|
|
42
|
+
"""The label as drawn: `stem` when it fits the cell (less the margin and the box's border on
|
|
43
|
+
each side), else its head with an ellipsis. It is right-aligned, so a label wider than the
|
|
44
|
+
cell used to lose its start off the left edge and show only its tail (#411). The width is the
|
|
45
|
+
averaged advance table's estimate."""
|
|
46
|
+
room = (cell_width - 2 * LABEL_MARGIN - 8) / float(max(1, font_size))
|
|
47
|
+
if text_width_em(stem) <= room:
|
|
48
|
+
return stem
|
|
49
|
+
if room <= 1:
|
|
50
|
+
return stem[:1] # no room for an ellipsis: keep the start, as a shortened label does (#416 review)
|
|
51
|
+
head = stem
|
|
52
|
+
while head and text_width_em(head + "\u2026") > room:
|
|
53
|
+
head = head[:-1]
|
|
54
|
+
return (head.rstrip() + "\u2026") if head else stem[:1]
|
|
55
|
+
|
|
56
|
+
|
|
38
57
|
def main() -> int:
|
|
39
58
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
40
59
|
ap.add_argument("inputs", nargs="+", help="video clips, filled into the grid left-to-right, top-to-bottom")
|
|
@@ -69,6 +88,9 @@ def main() -> int:
|
|
|
69
88
|
n = args.cols * args.rows
|
|
70
89
|
if args.cols <= 0 or args.rows <= 0:
|
|
71
90
|
die(f"--cols/--rows must be > 0, got cols={args.cols} rows={args.rows}")
|
|
91
|
+
if n == 1:
|
|
92
|
+
# xstack takes two inputs or more: a 1x1 grid used to reach ffmpeg and fail there (exit 222)
|
|
93
|
+
die("--cols 1 --rows 1 is a single cell; a grid needs at least two (fit.py letterboxes one clip)")
|
|
72
94
|
if len(args.inputs) != n:
|
|
73
95
|
die(f"--cols {args.cols} --rows {args.rows} needs exactly {n} inputs, got {len(args.inputs)}")
|
|
74
96
|
if args.cell_width <= 0 or args.cell_height <= 0:
|
|
@@ -95,10 +117,26 @@ def main() -> int:
|
|
|
95
117
|
durations = [m.get("duration") or 0.0 for m in metas]
|
|
96
118
|
target_duration = max(durations) if args.pad else min(durations)
|
|
97
119
|
|
|
98
|
-
|
|
99
|
-
|
|
120
|
+
# each label is drawn with a font that covers its own script, as every other text-drawing tool
|
|
121
|
+
# picks one (through 3.1.3 a Japanese or Thai filename came out as tofu, #411); `font_used` is
|
|
122
|
+
# the first label's choice, and a grid whose labels needed different fonts says so in a note
|
|
123
|
+
stems = [os.path.splitext(os.path.basename(p))[0] for p in args.inputs]
|
|
124
|
+
font_explicit = args.font != ap.get_default("font")
|
|
125
|
+
label_fonts = []
|
|
126
|
+
for stem in (stems if args.label == "auto" else []):
|
|
127
|
+
_sc, script_file, script_family = script_font_for_text(stem, font=args.font, font_explicit=font_explicit)
|
|
128
|
+
label_fonts.append(font_choice(args.font, script_file=script_file, script_family=script_family))
|
|
129
|
+
font_used = label_fonts[0] if label_fonts else font_choice(args.font)
|
|
100
130
|
if args.label == "auto":
|
|
101
131
|
record_font_choice(font_used)
|
|
132
|
+
if len({f.get("file") for f in label_fonts}) > 1:
|
|
133
|
+
note("cell labels drawn with more than one font, by script: "
|
|
134
|
+
+ "; ".join(sorted({str(f.get("family") or f.get("file")) for f in label_fonts})))
|
|
135
|
+
labels = [_fit_label(stem, args.cell_width, args.font_size) for stem in stems]
|
|
136
|
+
cut = [stem for stem, lab in zip(stems, labels) if lab != stem]
|
|
137
|
+
if args.label == "auto" and cut:
|
|
138
|
+
note(f"{len(cut)} cell label(s) wider than the {args.cell_width} px cell at --font-size "
|
|
139
|
+
f"{args.font_size} were shortened from the end with an ellipsis")
|
|
102
140
|
output = args.output or default_output(args.inputs[0], "grid")
|
|
103
141
|
|
|
104
142
|
cmd = ffmpeg_base()
|
|
@@ -117,6 +155,8 @@ def main() -> int:
|
|
|
117
155
|
convert = [matrix_chain(m, None) for m in metas]
|
|
118
156
|
if any(convert) or chroma:
|
|
119
157
|
convert = [c or label_chain(m, None) for c, m in zip(convert, metas)]
|
|
158
|
+
# xstack mixes samples: a full-range cell beside a limited one is taken to limited range (#410)
|
|
159
|
+
convert = [c + range_chain(m, None) for c, m in zip(convert, metas)]
|
|
120
160
|
parts = []
|
|
121
161
|
for i, p in enumerate(args.inputs):
|
|
122
162
|
drawn = [ # (with this colour, with black)
|
|
@@ -129,17 +169,19 @@ def main() -> int:
|
|
|
129
169
|
elif not args.pad:
|
|
130
170
|
drawn.append((f"trim=duration={target_duration:.3f}",) * 2)
|
|
131
171
|
if args.label == "auto":
|
|
132
|
-
|
|
172
|
+
font_file = label_fonts[i]["file"]
|
|
133
173
|
font_opt = drawtext_fontfile(font_file) if font_file else f"font='{escape_drawtext(args.font)}'"
|
|
134
|
-
drawn.append((f"drawtext=text='{escape_drawtext(
|
|
174
|
+
drawn.append((f"drawtext=text='{escape_drawtext(labels[i])}':{font_opt}:fontsize={args.font_size}:"
|
|
135
175
|
f"fontcolor={args.font_color}:x=w-tw-{LABEL_MARGIN}:y=h-th-{LABEL_MARGIN}:"
|
|
136
176
|
f"box=1:boxcolor=black@0.5:boxborderw=4", "null"))
|
|
137
177
|
gap_r = args.gap if i % args.cols < args.cols - 1 else 0
|
|
138
178
|
gap_b = args.gap if i // args.cols < args.rows - 1 else 0
|
|
139
179
|
if chroma and (gap_r or gap_b):
|
|
140
180
|
drawn.append((f"pad=iw+{gap_r}:ih+{gap_b}:0:0:color={args.background}", f"pad=iw+{gap_r}:ih+{gap_b}:0:0:color=black"))
|
|
141
|
-
|
|
142
|
-
|
|
181
|
+
# a non-square-pixel clip is squared at its display aspect before it is fitted (#411)
|
|
182
|
+
sq = (square_pixels(metas[i]["video"]) + ",") if square_pixels(metas[i]["video"]) else ""
|
|
183
|
+
chain = f"fps={args.fps:g},{sq}scale={args.cell_width}:{args.cell_height}:force_original_aspect_ratio=decrease,"
|
|
184
|
+
chain += drawn_chain(",".join(c for c, _n in drawn), as_limited(metas[i]), colours, ",".join(n for _c, n in drawn),
|
|
143
185
|
line_meta=None, tag=f"c{i}", labelled=True)
|
|
144
186
|
parts.append(f"[{i}:v]{convert[i]}{chain}[v{i}]")
|
|
145
187
|
|