ffmpeg-skill 3.6.5 → 3.6.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/README.md +10 -8
- package/SKILL.md +2 -2
- package/bin/install.js +79 -5
- package/docs/contract.md +9 -5
- package/package.json +1 -1
- package/references/scripts.md +6 -2
- package/scripts/_common/color.py +4 -3
- package/scripts/_common/probe.py +51 -8
- package/scripts/_common/runner.py +37 -3
- package/scripts/_contract.py +19 -11
- package/scripts/audio.py +22 -2
- package/scripts/caption.py +29 -3
- package/scripts/check.py +57 -8
- package/scripts/crop.py +3 -1
- package/scripts/cropdetect.py +2 -2
- package/scripts/cut.py +33 -6
- package/scripts/export.py +33 -15
- package/scripts/graphics.py +6 -0
- package/scripts/look.py +4 -4
- package/scripts/loudness.py +83 -16
- package/scripts/multicam.py +21 -4
- package/scripts/overlay.py +6 -0
- package/scripts/pad.py +6 -0
- package/scripts/render.py +30 -3
- package/scripts/scenes.py +2 -2
- package/scripts/silence.py +3 -1
- package/scripts/stabilize.py +5 -2
- package/scripts/sync.py +7 -3
- package/scripts/verify.py +9 -2
package/scripts/export.py
CHANGED
|
@@ -44,16 +44,17 @@ import os
|
|
|
44
44
|
import sys
|
|
45
45
|
import tempfile
|
|
46
46
|
from pathlib import Path
|
|
47
|
-
from typing import Dict, List
|
|
47
|
+
from typing import Dict, List, Optional
|
|
48
48
|
|
|
49
49
|
from _common.runner import child_exit_code, fail_if_classified, kept_clause, mark_delivered
|
|
50
50
|
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
|
|
51
51
|
from _common.decision import hw_preset_video, restate_last_swap
|
|
52
52
|
from _common.emit import note, record_fallback, record_skip, warn
|
|
53
|
-
from _common.probe import square_pixels, square_pixels_note
|
|
53
|
+
from _common.probe import PROBED_AUDIO, PROBED_VIDEO, probed_maps, square_pixels, square_pixels_note
|
|
54
54
|
from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, replace_checked, replace_checked_together, reserve_sibling, run_analysis as _run_analysis, subtitle_codec_for
|
|
55
55
|
from _common.emit import file_identity
|
|
56
56
|
from check import SPECS as PLATFORMS, measure_loudness
|
|
57
|
+
from _common.probe import SILENT_PEAK_DBFS, shorter_than_a_loudness_block
|
|
57
58
|
from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, resolve as resolve_platform
|
|
58
59
|
# Frame and duration limit come from the one platform table (scripts/_platforms.py) rather than
|
|
59
60
|
# from a literal restated here: before 1.14 they were typed twice and the facebook preset had
|
|
@@ -204,8 +205,8 @@ def _key_fill(args, p: Dict, meta: Dict, output: str, cmd: List[str], vf: List[s
|
|
|
204
205
|
key_chain = ",".join(vf + ["alphaextract", "format=yuv422p10le"])
|
|
205
206
|
tc = ((meta.get("timecode") or {}).get("value"))
|
|
206
207
|
tc_args = ["-timecode", tc] if tc else []
|
|
207
|
-
cmd = cmd + ["-filter_complex", f"[0:
|
|
208
|
-
cmd += ["-map", "[fill]"] + (
|
|
208
|
+
cmd = cmd + ["-filter_complex", f"[0:{PROBED_VIDEO}]split[f][k];[f]{fill_chain}[fill];[k]{key_chain}[key]"]
|
|
209
|
+
cmd += ["-map", "[fill]"] + probed_maps(meta, video=False, audio=has_audio) + p["video"] + tc_args + cfr_args(meta)
|
|
209
210
|
# the fill is always written to a hidden sibling and moved into place only after the encode
|
|
210
211
|
# succeeds: run() stages and cleans up only ffmpeg's last output (the key), so a failed encode
|
|
211
212
|
# left a partial new fill (the retry was refused as output_exists) or truncated an existing one
|
|
@@ -288,17 +289,22 @@ def _luma_stats(path: str) -> "Dict[str, float] | None":
|
|
|
288
289
|
return _frame_means([require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin"], path, "format=gray")
|
|
289
290
|
|
|
290
291
|
|
|
291
|
-
def _loudness_unmeasured(extra: Dict[str, object], notes: List[str], platform: str, spec: Dict, normalized: bool = False
|
|
292
|
-
|
|
292
|
+
def _loudness_unmeasured(extra: Dict[str, object], notes: List[str], platform: str, spec: Dict, normalized: bool = False,
|
|
293
|
+
tp: Optional[float] = None) -> None:
|
|
294
|
+
"""The written file's loudness could not be read (ffmpeg's loudnorm JSON did not parse, or,
|
|
295
|
+
with `tp`, the audio is shorter than one 400 ms gating block and has only a true peak): the
|
|
293
296
|
platform's loudness was not verified, and the document says so -- a `loudness` block with null
|
|
294
297
|
values and a reason, a failing `loudness` verification step, a warning -- rather than leaving
|
|
295
298
|
the block out, which read as "nothing to check". check.py reports the same case as a WARN row."""
|
|
296
299
|
reason = "not_measured"
|
|
297
|
-
extra["loudness"] = {"lufs": None, "tp":
|
|
298
|
-
"measured": False, "reason": reason, **({"
|
|
300
|
+
extra["loudness"] = {"lufs": None, "tp": tp, "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": None,
|
|
301
|
+
"measured": False, "reason": reason, **({"too_short": True} if tp is not None else {}),
|
|
302
|
+
**({"normalized": True} if normalized else {})}
|
|
299
303
|
extra["verification"] = [{"step": "loudness", "ok": False, "platform": platform, "reason": reason}]
|
|
304
|
+
why = ("the audio is shorter than one 400 ms loudness block, so it has a true peak but no integrated loudness"
|
|
305
|
+
if tp is not None else "ffmpeg's loudnorm output did not parse")
|
|
300
306
|
msg = (f"the written file's loudness could not be measured{' after --normalize' if normalized else ''} "
|
|
301
|
-
f"(
|
|
307
|
+
f"({why}), so {platform}'s {spec['lufs']:g} LUFS / {spec['tp']:g} dBTP was not "
|
|
302
308
|
"verified; run check.py on the file")
|
|
303
309
|
notes.append(msg)
|
|
304
310
|
warn(msg, "loudness_not_measured", platform=platform, normalized=normalized)
|
|
@@ -410,7 +416,9 @@ def media_damage(path: str) -> Dict[str, int]:
|
|
|
410
416
|
PTS) and every other error line, counted. What --repair is measured by, before and after."""
|
|
411
417
|
# repeat+: every occurrence on its own line, not "Last message repeated N times", which hid
|
|
412
418
|
# N timestamp warnings and was itself counted as a decode error
|
|
413
|
-
|
|
419
|
+
# the streams a copy export writes (probed_maps), not the ones default selection would decode
|
|
420
|
+
proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-nostdin", "-v", "repeat+warning", "-i", path,
|
|
421
|
+
"-map", f"0:{PROBED_VIDEO}?", "-map", f"0:{PROBED_AUDIO}?", "-f", "null", "-"],
|
|
414
422
|
check=False, record=True)
|
|
415
423
|
lines = [ln for ln in (proc.stderr or "").splitlines() if ln.strip()]
|
|
416
424
|
# "Packet corrupt (stream = 0, dts = ...)" names a dts but is damage, not a timestamp problem
|
|
@@ -585,16 +593,19 @@ def main() -> int:
|
|
|
585
593
|
# Subtitle and data streams (a soft-subtitle track muxed upstream, a camera's timecode) are
|
|
586
594
|
# kept where the container can hold them: ffmpeg's default stream selection takes one video
|
|
587
595
|
# and one audio stream only, and through 2.22.1 every preset dropped the rest without a word.
|
|
588
|
-
#
|
|
596
|
+
# The picture and the sound are mapped on every export, the same streams probe() describes:
|
|
597
|
+
# until A118 they were mapped only beside a subtitle or data stream, and without one ffmpeg's
|
|
598
|
+
# default selection took the larger picture and the track with the default disposition or the
|
|
599
|
+
# most channels, so which audio track an export kept depended on whether it had subtitles.
|
|
589
600
|
subs = meta.get("subtitle_stream_details") or []
|
|
590
601
|
data = _data_streams(args.input) if meta.get("data_streams") else []
|
|
591
602
|
sub_codecs = [_subtitle_codec(s.get("codec") or "", out_ext) for s in subs]
|
|
592
|
-
if args.preset != "gif" and not p.get("keyfill")
|
|
593
|
-
cmd +=
|
|
603
|
+
if args.preset != "gif" and not p.get("keyfill"):
|
|
604
|
+
cmd += probed_maps(meta, audio=has_audio)
|
|
594
605
|
|
|
595
606
|
if args.preset == "gif":
|
|
596
607
|
chain = ",".join(([gif_label] if gif_label else []) + ["fps=12"] + vf)
|
|
597
|
-
fc = f"[0:
|
|
608
|
+
fc = f"[0:{PROBED_VIDEO}]{chain},split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle"
|
|
598
609
|
cmd += ["-filter_complex", fc, "-loop", "0", output]
|
|
599
610
|
run(cmd)
|
|
600
611
|
info(f"wrote {output}")
|
|
@@ -717,7 +728,14 @@ def main() -> int:
|
|
|
717
728
|
# written file here so the result names the gap and the caller plans one pass, not two.
|
|
718
729
|
spec = PLATFORMS[platform]
|
|
719
730
|
m = measure_loudness(output)
|
|
720
|
-
|
|
731
|
+
written_audio = probe(output, role="output").get("audio") or {}
|
|
732
|
+
if (m and not math.isfinite(m["lufs"]) and math.isfinite(m["tp"]) and m["tp"] > SILENT_PEAK_DBFS
|
|
733
|
+
and shorter_than_a_loudness_block(written_audio.get("duration") or written_audio.get("tagged_duration"))):
|
|
734
|
+
# A257: too short for a gating block is not silent (it has a measured peak); a longer
|
|
735
|
+
# track under the gate is not "too short" and takes the path it took before
|
|
736
|
+
_loudness_unmeasured(extra, notes, platform, spec, tp=m["tp"])
|
|
737
|
+
m = {}
|
|
738
|
+
elif not m:
|
|
721
739
|
_loudness_unmeasured(extra, notes, platform, spec)
|
|
722
740
|
if m:
|
|
723
741
|
ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"] + TP_TOLERANCE_DB
|
package/scripts/graphics.py
CHANGED
|
@@ -202,6 +202,12 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
202
202
|
fps = meta["video"].get("fps")
|
|
203
203
|
s = time_arg(args.start, "--start", fps) if args.start else 0.0
|
|
204
204
|
e = time_arg(args.end, "--end", fps) if args.end else dur
|
|
205
|
+
# the picture's own length first: sound that runs on keeps the container open past the last frame
|
|
206
|
+
picture_len = meta["video"].get("duration") or dur
|
|
207
|
+
if picture_len and s >= picture_len:
|
|
208
|
+
# A172: a graphic that starts after the picture ends draws nothing (verified: true before)
|
|
209
|
+
die(f"--start {s:g} is at or after the end of the video ({picture_len:.3f} s): nothing would be drawn",
|
|
210
|
+
hint="give a --start inside the clip")
|
|
205
211
|
if e <= s:
|
|
206
212
|
die("--end must be after --start")
|
|
207
213
|
en = f"enable='between(t,{s:.3f},{e:.3f})'"
|
package/scripts/look.py
CHANGED
|
@@ -24,7 +24,7 @@ from _common import drawtext_fontfile # noqa: E402
|
|
|
24
24
|
from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
|
|
25
25
|
from _common.emit import note, record_fallback # noqa: E402
|
|
26
26
|
from _common.color import bt709_label_filter, bt2020_sdr_to_bt709_chain, hdr_to_sdr_chain, is_bt2020_sdr # noqa: E402
|
|
27
|
-
from _common.probe import display_size, square_pixels, square_pixels_note # noqa: E402
|
|
27
|
+
from _common.probe import PROBED_VIDEO, display_size, square_pixels, square_pixels_note # noqa: E402
|
|
28
28
|
|
|
29
29
|
# --guides: broadcast safe areas as outlines, in fractions of the frame (a tile as much as a frame).
|
|
30
30
|
# Action and title safe are SMPTE ST 2046-1's 93 % and 90 % of a 16:9 picture; the 4:3 centre cut
|
|
@@ -337,7 +337,7 @@ def main() -> int:
|
|
|
337
337
|
conv = "," + conv if conv else ""
|
|
338
338
|
return (f"scale={half}:{box_h}:force_original_aspect_ratio=decrease{conv}{pre},"
|
|
339
339
|
f"pad={half}:{box_h}:(ow-iw)/2:(oh-ih)/2:color=0x202020,setsar=1{stamp}")
|
|
340
|
-
fc = (f"[0:
|
|
340
|
+
fc = (f"[0:{PROBED_VIDEO}]{lab}{sq}{fit(tm)}[a];[1:{PROBED_VIDEO}]{lab2}{csq}{fit(tm2)}[b];"
|
|
341
341
|
f"[a][b]hstack=inputs=2[out]")
|
|
342
342
|
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", *still_input_args(args.input), "-i", args.input, "-ss", f"{sec:.3f}", *still_input_args(args.compare), "-i", args.compare,
|
|
343
343
|
"-filter_complex", fc, "-map", "[out]", "-frames:v", "1", "-update", "1", out]
|
|
@@ -354,7 +354,7 @@ def main() -> int:
|
|
|
354
354
|
# several frames, or -o given as a stem/prefix without an image extension
|
|
355
355
|
out = os.path.join(outdir, f"{args.output and Path(args.output).stem or stem}_{sec:.3f}s.png")
|
|
356
356
|
stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
|
|
357
|
-
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", *still_input_args(args.input), "-i", args.input, "-vf", f"{lab}{sq}scale={args.width}:-2{tc.replace(',' + timecode_filter(font_prefix), '')}{stamp}", "-frames:v", "1", "-update", "1", out]
|
|
357
|
+
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", *still_input_args(args.input), "-i", args.input, "-map", f"0:{PROBED_VIDEO}", "-vf", f"{lab}{sq}scale={args.width}:-2{tc.replace(',' + timecode_filter(font_prefix), '')}{stamp}", "-frames:v", "1", "-update", "1", out]
|
|
358
358
|
run(cmd)
|
|
359
359
|
outputs.append(out)
|
|
360
360
|
else:
|
|
@@ -384,7 +384,7 @@ def main() -> int:
|
|
|
384
384
|
tc_sheet = tc
|
|
385
385
|
vf = (f"select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,{step * 0.98:.6f})',{lab}{sq}scale={tile_w}:-2{tc_sheet},"
|
|
386
386
|
f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
|
|
387
|
-
cmd = ffmpeg_base() + ["-copyts", "-start_at_zero", "-ss", f"{seek:.6f}", *still_input_args(args.input), "-i", args.input, "-vf", vf, "-frames:v", "1", "-update", "1", out]
|
|
387
|
+
cmd = ffmpeg_base() + ["-copyts", "-start_at_zero", "-ss", f"{seek:.6f}", *still_input_args(args.input), "-i", args.input, "-map", f"0:{PROBED_VIDEO}", "-vf", vf, "-frames:v", "1", "-update", "1", out]
|
|
388
388
|
run(cmd)
|
|
389
389
|
outputs.append(out)
|
|
390
390
|
info(f"contact sheet: {n} frames every {step:.2f}s")
|
package/scripts/loudness.py
CHANGED
|
@@ -23,6 +23,8 @@ from _platforms import TP_TOLERANCE_DB
|
|
|
23
23
|
|
|
24
24
|
AUDIO_LENGTH_TOLERANCE = 0.03 # seconds: one AAC frame at 48 kHz is 0.021 s
|
|
25
25
|
LUFS_TOLERANCE = 1.0 # LU: a normalised file within this of the requested -I is on target
|
|
26
|
+
from _common.probe import PROBED_AUDIO, probed_maps, shorter_than_a_loudness_block
|
|
27
|
+
from _common.emit import refuse
|
|
26
28
|
from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, dry_run_input_pending
|
|
27
29
|
|
|
28
30
|
|
|
@@ -65,7 +67,11 @@ def measure(path: str, I: float, tp: float, lra: float) -> dict:
|
|
|
65
67
|
if dry_run_input_pending(path):
|
|
66
68
|
return {"input_i": "-20.0", "input_tp": "-3.0", "input_lra": "8.0", "input_thresh": "-30.0", "target_offset": "0.0", "silent": False, "placeholder": True}
|
|
67
69
|
ffmpeg = require_tool("ffmpeg")
|
|
68
|
-
|
|
70
|
+
# the first audio track, the one pass 2 normalises and probe() describes: without a -map,
|
|
71
|
+
# FFmpeg measured the track with the default disposition or the most channels, and the gain
|
|
72
|
+
# for that track was applied to a:0 (A110)
|
|
73
|
+
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", f"0:{PROBED_AUDIO}", "-vn",
|
|
74
|
+
"-af", f"loudnorm=I={I}:TP={tp}:LRA={lra}:print_format=json", "-f", "null", "-"]
|
|
69
75
|
# Pass 1 is a measurement: it runs under --dry-run too, so the planned pass-2 command and
|
|
70
76
|
# the reported input_i are real (before 1.4.6 a dry run returned a made-up -20 LUFS).
|
|
71
77
|
proc = run_analysis(cmd, check=False, record=True)
|
|
@@ -73,14 +79,37 @@ def measure(path: str, I: float, tp: float, lra: float) -> dict:
|
|
|
73
79
|
if proc.returncode != 0 or not m:
|
|
74
80
|
die(f"loudness measurement failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg")
|
|
75
81
|
data = json.loads(m.group(0))
|
|
82
|
+
data["too_short"] = False
|
|
76
83
|
for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset"):
|
|
77
84
|
if data.get(k) in (None, "-inf", "inf", "nan"):
|
|
78
|
-
|
|
85
|
+
# A257: a clip shorter than one 400 ms gating block has no integrated loudness but a
|
|
86
|
+
# true peak; that is not silence, and telling the caller to replace the audio was wrong.
|
|
87
|
+
# The length decides it, not the peak: a long track under the absolute gate has a
|
|
88
|
+
# finite peak and no integrated loudness as well, and stays "silent" as before.
|
|
89
|
+
data["too_short"] = (data.get("input_tp") not in (None, "-inf", "inf", "nan")
|
|
90
|
+
and shorter_than_a_loudness_block(audio_length(probe(path))))
|
|
91
|
+
data["silent"] = not data["too_short"]
|
|
79
92
|
return data
|
|
80
93
|
data["silent"] = False
|
|
81
94
|
return data
|
|
82
95
|
|
|
83
96
|
|
|
97
|
+
def loudnorm_mode(stats: dict, lufs: float, tp: float, lra: float) -> str:
|
|
98
|
+
"""The mode loudnorm runs pass 2 in, by its own rule (af_loudnorm.c init): linear only when the
|
|
99
|
+
measured values are all usable and the linear gain keeps the true peak under the ceiling and
|
|
100
|
+
the range within --lra; otherwise it falls back to dynamic, which compresses the range and can
|
|
101
|
+
land away from the target on its own (A111). The values are the strings pass 1 printed, which
|
|
102
|
+
are the ones pass 2 is given, so the comparison is ffmpeg's own. Checked against loudnorm's
|
|
103
|
+
print_format=json normalization_type by test_loudnorm_mode_matches_what_ffmpeg_reports."""
|
|
104
|
+
try:
|
|
105
|
+
mi, mtp, mlra, mth = (float(stats[k]) for k in ("input_i", "input_tp", "input_lra", "input_thresh"))
|
|
106
|
+
except (KeyError, TypeError, ValueError):
|
|
107
|
+
return "dynamic"
|
|
108
|
+
if mtp == 99 or mth == -70 or mlra == 0 or mi == 0:
|
|
109
|
+
return "dynamic"
|
|
110
|
+
return "linear" if mtp + (lufs - mi) <= tp and mlra <= lra else "dynamic"
|
|
111
|
+
|
|
112
|
+
|
|
84
113
|
def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output: str) -> dict:
|
|
85
114
|
"""One normalised file at `lufs` / `tp_max`, measured after: the keys emit() takes for it."""
|
|
86
115
|
af = (
|
|
@@ -112,9 +141,9 @@ def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output
|
|
|
112
141
|
af += f",atrim=duration={float(in_audio):.6f}"
|
|
113
142
|
cmd = ffmpeg_base() + ["-i", args.input, "-af", af, "-ar", str(sr)]
|
|
114
143
|
if ext in AUDIO_CODECS or not meta.get("video"):
|
|
115
|
-
cmd += ["-vn"] + audio_codec_for(output, bitrate)
|
|
144
|
+
cmd += ["-map", f"0:{PROBED_AUDIO}", "-vn"] + audio_codec_for(output, bitrate)
|
|
116
145
|
else:
|
|
117
|
-
cmd +=
|
|
146
|
+
cmd += probed_maps(meta) + ["-c:v", "copy", "-c:a", "aac", "-b:a", bitrate]
|
|
118
147
|
if ext in (".mp4", ".mov", ".m4v"):
|
|
119
148
|
cmd += ["-movflags", "+faststart"]
|
|
120
149
|
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
@@ -125,7 +154,8 @@ def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output
|
|
|
125
154
|
encode(tp_max, bitrate)
|
|
126
155
|
if STATE.dry_run:
|
|
127
156
|
# pass 1 measured the input for real; there is no output to measure
|
|
128
|
-
return {"output": output, "measured": {k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")}
|
|
157
|
+
return {"output": output, "measured": {k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")},
|
|
158
|
+
"loudnorm_mode": loudnorm_mode(stats, lufs, tp_max, args.lra)}
|
|
129
159
|
after = measure(output, lufs, tp_max, args.lra)
|
|
130
160
|
# loudnorm holds the ceiling on the float samples it outputs; the lossy encoder then adds
|
|
131
161
|
# its own overshoot. ffmpeg's native AAC at 192k turned one transient of a 12-minute film
|
|
@@ -157,22 +187,43 @@ def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output
|
|
|
157
187
|
if ext not in AUDIO_CODECS or "aac" in AUDIO_CODECS[ext]:
|
|
158
188
|
result["audio_bitrate_used"] = bitrate
|
|
159
189
|
result["encodes"] = rounds + 1
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
190
|
+
# the mode of the last encode: its ceiling is the one the file was made with
|
|
191
|
+
result["loudnorm_mode"] = loudnorm_mode(stats, lufs, ceiling, args.lra)
|
|
192
|
+
if not after.get("silent") and not after.get("too_short") and abs(float(after["input_i"]) - lufs) > LUFS_TOLERANCE:
|
|
193
|
+
if result["loudnorm_mode"] == "dynamic":
|
|
194
|
+
# A111: the miss is loudnorm's dynamic mode, not an encoder overshoot (it happens on a
|
|
195
|
+
# lossless output too): a linear gain to the target would have put the true peak over
|
|
196
|
+
# the ceiling, or the range is wider than --lra
|
|
197
|
+
result["note"] = (f"integrated loudness is {float(after['input_i']) - lufs:+.1f} LU from the target because "
|
|
198
|
+
f"loudnorm could not apply one linear gain (the input's true peak {float(stats['input_tp']):.1f} dBTP "
|
|
199
|
+
f"plus the gain would pass the {ceiling:.2f} dBTP ceiling, or its range "
|
|
200
|
+
f"{float(stats['input_lra']):.1f} LU is outside 0 < LRA <= --lra {args.lra:g}) and fell back to "
|
|
201
|
+
f"dynamic mode, which compresses the range; a lower -I, a higher --tp or a wider --lra keeps it linear")
|
|
202
|
+
else:
|
|
203
|
+
result["note"] = (f"integrated loudness is {float(after['input_i']) - lufs:+.1f} LU from the target because the "
|
|
204
|
+
f"true-peak ceiling had to absorb the encoder's overshoot; a lossless delivery (wav/flac) or a pinned "
|
|
205
|
+
f"higher --audio-bitrate keeps both")
|
|
164
206
|
if not after.get("silent") and float(after["input_tp"]) > tp_max + TP_TOLERANCE_DB:
|
|
165
207
|
die(f"true peak is still {float(after['input_tp']):.2f} dBTP after {rounds + 1} encodes (requested <= {tp_max:g}); "
|
|
166
208
|
f"the encoder overshoots more than the loudnorm ceiling can absorb at this bitrate",
|
|
167
209
|
kind="verification", output=output, result=result,
|
|
168
210
|
hint="raise --audio-bitrate (e.g. 256k) or deliver a lossless format (wav/flac) and let the platform encode")
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
211
|
+
if after.get("silent") or after.get("too_short"):
|
|
212
|
+
# an output with no integrated loudness was not measured against the target: through
|
|
213
|
+
# 3.6 a silent one passed this step (ok: true) with -inf in its numbers
|
|
214
|
+
verification = [{"step": "loudness", "ok": False, "target_lufs": lufs, "target_tp": tp_max,
|
|
215
|
+
"property": "loudness", "expected": {"lufs": lufs, "tp_max": tp_max},
|
|
216
|
+
"reason": "not_measured: the output has no integrated loudness ("
|
|
217
|
+
+ ("silent" if after.get("silent") else "shorter than one 400 ms block") + ")",
|
|
218
|
+
"provenance": "user_specified"}]
|
|
219
|
+
else:
|
|
220
|
+
verification = [{"step": "loudness",
|
|
221
|
+
"ok": abs(float(after["input_i"]) - lufs) <= LUFS_TOLERANCE and float(after["input_tp"]) <= tp_max + TP_TOLERANCE_DB,
|
|
222
|
+
"lufs": float(after["input_i"]), "tp": float(after["input_tp"]), "target_lufs": lufs, "target_tp": tp_max,
|
|
223
|
+
"property": "loudness", "expected": {"lufs": lufs, "tp_max": tp_max},
|
|
224
|
+
"measured": {"lufs": float(after["input_i"]), "tp": float(after["input_tp"])},
|
|
225
|
+
"delta": {"lufs": round(float(after["input_i"]) - lufs, 2), "tp": round(float(after["input_tp"]) - tp_max, 2)},
|
|
226
|
+
"tolerance": {"lufs": LUFS_TOLERANCE, "tp": TP_TOLERANCE_DB}, "provenance": "user_specified"}]
|
|
176
227
|
written = probe(output, role="output")
|
|
177
228
|
out_audio = audio_length(written)
|
|
178
229
|
if in_audio and out_audio:
|
|
@@ -199,6 +250,10 @@ def _versions(args, meta: dict, plan: list) -> int:
|
|
|
199
250
|
for label, lufs, tp in plan:
|
|
200
251
|
out = default_output(args.input, f"loudnorm_{label}")
|
|
201
252
|
stats = measure(args.input, lufs, tp, args.lra)
|
|
253
|
+
if stats.get("too_short"):
|
|
254
|
+
refuse("input", "audio_too_short",
|
|
255
|
+
f"input audio peaks at {stats.get('input_tp')} dBTP but is shorter than one 400 ms loudness block, so it has "
|
|
256
|
+
"no integrated loudness to normalise from (this is not silence)", input_tp=stats.get("input_tp"))
|
|
202
257
|
if stats.get("silent"):
|
|
203
258
|
die("input audio is silent; loudness normalisation is meaningless (use audio.py --replace to add a track)")
|
|
204
259
|
doc = _normalize(args, meta, stats, lufs, tp, out)
|
|
@@ -245,6 +300,18 @@ def main() -> int:
|
|
|
245
300
|
return _versions(args, meta, plan)
|
|
246
301
|
|
|
247
302
|
stats = measure(args.input, args.lufs, args.tp, args.lra)
|
|
303
|
+
if stats.get("too_short"):
|
|
304
|
+
info(f"audio is shorter than one 400 ms loudness block: no integrated loudness (true peak {stats.get('input_tp')} dBTP)")
|
|
305
|
+
if args.measure_only:
|
|
306
|
+
measured = {"silent": False, "too_short": True, "input_i": None, "input_tp": stats.get("input_tp")}
|
|
307
|
+
if STATE.json:
|
|
308
|
+
emit(None, measured=measured)
|
|
309
|
+
else:
|
|
310
|
+
print(json.dumps(measured, indent=2))
|
|
311
|
+
return 0
|
|
312
|
+
refuse("input", "audio_too_short",
|
|
313
|
+
f"input audio peaks at {stats.get('input_tp')} dBTP but is shorter than one 400 ms loudness block, so it has "
|
|
314
|
+
"no integrated loudness to normalise from (this is not silence)", input_tp=stats.get("input_tp"))
|
|
248
315
|
if stats.get("silent"):
|
|
249
316
|
info("audio is silent (integrated loudness -inf); nothing to normalise")
|
|
250
317
|
if args.measure_only:
|
package/scripts/multicam.py
CHANGED
|
@@ -33,7 +33,7 @@ import sys
|
|
|
33
33
|
from typing import Dict, List, Tuple
|
|
34
34
|
|
|
35
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_args, X264_PRESETS, fmt_secs, decode_pcm_mono, rms_envelope, STATE
|
|
36
|
-
from _common.emit import note, record_fallback, warn
|
|
36
|
+
from _common.emit import note, record_fallback, record_skip, warn
|
|
37
37
|
from _common.decision import av_trim, rebase_project_paths
|
|
38
38
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
39
39
|
from sync import measure_offset
|
|
@@ -179,9 +179,13 @@ def main() -> int:
|
|
|
179
179
|
offsets = [0.0]
|
|
180
180
|
ratios = [1.0]
|
|
181
181
|
conf = [1.0]
|
|
182
|
+
# A131: per camera, why --fix-drift measured no drift (too_short / low_confidence, as sync.py
|
|
183
|
+
# says it); None when it was measured, and for the reference, which is the clock (0 by definition)
|
|
184
|
+
drift_skipped: List[object] = [None]
|
|
182
185
|
for p in args.inputs[1:]:
|
|
183
186
|
off, score = measure_offset(args.inputs[0], p, 0.0, args.analyze_seconds, 20.0, args.max_offset, 1.0)
|
|
184
187
|
ratio = 1.0
|
|
188
|
+
skipped_reason = None
|
|
185
189
|
if args.fix_drift:
|
|
186
190
|
ref_dur = metas[0]["duration"] or 0.0
|
|
187
191
|
sec_dur = probe(p)["duration"] or 0.0
|
|
@@ -189,7 +193,11 @@ def main() -> int:
|
|
|
189
193
|
window = 60.0
|
|
190
194
|
head_len = min(args.analyze_seconds, overlap_end)
|
|
191
195
|
tail_start = overlap_end - window
|
|
192
|
-
if tail_start
|
|
196
|
+
if tail_start <= head_len / 2 + 5:
|
|
197
|
+
skipped_reason = "too_short"
|
|
198
|
+
record_skip(f"{p} too short to measure drift reliably; skipping drift correction", "drift_correction_skipped",
|
|
199
|
+
path=p, reason=skipped_reason, overlap_seconds=round(overlap_end, 3), drift_window=window)
|
|
200
|
+
else:
|
|
193
201
|
ref_start, sec_start = tail_start, tail_start - off
|
|
194
202
|
if sec_start < 0:
|
|
195
203
|
ref_start -= sec_start
|
|
@@ -204,10 +212,18 @@ def main() -> int:
|
|
|
204
212
|
if elapsed > 0 and sc > 0.1:
|
|
205
213
|
ratio = 1.0 - residual / elapsed
|
|
206
214
|
off = off + (ratio - 1.0) * (head_len / 2)
|
|
215
|
+
else:
|
|
216
|
+
skipped_reason = "low_confidence"
|
|
217
|
+
record_skip(f"could not measure drift with confidence for {p}; skipping drift correction",
|
|
218
|
+
"drift_correction_skipped", path=p, reason=skipped_reason,
|
|
219
|
+
confidence=round(max(0.0, sc), 3), measured_over_seconds=round(elapsed, 2))
|
|
220
|
+
drift_skipped.append(skipped_reason)
|
|
207
221
|
offsets.append(off)
|
|
208
222
|
ratios.append(ratio)
|
|
209
223
|
conf.append(score)
|
|
210
|
-
info(f"{p}: offset {off:+.3f}s (confidence {score:.2f})"
|
|
224
|
+
info(f"{p}: offset {off:+.3f}s (confidence {score:.2f})"
|
|
225
|
+
+ (f", drift {(ratio - 1) * 1e6:+.0f} ppm" if args.fix_drift and not skipped_reason else
|
|
226
|
+
f", drift not measured ({skipped_reason})" if args.fix_drift else ""))
|
|
211
227
|
if score < 0.1:
|
|
212
228
|
warn(f"{p} has low correlation confidence ({score:.2f}); check that it shares an audio event with the reference before "
|
|
213
229
|
"trusting this offset", "sync_low_confidence", path=p, confidence=round(score, 3))
|
|
@@ -215,7 +231,8 @@ def main() -> int:
|
|
|
215
231
|
report = {"inputs": args.inputs, "offsets_seconds": [round(o, 4) for o in offsets],
|
|
216
232
|
"confidence": [round(c, 3) for c in conf]}
|
|
217
233
|
if args.fix_drift:
|
|
218
|
-
report["drift_ppm"] = [round((r - 1) * 1e6, 1) for r in ratios]
|
|
234
|
+
report["drift_ppm"] = [None if why else round((r - 1) * 1e6, 1) for r, why in zip(ratios, drift_skipped)]
|
|
235
|
+
report["drift_skipped_reason"] = drift_skipped
|
|
219
236
|
if args.offsets_only:
|
|
220
237
|
emit(None, **report)
|
|
221
238
|
if not args.json:
|
package/scripts/overlay.py
CHANGED
|
@@ -388,6 +388,12 @@ def main() -> int:
|
|
|
388
388
|
end = time_arg(args.end, "--end", fps) if args.end else None
|
|
389
389
|
if start is not None and end is not None and end <= start:
|
|
390
390
|
die("--end must be after --start")
|
|
391
|
+
main_len = (meta.get("video") or {}).get("duration") or meta.get("duration")
|
|
392
|
+
if start is not None and main_len and start >= main_len:
|
|
393
|
+
# A172: an overlay that starts after the picture ends draws nothing, and the run said
|
|
394
|
+
# verified: true; --place already refuses the same case
|
|
395
|
+
die(f"--start {start:g} is at or after the end of the video ({main_len:.3f} s): nothing would be drawn",
|
|
396
|
+
hint="give a --start inside the clip")
|
|
391
397
|
if not 0 <= args.opacity <= 1:
|
|
392
398
|
die("--opacity must be within 0..1")
|
|
393
399
|
if args.chromakey and not args.video:
|
package/scripts/pad.py
CHANGED
|
@@ -16,6 +16,7 @@ Examples:
|
|
|
16
16
|
import argparse
|
|
17
17
|
import sys
|
|
18
18
|
|
|
19
|
+
from _common.emit import record_fallback
|
|
19
20
|
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, drawn_chain, emit, EVEN_SCALE, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs, run
|
|
20
21
|
|
|
21
22
|
|
|
@@ -58,6 +59,11 @@ def main() -> int:
|
|
|
58
59
|
# streams runs on 5.1-8.1 alike. run() only adds an even scale to a -vf, so add it here.
|
|
59
60
|
v = meta["video"]
|
|
60
61
|
even = EVEN_SCALE + "," if (v.get("width") or 0) % 2 or (v.get("height") or 0) % 2 else ""
|
|
62
|
+
if even:
|
|
63
|
+
# A239: the runner's retry records this when it adds the scale; here it is added up
|
|
64
|
+
# front (one -filter_complex), so the record is made here, as broll and stabilize do
|
|
65
|
+
record_fallback(f"{args.input} is {v.get('width')}x{v.get('height')}: scaled to even dimensions first",
|
|
66
|
+
"odd_dimensions_scaled_even", output=output, scale=EVEN_SCALE)
|
|
61
67
|
af = f"adelay={int(args.start * 1000)}:all=1,apad=pad_dur={args.end:.3f}"
|
|
62
68
|
cmd += ["-filter_complex", f"[0:v:0]{even}{vf}[v];[0:a:0]{af}[a]", "-map", "[v]", "-map", "[a]"]
|
|
63
69
|
else:
|
package/scripts/render.py
CHANGED
|
@@ -391,7 +391,8 @@ def render_pack(names: List[str], args) -> int:
|
|
|
391
391
|
size = os.path.getsize(out) if os.path.exists(out) else 0
|
|
392
392
|
rows.append({"platform": name, "file": os.path.basename(out), "path": out,
|
|
393
393
|
"size_bytes": size, "duration": probe_doc.get("duration"),
|
|
394
|
-
|
|
394
|
+
# a destination whose check did not run is "not checked", not "pass" (the class of A113)
|
|
395
|
+
"check": ("pass" if chk.get("ok") else ("%d FAIL" % chk["failed"]) if chk.get("failed") else ("not checked" if ok else "failed")),
|
|
395
396
|
"ok": bool(ok)})
|
|
396
397
|
outputs.append(out)
|
|
397
398
|
if not ok:
|
|
@@ -421,7 +422,8 @@ def render_pack(names: List[str], args) -> int:
|
|
|
421
422
|
code=stopped[0]["exit_code"] if stopped else 1, kind=stopped[0]["kind"] if stopped else "verification",
|
|
422
423
|
output=pack, dry_run=STATE.dry_run, pack=rows, outputs=outputs)
|
|
423
424
|
emit(pack, pack=rows, outputs=outputs, stages=["pack"],
|
|
424
|
-
|
|
425
|
+
# a destination whose check did not run has no check step: "not checked" is not ok: true
|
|
426
|
+
verification=[{"step": "check", "ok": True, "platform": r["platform"]} for r in rows if r["check"] == "pass"])
|
|
425
427
|
return 0
|
|
426
428
|
|
|
427
429
|
|
|
@@ -1186,6 +1188,31 @@ def _has_sound(current: str, proj: Dict[str, Any], rel: Any) -> bool:
|
|
|
1186
1188
|
return False
|
|
1187
1189
|
|
|
1188
1190
|
|
|
1191
|
+
def planned_clip_length(clip: Dict[str, Any], src: str, index: int) -> float:
|
|
1192
|
+
"""A dry run's length of clip `index` before its speed stage: the source's measured length,
|
|
1193
|
+
or its in..out range of it (the cut a real run measures does not exist yet). Only when neither
|
|
1194
|
+
can be read is the speed planned on a stand-in 10 s, and that is recorded (A97): a plan that
|
|
1195
|
+
gave no sign its fit.py --duration came from an invented number read as a measured one."""
|
|
1196
|
+
from _common.decision import parse_time
|
|
1197
|
+
length = fps = None
|
|
1198
|
+
if os.path.isfile(src):
|
|
1199
|
+
meta = probe(src)
|
|
1200
|
+
length = meta.get("duration") or None
|
|
1201
|
+
# hh:mm:ss:ff needs the source's rate, which the real cut reads from the same place
|
|
1202
|
+
fps = (meta.get("video") or {}).get("fps") or None
|
|
1203
|
+
try:
|
|
1204
|
+
start = parse_time(str(clip["in"]), fps) if clip.get("in") is not None else 0.0
|
|
1205
|
+
end = parse_time(str(clip["out"]), fps) if clip.get("out") is not None else length
|
|
1206
|
+
except ValueError:
|
|
1207
|
+
start, end = 0.0, None
|
|
1208
|
+
if end is not None and end - start > 0:
|
|
1209
|
+
return end - start
|
|
1210
|
+
record_skip(f"[dry-run] clip {index}: its length could not be read before the cut exists, so the speed stage is "
|
|
1211
|
+
"planned on a stand-in 10 s; a real run measures the cut", "speed_length_placeholder",
|
|
1212
|
+
stage="clips", clip=index, path=src, placeholder_seconds=10.0)
|
|
1213
|
+
return 10.0
|
|
1214
|
+
|
|
1215
|
+
|
|
1189
1216
|
def main() -> int:
|
|
1190
1217
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
1191
1218
|
ap.add_argument("project", nargs="?", help="project.json, or a plan.json written by <tool> --plan")
|
|
@@ -1474,7 +1501,7 @@ def main() -> int:
|
|
|
1474
1501
|
if not (spd > 0) or spd != spd or spd == float("inf"):
|
|
1475
1502
|
die(f"clip {i}: speed must be a positive number, got {c['speed']!r}")
|
|
1476
1503
|
if abs(spd - 1.0) > 1e-6: # speed 1.0 used to cost a full re-encode for nothing
|
|
1477
|
-
dur = (probe(part).get("duration") or 0.0) if not STATE.dry_run else
|
|
1504
|
+
dur = (probe(part).get("duration") or 0.0) if not STATE.dry_run else planned_clip_length(c, src, i)
|
|
1478
1505
|
fitted = str(work / f"clip{i:02d}_speed{mid}")
|
|
1479
1506
|
sh("fit.py", part, "--duration", f"{dur / spd:.3f}", "-o", fitted, stage="clips")
|
|
1480
1507
|
part = fitted
|
package/scripts/scenes.py
CHANGED
|
@@ -30,7 +30,7 @@ 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
32
|
from _common.color import bt709_label_filter # noqa: E402
|
|
33
|
-
from _common.probe import square_pixels # noqa: E402
|
|
33
|
+
from _common.probe import PROBED_VIDEO, square_pixels # noqa: E402
|
|
34
34
|
from _common import dry_run_input_pending # noqa: E402
|
|
35
35
|
|
|
36
36
|
SHOT_FPS = 4.0 # frames/second sampled for --shots' flow estimate
|
|
@@ -348,7 +348,7 @@ def main() -> int:
|
|
|
348
348
|
vf = (f"select='{expr}'," + (lab + "," if lab else "") + (square + "," if square else "")
|
|
349
349
|
+ f"scale={tile_w}:-2{stamp},"
|
|
350
350
|
f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
|
|
351
|
-
run(ffmpeg_base() + ["-i", args.input, "-an", "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", "-update", "1", args.sheet])
|
|
351
|
+
run(ffmpeg_base() + ["-i", args.input, "-map", f"0:{PROBED_VIDEO}", "-an", "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", "-update", "1", args.sheet])
|
|
352
352
|
info(f"wrote {args.sheet}")
|
|
353
353
|
result["sheet"] = args.sheet
|
|
354
354
|
|
package/scripts/silence.py
CHANGED
|
@@ -34,6 +34,7 @@ from _common import guard_sidecar, refuse_existing_outputs, refuse_output_is_inp
|
|
|
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
36
|
from _common.decision import frame_rate, stream_starts
|
|
37
|
+
from _common.probe import PROBED_AUDIO
|
|
37
38
|
|
|
38
39
|
|
|
39
40
|
|
|
@@ -305,7 +306,8 @@ def cut_levels(path: str, tm: Dict[str, Any], duration: float,
|
|
|
305
306
|
out = []
|
|
306
307
|
with tempfile.TemporaryDirectory(prefix="silence-levels-") as tmp:
|
|
307
308
|
raw = os.path.join(tmp, "pcm.s16le")
|
|
308
|
-
proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path,
|
|
309
|
+
proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path,
|
|
310
|
+
"-map", f"0:{PROBED_AUDIO}", "-vn",
|
|
309
311
|
"-ac", str(channels), "-ar", str(CUT_LEVEL_RATE), "-f", "s16le", "-y", raw],
|
|
310
312
|
check=False, text=False)
|
|
311
313
|
if proc.returncode != 0 or not os.path.isfile(raw) or not os.path.getsize(raw):
|
package/scripts/stabilize.py
CHANGED
|
@@ -27,6 +27,8 @@ import sys
|
|
|
27
27
|
import tempfile
|
|
28
28
|
from pathlib import Path
|
|
29
29
|
|
|
30
|
+
from _common.probe import PROBED_VIDEO
|
|
31
|
+
from _common.runner import default_selection_maps
|
|
30
32
|
from _common import STATE, 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
|
|
31
33
|
|
|
32
34
|
|
|
@@ -79,8 +81,9 @@ def main() -> int:
|
|
|
79
81
|
# A frame number, not a boolean: frame 1 is the standard reference for "lock to
|
|
80
82
|
# this fixed frame" (mirrors vidstabtransform's own tripod=1 below).
|
|
81
83
|
detect_vf += ":tripod=1"
|
|
84
|
+
# both passes read the picture probe() describes (its size chose the even scale above)
|
|
82
85
|
detect_cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", args.input,
|
|
83
|
-
"-vf", detect_vf, "-f", "null", "-"]
|
|
86
|
+
"-map", f"0:{PROBED_VIDEO}", "-vf", detect_vf, "-f", "null", "-"]
|
|
84
87
|
# Pass 1 is a measurement into a temp file (the transforms), so it runs under --dry-run
|
|
85
88
|
# as well; only pass 2, the write, is skipped there.
|
|
86
89
|
if not dry_run_input_pending(args.input):
|
|
@@ -94,7 +97,7 @@ def main() -> int:
|
|
|
94
97
|
# Equivalent to relative=0:smoothing=0 -- overrides --smoothing, since averaging a
|
|
95
98
|
# camera path makes no sense once every frame is locked to one fixed reference.
|
|
96
99
|
transform_vf += ":tripod=1"
|
|
97
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-vf", transform_vf]
|
|
100
|
+
cmd = ffmpeg_base() + ["-i", args.input] + default_selection_maps(meta, output) + ["-vf", transform_vf]
|
|
98
101
|
cmd += video_args(meta, args.crf, args.preset)
|
|
99
102
|
cmd += cfr_args(meta)
|
|
100
103
|
if has_audio:
|
package/scripts/sync.py
CHANGED
|
@@ -37,6 +37,7 @@ from _common import video_args, add_common, apply_common, emit, aac_args, audio_
|
|
|
37
37
|
from _common.emit import record_skip, warn
|
|
38
38
|
from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, stream_starts
|
|
39
39
|
from _common.timeline import output_timecode_args
|
|
40
|
+
from _common.runner import default_selection_maps
|
|
40
41
|
|
|
41
42
|
SR = 8000 # decode sample rate
|
|
42
43
|
|
|
@@ -392,14 +393,15 @@ def main() -> int:
|
|
|
392
393
|
# Even a keyframe on the trim point moved the picture by its B-frame delay (+69 ms).
|
|
393
394
|
# The re-encode below starts on the frame itself; a sound-only file is still copied.
|
|
394
395
|
if head_trim > 0 and not drift_af and not has_video:
|
|
395
|
-
|
|
396
|
+
# the second file's first audio track: the one decode_pcm_mono() measured
|
|
397
|
+
cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second] + default_selection_maps(second_meta, output) + ["-c", "copy"]
|
|
396
398
|
if os.path.splitext(output)[1].lower() in (".mp4", ".mov", ".m4v"):
|
|
397
399
|
cmd += ["-movflags", "+faststart"]
|
|
398
400
|
cmd += ["-avoid_negative_ts", "make_zero", output]
|
|
399
401
|
proc = run(cmd, check=False)
|
|
400
402
|
fail_if_classified(proc)
|
|
401
403
|
if proc.returncode != 0:
|
|
402
|
-
cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second] + (video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf) if has_video else []) + audio_codec_for(output) + [output]
|
|
404
|
+
cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second] + default_selection_maps(second_meta, output) + (video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf) if has_video else []) + audio_codec_for(output) + [output]
|
|
403
405
|
run(cmd)
|
|
404
406
|
else:
|
|
405
407
|
# Seek SEEK_MARGIN early and drop the margin on the output side, as cut.py's
|
|
@@ -415,7 +417,9 @@ def main() -> int:
|
|
|
415
417
|
cmd += ["-ss", f"{head_trim - m:.6f}"]
|
|
416
418
|
if m > 0:
|
|
417
419
|
out_seek = ["-ss", f"{m / (drift_ratio if drift_af else 1.0):.6f}"]
|
|
418
|
-
|
|
420
|
+
# -map after the output-side -ss: both are output options, so the order is the
|
|
421
|
+
# same command, and the seek stays next to the -i it drops the margin from
|
|
422
|
+
cmd += ["-i", args.second] + out_seek + default_selection_maps(second_meta, output)
|
|
419
423
|
af_parts = on_clock + drift_af
|
|
420
424
|
if delay_ms > 0:
|
|
421
425
|
af_parts.append(f"adelay={delay_ms}:all=1")
|