ffmpeg-skill 3.8.12 → 3.8.14
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/README.md +2 -1
- package/SKILL.md +1 -1
- package/docs/contract.md +11 -5
- package/mcp/server.py +0 -1
- package/package.json +5 -2
- package/references/flags.md +2 -2
- package/references/scripts.md +72 -27
- package/scripts/_common/asr.py +1 -1
- package/scripts/_common/color.py +7 -2
- package/scripts/_common/decision.py +40 -8
- package/scripts/_common/emit.py +38 -10
- package/scripts/_common/probe.py +34 -6
- package/scripts/_common/runner.py +76 -7
- package/scripts/_contract.py +24 -10
- package/scripts/_platforms.py +0 -5
- package/scripts/_smartcut.py +23 -5
- package/scripts/audio.py +86 -28
- package/scripts/background.py +1 -1
- package/scripts/caption.py +2 -3
- package/scripts/check.py +3 -3
- package/scripts/color.py +7 -1
- package/scripts/crop.py +3 -2
- package/scripts/cropdetect.py +1 -1
- package/scripts/cut.py +50 -17
- package/scripts/export.py +39 -6
- package/scripts/fit.py +4 -3
- package/scripts/freeze.py +1 -1
- package/scripts/graphics.py +2 -3
- package/scripts/insert.py +1 -1
- package/scripts/look.py +9 -4
- package/scripts/loop.py +3 -3
- package/scripts/loudness.py +20 -12
- package/scripts/multicam.py +56 -15
- package/scripts/overlay.py +1 -1
- package/scripts/probe.py +13 -2
- package/scripts/proxy.py +11 -2
- package/scripts/redact.py +5 -1
- package/scripts/scenes.py +82 -21
- package/scripts/silence.py +62 -16
- package/scripts/sphere.py +23 -3
- package/scripts/stabilize.py +5 -2
- package/scripts/sync.py +11 -6
- package/scripts/verify.py +82 -20
- package/scripts/waveform.py +12 -2
package/scripts/multicam.py
CHANGED
|
@@ -30,9 +30,9 @@ import json
|
|
|
30
30
|
import math
|
|
31
31
|
import os
|
|
32
32
|
import sys
|
|
33
|
-
from typing import Dict, List, Tuple
|
|
33
|
+
from typing import Dict, List, Optional, Tuple
|
|
34
34
|
|
|
35
|
-
from _common import guard_sidecar, label_chain, matrix_chain, range_chain, video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run,
|
|
35
|
+
from _common import guard_sidecar, label_chain, matrix_chain, range_chain, video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, X264_PRESETS, fmt_secs, decode_pcm_mono, rms_envelope, STATE
|
|
36
36
|
from _common.emit import note, record_fallback, record_skip, warn
|
|
37
37
|
from _common.decision import av_trim, conform_output_t, conform_to_length, rebase_project_paths
|
|
38
38
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
@@ -60,8 +60,19 @@ def parse_switch(spec: str, n: int) -> List[Tuple[float, float, int]]:
|
|
|
60
60
|
die(f"camera {c} does not exist (inputs are 0..{n - 1})")
|
|
61
61
|
if e <= s:
|
|
62
62
|
die(f"switch entry '{raw}': end must be after start")
|
|
63
|
+
if round(e, 3) <= round(s, 3):
|
|
64
|
+
# the cut list (--edl, `cuts`) is written to the millisecond: this range was its
|
|
65
|
+
# "0.000-0.000", a line cut.py --segments refuses (the scenes.py --edl sibling)
|
|
66
|
+
die(f"switch entry '{raw}': shorter than a millisecond, the precision the cut list is written in",
|
|
67
|
+
kind="input")
|
|
63
68
|
out.append((s, e, c))
|
|
64
69
|
out.sort()
|
|
70
|
+
# A132: ranges on one timeline cannot overlap. The fill below kept both, so the picture of the
|
|
71
|
+
# overlap played twice while the sound played once, and every later cut ran behind it
|
|
72
|
+
for (s0, e0, _c0), (s1, e1, _c1) in zip(out, out[1:]):
|
|
73
|
+
if s1 < e0:
|
|
74
|
+
die(f"--switch ranges overlap: {fmt_secs(s0)}-{fmt_secs(e0)} and {fmt_secs(s1)}-{fmt_secs(e1)} "
|
|
75
|
+
"(on the reference timeline each moment has one camera)", kind="input")
|
|
65
76
|
return out
|
|
66
77
|
|
|
67
78
|
|
|
@@ -158,7 +169,7 @@ def main() -> int:
|
|
|
158
169
|
media_out = args.output or default_output(args.inputs[0], "multicam", "mp4")
|
|
159
170
|
guard_sidecar(args.edl, *args.inputs, siblings=(args.write_project, media_out))
|
|
160
171
|
guard_sidecar(args.write_project, *args.inputs, siblings=(media_out,))
|
|
161
|
-
if args.edl or args.write_project: #
|
|
172
|
+
if args.edl or args.write_project: # refused up front with them, before any decode
|
|
162
173
|
guard_sidecar(media_out, *args.inputs)
|
|
163
174
|
if args.analyze_seconds > 900:
|
|
164
175
|
die(f"--analyze-seconds {args.analyze_seconds:g}: the window is decoded into memory; 900 s is the ceiling")
|
|
@@ -272,17 +283,26 @@ def main() -> int:
|
|
|
272
283
|
cursor = max(cursor, e)
|
|
273
284
|
if cursor < ref_dur:
|
|
274
285
|
filled.append((cursor, ref_dur, 0))
|
|
286
|
+
# a gap or a clipped range under a millisecond is nothing at the precision the cut list is
|
|
287
|
+
# written in (--switch "0.0004-3:1" left a leading "0.000-0.000", the line cut.py --segments
|
|
288
|
+
# refuses, and a 0.4 ms trim in the render): its neighbour takes it, so the timeline stays whole
|
|
289
|
+
whole: List[Tuple[float, float, int]] = []
|
|
290
|
+
carry: Optional[float] = None
|
|
291
|
+
for s, e, c in filled:
|
|
292
|
+
if carry is not None:
|
|
293
|
+
s, carry = carry, None
|
|
294
|
+
if round(e, 3) <= round(s, 3):
|
|
295
|
+
if whole:
|
|
296
|
+
whole[-1] = (whole[-1][0], e, whole[-1][2])
|
|
297
|
+
else:
|
|
298
|
+
carry = s
|
|
299
|
+
continue
|
|
300
|
+
whole.append((s, e, c))
|
|
301
|
+
filled = whole
|
|
275
302
|
for s, e, c in filled:
|
|
276
303
|
if not metas[c].get("video"):
|
|
277
304
|
die(f"camera {c} ({args.inputs[c]}) has no video; it can only be used with --audio")
|
|
278
305
|
|
|
279
|
-
if args.edl:
|
|
280
|
-
if not STATE.dry_run:
|
|
281
|
-
with open(args.edl, "w", encoding="utf-8") as fh:
|
|
282
|
-
for s, e, _c in filled:
|
|
283
|
-
fh.write(f"{s:.3f}-{e:.3f}\n")
|
|
284
|
-
info(f"wrote {args.edl}")
|
|
285
|
-
|
|
286
306
|
v0 = metas[0]["video"]
|
|
287
307
|
# camera 0's displayed frame: rotation applied, non-square pixels squared (#411)
|
|
288
308
|
w, h = args.width or display_size(v0)[0], args.height or display_size(v0)[1]
|
|
@@ -322,9 +342,18 @@ def main() -> int:
|
|
|
322
342
|
c, src_s, src_e = 0, s, e
|
|
323
343
|
rendered.append((s, e, c))
|
|
324
344
|
sq = (square_pixels(metas[c]["video"]) + ",") if square_pixels(metas[c]["video"]) else ""
|
|
325
|
-
|
|
345
|
+
# A130: a drift-corrected camera's range is (e - s) * ratio of its own time; played at that
|
|
346
|
+
# rate it lasts e - s on the reference, as its sound does (asetrate) -- at its own rate the
|
|
347
|
+
# picture ran (ratio - 1) * (e - s) long and every later cut fell behind the sound
|
|
348
|
+
retime = f"(PTS-STARTPTS)/{ratios[c]:.9f}" if abs(ratios[c] - 1.0) > 1e-7 else "PTS-STARTPTS"
|
|
349
|
+
parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts={retime},{convert[c]}{sq}{geo}[v{i}]")
|
|
326
350
|
labels.append(f"[v{i}]")
|
|
327
|
-
|
|
351
|
+
clip = {"src": args.inputs[c], "in": round(src_s, 3), "out": round(src_e, 3)}
|
|
352
|
+
if abs(ratios[c] - 1.0) > 1e-7:
|
|
353
|
+
# render.py plays a clip at clips[].speed (its length / speed), as the retime above
|
|
354
|
+
# does: without it a drift-corrected camera's clip ran (ratio - 1) * (e - s) long
|
|
355
|
+
clip["speed"] = round(ratios[c], 9)
|
|
356
|
+
project_clips.append(clip)
|
|
328
357
|
parts.append("".join(labels) + f"concat=n={len(filled)}:v=1:a=0[vout]")
|
|
329
358
|
a = args.audio
|
|
330
359
|
a_start = -offsets[a] if offsets[a] < 0 else 0.0
|
|
@@ -358,17 +387,29 @@ def main() -> int:
|
|
|
358
387
|
# render.py project instead of the direct render this tool already does -- additive, not
|
|
359
388
|
# a replacement: the combined output below is still written as usual.
|
|
360
389
|
project = {"output": default_output(args.inputs[0], "multicam_project", "mp4"),
|
|
361
|
-
"clips": project_clips
|
|
390
|
+
"clips": project_clips,
|
|
391
|
+
# a switch is a cut: render.py's default is a 0.5 s crossfade at every join,
|
|
392
|
+
# which shortens the timeline by 0.5 s a switch and moves every later one
|
|
393
|
+
"transition": {"type": "none"}}
|
|
362
394
|
# the inputs are named as given, relative to the working directory, and render.py reads a
|
|
363
395
|
# project's relative paths against the project file's directory: written elsewhere
|
|
364
396
|
# (--write-project out/cut.json), they named files that are not there
|
|
365
397
|
project = rebase_project_paths(project, os.getcwd())
|
|
398
|
+
|
|
399
|
+
run(cmd)
|
|
400
|
+
# the EDL and the project record the cut: written once it exists, not before an encode that then
|
|
401
|
+
# failed (#462 A41, the silence.py --edl sibling)
|
|
402
|
+
if args.edl:
|
|
403
|
+
if not STATE.dry_run:
|
|
404
|
+
with open(args.edl, "w", encoding="utf-8") as fh:
|
|
405
|
+
for s, e, _c in filled:
|
|
406
|
+
fh.write(f"{s:.3f}-{e:.3f}\n")
|
|
407
|
+
info(f"wrote {args.edl}")
|
|
408
|
+
if args.write_project:
|
|
366
409
|
if not STATE.dry_run:
|
|
367
410
|
with open(args.write_project, "w", encoding="utf-8") as fh:
|
|
368
411
|
fh.write(json.dumps(project, indent=2) + "\n")
|
|
369
412
|
info(f"wrote {args.write_project}")
|
|
370
|
-
|
|
371
|
-
run(cmd)
|
|
372
413
|
r = probe(output, role="output")
|
|
373
414
|
info(f"wrote {output} ({fmt_secs(r['duration'])}, {len(filled)} cuts, audio from input {a})")
|
|
374
415
|
extra = {"switch_mode": "energy", "min_shot": args.min_shot} if energy_mode else {}
|
package/scripts/overlay.py
CHANGED
|
@@ -35,7 +35,7 @@ from typing import Any, Dict, List, Optional
|
|
|
35
35
|
|
|
36
36
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
37
37
|
from _common import still_input_args # noqa: E402
|
|
38
|
-
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, overlay_format, 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,
|
|
38
|
+
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, overlay_format, 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, probe, run_keeping_subtitles, validate_color, X264_PRESETS, time_arg, fmt_secs
|
|
39
39
|
from _common.emit import note, warn
|
|
40
40
|
from _common.color import is_rgb
|
|
41
41
|
from _common.decision import length_output_t, window_expr, windowed_overlay_x
|
package/scripts/probe.py
CHANGED
|
@@ -15,10 +15,11 @@ Examples:
|
|
|
15
15
|
points a stream copy can start at, and where cut.py --smart's re-encode around a cut ends.
|
|
16
16
|
"""
|
|
17
17
|
import argparse
|
|
18
|
+
import json
|
|
18
19
|
import math
|
|
19
20
|
import sys
|
|
20
21
|
|
|
21
|
-
from _common import add_common, analyze_levels, apply_common, die, keyframe_times, print_json, probe, time_arg
|
|
22
|
+
from _common import STATE, add_common, analyze_levels, apply_common, die, keyframe_times, print_json, probe, time_arg
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
def keyframe_candidates(result: dict, times: list, window: float) -> list:
|
|
@@ -87,7 +88,17 @@ def main() -> int:
|
|
|
87
88
|
cur = r
|
|
88
89
|
for key in args.field.split("."):
|
|
89
90
|
cur = cur.get(key) if isinstance(cur, dict) else None
|
|
90
|
-
|
|
91
|
+
# a string as itself; anything else as JSON (true, null, {"codec": ...}), not Python's
|
|
92
|
+
# repr (True, None, {'codec': ...}), which no caller can parse (through 3.8.8, A123).
|
|
93
|
+
# Under --json (or --json-brief, which implies it) a missing field is null and a string
|
|
94
|
+
# is quoted: every line is JSON. Without either a missing field is an empty line, as it
|
|
95
|
+
# always was, so `[ -z "$(probe.py f --field x)" ]` keeps working
|
|
96
|
+
if isinstance(cur, str) and not STATE.json:
|
|
97
|
+
print(cur)
|
|
98
|
+
elif cur is None and not STATE.json:
|
|
99
|
+
print("")
|
|
100
|
+
else:
|
|
101
|
+
print(json.dumps(cur, ensure_ascii=False))
|
|
91
102
|
return 0
|
|
92
103
|
|
|
93
104
|
if args.compact:
|
package/scripts/proxy.py
CHANGED
|
@@ -5,7 +5,8 @@ editing-decision workflows that only need to look at (or feed a model) a
|
|
|
5
5
|
much smaller stand-in for the original.
|
|
6
6
|
|
|
7
7
|
Resizes to --width (default 640px, height follows the source aspect) or by
|
|
8
|
-
--scale factor,
|
|
8
|
+
--scale factor, never wider than the source (a proxy is not upscaled) and
|
|
9
|
+
never under 2px, re-encodes at a proxy-grade --quality (default 30 - well above any
|
|
9
10
|
delivery preset's 18-24 in export.py, since a proxy trades visual quality for
|
|
10
11
|
size and speed), and always uses the fastest x264/x265 preset. Keeps the
|
|
11
12
|
source's own dynamic range (an HDR source proxies to HEVC10, same as every
|
|
@@ -65,7 +66,15 @@ def main() -> int:
|
|
|
65
66
|
note(square_pixels_note(meta["video"]))
|
|
66
67
|
has_audio = bool(meta.get("audio")) and not args.no_audio
|
|
67
68
|
|
|
68
|
-
|
|
69
|
+
# A proxy is never wider than its source, and never under 2 px: through 3.8.8 the default
|
|
70
|
+
# --width 640 upscaled a smaller source, and a --scale that rounded to 0 became scale=0, which
|
|
71
|
+
# ffmpeg reads as "the input's width" (a full-size proxy, audit A162)
|
|
72
|
+
asked = even(sw * args.scale) if args.scale is not None else even(args.width)
|
|
73
|
+
ceiling = max(2, sw - sw % 2) if sw else asked
|
|
74
|
+
out_w = max(2, min(asked, ceiling))
|
|
75
|
+
if out_w != asked:
|
|
76
|
+
note(f"proxy width {out_w}px, not {asked}px: "
|
|
77
|
+
+ (f"the source is {sw}px wide and a proxy is not upscaled" if asked > ceiling else "2px is the smallest width"))
|
|
69
78
|
output = args.output or default_output(args.input, "proxy")
|
|
70
79
|
|
|
71
80
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", (square + "," if square else "") + f"scale={out_w}:-2"]
|
package/scripts/redact.py
CHANGED
|
@@ -113,7 +113,11 @@ def main() -> int:
|
|
|
113
113
|
# boxblur refuses a radius above half the plane: the chroma planes of 4:2:0 are half-size,
|
|
114
114
|
# so they get their own (halved) radius; a 30 px region with the default 20 used to fail
|
|
115
115
|
radius = max(1, min(args.blur_strength, min(args.width, args.height) // 2 - 1))
|
|
116
|
-
|
|
116
|
+
# at least 1 wherever the chroma plane can hold it: radius // 2 alone was 0 at
|
|
117
|
+
# --blur-strength 1, which left colour-only detail readable (review of #521). (FFmpeg
|
|
118
|
+
# 7.1 refuses a chroma radius of 1 on a 2 px chroma plane, a 4 px wide region; 6.1
|
|
119
|
+
# took it.) 0 is only a plane too small to blur: the luma still is
|
|
120
|
+
chroma = max(0, min(max(1, radius // 2), (min(args.width, args.height) // 2 - 1) // 2))
|
|
117
121
|
region = f"{crop},boxblur={radius}:{radius}:{chroma}:{radius}"
|
|
118
122
|
else:
|
|
119
123
|
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"
|
package/scripts/scenes.py
CHANGED
|
@@ -110,6 +110,48 @@ def speech_music_ratio(samples: "List[float]", rate: int, step_s: float = SPEECH
|
|
|
110
110
|
return [{"time": round(i * step_s, 2), "speech_music_ratio": round(z / med, 3)} for i, z in enumerate(zcrs)]
|
|
111
111
|
|
|
112
112
|
|
|
113
|
+
# A --sheet picks one frame a scene with select. One expression summing a term a scene was refused
|
|
114
|
+
# by FFmpeg 7.1 and later at 3,000 scenes ("Error while parsing expression", ENOMEM) and taken by
|
|
115
|
+
# 6.1. No expression here grows with the scene count: the scenes are dealt round-robin to branches
|
|
116
|
+
# of at most SHEET_SELECT_TERMS terms, each summed as a balanced tree (at most
|
|
117
|
+
# ceil(log2(SHEET_SELECT_TERMS)) = 6 nested parentheses), and the branches' frames are merged back in
|
|
118
|
+
# time order by interleave. Round-robin keeps the branches level, so interleave holds about one
|
|
119
|
+
# thumbnail a branch.
|
|
120
|
+
SHEET_SELECT_TERMS = 64
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _first_at(t: float) -> str:
|
|
124
|
+
"""The select term for the first frame at or after `t`. The starts are presentation times, so
|
|
125
|
+
the frame is chosen by its time, not by start x fps as a frame number -- on a
|
|
126
|
+
variable-frame-rate source that number is another frame (#411)."""
|
|
127
|
+
a = f"{max(0.0, t - 0.0006):.4f}"
|
|
128
|
+
return f"gte(t\\,{a})*(isnan(prev_t)+lt(prev_t\\,{a}))"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _balanced_sum(terms: List[str]) -> str:
|
|
132
|
+
if len(terms) == 1:
|
|
133
|
+
return terms[0]
|
|
134
|
+
mid = len(terms) // 2
|
|
135
|
+
return f"({_balanced_sum(terms[:mid])}+{_balanced_sum(terms[mid:])})"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def sheet_graph(starts: List[float], src: str, thumb: str, tile: str) -> str:
|
|
139
|
+
"""The --sheet filter graph: exactly one frame a scene (the first at or after its start), each
|
|
140
|
+
made a thumbnail by `thumb`, laid out by `tile`, out as [sheet]."""
|
|
141
|
+
# no scene (a dry run whose input an earlier step has yet to write, a clip of under one scene):
|
|
142
|
+
# the sheet is its one tile, the first frame, as the 1x1 layout the caller sized for it
|
|
143
|
+
starts = starts or [0.0]
|
|
144
|
+
k = max(1, math.ceil(len(starts) / SHEET_SELECT_TERMS))
|
|
145
|
+
if k == 1:
|
|
146
|
+
return f"{src}select='{_balanced_sum([_first_at(t) for t in starts])}',{thumb},{tile}[sheet]"
|
|
147
|
+
parts = [f"{src}split={k}" + "".join(f"[s{j}]" for j in range(k))]
|
|
148
|
+
for j in range(k):
|
|
149
|
+
expr = _balanced_sum([_first_at(t) for t in starts[j::k]])
|
|
150
|
+
parts.append(f"[s{j}]select='{expr}',{thumb}[t{j}]")
|
|
151
|
+
parts.append("".join(f"[t{j}]" for j in range(k)) + f"interleave=nb_inputs={k},{tile}[sheet]")
|
|
152
|
+
return ";".join(parts)
|
|
153
|
+
|
|
154
|
+
|
|
113
155
|
def main() -> int:
|
|
114
156
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
115
157
|
ap.add_argument("input")
|
|
@@ -305,6 +347,7 @@ def main() -> int:
|
|
|
305
347
|
rank_key = lambda sc: (-sc["audio_rms"], sc["start"])
|
|
306
348
|
ranked = sorted(scenes, key=rank_key)[: args.highlights]
|
|
307
349
|
picks: List[Tuple[float, float]] = []
|
|
350
|
+
empty: List[Tuple[float, float]] = []
|
|
308
351
|
budget = args.target if args.target else None
|
|
309
352
|
per = (budget / max(1, len(ranked))) if budget else args.max_scene
|
|
310
353
|
for sc in ranked:
|
|
@@ -314,36 +357,44 @@ def main() -> int:
|
|
|
314
357
|
if env and length < sc["duration"]:
|
|
315
358
|
best, best_s = -1.0, sc["start"]
|
|
316
359
|
win = max(1, int(length / step_s))
|
|
317
|
-
|
|
318
|
-
|
|
360
|
+
# A139: a window starts at the scene's start or on the step grid after it, and
|
|
361
|
+
# ends inside the scene. The grid point at or before the start (floor) was a
|
|
362
|
+
# candidate, so a pick opened up to one step (0.5 s) early, on the previous shot
|
|
363
|
+
latest = sc["end"] - length
|
|
364
|
+
starts = [sc["start"]] + [i * step_s for i in range(math.ceil(sc["start"] / step_s - 1e-9),
|
|
365
|
+
math.floor(latest / step_s + 1e-9) + 1)
|
|
366
|
+
if sc["start"] < i * step_s <= latest]
|
|
367
|
+
for t in starts:
|
|
368
|
+
i = int(t / step_s + 1e-9)
|
|
319
369
|
val = sum(env[i:i + win])
|
|
320
370
|
if val > best:
|
|
321
|
-
best, best_s = val,
|
|
322
|
-
|
|
371
|
+
best, best_s = val, t
|
|
372
|
+
# to the hundredth the result and the EDL are written in, inward: a start of 3.333
|
|
373
|
+
# written as 3.33 opened on the frame before the cut
|
|
374
|
+
pick_s = math.ceil(best_s * 100 - 1e-6) / 100
|
|
375
|
+
pick_e = math.floor(min(sc["end"], pick_s + length) * 100 + 1e-6) / 100
|
|
376
|
+
if pick_e <= pick_s:
|
|
377
|
+
# no hundredth fits inside it: written as 0.00-0.00 it was a range cut.py refuses
|
|
378
|
+
empty.append((sc["start"], sc["end"]))
|
|
379
|
+
continue
|
|
380
|
+
picks.append((pick_s, pick_e))
|
|
381
|
+
if empty and not picks:
|
|
382
|
+
die(f"--highlights found no range of at least 0.01 s, the precision the ranges are written in "
|
|
383
|
+
f"(each pick is at most {min(per, args.max_scene):g} s, or its scene is shorter)",
|
|
384
|
+
kind="input", hint="raise --target (or drop it), or lower --highlights")
|
|
385
|
+
for s, e in empty:
|
|
386
|
+
note(f"--highlights: the scene {s:.3f}-{e:.3f}s holds no range of at least 0.01 s and has no pick")
|
|
323
387
|
picks.sort()
|
|
324
388
|
result["highlights"] = [{"start": s, "end": e, "duration": round(e - s, 2)} for s, e in picks]
|
|
325
389
|
result["highlights_total"] = round(sum(e - s for s, e in picks), 2)
|
|
326
390
|
result["highlights_rank_by"] = args.rank_by
|
|
327
391
|
info(f"proposed {len(picks)} highlight ranges totalling {result['highlights_total']:.1f}s")
|
|
328
|
-
if args.edl:
|
|
329
|
-
if not STATE.dry_run: # the contract says --edl is not written under --dry-run
|
|
330
|
-
with open(args.edl, "w", encoding="utf-8") as fh:
|
|
331
|
-
for s, e in picks:
|
|
332
|
-
fh.write(f"{s:.2f}-{e:.2f}\n")
|
|
333
|
-
info(f"wrote {args.edl}")
|
|
334
392
|
|
|
335
393
|
if args.sheet:
|
|
336
394
|
n = len(scenes)
|
|
337
395
|
cols = min(4, max(1, n))
|
|
338
396
|
rows = max(1, math.ceil(n / cols))
|
|
339
397
|
tile_w = 1280 // cols // 2 * 2
|
|
340
|
-
# exactly one frame per scene: the first frame at or after the scene start. The starts are
|
|
341
|
-
# presentation times, so the frame is chosen by its time, not by start x fps as a frame
|
|
342
|
-
# number -- on a variable-frame-rate source that number is another frame (#411)
|
|
343
|
-
def first_at(t: float) -> str:
|
|
344
|
-
a = f"{max(0.0, t - 0.0006):.4f}"
|
|
345
|
-
return f"gte(t\\,{a})*(isnan(prev_t)+lt(prev_t\\,{a}))"
|
|
346
|
-
expr = "+".join(first_at(sc["start"]) for sc in scenes)
|
|
347
398
|
stamp = ""
|
|
348
399
|
if not args.no_timecode and ffmpeg_filters() and "drawtext" not in ffmpeg_filters():
|
|
349
400
|
# as look.py (#394): the sheet needs drawtext only for its stamps
|
|
@@ -360,13 +411,23 @@ def main() -> int:
|
|
|
360
411
|
# a non-square-pixel source is squared at its display aspect (#411)
|
|
361
412
|
lab = bt709_label_filter(meta)
|
|
362
413
|
square = square_pixels(meta["video"])
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
run(ffmpeg_base() + ["-i", args.input, "-
|
|
414
|
+
thumb = (lab + "," if lab else "") + (square + "," if square else "") + f"scale={tile_w}:-2{stamp}"
|
|
415
|
+
graph = sheet_graph([sc["start"] for sc in scenes], f"[0:{PROBED_VIDEO}]", thumb,
|
|
416
|
+
f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
|
|
417
|
+
run(ffmpeg_base() + ["-i", args.input, "-filter_complex", graph, "-map", "[sheet]", "-an",
|
|
418
|
+
"-frames:v", "1", "-fps_mode", "vfr", "-update", "1", args.sheet])
|
|
367
419
|
info(f"wrote {args.sheet}")
|
|
368
420
|
result["sheet"] = args.sheet
|
|
369
421
|
|
|
422
|
+
# after the sheet, as silence.py writes its EDL after the cut (#462 A41): a --sheet refused as an
|
|
423
|
+
# existing file, or failed, left the EDL behind, and the corrected retry was then refused for it
|
|
424
|
+
if args.highlights and args.edl:
|
|
425
|
+
if not STATE.dry_run: # the contract says --edl is not written under --dry-run
|
|
426
|
+
with open(args.edl, "w", encoding="utf-8") as fh:
|
|
427
|
+
for s, e in picks:
|
|
428
|
+
fh.write(f"{s:.2f}-{e:.2f}\n")
|
|
429
|
+
info(f"wrote {args.edl}")
|
|
430
|
+
|
|
370
431
|
if STATE.json: # --json-brief too: emit() is what adds the diagnostics (scenes_sheet_no_drawtext)
|
|
371
432
|
emit(None, **result)
|
|
372
433
|
else:
|
package/scripts/silence.py
CHANGED
|
@@ -33,7 +33,7 @@ from _common.runner import parse_json, replace_checked
|
|
|
33
33
|
from _common import guard_sidecar, refuse_existing_outputs, refuse_output_is_input, _check_output_path
|
|
34
34
|
from _common.emit import refuse
|
|
35
35
|
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
|
|
36
|
-
from _common.decision import frame_rate, stream_starts
|
|
36
|
+
from _common.decision import frame_rate, grid_at_or_after, stream_starts
|
|
37
37
|
from _common.probe import PROBED_AUDIO
|
|
38
38
|
from _common.emit import input_length
|
|
39
39
|
|
|
@@ -163,21 +163,47 @@ def speech_aware_silences(path: str, threshold: float, min_silence: float) -> "T
|
|
|
163
163
|
return boundaries, breaths
|
|
164
164
|
|
|
165
165
|
|
|
166
|
+
# how close to the start or the end of the sound a silence's edge may sit and still be where the sound
|
|
167
|
+
# starts or ends: silencedetect closes a silence that runs to the end at its last decoded block (7.509
|
|
168
|
+
# for a sound probed as 7.500), and an AAC stream's stated start sits a priming frame off its samples
|
|
169
|
+
SOUND_EDGE = 0.05
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def sound_span(meta: Dict[str, Any], duration: float) -> Tuple[float, float]:
|
|
173
|
+
"""Where the sound starts and ends on the clock silencedetect reports on (the file's), for
|
|
174
|
+
inside_margin(): its start, and its own end (the file's length when that is not stated). The
|
|
175
|
+
end is stream_end()'s: a Matroska DURATION tag is already where the sound ends, and adding the
|
|
176
|
+
start to it put the end of a sound from 1 s to 6 s at 7 s."""
|
|
177
|
+
from _common.emit import file_origin, stream_end
|
|
178
|
+
a0 = stream_starts(meta)[1]
|
|
179
|
+
end = stream_end(meta, "audio")
|
|
180
|
+
return a0, (end - file_origin(meta)) if end is not None else duration
|
|
181
|
+
|
|
182
|
+
|
|
166
183
|
def inside_margin(silences: List[Tuple[float, float]], duration: float, margin: float,
|
|
167
|
-
lead: Optional[float] = None
|
|
184
|
+
lead: Optional[float] = None,
|
|
185
|
+
sound: Optional[Tuple[float, float]] = None) -> List[Tuple[float, float]]:
|
|
168
186
|
"""Each silence with a margin kept on both sides: the stretch actually removed. `margin` is
|
|
169
187
|
kept after the speech that ends where the silence starts (--margin-tail), `lead` before the
|
|
170
188
|
speech that starts where it ends (--margin-lead; the same as `margin` when not given). A
|
|
171
189
|
silence too short to hold both removes nothing (it is dropped, never turned inside out).
|
|
172
190
|
|
|
191
|
+
A silence with no speech on one side keeps no margin on that side (#462 A40): one that starts
|
|
192
|
+
where the sound does (`sound`, from sound_span()) is removed from the file's start, and one
|
|
193
|
+
that runs to where the sound ends (or that silencedetect never closed, `inf`) to the file's end.
|
|
194
|
+
A margin there kept dead air as a piece of its own, with a jump cut after it; FFmpeg 6.1 closes
|
|
195
|
+
a silence at the end of the file, so the `inf` end alone never caught the last one.
|
|
196
|
+
|
|
173
197
|
The margin is applied to the silences alone, before they are merged with filler spans (#414):
|
|
174
198
|
a filler word's span is the word itself plus --filler-pad, and taking a 0.2 s margin off each
|
|
175
199
|
side of it turned every word narrower than 2 x --margin inside out -- kept, and still counted
|
|
176
200
|
as removed."""
|
|
177
201
|
out: List[Tuple[float, float]] = []
|
|
178
202
|
for s, e in silences:
|
|
179
|
-
|
|
180
|
-
|
|
203
|
+
opens = sound is not None and s <= sound[0] + SOUND_EDGE
|
|
204
|
+
closes = e == float("inf") or (sound is not None and e >= sound[1] - SOUND_EDGE)
|
|
205
|
+
a = 0.0 if opens else s + margin
|
|
206
|
+
b = duration if closes else min(duration, e - (margin if lead is None else lead))
|
|
181
207
|
if b > a:
|
|
182
208
|
out.append((a, b))
|
|
183
209
|
return out
|
|
@@ -215,7 +241,10 @@ def rendered_ranges(keeps: List[Tuple[float, float]], meta: Dict[str, Any], dura
|
|
|
215
241
|
v0 = stream_starts(meta)[0]
|
|
216
242
|
|
|
217
243
|
def snap(t: float) -> float:
|
|
218
|
-
|
|
244
|
+
# a video that starts after the sound puts frame times before 0 on its grid's extension: a
|
|
245
|
+
# bound there moves to the first one at or after 0, which is where the re-cut starts (#462 A43)
|
|
246
|
+
t = v0 + round((t - v0) * rate) / rate
|
|
247
|
+
return min(t if t >= 0 else grid_at_or_after(0.0, v0, rate), duration)
|
|
219
248
|
return [(a, b) for a, b in ((snap(s), snap(e)) for s, e in keeps) if b > a]
|
|
220
249
|
|
|
221
250
|
|
|
@@ -307,8 +336,10 @@ def cut_levels(path: str, tm: Dict[str, Any], duration: float,
|
|
|
307
336
|
out = []
|
|
308
337
|
with tempfile.TemporaryDirectory(prefix="silence-levels-") as tmp:
|
|
309
338
|
raw = os.path.join(tmp, "pcm.s16le")
|
|
339
|
+
# padded from the file's start, as decode_pcm_mono() pads it: the windows are read at
|
|
340
|
+
# t x rate, so a sound that starts late was read that far after each cut (#462 A42)
|
|
310
341
|
proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path,
|
|
311
|
-
"-map", f"0:{PROBED_AUDIO}", "-vn",
|
|
342
|
+
"-map", f"0:{PROBED_AUDIO}", "-vn", "-af", "aresample=async=1:first_pts=0",
|
|
312
343
|
"-ac", str(channels), "-ar", str(CUT_LEVEL_RATE), "-f", "s16le", "-y", raw],
|
|
313
344
|
check=False, text=False)
|
|
314
345
|
if proc.returncode != 0 or not os.path.isfile(raw) or not os.path.getsize(raw):
|
|
@@ -624,8 +655,9 @@ def main() -> int:
|
|
|
624
655
|
fil.add_argument("--filler-list", action="store_true",
|
|
625
656
|
help="report what --filler would remove and write nothing")
|
|
626
657
|
fil.add_argument("--max-cuts", type=int, default=400,
|
|
627
|
-
help="refuse above this many removal ranges
|
|
628
|
-
"
|
|
658
|
+
help="refuse to render above this many removal ranges (default 400): that many cuts usually "
|
|
659
|
+
"means --threshold, --min-silence or the filler list is catching speech. --list and "
|
|
660
|
+
"--filler-list report them without the limit")
|
|
629
661
|
ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
|
|
630
662
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
631
663
|
add_common(ap)
|
|
@@ -686,6 +718,7 @@ def main() -> int:
|
|
|
686
718
|
if not meta.get("audio"):
|
|
687
719
|
die("input has no audio stream to analyse")
|
|
688
720
|
duration = input_length(meta)
|
|
721
|
+
sound = sound_span(meta, duration)
|
|
689
722
|
breaths: List[Tuple[float, float]] = []
|
|
690
723
|
if args.speech_aware:
|
|
691
724
|
silences, breaths = speech_aware_silences(args.input, args.threshold, args.min_silence)
|
|
@@ -717,10 +750,10 @@ def main() -> int:
|
|
|
717
750
|
def margined(spans: List[Tuple[float, float]]) -> List[Tuple[float, float]]:
|
|
718
751
|
"""The stretch of each silence that is removed: its own tail for one after a sentence end."""
|
|
719
752
|
if not by_sentence:
|
|
720
|
-
return inside_margin(spans, duration, tail, lead)
|
|
753
|
+
return inside_margin(spans, duration, tail, lead, sound)
|
|
721
754
|
out: List[Tuple[float, float]] = []
|
|
722
755
|
for span, pos in zip(spans, positions):
|
|
723
|
-
out += inside_margin([span], duration, sentence_tail if pos == "sentence_end" else tail, lead)
|
|
756
|
+
out += inside_margin([span], duration, sentence_tail if pos == "sentence_end" else tail, lead, sound)
|
|
724
757
|
return out
|
|
725
758
|
# One sorted, merged removal list through the graph the tool already has: filler removal IS
|
|
726
759
|
# time-range removal, so it reuses keep_ranges() and the same select chain.
|
|
@@ -730,7 +763,9 @@ def main() -> int:
|
|
|
730
763
|
keeps = keep_ranges(removals, duration, 0.0, args.min_keep)
|
|
731
764
|
# what the render holds (on a picture, each bound on its nearest frame): the report, the EDL
|
|
732
765
|
# and the time map all describe these, so a re-cut or a sum of `keep` is the rendered edit
|
|
733
|
-
|
|
766
|
+
# from the -o given, under --list and --filler-list too (which write no media): a list for an
|
|
767
|
+
# audio -o reported frame-snapped ranges that the audio run does not cut (#462 A48)
|
|
768
|
+
video_out = bool(meta.get("video")) and not is_audio_output(args.output or "")
|
|
734
769
|
held = rendered_ranges(keeps, meta, duration, video_out)
|
|
735
770
|
kept = sum(e - s for s, e in held)
|
|
736
771
|
removed = max(0.0, duration - kept)
|
|
@@ -787,10 +822,6 @@ def main() -> int:
|
|
|
787
822
|
# an English-only transcript made on an assumption (asr.assumed_english_note): the
|
|
788
823
|
# bridge already logged the warning; the result document carries it at the top level
|
|
789
824
|
summary["notes"] = list(args._asr_notes)
|
|
790
|
-
if len(keeps) > args.max_cuts:
|
|
791
|
-
die(f"{len(keeps)} keep ranges is above --max-cuts {args.max_cuts}: the filter graph grows "
|
|
792
|
-
"with every range and a graph this size is slow and fragile. Raise --max-cuts if you "
|
|
793
|
-
"mean it, or use --min-silence/--filler-pad to merge the short ones.", kind="input")
|
|
794
825
|
filler_only = args.filler and not args.speech_aware and not by_sentence
|
|
795
826
|
if filler_only:
|
|
796
827
|
info(f"--filler only (no --speech-aware): skipping generic silence detection, "
|
|
@@ -812,6 +843,14 @@ def main() -> int:
|
|
|
812
843
|
info("hint: " + summary["hint"])
|
|
813
844
|
|
|
814
845
|
tm = time_map(held, duration)
|
|
846
|
+
cuts = len(tm["removed"])
|
|
847
|
+
if cuts > args.max_cuts and not (args.list or args.filler_list):
|
|
848
|
+
# the removal ranges, as --max-cuts says, not the kept ones; a list renders nothing and is
|
|
849
|
+
# how the caller sees what the limit is about (#462 A49)
|
|
850
|
+
die(f"{cuts} removal ranges is above --max-cuts {args.max_cuts}: that many cuts usually means "
|
|
851
|
+
"--threshold, --min-silence or the filler list is catching speech. Check them with --list, then "
|
|
852
|
+
"raise --max-cuts if you mean it, or use --min-silence/--filler-pad to merge the short ones.",
|
|
853
|
+
kind="input")
|
|
815
854
|
points: Any = False # False: not measured (a dry run over a pending input); None: the decode failed
|
|
816
855
|
if not (STATE.dry_run and not os.path.exists(args.input)):
|
|
817
856
|
points = cut_levels(args.input, tm, duration, list(filler_ranges))
|
|
@@ -841,7 +880,12 @@ def main() -> int:
|
|
|
841
880
|
info(f"wrote {args.timemap}")
|
|
842
881
|
summary["timemap"] = args.timemap
|
|
843
882
|
|
|
844
|
-
|
|
883
|
+
def finish_edl() -> None:
|
|
884
|
+
"""Written with the time map, once the cut exists (or under --list): an EDL left by a
|
|
885
|
+
refused run (an existing -o, -o the input, nothing kept) or a failed encode described
|
|
886
|
+
media never made, and then blocked the corrected retry as an existing file (#462 A41)."""
|
|
887
|
+
if not args.edl:
|
|
888
|
+
return
|
|
845
889
|
if not STATE.dry_run: # the EDL is an artifact like the cut itself: a plan writes nothing
|
|
846
890
|
with open(args.edl, "w", encoding="utf-8") as fh:
|
|
847
891
|
on_frames = bool(video_out and frame_rate(meta))
|
|
@@ -852,6 +896,7 @@ def main() -> int:
|
|
|
852
896
|
info(f"wrote {args.edl}")
|
|
853
897
|
|
|
854
898
|
if args.list or args.filler_list:
|
|
899
|
+
finish_edl()
|
|
855
900
|
finish_time_map()
|
|
856
901
|
if args.json:
|
|
857
902
|
emit(None, **summary)
|
|
@@ -883,6 +928,7 @@ def main() -> int:
|
|
|
883
928
|
_cut.join_from_source(args.input, held, output, meta, args.crf, args.preset, tmp)
|
|
884
929
|
r = probe(output, role="output")
|
|
885
930
|
info(f"wrote {output} ({fmt_secs(r['duration'])}, expected ~{kept:.3f}s)")
|
|
931
|
+
finish_edl()
|
|
886
932
|
finish_time_map()
|
|
887
933
|
emit(output, **summary)
|
|
888
934
|
return 0
|
package/scripts/sphere.py
CHANGED
|
@@ -6,7 +6,8 @@ turning an equirectangular (or other spherical-projection) source into an
|
|
|
6
6
|
ordinary flat video pointed at a chosen direction -- the same "look this way"
|
|
7
7
|
operation a VR headset or a 360 video player's viewport does, baked into a
|
|
8
8
|
real file. --yaw/--pitch/--roll aim the camera; --h-fov/--v-fov set how wide
|
|
9
|
-
the view is
|
|
9
|
+
the view is (without --v-fov, the vertical view is the one that keeps the
|
|
10
|
+
output's pixels square).
|
|
10
11
|
|
|
11
12
|
This tool does not decide WHERE to point the camera -- there is no subject
|
|
12
13
|
detection or tracking here, only the typed rotation/FOV you give it (see this
|
|
@@ -29,9 +30,11 @@ Examples:
|
|
|
29
30
|
python3 sphere.py gopro_max.mp4 --input-projection fisheye --yaw -45 --pitch 10 -o angle.mp4
|
|
30
31
|
"""
|
|
31
32
|
import argparse
|
|
33
|
+
import math
|
|
32
34
|
import sys
|
|
33
35
|
|
|
34
36
|
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
|
|
37
|
+
from _common.emit import note
|
|
35
38
|
|
|
36
39
|
# v360's own AVOption names for the input projections real 360 cameras/exports actually
|
|
37
40
|
# produce (ffmpeg -h filter=v360 documents 24 total; this is the subset a caller is likely
|
|
@@ -59,7 +62,9 @@ def main() -> int:
|
|
|
59
62
|
aim.add_argument("--roll", type=float, default=0.0, help="tilt/roll rotation in degrees, -180..180 (default 0)")
|
|
60
63
|
fov = ap.add_argument_group("field of view")
|
|
61
64
|
fov.add_argument("--h-fov", type=float, default=90.0, help="output horizontal field of view in degrees, 1..170 (default 90)")
|
|
62
|
-
fov.add_argument("--v-fov", type=float, default=
|
|
65
|
+
fov.add_argument("--v-fov", type=float, default=None,
|
|
66
|
+
help="output vertical field of view in degrees, 1..170 (default: the one that keeps pixels square at "
|
|
67
|
+
"--h-fov and --width/--height, 58.7 at the defaults)")
|
|
63
68
|
out = ap.add_argument_group("output frame")
|
|
64
69
|
out.add_argument("--width", type=int, default=1920, help="output width in px, must be even (default 1920)")
|
|
65
70
|
out.add_argument("--height", type=int, default=1080, help="output height in px, must be even (default 1080)")
|
|
@@ -81,12 +86,27 @@ def main() -> int:
|
|
|
81
86
|
die(f"--roll must be -180..180, got {args.roll}")
|
|
82
87
|
if not 1 <= args.h_fov <= 170:
|
|
83
88
|
die(f"--h-fov must be 1..170, got {args.h_fov}")
|
|
84
|
-
if not 1 <= args.v_fov <= 170:
|
|
89
|
+
if args.v_fov is not None and not 1 <= args.v_fov <= 170:
|
|
85
90
|
die(f"--v-fov must be 1..170, got {args.v_fov}")
|
|
86
91
|
if args.width <= 0 or args.height <= 0:
|
|
87
92
|
die(f"--width/--height must be > 0, got width={args.width} height={args.height}")
|
|
88
93
|
if args.width % 2 or args.height % 2:
|
|
89
94
|
die(f"--width/--height must be even (4:2:0 chroma), got width={args.width} height={args.height}")
|
|
95
|
+
# A rectilinear view has square pixels when tan(v/2) / tan(h/2) = H / W. Through 3.8.8 the
|
|
96
|
+
# default was a fixed 60 degrees, which at 90 x 1920x1080 squashed the view 2.6 % vertically
|
|
97
|
+
# (audit A163); the vertical FOV now follows from the other three unless it is given.
|
|
98
|
+
square_v = math.degrees(2 * math.atan(math.tan(math.radians(args.h_fov) / 2) * args.height / args.width))
|
|
99
|
+
if args.v_fov is None:
|
|
100
|
+
if not 1 <= square_v <= 170:
|
|
101
|
+
die(f"--h-fov {args.h_fov:g} at {args.width}x{args.height} needs a vertical field of view of {square_v:.1f} "
|
|
102
|
+
"degrees for square pixels, outside 1..170", hint="give --v-fov, or a lower --h-fov")
|
|
103
|
+
args.v_fov = round(square_v, 4)
|
|
104
|
+
else:
|
|
105
|
+
stretch = math.tan(math.radians(args.v_fov) / 2) / math.tan(math.radians(square_v) / 2)
|
|
106
|
+
if abs(stretch - 1) > 0.005:
|
|
107
|
+
note(f"--h-fov {args.h_fov:g} / --v-fov {args.v_fov:g} at {args.width}x{args.height} is not square-pixel: the view is "
|
|
108
|
+
f"{'squashed' if stretch > 1 else 'stretched'} {abs(stretch - 1) * 100:.1f} % vertically "
|
|
109
|
+
f"(--v-fov {square_v:.2f} keeps it square; omit --v-fov to get it)")
|
|
90
110
|
|
|
91
111
|
meta = probe(args.input)
|
|
92
112
|
if not meta.get("video"):
|
package/scripts/stabilize.py
CHANGED
|
@@ -29,7 +29,7 @@ from pathlib import Path
|
|
|
29
29
|
|
|
30
30
|
from _common.probe import PROBED_VIDEO
|
|
31
31
|
from _common.runner import ffmpeg_failure_extra, default_selection_maps, require_filter
|
|
32
|
-
from _common import
|
|
32
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs, record_fallback, EVEN_SCALE
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def main() -> int:
|
|
@@ -96,7 +96,10 @@ def main() -> int:
|
|
|
96
96
|
die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr))
|
|
97
97
|
|
|
98
98
|
crop_mode = {"keep": 0, "black": 1}[args.crop]
|
|
99
|
-
|
|
99
|
+
# optzoom=0: the zoom is --zoom and nothing else. vidstab's default (optzoom=1) adds its own
|
|
100
|
+
# "optimal" zoom on top, so --zoom 0 did not keep the original framing and --crop black
|
|
101
|
+
# rarely had an edge to fill (through 3.8.8, audit A156)
|
|
102
|
+
transform_vf = f"{even}vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=0"
|
|
100
103
|
if args.tripod:
|
|
101
104
|
# Equivalent to relative=0:smoothing=0 -- overrides --smoothing, since averaging a
|
|
102
105
|
# camera path makes no sense once every frame is locked to one fixed reference.
|