ffmpeg-skill 3.6.5 → 3.6.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 +2 -2
- package/package.json +1 -1
- package/scripts/_common/color.py +4 -3
- package/scripts/_common/probe.py +38 -8
- package/scripts/_common/runner.py +20 -0
- package/scripts/audio.py +22 -2
- package/scripts/check.py +5 -1
- package/scripts/crop.py +3 -1
- package/scripts/cropdetect.py +2 -2
- package/scripts/cut.py +33 -6
- package/scripts/export.py +13 -8
- package/scripts/look.py +4 -4
- package/scripts/loudness.py +8 -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/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`3.6.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.6.6`) | any release |
|
|
25
25
|
|
|
26
26
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
27
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
149
149
|
"deprecated": [],
|
|
150
150
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
|
|
151
151
|
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
|
|
152
|
-
"skill": {"id": "ffmpeg-skill", "version": "3.6.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.6.6", "execution_mode": "local", "kind": "execution",
|
|
153
153
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
154
154
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
155
155
|
"requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.6.
|
|
3
|
+
"version": "3.6.6",
|
|
4
4
|
"mcpName": "io.github.kajisho5/ffmpeg-skill",
|
|
5
5
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
6
6
|
"keywords": [
|
package/scripts/_common/color.py
CHANGED
|
@@ -74,7 +74,8 @@ def is_rgb(meta: Optional[Dict[str, Any]]) -> bool:
|
|
|
74
74
|
_EVEN_PAD = "pad=ceil(iw/2)*2:ceil(ih/2)*2:0:0,"
|
|
75
75
|
_MATRIX_FILTER_RE = re.compile(r"^(?:(?:%s)?colorspace=ispace=(?:%s):iprimaries=bt709:itrc=bt709:space=bt709:primaries=bt709:trc=bt709(?:,crop=iw-[01]:ih-[01]:0:0:exact=1)?|(?:format=rgb24,)?%s)$"
|
|
76
76
|
% (re.escape(_EVEN_PAD), "|".join(NON_BT709_MATRICES), re.escape(rgb_to_bt709_filter())))
|
|
77
|
-
|
|
77
|
+
# [N:v], [N:v:0] and [N:V:0] (PROBED_VIDEO: the motion picture, not an attached cover)
|
|
78
|
+
_INPUT_VIDEO_REF = re.compile(r"\[(\d+):[vV](?::0)?\]")
|
|
78
79
|
|
|
79
80
|
|
|
80
81
|
def bt709_matrix_filter(meta: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
@@ -263,7 +264,7 @@ def fold_bt709_matrix(cmd: Sequence[str]) -> List[str]:
|
|
|
263
264
|
|
|
264
265
|
- no other filter: unchanged (the -vf is the graph);
|
|
265
266
|
- a -vf: the conversion is prepended to that chain;
|
|
266
|
-
- a -filter_complex: every `[N:v]` / `[N:v:0]` reference to an input that reads the same file
|
|
267
|
+
- a -filter_complex: every `[N:v]` / `[N:v:0]` / `[N:V:0]` reference to an input that reads the same file
|
|
267
268
|
as input 0 (the file the encoder line was built for; cut.py's segment join opens it once
|
|
268
269
|
per segment) reads that input through the conversion instead. Other inputs are untouched.
|
|
269
270
|
|
|
@@ -298,7 +299,7 @@ def fold_bt709_matrix(cmd: Sequence[str]) -> List[str]:
|
|
|
298
299
|
graph = _INPUT_VIDEO_REF.sub(through, str(out[graph_at + 1]))
|
|
299
300
|
if not refs:
|
|
300
301
|
die("internal: a BT.709 matrix conversion was requested for a -filter_complex that never reads "
|
|
301
|
-
"input 0's video as [0:v]", kind="ffmpeg")
|
|
302
|
+
"input 0's video as [0:v] or [0:V:0]", kind="ffmpeg")
|
|
302
303
|
# one conversion per input, split when the graph reads that input more than once
|
|
303
304
|
# (speedramp.py trims [0:v] once per segment)
|
|
304
305
|
chains = [f"{ref}{conv}" + (f",split={len(labels)}" if len(labels) > 1 else "") + "".join(labels)
|
package/scripts/_common/probe.py
CHANGED
|
@@ -15,6 +15,14 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
|
15
15
|
from _common.emit import die, warn
|
|
16
16
|
from _common.runner import STATE, dry_run_input_pending, require_tool, run, run_analysis, still_input_args
|
|
17
17
|
|
|
18
|
+
# The streams probe() describes, as stream specifiers: its `video` is the first picture that is not
|
|
19
|
+
# a cover (`V`), its `audio` the first sound stream. Without a -map, FFmpeg picks the largest
|
|
20
|
+
# picture and the sound with the default disposition or the most channels, so on a file with two
|
|
21
|
+
# of either a tool measured one stream and wrote another: loudness measured track 1 and applied the
|
|
22
|
+
# gain to track 0, cut and crop wrote the larger picture they never probed (A110, A118, A128, A258).
|
|
23
|
+
PROBED_VIDEO = "V:0"
|
|
24
|
+
PROBED_AUDIO = "a:0"
|
|
25
|
+
|
|
18
26
|
|
|
19
27
|
def fingerprint(path: str) -> Dict[str, Any]:
|
|
20
28
|
"""Size plus a sha256 over the first and last 8 MiB: enough to notice a re-export, a re-trim
|
|
@@ -50,7 +58,8 @@ def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None
|
|
|
50
58
|
# first_pts=0 pads a sound that starts after the file's start (or after `start`) with silence,
|
|
51
59
|
# so sample i is at start + i / sample_rate on the file's clock: sync.py measured a track that
|
|
52
60
|
# starts 0.3 s late 0.279 s off, and --replace-audio placed it that far late (#409)
|
|
53
|
-
|
|
61
|
+
# the first audio track, the one probe() describes (default selection takes the most channels)
|
|
62
|
+
cmd += ["-map", f"0:{PROBED_AUDIO}", "-vn", "-af", "aresample=async=1:first_pts=0", "-ac", "1", "-ar", str(sample_rate), "-f", "s16le", "-"]
|
|
54
63
|
proc = run_analysis(cmd, check=False, text=False)
|
|
55
64
|
if proc.returncode != 0 or not proc.stdout:
|
|
56
65
|
if check:
|
|
@@ -99,7 +108,7 @@ def decode_gray_frames(path: str, fps: float, width: int, height: int, *, start:
|
|
|
99
108
|
cmd += ["-i", path]
|
|
100
109
|
if seconds is not None:
|
|
101
110
|
cmd += ["-t", f"{max(0.0, seconds):.3f}"]
|
|
102
|
-
cmd += ["-vf", f"fps={fps:g},scale={width}:{height}:flags=area,format=gray",
|
|
111
|
+
cmd += ["-map", f"0:{PROBED_VIDEO}", "-vf", f"fps={fps:g},scale={width}:{height}:flags=area,format=gray",
|
|
103
112
|
"-f", "rawvideo", "-"]
|
|
104
113
|
proc = run_analysis(cmd, check=False, text=False)
|
|
105
114
|
if proc.returncode != 0 or not proc.stdout:
|
|
@@ -422,6 +431,26 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
422
431
|
_CARRY: Dict[str, Dict[str, Any]] = {}
|
|
423
432
|
|
|
424
433
|
|
|
434
|
+
def probed_maps(meta: Optional[Dict[str, Any]], *, video: bool = True, audio: bool = True,
|
|
435
|
+
audio_stream: int = 0, input_index: int = 0) -> List[str]:
|
|
436
|
+
"""`-map` arguments for the picture and sound probe() describes in `meta` (audio track
|
|
437
|
+
`audio_stream` where a tool takes --audio-stream). A kind the file does not have is left out,
|
|
438
|
+
so the same call serves a silent clip and an audio-only file."""
|
|
439
|
+
out: List[str] = []
|
|
440
|
+
if video and (meta or {}).get("video"):
|
|
441
|
+
out += ["-map", f"{input_index}:{PROBED_VIDEO}"]
|
|
442
|
+
if audio and (meta or {}).get("audio"):
|
|
443
|
+
out += ["-map", f"{input_index}:a:{audio_stream}"]
|
|
444
|
+
return out
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def several_av_streams(meta: Optional[Dict[str, Any]]) -> bool:
|
|
448
|
+
"""True when FFmpeg's default stream selection could pick a picture or a sound other than the
|
|
449
|
+
one probe() describes: more than one playable video stream or more than one audio stream."""
|
|
450
|
+
counts = (meta or {}).get("stream_counts") or {}
|
|
451
|
+
return (counts.get("video") or 0) > 1 or (counts.get("audio") or 0) > 1
|
|
452
|
+
|
|
453
|
+
|
|
425
454
|
def carry_inventory(path: str) -> "Optional[Dict[str, Any]]":
|
|
426
455
|
"""The carry-over items probe() last read off `path` (see emit()'s source_carry), or None."""
|
|
427
456
|
return _CARRY.get(os.path.realpath(path)) if path else None
|
|
@@ -628,10 +657,11 @@ def measured_level_dbfs(path: str, seconds: Optional[float] = 120.0,
|
|
|
628
657
|
audio_stream: Optional[int] = None) -> Optional[Dict[str, float]]:
|
|
629
658
|
"""Mean and peak level of the first `seconds` of audio (volumedetect), in dBFS; None if unmeasurable.
|
|
630
659
|
Cheap enough to run once as a hint when a threshold-based tool found nothing. seconds=None
|
|
631
|
-
measures the whole file; audio_stream picks the Nth audio stream (default:
|
|
660
|
+
measures the whole file; audio_stream picks the Nth audio stream (default: the first, the one
|
|
661
|
+
probe() describes; FFmpeg's own choice was the track with the most channels)."""
|
|
632
662
|
ffmpeg = require_tool("ffmpeg")
|
|
633
663
|
head = ["-t", f"{seconds:.0f}"] if seconds is not None else []
|
|
634
|
-
pick = ["-map", f"0:a:{audio_stream}"]
|
|
664
|
+
pick = ["-map", f"0:a:{audio_stream or 0}"]
|
|
635
665
|
proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", *head, "-i", path, *pick, "-vn",
|
|
636
666
|
"-af", "volumedetect", "-f", "null", "-"], check=False)
|
|
637
667
|
m_mean = re.search(r"mean_volume:\s*(-?[0-9.]+) dB", proc.stderr)
|
|
@@ -751,7 +781,7 @@ def measure_signal_range(path: str, standard: str, window: float = 10.0,
|
|
|
751
781
|
stats_file = os.path.join(tmp, "stats.txt")
|
|
752
782
|
vf = (f"signalstats=stat=brng,metadata=print:file={escape_filter_path(stats_file)},"
|
|
753
783
|
f"{mask},signalstats,metadata=print:file=-")
|
|
754
|
-
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", "0:
|
|
784
|
+
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", f"0:{PROBED_VIDEO}", "-an", "-sn", "-dn", "-vf", vf, "-f", "null", "-"]
|
|
755
785
|
proc = run_analysis(cmd, check=False, record=True)
|
|
756
786
|
try:
|
|
757
787
|
with open(stats_file, encoding="utf-8", errors="replace") as f:
|
|
@@ -804,7 +834,7 @@ def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
|
|
|
804
834
|
white below ~235 with low saturation: the image looks grey and flat but is tagged as plain SDR.
|
|
805
835
|
"""
|
|
806
836
|
ffmpeg = require_tool("ffmpeg")
|
|
807
|
-
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.1f}", "-i", path, "-an",
|
|
837
|
+
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.1f}", "-i", path, "-map", f"0:{PROBED_VIDEO}", "-an",
|
|
808
838
|
"-vf", "fps=2,signalstats,metadata=print:file=-", "-f", "null", "-"]
|
|
809
839
|
proc = run_analysis(cmd, check=False)
|
|
810
840
|
vals: Dict[str, List[float]] = {}
|
|
@@ -976,7 +1006,7 @@ def detect_silences(path: str, threshold: float, min_silence: float) -> "List[Tu
|
|
|
976
1006
|
if dry_run_input_pending(path):
|
|
977
1007
|
return []
|
|
978
1008
|
ffmpeg = require_tool("ffmpeg")
|
|
979
|
-
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af",
|
|
1009
|
+
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", f"0:{PROBED_AUDIO}", "-vn", "-af",
|
|
980
1010
|
f"silencedetect=noise={threshold}dB:d={min_silence}", "-f", "null", "-"]
|
|
981
1011
|
proc = run_analysis(cmd, check=False, record=True)
|
|
982
1012
|
if proc.returncode != 0:
|
|
@@ -1188,7 +1218,7 @@ def detect_scenes(path: str, threshold: float, min_len: float, duration: float,
|
|
|
1188
1218
|
a real cut is a one-frame spike. On real footage this roughly doubles precision at
|
|
1189
1219
|
equal recall compared with the raw scdet threshold."""
|
|
1190
1220
|
ffmpeg = require_tool("ffmpeg")
|
|
1191
|
-
proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-an", "-vf",
|
|
1221
|
+
proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", f"0:{PROBED_VIDEO}", "-an", "-vf",
|
|
1192
1222
|
"scale=320:-2,scdet=threshold=0,metadata=print:file=-", "-f", "null", "-"])
|
|
1193
1223
|
# No `sc_pass=1` on scdet: on FFmpeg 5.x that option means "pass only the frames whose
|
|
1194
1224
|
# score exceeds the threshold", so every truly static frame (score exactly 0 -- a title
|
|
@@ -2045,6 +2045,26 @@ def subtitle_container_holds(codec: str, out_ext: str) -> bool:
|
|
|
2045
2045
|
return True
|
|
2046
2046
|
|
|
2047
2047
|
|
|
2048
|
+
def default_selection_maps(meta: Optional[Dict[str, Any]], dst: str) -> List[str]:
|
|
2049
|
+
"""The -map arguments for a re-encode that would otherwise rely on ffmpeg's default stream
|
|
2050
|
+
selection: none for a file with one picture and one sound (the default picks those, and a
|
|
2051
|
+
subtitle stream the container can hold, as it always did), and with several, the picture
|
|
2052
|
+
and sound probe() describes plus the first subtitle stream when `dst`'s container can hold it
|
|
2053
|
+
-- what the default would have carried, without its largest-picture / most-channels choice
|
|
2054
|
+
(A258, #484 review)."""
|
|
2055
|
+
from _common.probe import probed_maps, several_av_streams # probe imports this module
|
|
2056
|
+
if not several_av_streams(meta):
|
|
2057
|
+
return []
|
|
2058
|
+
maps = probed_maps(meta)
|
|
2059
|
+
subs = (meta or {}).get("subtitle_stream_details") or []
|
|
2060
|
+
codec, ext = (subs[0].get("codec") or "") if subs else "", os.path.splitext(dst)[1]
|
|
2061
|
+
if subs and subtitle_container_holds(codec, ext):
|
|
2062
|
+
# with its own -c:s: the default subtitle encoder does not exist for every container
|
|
2063
|
+
# (none for MP4 in FFmpeg 6.1) and is a text encoder that cannot take a bitmap track
|
|
2064
|
+
maps += ["-map", "0:s:0", "-c:s", subtitle_codec_for(codec, ext)]
|
|
2065
|
+
return maps
|
|
2066
|
+
|
|
2067
|
+
|
|
2048
2068
|
def run_keeping_subtitles(cmd: List[str], output: str, relabel_timecode: bool = False) -> bool:
|
|
2049
2069
|
"""Run an ffmpeg command that already maps its video/audio, trying first to also
|
|
2050
2070
|
stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
|
package/scripts/audio.py
CHANGED
|
@@ -157,6 +157,22 @@ def find_silent_tracks(args: argparse.Namespace, problems: List[Dict[str, Any]])
|
|
|
157
157
|
return silent
|
|
158
158
|
|
|
159
159
|
|
|
160
|
+
def main_track_channels(args: argparse.Namespace, meta: Dict[str, Any]) -> int:
|
|
161
|
+
"""Channel count of the track audio.py processes: --replace's first audio track, else the
|
|
162
|
+
input's --audio-stream track; 0 when it is not known (a dry run's pending input)."""
|
|
163
|
+
if args.replace:
|
|
164
|
+
if dry_run_input_pending(args.replace) or not os.path.isfile(args.replace):
|
|
165
|
+
return 0
|
|
166
|
+
proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "a:0", "-show_entries",
|
|
167
|
+
"stream=channels", "-of", "csv=p=0", args.replace], quiet=True, check=False)
|
|
168
|
+
first = (proc.stdout or "").strip().splitlines()[:1]
|
|
169
|
+
return int(first[0]) if first and first[0].strip().isdigit() else 0
|
|
170
|
+
streams = meta.get("audio_streams") or []
|
|
171
|
+
if 0 <= args.audio_stream < len(streams):
|
|
172
|
+
return streams[args.audio_stream].get("channels") or 0
|
|
173
|
+
return (meta.get("audio") or {}).get("channels") or 0
|
|
174
|
+
|
|
175
|
+
|
|
160
176
|
def main() -> int:
|
|
161
177
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
162
178
|
ap.add_argument("input")
|
|
@@ -279,7 +295,10 @@ def main() -> int:
|
|
|
279
295
|
die(f"--audio-stream {args.audio_stream}: input has {len(streams)} audio stream(s), 0..{len(streams) - 1}")
|
|
280
296
|
if args.audio_stream and not streams and not STATE.dry_run:
|
|
281
297
|
die("--audio-stream needs an input with audio streams")
|
|
282
|
-
|
|
298
|
+
# the channels of the track the graph processes (main_src below): the --audio-stream track,
|
|
299
|
+
# or --replace's first track. meta["audio"] is the input's first track, so a mono a:0 left a
|
|
300
|
+
# stereo --audio-stream 1 unfolded by --mono, and a stereo a:0 halved a mono one (A128)
|
|
301
|
+
in_channels = main_track_channels(args, meta)
|
|
283
302
|
if args.stereo_widen is not None:
|
|
284
303
|
if args.mono:
|
|
285
304
|
die("--stereo-widen and --mono contradict each other: there is no stereo image in a 1-channel output")
|
|
@@ -324,7 +343,8 @@ def main() -> int:
|
|
|
324
343
|
if args.limit:
|
|
325
344
|
fx.append(dynamics_filter("alimiter", args))
|
|
326
345
|
if args.mono:
|
|
327
|
-
|
|
346
|
+
# after --downmix the track is the stereo fold-down, whatever the source had
|
|
347
|
+
in_ch = 2 if args.downmix else (in_channels or 2)
|
|
328
348
|
if in_ch == 2:
|
|
329
349
|
fx.append("pan=mono|c0=0.5*c0+0.5*c1")
|
|
330
350
|
elif in_ch > 2:
|
package/scripts/check.py
CHANGED
|
@@ -47,6 +47,7 @@ from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, re
|
|
|
47
47
|
from _common.probe import (CHANGE_FLOOR, CHANGE_GRID, change_mask_tiles, change_tiles, LEGAL_RANGE_CHOICES, measure_signal_range, COMPARE_SSIM_WARN, CONTENT_FLAT_STD, SILENT_PEAK_DBFS, compare_pictures, detect_content,
|
|
48
48
|
detect_flashes, first_frame_stats, presentation_times, measured_level_dbfs)
|
|
49
49
|
from _common.emit import file_identity
|
|
50
|
+
from _common.probe import PROBED_AUDIO
|
|
50
51
|
from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
|
|
51
52
|
|
|
52
53
|
# The one delivery table (scripts/_platforms.py): check.py's rows, export.py's presets and the
|
|
@@ -61,7 +62,10 @@ def measure_loudness(path: str) -> Dict[str, float]:
|
|
|
61
62
|
if dry_run_input_pending(path):
|
|
62
63
|
return {}
|
|
63
64
|
ffmpeg = require_tool("ffmpeg")
|
|
64
|
-
|
|
65
|
+
# the first audio track, the one the audio row's probe facts describe (and the one export and
|
|
66
|
+
# loudness write): default selection measured the track with the most channels
|
|
67
|
+
proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", f"0:{PROBED_AUDIO}", "-vn",
|
|
68
|
+
"-af", "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"], check=False, record=True)
|
|
65
69
|
m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr, re.S)
|
|
66
70
|
if not m:
|
|
67
71
|
return {}
|
package/scripts/crop.py
CHANGED
|
@@ -23,6 +23,7 @@ import sys
|
|
|
23
23
|
|
|
24
24
|
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
|
|
25
25
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
26
|
+
from _common.runner import default_selection_maps
|
|
26
27
|
from _common.emit import note
|
|
27
28
|
|
|
28
29
|
|
|
@@ -65,7 +66,8 @@ def main() -> int:
|
|
|
65
66
|
if square_pixels(meta["video"]):
|
|
66
67
|
vf.insert(0, square_pixels(meta["video"]))
|
|
67
68
|
note(square_pixels_note(meta["video"]))
|
|
68
|
-
|
|
69
|
+
# the picture the rectangle was checked against, not the largest one default selection takes
|
|
70
|
+
cmd = ffmpeg_base() + ["-i", args.input] + default_selection_maps(meta, output) + ["-vf", ",".join(vf)]
|
|
69
71
|
cmd += video_args(meta, args.crf, args.preset)
|
|
70
72
|
cmd += cfr_args(meta, args.fps)
|
|
71
73
|
if has_audio:
|
package/scripts/cropdetect.py
CHANGED
|
@@ -35,7 +35,7 @@ from typing import Dict, List, Optional, Tuple
|
|
|
35
35
|
|
|
36
36
|
from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool, run_analysis, decode_gray_frames, frame_flow
|
|
37
37
|
from _common.emit import note, warn
|
|
38
|
-
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
38
|
+
from _common.probe import PROBED_VIDEO, display_size, square_pixels, square_pixels_note
|
|
39
39
|
|
|
40
40
|
MOTION_CENTRE_FPS = 2.0
|
|
41
41
|
MOTION_CENTRE_W, MOTION_CENTRE_H = 64, 36
|
|
@@ -57,7 +57,7 @@ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int,
|
|
|
57
57
|
start = 0.0 if duration <= 0 else (duration - per_window) * i / max(1, samples - 1) if samples > 1 else 0.0
|
|
58
58
|
start = max(0.0, start)
|
|
59
59
|
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{per_window:.3f}",
|
|
60
|
-
"-vf", (square + "," if square else "") + f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
|
|
60
|
+
"-map", f"0:{PROBED_VIDEO}", "-vf", (square + "," if square else "") + f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
|
|
61
61
|
proc = run_analysis(cmd, check=False)
|
|
62
62
|
if proc.returncode != 0:
|
|
63
63
|
# One window ffmpeg cannot decode (a damaged stretch) is skipped; the other windows
|
package/scripts/cut.py
CHANGED
|
@@ -61,7 +61,8 @@ from typing import List, Tuple
|
|
|
61
61
|
from _common.runner import JsonTooDeep, fail_if_classified, read_json
|
|
62
62
|
from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
|
|
63
63
|
from _common import require_tool
|
|
64
|
-
from _common.probe import TS_EXTS, signatures_match, stream_signature
|
|
64
|
+
from _common.probe import PROBED_AUDIO, PROBED_VIDEO, TS_EXTS, probed_maps, several_av_streams, signatures_match, stream_signature
|
|
65
|
+
from _common.runner import subtitle_codec_for, subtitle_container_holds
|
|
65
66
|
from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, stream_starts
|
|
66
67
|
from _common.timeline import output_timecode_args
|
|
67
68
|
from _common import warn, record_fallback, refuse_existing_outputs, sibling_temp, source_codec_video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, place_output, refuse_output_is_input, fmt_secs, measure_frame_timing, dry_run_input_pending
|
|
@@ -147,6 +148,29 @@ def copy_args(meta: dict, dst: str) -> List[str]:
|
|
|
147
148
|
return args
|
|
148
149
|
|
|
149
150
|
|
|
151
|
+
def stream_maps(meta: dict, dst: str, copy: bool) -> List[str]:
|
|
152
|
+
"""The -map arguments of a cut: the picture and the sound probe() describes, which every
|
|
153
|
+
measurement here (keyframes, start skew, timecode, length) is taken from. ffmpeg's default
|
|
154
|
+
selection wrote the largest picture and the track with the default disposition or the most
|
|
155
|
+
channels instead (A258). A stream copy of a file with one picture and one sound keeps the
|
|
156
|
+
default selection, command for command (it also carries a subtitle stream into a container
|
|
157
|
+
whose muxer names a subtitle codec, such as Matroska). With several, the first subtitle stream
|
|
158
|
+
is mapped when the container holds it as it is, and is otherwise left out and reported
|
|
159
|
+
(DROPPED_STREAMS)."""
|
|
160
|
+
audio_only = is_audio_output(dst) or not meta.get("video")
|
|
161
|
+
if copy and not several_av_streams(meta):
|
|
162
|
+
return []
|
|
163
|
+
maps = probed_maps(meta, video=not audio_only)
|
|
164
|
+
subs = meta.get("subtitle_stream_details") or []
|
|
165
|
+
if copy and not audio_only and subs:
|
|
166
|
+
codec, ext = subs[0].get("codec") or "", os.path.splitext(dst)[1].lstrip(".").lower()
|
|
167
|
+
if subtitle_container_holds(codec, ext) and subtitle_codec_for(codec, ext) == "copy":
|
|
168
|
+
maps += ["-map", "0:s:0"]
|
|
169
|
+
else:
|
|
170
|
+
DROPPED_STREAMS.append(dst)
|
|
171
|
+
return maps
|
|
172
|
+
|
|
173
|
+
|
|
150
174
|
LOSSLESS_AUDIO = {"pcm_s16le", "pcm_s24le", "pcm_s32le", "pcm_f32le", "flac"}
|
|
151
175
|
|
|
152
176
|
|
|
@@ -870,7 +894,7 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
870
894
|
# -ss before -i seeks, then decoding discards samples up to the exact start
|
|
871
895
|
# (accurate_seek); atrim bounds the decoded stream to the requested length at sample
|
|
872
896
|
# resolution.
|
|
873
|
-
cmd = ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur:.6f}"]
|
|
897
|
+
cmd = ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur:.6f}"] + stream_maps(meta, dst, copy=False)
|
|
874
898
|
cmd += ["-af", f"atrim=end={dur:.6f},asetpts=PTS-STARTPTS"]
|
|
875
899
|
else:
|
|
876
900
|
# Seek SEEK_MARGIN early, then drop the margin on the output side, which decodes and
|
|
@@ -879,6 +903,7 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
879
903
|
# `start` and lose the frames before it (-ss 9.9 on a B-frame HEVC file began at 10.0).
|
|
880
904
|
m = min(SEEK_MARGIN, start)
|
|
881
905
|
cmd = ffmpeg_base() + ["-ss", f"{start - m:.6f}", "-i", src, "-ss", f"{m:.6f}", "-t", f"{dur:.6f}"]
|
|
906
|
+
cmd += stream_maps(meta, dst, copy=False)
|
|
882
907
|
# ffmpeg's default stream selection also picks one subtitle stream; a re-encode cannot
|
|
883
908
|
# trim it (the cues kept their timestamps and the container grew to 2 s for a 1 s cut,
|
|
884
909
|
# sweep F2), so the re-encode carries video/audio only and the result says so
|
|
@@ -901,7 +926,8 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
901
926
|
elif audio_only:
|
|
902
927
|
# output-side seek: an input seek on a video file lands on the previous video keyframe and on
|
|
903
928
|
# FLAC/MP3 on a coarse index; reading from the start and dropping packets is exact to the packet
|
|
904
|
-
cmd = ffmpeg_base() + ["-i", src, "-ss", f"{start:.6f}", "-t", f"{dur:.6f}"] +
|
|
929
|
+
cmd = (ffmpeg_base() + ["-i", src, "-ss", f"{start:.6f}", "-t", f"{dur:.6f}"] + stream_maps(meta, dst, copy=True)
|
|
930
|
+
+ copy_args(meta, dst) + ["-avoid_negative_ts", "make_zero", dst])
|
|
905
931
|
else:
|
|
906
932
|
# With --edit-list a single MP4/MOV output keeps the edit list the plain copy writes: the
|
|
907
933
|
# keyframe's pre-roll is stored but hidden, so the picture and the sound start at `start`.
|
|
@@ -913,7 +939,8 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
913
939
|
# Other concat parts (Matroska, MPEG-TS) keep make_zero.
|
|
914
940
|
edit_list = (edit_list_ok or copy_t is not None) and os.path.splitext(dst)[1].lower() in EDIT_LIST_EXTS
|
|
915
941
|
cmd = (ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur if copy_t is None else copy_t:.6f}"]
|
|
916
|
-
+
|
|
942
|
+
+ stream_maps(meta, dst, copy=True) + copy_args(meta, dst)
|
|
943
|
+
+ ([] if edit_list else ["-avoid_negative_ts", "make_zero"]) + [dst])
|
|
917
944
|
if not audio_only and (meta.get("timecode") or {}).get("value"):
|
|
918
945
|
cmd[-1:-1] = output_timecode_args(meta, first_frame_from(src, meta, start, reencode, edit_list and copy_t is None),
|
|
919
946
|
delivered_rate(meta) if reencode else None)
|
|
@@ -1098,7 +1125,7 @@ def _join_chunk(src: str, segments: List[Tuple[float, float]], dst: str, meta: d
|
|
|
1098
1125
|
# concat filter starts the next segment after the longer stream, so a short picture
|
|
1099
1126
|
# would leave a hole in the video there
|
|
1100
1127
|
pad = f"tpad=stop_mode=clone:stop_duration={e - vend + 1.0:.6f}," if first + i in hold else ""
|
|
1101
|
-
graph.append(f"[{i}:
|
|
1128
|
+
graph.append(f"[{i}:{PROBED_VIDEO}]{pad}trim=start={a - half:.6f}:end={b - half:.6f},setpts=PTS-{a:.6f}/TB[v{i}]")
|
|
1102
1129
|
pads += f"[v{i}]"
|
|
1103
1130
|
if has_a:
|
|
1104
1131
|
# fade only the sides that meet another segment: the file's own start and end stay as cut
|
|
@@ -1109,7 +1136,7 @@ def _join_chunk(src: str, segments: List[Tuple[float, float]], dst: str, meta: d
|
|
|
1109
1136
|
fades += f",afade=t=in:st=0:d={fade:.6f}"
|
|
1110
1137
|
if fade > 0 and n < (len(segments) if total is None else total) - 1:
|
|
1111
1138
|
fades += f",afade=t=out:st={b - a - fade:.6f}:d={fade:.6f}"
|
|
1112
|
-
graph.append(f"[{i}:
|
|
1139
|
+
graph.append(f"[{i}:{PROBED_AUDIO}]atrim=start={a:.6f}:end={b:.6f},asetpts=PTS-{a:.6f}/TB,"
|
|
1113
1140
|
f"aresample=async=1:first_pts=0{fades}[a{i}]")
|
|
1114
1141
|
pads += f"[a{i}]"
|
|
1115
1142
|
outs = ("[v]" if has_v else "") + ("[a]" if has_a else "")
|
package/scripts/export.py
CHANGED
|
@@ -50,7 +50,7 @@ from _common.runner import child_exit_code, fail_if_classified, kept_clause, mar
|
|
|
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
|
|
@@ -204,8 +204,8 @@ def _key_fill(args, p: Dict, meta: Dict, output: str, cmd: List[str], vf: List[s
|
|
|
204
204
|
key_chain = ",".join(vf + ["alphaextract", "format=yuv422p10le"])
|
|
205
205
|
tc = ((meta.get("timecode") or {}).get("value"))
|
|
206
206
|
tc_args = ["-timecode", tc] if tc else []
|
|
207
|
-
cmd = cmd + ["-filter_complex", f"[0:
|
|
208
|
-
cmd += ["-map", "[fill]"] + (
|
|
207
|
+
cmd = cmd + ["-filter_complex", f"[0:{PROBED_VIDEO}]split[f][k];[f]{fill_chain}[fill];[k]{key_chain}[key]"]
|
|
208
|
+
cmd += ["-map", "[fill]"] + probed_maps(meta, video=False, audio=has_audio) + p["video"] + tc_args + cfr_args(meta)
|
|
209
209
|
# the fill is always written to a hidden sibling and moved into place only after the encode
|
|
210
210
|
# succeeds: run() stages and cleans up only ffmpeg's last output (the key), so a failed encode
|
|
211
211
|
# left a partial new fill (the retry was refused as output_exists) or truncated an existing one
|
|
@@ -410,7 +410,9 @@ def media_damage(path: str) -> Dict[str, int]:
|
|
|
410
410
|
PTS) and every other error line, counted. What --repair is measured by, before and after."""
|
|
411
411
|
# repeat+: every occurrence on its own line, not "Last message repeated N times", which hid
|
|
412
412
|
# N timestamp warnings and was itself counted as a decode error
|
|
413
|
-
|
|
413
|
+
# the streams a copy export writes (probed_maps), not the ones default selection would decode
|
|
414
|
+
proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-nostdin", "-v", "repeat+warning", "-i", path,
|
|
415
|
+
"-map", f"0:{PROBED_VIDEO}?", "-map", f"0:{PROBED_AUDIO}?", "-f", "null", "-"],
|
|
414
416
|
check=False, record=True)
|
|
415
417
|
lines = [ln for ln in (proc.stderr or "").splitlines() if ln.strip()]
|
|
416
418
|
# "Packet corrupt (stream = 0, dts = ...)" names a dts but is damage, not a timestamp problem
|
|
@@ -585,16 +587,19 @@ def main() -> int:
|
|
|
585
587
|
# Subtitle and data streams (a soft-subtitle track muxed upstream, a camera's timecode) are
|
|
586
588
|
# kept where the container can hold them: ffmpeg's default stream selection takes one video
|
|
587
589
|
# and one audio stream only, and through 2.22.1 every preset dropped the rest without a word.
|
|
588
|
-
#
|
|
590
|
+
# The picture and the sound are mapped on every export, the same streams probe() describes:
|
|
591
|
+
# until A118 they were mapped only beside a subtitle or data stream, and without one ffmpeg's
|
|
592
|
+
# default selection took the larger picture and the track with the default disposition or the
|
|
593
|
+
# most channels, so which audio track an export kept depended on whether it had subtitles.
|
|
589
594
|
subs = meta.get("subtitle_stream_details") or []
|
|
590
595
|
data = _data_streams(args.input) if meta.get("data_streams") else []
|
|
591
596
|
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 +=
|
|
597
|
+
if args.preset != "gif" and not p.get("keyfill"):
|
|
598
|
+
cmd += probed_maps(meta, audio=has_audio)
|
|
594
599
|
|
|
595
600
|
if args.preset == "gif":
|
|
596
601
|
chain = ",".join(([gif_label] if gif_label else []) + ["fps=12"] + vf)
|
|
597
|
-
fc = f"[0:
|
|
602
|
+
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
603
|
cmd += ["-filter_complex", fc, "-loop", "0", output]
|
|
599
604
|
run(cmd)
|
|
600
605
|
info(f"wrote {output}")
|
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,7 @@ 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
|
|
26
27
|
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
28
|
|
|
28
29
|
|
|
@@ -65,7 +66,11 @@ def measure(path: str, I: float, tp: float, lra: float) -> dict:
|
|
|
65
66
|
if dry_run_input_pending(path):
|
|
66
67
|
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
68
|
ffmpeg = require_tool("ffmpeg")
|
|
68
|
-
|
|
69
|
+
# the first audio track, the one pass 2 normalises and probe() describes: without a -map,
|
|
70
|
+
# FFmpeg measured the track with the default disposition or the most channels, and the gain
|
|
71
|
+
# for that track was applied to a:0 (A110)
|
|
72
|
+
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", f"0:{PROBED_AUDIO}", "-vn",
|
|
73
|
+
"-af", f"loudnorm=I={I}:TP={tp}:LRA={lra}:print_format=json", "-f", "null", "-"]
|
|
69
74
|
# Pass 1 is a measurement: it runs under --dry-run too, so the planned pass-2 command and
|
|
70
75
|
# the reported input_i are real (before 1.4.6 a dry run returned a made-up -20 LUFS).
|
|
71
76
|
proc = run_analysis(cmd, check=False, record=True)
|
|
@@ -112,9 +117,9 @@ def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output
|
|
|
112
117
|
af += f",atrim=duration={float(in_audio):.6f}"
|
|
113
118
|
cmd = ffmpeg_base() + ["-i", args.input, "-af", af, "-ar", str(sr)]
|
|
114
119
|
if ext in AUDIO_CODECS or not meta.get("video"):
|
|
115
|
-
cmd += ["-vn"] + audio_codec_for(output, bitrate)
|
|
120
|
+
cmd += ["-map", f"0:{PROBED_AUDIO}", "-vn"] + audio_codec_for(output, bitrate)
|
|
116
121
|
else:
|
|
117
|
-
cmd +=
|
|
122
|
+
cmd += probed_maps(meta) + ["-c:v", "copy", "-c:a", "aac", "-b:a", bitrate]
|
|
118
123
|
if ext in (".mp4", ".mov", ".m4v"):
|
|
119
124
|
cmd += ["-movflags", "+faststart"]
|
|
120
125
|
dropped_streams = run_keeping_subtitles(cmd, output)
|
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")
|