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/overlay.py
CHANGED
|
@@ -30,8 +30,12 @@ import sys
|
|
|
30
30
|
from typing import List, Optional
|
|
31
31
|
|
|
32
32
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
33
|
-
from _common import
|
|
34
|
-
from _common
|
|
33
|
+
from _common import still_input_args # noqa: E402
|
|
34
|
+
from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, layer_base_chain, matrix_chain, range_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
|
|
35
|
+
from _common.emit import note, warn
|
|
36
|
+
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
37
|
+
from _common.wrap import wrap_text
|
|
38
|
+
from _common import text_width_em
|
|
35
39
|
from _common.fonts import font_choice, record_font_choice
|
|
36
40
|
from _common import drawtext_fontfile # noqa: E402
|
|
37
41
|
|
|
@@ -73,6 +77,48 @@ def position_exprs(pos: str, margin: int, text_mode: bool, margins: Optional[dic
|
|
|
73
77
|
return x, y
|
|
74
78
|
|
|
75
79
|
|
|
80
|
+
def _wrap_to_frame(args, frame_w: int, margins: Optional[dict], frame_h: int = 0) -> str:
|
|
81
|
+
"""--text broken into lines that fit between the frame's side margins at --font-size, by the
|
|
82
|
+
same phrase rules graphics.py and caption.py wrap with; text that fits comes back unchanged.
|
|
83
|
+
drawtext never wraps, so through 3.1.3 a long line ran off the frame with no word (#411). The
|
|
84
|
+
width is the averaged advance table's estimate, so a wrapped run says so in its notes."""
|
|
85
|
+
text = args.text or ""
|
|
86
|
+
if not (frame_w and text and args.font_size > 0):
|
|
87
|
+
return text
|
|
88
|
+
m = {edge: int((margins or {}).get(edge, args.margin)) for edge in ("left", "right")}
|
|
89
|
+
left, right = m["left"], m["right"]
|
|
90
|
+
if args.position not in POS:
|
|
91
|
+
try:
|
|
92
|
+
xv = int(args.position.split(",")[0])
|
|
93
|
+
left, right = (xv, right) if xv >= 0 else (0, -xv)
|
|
94
|
+
except ValueError:
|
|
95
|
+
pass
|
|
96
|
+
inset = 2 * args.border + (24 if args.box else 0) # the border on both sides, boxborderw=12
|
|
97
|
+
room = frame_w - left - right - inset
|
|
98
|
+
if room <= args.font_size:
|
|
99
|
+
# a position this close to the edge leaves no room to wrap in: say the text runs off
|
|
100
|
+
# rather than draw it past the edge without a word (#416 review)
|
|
101
|
+
if max(text_width_em(ln) for ln in text.split("\n")) * args.font_size > max(room, 0):
|
|
102
|
+
note(f"--text does not fit: --position {args.position} leaves {max(room, 0)} px to the frame's edge "
|
|
103
|
+
f"at --font-size {args.font_size}, so the text runs past it")
|
|
104
|
+
return text
|
|
105
|
+
lines: List[str] = []
|
|
106
|
+
for para in text.split("\n"):
|
|
107
|
+
lines.extend(wrap_text(para, room / float(args.font_size), lang=getattr(args, "lang", None),
|
|
108
|
+
slice_overlong=True) if para.strip() else [para])
|
|
109
|
+
wrapped = "\n".join(lines)
|
|
110
|
+
if wrapped != text:
|
|
111
|
+
note(f"--text is wider than the frame at --font-size {args.font_size} "
|
|
112
|
+
f"({frame_w} px, {left}/{right} px margins): wrapped to {len(lines)} lines")
|
|
113
|
+
# the wrap only measures width: a block taller than the frame loses the lines past its
|
|
114
|
+
# edge (at a bottom position, the first ones), so that is said too (#416 review)
|
|
115
|
+
tall = args.font_size + (len(lines) - 1) * int(args.font_size * 1.2)
|
|
116
|
+
if frame_h and tall > frame_h:
|
|
117
|
+
note(f"the {len(lines)} wrapped lines are about {tall} px tall, taller than the {frame_h} px frame: "
|
|
118
|
+
f"the lines past its edge are not drawn -- lower --font-size or shorten --text")
|
|
119
|
+
return wrapped
|
|
120
|
+
|
|
121
|
+
|
|
76
122
|
def enable_expr(start: Optional[float], end: Optional[float]) -> str:
|
|
77
123
|
if start is None and end is None:
|
|
78
124
|
return ""
|
|
@@ -150,7 +196,8 @@ def per_line_drawtext(lines: List[str], x: str, y: str, font_opt: str, args: arg
|
|
|
150
196
|
_PLACE_TIME = r"[^@\-]+(?:@[0-9.]+)?"
|
|
151
197
|
|
|
152
198
|
|
|
153
|
-
def parse_placements(specs: List[str], main_fps: Optional[float], layer_fps: Optional[float]
|
|
199
|
+
def parse_placements(specs: List[str], main_fps: Optional[float], layer_fps: Optional[float],
|
|
200
|
+
layer_meta: Optional[dict] = None, main_meta: Optional[dict] = None) -> List[tuple]:
|
|
154
201
|
"""--place SRC_START-SRC_END@AT -> [(src_start, src_end, at)], refusing empty, negative or
|
|
155
202
|
overlapping placements (two pieces on screen at once would stack in an order nobody asked for).
|
|
156
203
|
SRC_START/SRC_END are positions in the layer, so an hh:mm:ss:ff timecode there counts the
|
|
@@ -160,9 +207,9 @@ def parse_placements(specs: List[str], main_fps: Optional[float], layer_fps: Opt
|
|
|
160
207
|
m = re.fullmatch(rf"\s*({_PLACE_TIME})-({_PLACE_TIME})@({_PLACE_TIME})\s*", spec)
|
|
161
208
|
if not m:
|
|
162
209
|
die(f"--place {spec!r}: use SRC_START-SRC_END@AT, e.g. 0-2.5@12 (times are not negative)")
|
|
163
|
-
a = time_arg(m.group(1), "--place", layer_fps)
|
|
164
|
-
b = time_arg(m.group(2), "--place", layer_fps)
|
|
165
|
-
at = time_arg(m.group(3), "--place", main_fps)
|
|
210
|
+
a = time_arg(m.group(1), "--place", layer_fps, source=layer_meta)
|
|
211
|
+
b = time_arg(m.group(2), "--place", layer_fps, source=layer_meta)
|
|
212
|
+
at = time_arg(m.group(3), "--place", main_fps, source=main_meta)
|
|
166
213
|
if b <= a:
|
|
167
214
|
die(f"--place {spec!r}: the layer range ends before it starts")
|
|
168
215
|
out.append((a, b, at))
|
|
@@ -303,12 +350,14 @@ def main() -> int:
|
|
|
303
350
|
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
304
351
|
if args.audio_stream and not audio_streams:
|
|
305
352
|
die("--audio-stream needs an input with audio streams")
|
|
306
|
-
vw = meta["video"]["width"] or 0
|
|
307
|
-
vh = meta["video"].get("height") or 0
|
|
308
353
|
# ffmpeg applies display rotation before the filter graph, so the frame this overlay is
|
|
309
354
|
# composited on is the displayed one. A 320x180 phone file tagged 90 degrees is 180 wide here.
|
|
310
|
-
|
|
311
|
-
|
|
355
|
+
# A non-square-pixel source is made square at its display aspect first (#411), so a logo or
|
|
356
|
+
# a line of text is not drawn stretched by the player's SAR.
|
|
357
|
+
vw, vh = display_size(meta["video"])
|
|
358
|
+
square = square_pixels(meta["video"])
|
|
359
|
+
if square:
|
|
360
|
+
note(square_pixels_note(meta["video"]))
|
|
312
361
|
# --platform: the edges this destination's own UI covers, in pixels of this frame. An
|
|
313
362
|
# explicit --margin (or a brand safe_margin, applied above) is the more specific statement
|
|
314
363
|
# and wins; without either, the historical 24 px default is unchanged.
|
|
@@ -372,11 +421,11 @@ def main() -> int:
|
|
|
372
421
|
if enable:
|
|
373
422
|
ov += f":enable='{enable}'"
|
|
374
423
|
# -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
|
|
375
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", "-i", args.image]
|
|
424
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", *still_input_args(args.image), "-i", args.image]
|
|
376
425
|
# the RGBA still is converted to the BT.709 the output is tagged with (overlay's own
|
|
377
426
|
# RGB-to-YUV step used BT.601), and an untagged main is labelled BT.709 (tags only) so
|
|
378
427
|
# 7.1+'s negotiation does not convert one picture to the other's "unknown" matrix
|
|
379
|
-
base =
|
|
428
|
+
base = layer_base_chain(meta)
|
|
380
429
|
fc = (f"[1:v]{','.join(chain)},setpts=PTS-STARTPTS{rgb_chain_to_bt709(meta)}[ov];"
|
|
381
430
|
+ (f"[0:v]{base}null[base];[base]" if base else "[0:v]") + f"[ov]{ov}[out]")
|
|
382
431
|
cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"]
|
|
@@ -397,7 +446,7 @@ def main() -> int:
|
|
|
397
446
|
if not pip_meta.get("video"):
|
|
398
447
|
die(f"--video {args.video} has no video stream")
|
|
399
448
|
layer_fps = pip_meta["video"].get("fps")
|
|
400
|
-
placements = parse_placements(args.place, fps, layer_fps) if args.place else []
|
|
449
|
+
placements = parse_placements(args.place, fps, layer_fps, pip_meta, meta) if args.place else []
|
|
401
450
|
# bounds from the video streams (a longer audio track would let a range run past the last
|
|
402
451
|
# picture); the container duration only when a stream has none
|
|
403
452
|
layer_dur = pip_meta["video"].get("duration") or pip_meta.get("duration")
|
|
@@ -414,7 +463,7 @@ def main() -> int:
|
|
|
414
463
|
die(f"--place {a:g}-{b:g}@{at:g}: the layer is only {layer_dur:.3f} s long")
|
|
415
464
|
if main_dur and at >= main_dur:
|
|
416
465
|
die(f"--place {a:g}-{b:g}@{at:g}: starts after the main video ends ({main_dur:.3f} s)")
|
|
417
|
-
chain = []
|
|
466
|
+
chain = [square_pixels(pip_meta["video"])] if square_pixels(pip_meta["video"]) else []
|
|
418
467
|
if args.scale_percent:
|
|
419
468
|
chain.append(f"scale={int(vw * args.scale_percent / 100)}:-2")
|
|
420
469
|
elif args.scale:
|
|
@@ -435,6 +484,8 @@ def main() -> int:
|
|
|
435
484
|
base, pip = matrix_chain(meta, meta), matrix_chain(pip_meta, meta)
|
|
436
485
|
if base or pip:
|
|
437
486
|
base, pip = base or label_chain(meta, meta), pip or label_chain(pip_meta, meta)
|
|
487
|
+
# overlay mixes samples: a full-range picture is taken to limited range (#410)
|
|
488
|
+
base, pip = base + range_chain(meta, meta), pip + range_chain(pip_meta, meta)
|
|
438
489
|
if placements:
|
|
439
490
|
# each placed piece: its range of the layer, re-timed to start at AT; eof_action=pass shows
|
|
440
491
|
# the main picture untouched once a piece ends (the default would freeze its last frame)
|
|
@@ -463,6 +514,7 @@ def main() -> int:
|
|
|
463
514
|
else:
|
|
464
515
|
cmd += ["-shortest"]
|
|
465
516
|
else:
|
|
517
|
+
args.text = _wrap_to_frame(args, vw, safe_margins, vh)
|
|
466
518
|
x, y = position_exprs(args.position, args.margin, text_mode=True, margins=safe_margins)
|
|
467
519
|
# 1.15: the text goes in a FILE with expansion off, so `'` and `%` survive verbatim
|
|
468
520
|
# (they used to be dropped by escape_drawtext) and no character can reach the graph parser.
|
|
@@ -482,10 +534,13 @@ def main() -> int:
|
|
|
482
534
|
drawing = "drawtext=" + ":".join(opts)
|
|
483
535
|
# a font/border/box colour with chroma lands in the BT.709 the output is tagged with
|
|
484
536
|
colours = [args.font_color] + ([args.border_color] if args.border else []) + ([args.box_color] if args.box else [])
|
|
485
|
-
cmd += ["-vf", drawn_chain(drawing, meta, colours), "-map", "0:v:0"]
|
|
537
|
+
cmd += ["-vf", (square + "," if square else "") + drawn_chain(drawing, meta, colours), "-map", "0:v:0"]
|
|
486
538
|
if meta.get("audio"):
|
|
487
539
|
cmd += ["-map", f"0:a:{args.audio_stream}"]
|
|
488
540
|
|
|
541
|
+
if square and "-filter_complex" in cmd:
|
|
542
|
+
i = cmd.index("-filter_complex") + 1
|
|
543
|
+
cmd[i] = f"[0:v]{square}[sq];" + cmd[i].replace("[0:v]", "[sq]")
|
|
489
544
|
cmd += video_args(meta, args.crf, args.preset, convert_matrix=not args.video) + cfr_args(meta)
|
|
490
545
|
cmd += aac_args() if meta.get("audio") else ["-an"]
|
|
491
546
|
dropped_streams = run_keeping_subtitles(cmd, output)
|
package/scripts/proxy.py
CHANGED
|
@@ -26,6 +26,8 @@ import argparse
|
|
|
26
26
|
import sys
|
|
27
27
|
|
|
28
28
|
from _common import add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, fmt_secs
|
|
29
|
+
from _common.emit import note
|
|
30
|
+
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
def even(n: float) -> int:
|
|
@@ -56,15 +58,17 @@ def main() -> int:
|
|
|
56
58
|
meta = probe(args.input)
|
|
57
59
|
if not meta.get("video"):
|
|
58
60
|
die("input has no video stream")
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
# the displayed width: rotation applied, non-square pixels squared first (#411)
|
|
62
|
+
sw = display_size(meta["video"])[0]
|
|
63
|
+
square = square_pixels(meta["video"])
|
|
64
|
+
if square:
|
|
65
|
+
note(square_pixels_note(meta["video"]))
|
|
62
66
|
has_audio = bool(meta.get("audio")) and not args.no_audio
|
|
63
67
|
|
|
64
68
|
out_w = even(sw * args.scale) if args.scale is not None else even(args.width)
|
|
65
69
|
output = args.output or default_output(args.input, "proxy")
|
|
66
70
|
|
|
67
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-vf", f"scale={out_w}:-2"]
|
|
71
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", (square + "," if square else "") + f"scale={out_w}:-2"]
|
|
68
72
|
cmd += video_args(meta, args.crf, "veryfast")
|
|
69
73
|
cmd += cfr_args(meta, args.fps)
|
|
70
74
|
cmd += ["-map", "0:v:0"] + (["-map", "0:a:0", "-c:a", "aac", "-b:a", "96k"] if has_audio else ["-an"])
|
package/scripts/redact.py
CHANGED
|
@@ -28,6 +28,8 @@ import argparse
|
|
|
28
28
|
import sys
|
|
29
29
|
|
|
30
30
|
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, fmt_secs
|
|
31
|
+
from _common.emit import note
|
|
32
|
+
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
def main() -> int:
|
|
@@ -67,9 +69,12 @@ def main() -> int:
|
|
|
67
69
|
meta = probe(args.input)
|
|
68
70
|
if not meta.get("video"):
|
|
69
71
|
die("input has no video stream")
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
72
|
+
# the rectangle is in the displayed frame: rotation applied, and a non-square-pixel source
|
|
73
|
+
# squared at its display aspect first (#411), the frame look.py and cropdetect.py show
|
|
74
|
+
sw, sh = display_size(meta["video"])
|
|
75
|
+
square = square_pixels(meta["video"])
|
|
76
|
+
if square:
|
|
77
|
+
note(square_pixels_note(meta["video"]))
|
|
73
78
|
if args.x + args.width > sw or args.y + args.height > sh:
|
|
74
79
|
die(f"redaction rectangle ({args.x},{args.y},{args.width}x{args.height}) exceeds the source frame ({sw}x{sh})")
|
|
75
80
|
if args.mode == "delogo":
|
|
@@ -93,7 +98,7 @@ def main() -> int:
|
|
|
93
98
|
output = args.output or default_output(args.input, "redact")
|
|
94
99
|
if args.mode == "delogo":
|
|
95
100
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf",
|
|
96
|
-
f"delogo=x={args.x}:y={args.y}:w={args.width}:h={args.height}",
|
|
101
|
+
(square + "," if square else "") + f"delogo=x={args.x}:y={args.y}:w={args.width}:h={args.height}",
|
|
97
102
|
"-map", "0:v:0"]
|
|
98
103
|
else:
|
|
99
104
|
crop = f"crop={args.width}:{args.height}:{args.x}:{args.y}"
|
|
@@ -105,7 +110,7 @@ def main() -> int:
|
|
|
105
110
|
region = f"{crop},boxblur={radius}:{radius}:{chroma}:{radius}"
|
|
106
111
|
else:
|
|
107
112
|
region = f"{crop},scale={max(1, args.width // args.block_size)}:{max(1, args.height // args.block_size)}:flags=neighbor,scale={args.width}:{args.height}:flags=neighbor"
|
|
108
|
-
fc = f"[0:v]split=2[base][region];[region]{region}[patched];[base][patched]overlay={args.x}:{args.y}[out]"
|
|
113
|
+
fc = f"[0:v]{square + ',' if square else ''}split=2[base][region];[region]{region}[patched];[base][patched]overlay={args.x}:{args.y}[out]"
|
|
109
114
|
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", fc, "-map", "[out]"]
|
|
110
115
|
if has_audio:
|
|
111
116
|
cmd += ["-map", f"0:a:{args.audio_stream}?"]
|
package/scripts/render.py
CHANGED
|
@@ -342,6 +342,11 @@ def render_pack(names: List[str], args) -> int:
|
|
|
342
342
|
and platform check), so the pack reports per-platform results rather than one verdict."""
|
|
343
343
|
stem = Path(args.input).with_suffix("").name
|
|
344
344
|
outdir = Path(args.output).parent if args.output else Path(args.input).resolve().parent
|
|
345
|
+
# the table is a file this run writes itself (#408): an existing one is refused without
|
|
346
|
+
# --overwrite, before the first destination renders
|
|
347
|
+
if outdir.is_dir(): # a missing folder is the destinations' own refusal (and a dry run's plan)
|
|
348
|
+
side = [v for k, v in vars(args).items() if k != "output" and isinstance(v, str) and os.path.isfile(v)]
|
|
349
|
+
guard_sidecar(str(outdir / f"{stem}_pack.md"), args.input, *side)
|
|
345
350
|
dests = {name: str((load_template(name).get("check") or {}).get("platform") or name) for name in names}
|
|
346
351
|
rows: List[Dict[str, Any]] = []
|
|
347
352
|
outputs: List[str] = []
|
|
@@ -399,6 +404,9 @@ def render_pack(names: List[str], args) -> int:
|
|
|
399
404
|
lines += ["", f"{len(rows)} destinations from one edit ({os.path.basename(args.input)}).",
|
|
400
405
|
"Rendered by ffmpeg-skill; `report.py --pack` turns this table into an HTML report."]
|
|
401
406
|
if not STATE.dry_run:
|
|
407
|
+
if not outdir.is_dir():
|
|
408
|
+
die(f"pack: the output folder {outdir} does not exist, so the table was not written", kind="input",
|
|
409
|
+
pack=rows, outputs=outputs)
|
|
402
410
|
Path(pack).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
403
411
|
info(("[dry-run] would write " if STATE.dry_run else "wrote ") + pack)
|
|
404
412
|
if failed:
|
package/scripts/reverse.py
CHANGED
|
@@ -12,10 +12,12 @@ Examples:
|
|
|
12
12
|
python3 reverse.py input.mp4 --no-audio -o backwards.mp4
|
|
13
13
|
"""
|
|
14
14
|
import argparse
|
|
15
|
+
import math
|
|
15
16
|
import sys
|
|
16
17
|
|
|
17
18
|
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs
|
|
18
19
|
from _common.emit import warn
|
|
20
|
+
from _common.decision import av_clip_length, av_trim, frame_durations, frame_rate, stream_starts
|
|
19
21
|
|
|
20
22
|
|
|
21
23
|
def main() -> int:
|
|
@@ -39,9 +41,32 @@ def main() -> int:
|
|
|
39
41
|
warn(f"reverse buffers every decoded frame in memory; {meta['duration']:.0f}s of "
|
|
40
42
|
f"{meta['video']['width']}x{meta['video']['height']} can exhaust RAM -- cut the part to reverse first (cut.py)",
|
|
41
43
|
"memory_heavy", duration=meta["duration"])
|
|
42
|
-
cmd = ffmpeg_base() + ["-i", args.input
|
|
44
|
+
cmd = ffmpeg_base() + ["-i", args.input]
|
|
43
45
|
if has_audio:
|
|
44
|
-
|
|
46
|
+
# Both streams are reversed about one end (#409); each reversed about its own end put a sound
|
|
47
|
+
# 0.6 s longer than its picture 0.396 s off. As in a join, both start at the first frame (the
|
|
48
|
+
# sound shifted by the same constant, padded when it starts late) and end together, on the
|
|
49
|
+
# first frame time at or after the clip's one length (av_clip_length: the picture's, or the
|
|
50
|
+
# sound's when it runs more than a frame past it). The last frame is held and the sound
|
|
51
|
+
# padded with silence to that end; the end is a frame time because a copied cut's stated
|
|
52
|
+
# video length can run past its last frame (7.517 s for frames ending at 7.5). One graph for
|
|
53
|
+
# both streams, as pad.py's padding needs. areverse keeps its input's timestamps in order,
|
|
54
|
+
# so the reversed sound is retimed by its samples. The hold (tpad) runs before av_trim's
|
|
55
|
+
# setpts: from FFmpeg 7.0 setpts leaves its output's frame rate unknown and its frames'
|
|
56
|
+
# durations 0, so a tpad after it clones nothing (the picture stayed 0.6 s short on 7.1);
|
|
57
|
+
# the closing fps gives the frames their durations back (frame_durations).
|
|
58
|
+
rate = frame_rate(meta) or meta["video"].get("fps") or 30.0
|
|
59
|
+
length = av_clip_length(meta, rate)
|
|
60
|
+
vlen = meta["video"].get("duration") or length
|
|
61
|
+
end = math.ceil(length * rate - 1e-6) / rate
|
|
62
|
+
vtrim, atrim = av_trim(stream_starts(meta)[0], None)
|
|
63
|
+
cmd += ["-filter_complex",
|
|
64
|
+
f"[0:v:0]tpad=stop_mode=clone:stop_duration={max(0.0, end - vlen) + 2 / rate:.6f},{vtrim},"
|
|
65
|
+
f"trim=end={end - 0.5 / rate:.6f},reverse,{frame_durations(meta, rate)}[v];"
|
|
66
|
+
f"[0:a:0]{atrim},apad=whole_dur={end:.6f},atrim=end={end:.6f},areverse,asetpts=N/SR/TB[a]",
|
|
67
|
+
"-map", "[v]", "-map", "[a]"]
|
|
68
|
+
else:
|
|
69
|
+
cmd += ["-vf", "reverse"]
|
|
45
70
|
cmd += video_args(meta, args.crf, args.preset)
|
|
46
71
|
cmd += cfr_args(meta)
|
|
47
72
|
if has_audio:
|
package/scripts/scenes.py
CHANGED
|
@@ -29,6 +29,8 @@ from _common import (guard_sidecar, detect_scenes, STATE, add_common, apply_comm
|
|
|
29
29
|
from _common import drawtext_fontfile # noqa: E402
|
|
30
30
|
from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
|
|
31
31
|
from _common.emit import note, record_fallback # noqa: E402
|
|
32
|
+
from _common.color import bt709_label_filter # noqa: E402
|
|
33
|
+
from _common.probe import square_pixels # noqa: E402
|
|
32
34
|
|
|
33
35
|
SHOT_FPS = 4.0 # frames/second sampled for --shots' flow estimate
|
|
34
36
|
SHOT_W, SHOT_H = 48, 27 # decode size for --shots (16:9-ish; enough blocks, still a few KB/shot)
|
|
@@ -285,9 +287,13 @@ def main() -> int:
|
|
|
285
287
|
cols = min(4, max(1, n))
|
|
286
288
|
rows = max(1, math.ceil(n / cols))
|
|
287
289
|
tile_w = 1280 // cols // 2 * 2
|
|
288
|
-
# exactly one frame per scene: the frame
|
|
289
|
-
|
|
290
|
-
|
|
290
|
+
# exactly one frame per scene: the first frame at or after the scene start. The starts are
|
|
291
|
+
# presentation times, so the frame is chosen by its time, not by start x fps as a frame
|
|
292
|
+
# number -- on a variable-frame-rate source that number is another frame (#411)
|
|
293
|
+
def first_at(t: float) -> str:
|
|
294
|
+
a = f"{max(0.0, t - 0.0006):.4f}"
|
|
295
|
+
return f"gte(t\\,{a})*(isnan(prev_t)+lt(prev_t\\,{a}))"
|
|
296
|
+
expr = "+".join(first_at(sc["start"]) for sc in scenes)
|
|
291
297
|
stamp = ""
|
|
292
298
|
if not args.no_timecode and ffmpeg_filters() and "drawtext" not in ffmpeg_filters():
|
|
293
299
|
# as look.py (#394): the sheet needs drawtext only for its stamps
|
|
@@ -300,9 +306,14 @@ def main() -> int:
|
|
|
300
306
|
default_font = default_font_file("DejaVu Sans")
|
|
301
307
|
font_prefix = f"{drawtext_fontfile(default_font)}:" if default_font else ""
|
|
302
308
|
stamp = f",drawtext=text='%{{pts\\:hms}}':{font_prefix}fontcolor=white:fontsize=h/14:box=1:boxcolor=black@0.55:boxborderw=4:x=6:y=6"
|
|
303
|
-
|
|
309
|
+
# an untagged source is labelled BT.709 (tags only), or the sheet's RGB read it as BT.601 (#410);
|
|
310
|
+
# a non-square-pixel source is squared at its display aspect (#411)
|
|
311
|
+
lab = bt709_label_filter(meta)
|
|
312
|
+
square = square_pixels(meta["video"])
|
|
313
|
+
vf = (f"select='{expr}'," + (lab + "," if lab else "") + (square + "," if square else "")
|
|
314
|
+
+ f"scale={tile_w}:-2{stamp},"
|
|
304
315
|
f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
|
|
305
|
-
run(ffmpeg_base() + ["-i", args.input, "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", args.sheet])
|
|
316
|
+
run(ffmpeg_base() + ["-i", args.input, "-an", "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", "-update", "1", args.sheet])
|
|
306
317
|
info(f"wrote {args.sheet}")
|
|
307
318
|
result["sheet"] = args.sheet
|
|
308
319
|
|
package/scripts/sequence.py
CHANGED
|
@@ -21,7 +21,8 @@ import tempfile
|
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
|
|
23
23
|
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, concat_list_line, fmt_secs
|
|
24
|
-
from _common.emit import warn
|
|
24
|
+
from _common.emit import note, warn
|
|
25
|
+
from _common.probe import display_size, square_pixels_note
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
def even(n: float) -> int:
|
|
@@ -105,7 +106,10 @@ def main() -> int:
|
|
|
105
106
|
frame_meta = probe(str(frames[0]))
|
|
106
107
|
if not frame_meta.get("video"):
|
|
107
108
|
die(f"{frames[0]} is not a readable image")
|
|
108
|
-
|
|
109
|
+
# the displayed frame: an EXIF rotation applied, non-square pixels squared (#411)
|
|
110
|
+
sw, sh = display_size(frame_meta["video"])
|
|
111
|
+
if square_pixels_note(frame_meta["video"], "the first frame"):
|
|
112
|
+
note(square_pixels_note(frame_meta["video"], "the first frame"))
|
|
109
113
|
|
|
110
114
|
if args.width and args.height:
|
|
111
115
|
out_w, out_h = even(args.width), even(args.height)
|
package/scripts/silence.py
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
Detects quiet stretches with ffmpeg's silencedetect, keeps a margin on each
|
|
5
5
|
side so words are not clipped, drops gaps shorter than --min-silence, and
|
|
6
|
-
writes a frame-accurate re-encode
|
|
6
|
+
writes a frame-accurate re-encode: the kept ranges are re-cut from the source the way
|
|
7
|
+
cut.py --segments --accurate cuts them (both streams on one set of frame-grid bounds).
|
|
7
8
|
|
|
8
9
|
Examples:
|
|
9
10
|
python3 silence.py talk.mp4 # -35 dB, gaps >= 0.6 s, 0.2 s margin
|
|
@@ -13,6 +14,7 @@ Examples:
|
|
|
13
14
|
"""
|
|
14
15
|
import argparse
|
|
15
16
|
import json
|
|
17
|
+
import math
|
|
16
18
|
import os
|
|
17
19
|
import sys
|
|
18
20
|
from typing import Any, Dict, List, Optional, Tuple
|
|
@@ -25,7 +27,8 @@ from _common import asr as _asr
|
|
|
25
27
|
from _common.emit import warn
|
|
26
28
|
from _common import guard_sidecar, refuse_existing_outputs, refuse_output_is_input, _check_output_path
|
|
27
29
|
from _common.emit import refuse
|
|
28
|
-
from _common import detect_silences as detect, STATE,
|
|
30
|
+
from _common import detect_silences as detect, STATE, add_common, apply_common, default_output, die, emit, info, is_audio_output, print_json, probe, X264_PRESETS, measured_level_dbfs, fmt_secs
|
|
31
|
+
from _common.decision import frame_rate, stream_starts
|
|
29
32
|
|
|
30
33
|
|
|
31
34
|
|
|
@@ -97,16 +100,38 @@ def keep_ranges(silences: List[Tuple[float, float]], duration: float, margin: fl
|
|
|
97
100
|
return keeps
|
|
98
101
|
|
|
99
102
|
|
|
103
|
+
def rendered_ranges(keeps: List[Tuple[float, float]], meta: Dict[str, Any], duration: float,
|
|
104
|
+
video_out: bool) -> List[Tuple[float, float]]:
|
|
105
|
+
"""The source ranges the render holds, and is given: with a picture, each bound moves to the
|
|
106
|
+
nearest frame time, for the sound too, so cut.py's re-cut (which cuts at the first frame at or
|
|
107
|
+
after a bound) cuts exactly there. A measured bound sits a few microseconds off a frame
|
|
108
|
+
(silencedetect's 4.00002), which "at or after" would have turned into a whole frame dropped.
|
|
109
|
+
Sound alone is cut where asked. --timemap is built from these, so its times are the output's."""
|
|
110
|
+
rate = frame_rate(meta) if video_out else 0.0
|
|
111
|
+
if not rate:
|
|
112
|
+
return list(keeps)
|
|
113
|
+
v0 = stream_starts(meta)[0]
|
|
114
|
+
|
|
115
|
+
def snap(t: float) -> float:
|
|
116
|
+
return min(v0 + round((t - v0) * rate) / rate, duration)
|
|
117
|
+
return [(a, b) for a, b in ((snap(s), snap(e)) for s, e in keeps) if b > a]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _edl_time(t: float, on_frames: bool) -> str:
|
|
121
|
+
"""An EDL bound: a frame time truncated to the millisecond (below 1000 fps the frame at or
|
|
122
|
+
after it is still that frame), any other time rounded as before."""
|
|
123
|
+
return f"{math.floor(t * 1000 + 1e-6) / 1000:.3f}" if on_frames else f"{t:.3f}"
|
|
124
|
+
|
|
125
|
+
|
|
100
126
|
def time_map(keeps: List[Tuple[float, float]], duration: float) -> Dict[str, Any]:
|
|
101
127
|
"""Each kept range with its time in the source and in the output, and each removed range with
|
|
102
|
-
the output time it was cut at: re-timing subtitles, or finding a removed sentence again.
|
|
128
|
+
the output time it was cut at: re-timing subtitles, or finding a removed sentence again.
|
|
129
|
+
`keeps` are the ranges the render holds (rendered_ranges): the output advances by each one's
|
|
130
|
+
exact length, rounded only for display."""
|
|
103
131
|
kept: List[Dict[str, float]] = []
|
|
104
132
|
removed: List[Dict[str, float]] = []
|
|
105
133
|
out, prev = 0.0, 0.0
|
|
106
134
|
for s, e in keeps:
|
|
107
|
-
# the render cuts on bounds rounded to the millisecond (between(t,s,e) below): advance by
|
|
108
|
-
# that rounded length, or every off-grid range adds its own error to every later time
|
|
109
|
-
s, e = round(s, 3), round(e, 3)
|
|
110
135
|
if s - prev > 0.0005:
|
|
111
136
|
removed.append({"source_start": round(prev, 3), "source_end": round(s, 3), "output_time": round(out, 3)})
|
|
112
137
|
kept.append({"source_start": round(s, 3), "source_end": round(e, 3),
|
|
@@ -502,7 +527,11 @@ def main() -> int:
|
|
|
502
527
|
# time-range removal, so it reuses keep_ranges() and the same select chain.
|
|
503
528
|
removals = merge_spans(list(silences) + list(filler_ranges))
|
|
504
529
|
keeps = keep_ranges(removals, duration, args.margin, args.min_keep)
|
|
505
|
-
|
|
530
|
+
# what the render holds (on a picture, each bound on its nearest frame): the report, the EDL
|
|
531
|
+
# and the time map all describe these, so a re-cut or a sum of `keep` is the rendered edit
|
|
532
|
+
video_out = bool(meta.get("video")) and not (media_out and is_audio_output(media_out))
|
|
533
|
+
held = rendered_ranges(keeps, meta, duration, video_out)
|
|
534
|
+
kept = sum(e - s for s, e in held)
|
|
506
535
|
removed = max(0.0, duration - kept)
|
|
507
536
|
# `removed_seconds` keeps the meaning it has had since this tool existed: the seconds of
|
|
508
537
|
# SILENCE this run removes. It must not quietly start counting filler time as well, because a
|
|
@@ -512,11 +541,11 @@ def main() -> int:
|
|
|
512
541
|
silence_only = removed
|
|
513
542
|
if args.filler:
|
|
514
543
|
silence_keeps = keep_ranges(merge_spans(list(silences)), duration, args.margin, args.min_keep)
|
|
515
|
-
silence_only = max(0.0, duration - sum(e - s for s, e in silence_keeps))
|
|
544
|
+
silence_only = max(0.0, duration - sum(e - s for s, e in rendered_ranges(silence_keeps, meta, duration, video_out)))
|
|
516
545
|
kinds = silence_kinds(args.input, silences, duration, args.threshold)
|
|
517
546
|
summary = {
|
|
518
547
|
"silences": [[round(s, 3), None if e == float("inf") else round(e, 3)] for s, e in silences],
|
|
519
|
-
"keep": [[round(s, 3), round(e, 3)] for s, e in
|
|
548
|
+
"keep": [[round(s, 3), round(e, 3)] for s, e in held],
|
|
520
549
|
"input_duration": round(duration, 3),
|
|
521
550
|
"kept_duration": round(kept, 3),
|
|
522
551
|
"removed_seconds": round(silence_only, 3),
|
|
@@ -572,7 +601,7 @@ def main() -> int:
|
|
|
572
601
|
summary["hint"] = f"no passage sits below {args.threshold:g} dBFS for {args.min_silence:g}s; try a higher --threshold (e.g. -25) or a shorter --min-silence"
|
|
573
602
|
info("hint: " + summary["hint"])
|
|
574
603
|
|
|
575
|
-
tm = time_map(
|
|
604
|
+
tm = time_map(held, duration)
|
|
576
605
|
points: Any = False # False: not measured (a dry run over a pending input); None: the decode failed
|
|
577
606
|
if not (STATE.dry_run and not os.path.exists(args.input)):
|
|
578
607
|
points = cut_levels(args.input, tm, duration, list(filler_ranges))
|
|
@@ -604,8 +633,11 @@ def main() -> int:
|
|
|
604
633
|
if args.edl:
|
|
605
634
|
if not STATE.dry_run: # the EDL is an artifact like the cut itself: a plan writes nothing
|
|
606
635
|
with open(args.edl, "w", encoding="utf-8") as fh:
|
|
607
|
-
|
|
608
|
-
|
|
636
|
+
on_frames = bool(video_out and frame_rate(meta))
|
|
637
|
+
for s, e in held:
|
|
638
|
+
# a frame-time bound is written down to the millisecond, not rounded up past
|
|
639
|
+
# its frame: cut.py --segments cuts at the first frame at or after it (#416 review)
|
|
640
|
+
fh.write(f"{_edl_time(s, on_frames)}-{_edl_time(e, on_frames)}\n")
|
|
609
641
|
info(f"wrote {args.edl}")
|
|
610
642
|
|
|
611
643
|
if args.list or args.filler_list:
|
|
@@ -615,29 +647,28 @@ def main() -> int:
|
|
|
615
647
|
else:
|
|
616
648
|
print_json(summary)
|
|
617
649
|
return 0
|
|
618
|
-
if not keeps:
|
|
650
|
+
if not keeps or not held:
|
|
619
651
|
die("nothing would be kept; raise --threshold (e.g. -45) or check the audio")
|
|
620
652
|
if not silences or removed < 0.05:
|
|
621
653
|
info("no removable silence found; output would equal the input")
|
|
622
654
|
|
|
623
655
|
output = args.output or default_output(args.input, "tight")
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
#
|
|
628
|
-
#
|
|
629
|
-
#
|
|
630
|
-
#
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
run(cmd)
|
|
656
|
+
# the join of more than 32 ranges concats a list of chunks, so its last command never names the
|
|
657
|
+
# source and run()'s own check cannot see -o replace it (#416 review)
|
|
658
|
+
refuse_output_is_input(output, args.input)
|
|
659
|
+
# The kept ranges are re-cut from the source as cut.py --segments --accurate cuts them (#409):
|
|
660
|
+
# each range is its own seeked input, both streams cut on the same frame-grid bounds and shifted
|
|
661
|
+
# by one constant, the sound padded to the range's origin, at most 32 ranges per ffmpeg call.
|
|
662
|
+
# The one-pass select/ametadata graph this replaced rounded every range twice, the picture to
|
|
663
|
+
# whole frames and the sound to whole AAC frames, and the errors added up (150 ms over 21 ranges
|
|
664
|
+
# on a 61 s source); its asetpts=N/SR also moved a sound that starts late by its whole offset.
|
|
665
|
+
# No declick fade: the joins sit in the --margin the tool keeps, as they always have.
|
|
666
|
+
# imported here, not at the top: the contract and MCP load every tool's parser on its own
|
|
667
|
+
import cut as _cut
|
|
668
|
+
_cut.DECLICK_S = 0.0
|
|
669
|
+
import tempfile
|
|
670
|
+
with tempfile.TemporaryDirectory(prefix="ffskill_silence_") as tmp:
|
|
671
|
+
_cut.join_from_source(args.input, held, output, meta, args.crf, args.preset, tmp)
|
|
641
672
|
r = probe(output, role="output")
|
|
642
673
|
info(f"wrote {output} ({fmt_secs(r['duration'])}, expected ~{kept:.3f}s)")
|
|
643
674
|
finish_time_map()
|
package/scripts/speedramp.py
CHANGED
|
@@ -19,6 +19,7 @@ import argparse
|
|
|
19
19
|
import sys
|
|
20
20
|
from typing import List, Tuple
|
|
21
21
|
|
|
22
|
+
from _common.decision import av_trim, frame_rate, grid_at_or_after, stream_starts
|
|
22
23
|
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs, time_arg
|
|
23
24
|
|
|
24
25
|
MAX_SPEED = 20.0
|
|
@@ -89,15 +90,26 @@ def main() -> int:
|
|
|
89
90
|
# the last factor. The end bound is omitted only when the stated end is the file's end
|
|
90
91
|
# (within 50 ms): a trim a few milliseconds short of the last packet drops that packet.
|
|
91
92
|
last_runs_to_eof = abs(segments[-1][1] - dur) <= 0.05
|
|
93
|
+
# Every boundary is a frame time and both streams of a segment are cut on it and shifted by the
|
|
94
|
+
# same constant before the speed change (#409), as cut.py's re-cut does. Rebasing each stream
|
|
95
|
+
# of each segment to its own first sample moved a sound that starts 0.3 s late 0.3 s early, and
|
|
96
|
+
# the factor scaled that (+570 ms at 0.5x). The first segment starts at the first frame.
|
|
97
|
+
v0 = stream_starts(meta)[0]
|
|
98
|
+
rate = frame_rate(meta)
|
|
99
|
+
bounds = [v0] + [grid_at_or_after(end, v0, rate) for _, end, _ in segments[:-1]]
|
|
92
100
|
for i, (start, end, factor) in enumerate(segments):
|
|
93
101
|
vlabel, alabel = f"v{i}", f"a{i}"
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
102
|
+
to_eof = i == len(segments) - 1 and last_runs_to_eof
|
|
103
|
+
seg_end = None if to_eof else (bounds[i + 1] if i + 1 < len(bounds) else grid_at_or_after(end, v0, rate))
|
|
104
|
+
if seg_end is not None and seg_end <= bounds[i] + 1e-6:
|
|
105
|
+
# both ends on the same frame: the trims would be empty and the factor reach no frame
|
|
106
|
+
die(f"--segment {start:g}-{end:g}:{factor:g} holds no frame: both ends fall on the frame at "
|
|
107
|
+
f"{bounds[i]:.3f}s; widen it or merge it into a neighbour")
|
|
108
|
+
vtrim, atrim = av_trim(bounds[i], seg_end, rate)
|
|
109
|
+
vparts.append(f"[0:v]{vtrim},setpts=PTS/{factor:.6f}[{vlabel}]")
|
|
97
110
|
labels.append(f"[{vlabel}]")
|
|
98
111
|
if has_audio:
|
|
99
|
-
|
|
100
|
-
aparts.append(f"[0:a]{atrim},asetpts=PTS-STARTPTS,{atempo_chain(factor)}[{alabel}]")
|
|
112
|
+
aparts.append(f"[0:a]{atrim},{atempo_chain(factor)}[{alabel}]")
|
|
101
113
|
|
|
102
114
|
if has_audio:
|
|
103
115
|
concat_inputs = "".join(f"[v{i}][a{i}]" for i in range(len(segments)))
|
package/scripts/straighten.py
CHANGED
|
@@ -23,6 +23,8 @@ import math
|
|
|
23
23
|
import sys
|
|
24
24
|
|
|
25
25
|
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, drawn_chain, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, fmt_secs
|
|
26
|
+
from _common.emit import note
|
|
27
|
+
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
26
28
|
|
|
27
29
|
|
|
28
30
|
def main() -> int:
|
|
@@ -68,15 +70,25 @@ def main() -> int:
|
|
|
68
70
|
rotate = drawn_chain(f"rotate={radians:.8f}:fillcolor={args.fill_color}:{geometry}", meta, [args.fill_color],
|
|
69
71
|
f"rotate={radians:.8f}:fillcolor=black:{geometry}")
|
|
70
72
|
else:
|
|
71
|
-
# Pre-scale the frame up
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
s =
|
|
73
|
+
# Pre-scale the frame up so the rotated content fully covers the original W:H window with
|
|
74
|
+
# no black corner, then rotate in place (canvas stays at the scaled size) and crop back
|
|
75
|
+
# down to the original W:H, centred. The window turned back by the angle must fit inside
|
|
76
|
+
# the scaled frame: W*cos + H*sin <= s*W and W*sin + H*cos <= s*H, so
|
|
77
|
+
# s = |cos| + max(W/H, H/W) * |sin| for the displayed (squared, upright) frame. Through
|
|
78
|
+
# 3.1.5 it was |cos| + |sin|, a square's factor, which left two corners black on 16:9
|
|
79
|
+
# (#416 review).
|
|
80
|
+
dw, dh = display_size(meta["video"])
|
|
81
|
+
aspect = max(dw / dh, dh / dw) if dw and dh else 1.0
|
|
82
|
+
s = abs(math.cos(radians)) + aspect * abs(math.sin(radians))
|
|
76
83
|
# crop dimensions must round down to even (4:2:0 chroma); trunc(.../2)*2 floors to the
|
|
77
84
|
# nearest even value instead of leaving an odd width/height that the encoder would refuse.
|
|
78
85
|
rotate = f"scale=iw*{s:.8f}:ih*{s:.8f},rotate={radians:.8f},crop=trunc(iw/{s:.8f}/2)*2:trunc(ih/{s:.8f}/2)*2"
|
|
79
86
|
|
|
87
|
+
# a rotation of non-square pixels is a shear on screen: the picture is squared at its
|
|
88
|
+
# display aspect first (#411)
|
|
89
|
+
if square_pixels(meta["video"]):
|
|
90
|
+
rotate = square_pixels(meta["video"]) + "," + rotate
|
|
91
|
+
note(square_pixels_note(meta["video"]))
|
|
80
92
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", rotate, "-map", "0:v:0"]
|
|
81
93
|
if has_audio:
|
|
82
94
|
cmd += ["-map", f"0:a:{args.audio_stream}?"]
|