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 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.5`) | any release |
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.5", "execution_mode": "local", "kind": "execution",
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.5",
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": [
@@ -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
- _INPUT_VIDEO_REF = re.compile(r"\[(\d+):v(?::0)?\]")
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)
@@ -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
- cmd += ["-vn", "-af", "aresample=async=1:first_pts=0", "-ac", "1", "-ar", str(sample_rate), "-f", "s16le", "-"]
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: ffmpeg's choice)."""
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}"] if audio_stream is not None else []
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:v:0", "-an", "-sn", "-dn", "-vf", vf, "-f", "null", "-"]
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
- in_channels = (meta.get("audio") or {}).get("channels") or 0
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
- in_ch = (meta.get("audio") or {}).get("channels") or 2
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
- proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"], check=False, record=True)
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
- cmd = ffmpeg_base() + ["-i", args.input, "-vf", ",".join(vf)]
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:
@@ -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}"] + copy_args(meta, dst) + ["-avoid_negative_ts", "make_zero", dst]
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
- + copy_args(meta, dst) + ([] if edit_list else ["-avoid_negative_ts", "make_zero"]) + [dst])
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}:v:0]{pad}trim=start={a - half:.6f}:end={b - half:.6f},setpts=PTS-{a:.6f}/TB[v{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}:a:0]atrim=start={a:.6f}:end={b:.6f},asetpts=PTS-{a:.6f}/TB,"
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:v:0]split[f][k];[f]{fill_chain}[fill];[k]{key_chain}[key]"]
208
- cmd += ["-map", "[fill]"] + (["-map", "0:a:0"] if has_audio else []) + p["video"] + tc_args + cfr_args(meta)
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
- proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-nostdin", "-v", "repeat+warning", "-i", path, "-f", "null", "-"],
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
- # Mapping them means mapping the picture and the sound too, the same streams probe() describes.
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") and (subs or data):
593
- cmd += ["-map", "0:V:0"] + (["-map", "0:a:0"] if has_audio else [])
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:v]{chain},split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle"
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:v]{lab}{sq}{fit(tm)}[a];[1:v]{lab2}{csq}{fit(tm2)}[b];"
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")
@@ -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
- cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", f"loudnorm=I={I}:TP={tp}:LRA={lra}:print_format=json", "-f", "null", "-"]
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 += ["-map", "0:v:0", "-map", "0:a:0", "-c:v", "copy", "-c:a", "aac", "-b:a", bitrate]
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
 
@@ -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, "-vn",
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):
@@ -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
- cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second, "-c", "copy"]
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
- cmd += ["-i", args.second] + out_seek
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")