ffmpeg-skill 3.6.4 → 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/scripts/batch.py CHANGED
@@ -33,7 +33,7 @@ from pathlib import Path
33
33
  from typing import Any, Dict, List, Optional
34
34
 
35
35
  from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool, MEDIA_EXT as _MEDIA_EXT
36
- from _common.runner import (HW_FORCED_ENV, OWNED_ENV, _OutputLock, add_hw_orchestrator_args, copy_to_sibling, env_hw,
36
+ from _common.runner import (HW_FORCED_ENV, OWNED_ENV, _OutputLock, add_hw_orchestrator_args, copy_to_sibling, env_hw, read_json,
37
37
  owned_identity, stop_children)
38
38
  from _common.emit import absorb_stage_hw, audit, note, shown, steps_encoder_report, warn
39
39
  from _common.decision import rebase_project_paths
@@ -235,6 +235,39 @@ def intermediate_ext(src: Path) -> str:
235
235
  return src.suffix.lstrip(".") if src.suffix.lower() in (".wav", ".mp3", ".m4a", ".flac") else "mp4"
236
236
 
237
237
 
238
+ # the recipe keys the run reads as a string: a path, a glob, a file-name part
239
+ RECIPE_STRINGS = ("glob", "project", "output_dir", "suffix", "ext")
240
+
241
+
242
+ def check_recipe_types(recipe: Dict[str, Any]) -> None:
243
+ """Refuse a recipe value of the wrong JSON type before anything runs (A18): a number where a
244
+ path or glob goes, an absolute glob (pathlib refuses it), a step that is not a list. Each was
245
+ a traceback, or a failure of every item naming the step's first character."""
246
+ for key in RECIPE_STRINGS:
247
+ value = recipe.get(key)
248
+ if value is not None and not isinstance(value, str):
249
+ die(f'recipe "{key}" must be a string, got {type(value).__name__} {value!r:.60}', kind="input")
250
+ glob = recipe.get("glob")
251
+ if glob and (os.path.isabs(glob) or glob.startswith(("/", "\\"))):
252
+ die(f'recipe "glob" {glob!r} is an absolute path: it matches names inside the batch folder, e.g. "*.mp4" '
253
+ 'or "sub/*.mov"', kind="input")
254
+ key = recipe.get("clip_key")
255
+ try:
256
+ index = int(key) if key is not None and not isinstance(key, (bool, float)) else (0 if key is None else -1)
257
+ except (TypeError, ValueError):
258
+ index = -1
259
+ if index < 0:
260
+ die(f'recipe "clip_key" must be a clip index (0, 1, ...), got {key!r:.60}', kind="input")
261
+ steps = recipe.get("steps")
262
+ if steps is not None and not isinstance(steps, list):
263
+ die(f'recipe "steps" must be a list of steps, each ["script.py", args...], got {type(steps).__name__}',
264
+ kind="input")
265
+ for i, st in enumerate(steps or []):
266
+ if not isinstance(st, list) or not st:
267
+ die(f'recipe step {i} must be a list ["script.py", args...], got {type(st).__name__} {st!r:.60}',
268
+ kind="input")
269
+
270
+
238
271
  def step_writes(step: Any) -> bool:
239
272
  """Whether a recipe step writes a file: it names {out} (check.py and probe.py do not)."""
240
273
  return isinstance(step, list) and any("{out}" in str(a) for a in step[1:])
@@ -382,14 +415,14 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
382
415
  return max(1.0, deadline - time.monotonic())
383
416
  if recipe.get("project"):
384
417
  try:
385
- proj = json.loads(Path(str(recipe["project"])).read_text(encoding="utf-8"))
418
+ proj = read_json(str(recipe["project"])) # too deep is a ValueError too (A20)
386
419
  except (OSError, ValueError) as e:
387
420
  raise ItemError({"step": None, "tool": "render.py", "kind": "input", "reason": "recipe_project_unreadable",
388
421
  "message": f"recipe.project: {recipe['project']} could not be read as JSON: {e}"})
389
422
  if not isinstance(proj, dict):
390
423
  raise ItemError({"step": None, "tool": "render.py", "kind": "input", "reason": "recipe_project_unreadable",
391
424
  "message": f"recipe.project: {recipe['project']} is not a JSON object"})
392
- idx = int(recipe.get("clip_key", 0))
425
+ idx = int(recipe.get("clip_key") or 0) # null is clip 0, as check_recipe_types() reads it
393
426
  proj.setdefault("clips", [{}])
394
427
  while len(proj["clips"]) <= idx:
395
428
  proj["clips"].append({})
@@ -539,11 +572,12 @@ def main() -> int:
539
572
  if not folder.is_dir():
540
573
  die(f"not a folder: {folder}")
541
574
  try:
542
- recipe = json.loads(Path(args.recipe).read_text(encoding="utf-8"))
575
+ recipe = read_json(args.recipe) # too deep is a ValueError too (A20)
543
576
  except (OSError, ValueError) as exc:
544
- die(f"cannot read recipe: {exc}")
577
+ die(f"cannot read recipe: {exc}", kind="input")
545
578
  if not isinstance(recipe, dict):
546
- die("cannot read recipe: not a JSON object")
579
+ die("cannot read recipe: not a JSON object", kind="input")
580
+ check_recipe_types(recipe)
547
581
  if recipe.get("project") and not os.path.isabs(recipe["project"]):
548
582
  recipe["project"] = str((Path(args.recipe).resolve().parent / recipe["project"]))
549
583
  # a recipe error is the same for every item: refuse it once, before any item runs, rather than
@@ -59,6 +59,8 @@ from _common.runner import subtitle_codec_for, subtitle_container_holds
59
59
  from _common.fonts import font_choice, record_font_choice
60
60
  from _common import (ASR_INSTALL_HINT, die_no_engine, parse_srt, transcribe, whisper_word_timings,
61
61
  write_srt)
62
+ from _common.asr import srt_blocks
63
+ from _common.runner import normalize_text
62
64
  from _common import (SAFE_WIDTH_FRACTION, ORPHAN_MIN_EM, WRAP_MODES, wrap_text, wrap_variants, best_break,
63
65
  fit_size, line_em_for_size, MIN_CAPTION_FRACTION,
64
66
  break_penalty, _is_weak_line, _atoms, _join, _break_spaced, _bare_word, _function_words,
@@ -83,10 +85,21 @@ ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3
83
85
  FORCE_STYLE_ALIGN = {"bottom": 2, "top": 6, "center": 10, "bottom-left": 1, "bottom-right": 3, "top-left": 5, "top-right": 7}
84
86
 
85
87
  TIME_RE = re.compile(
86
- r"^\s*(?P<a>[\d:.,@]+)\s*(?:-->|-|–|to)\s*(?P<b>[\d:.,@]+)\s+(?P<text>.+)$" # @ = the 1.9 @fps suffix
88
+ r"^\s*(?P<a>[\d:.,@]+)\s*(?P<sep>-->|-|–|to)\s*(?P<b>[\d:.,@]+)\s+(?P<text>.+)$" # @ = the 1.9 @fps suffix
87
89
  )
88
90
 
89
91
 
92
+ def cue_times_match(line: str) -> "Optional[re.Match[str]]":
93
+ """TIME_RE's match of a --text line, or None when the line is caption text. The documented
94
+ separators are "-" and "-->", and with them bare seconds are times ("0-1.5 Hello"). "to" is
95
+ also an English word: "3 to 5 apples" is text, so with "to" both sides must be clock times
96
+ (a ':' in each, "0:03 to 0:05 Hello")."""
97
+ m = TIME_RE.match(line)
98
+ if m and m.group("sep") == "to" and not (":" in m.group("a") and ":" in m.group("b")):
99
+ return None
100
+ return m
101
+
102
+
90
103
  # an SRT timing line: the times alone, the cue text on the lines after it
91
104
  DROP_FRAME_RE = re.compile(r"^\s*\d+:\d{2}:\d{2};\d+")
92
105
  SRT_TIMING_RE = re.compile(r"^\s*\d+:\d{2}:\d{2}[,.]\d+\s*-->\s*\d+:\d{2}:\d{2}[,.]\d+\s*$")
@@ -95,9 +108,11 @@ SRT_TIMING_RE = re.compile(r"^\s*\d+:\d{2}:\d{2}[,.]\d+\s*-->\s*\d+:\d{2}:\d{2}[
95
108
  def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[float] = None) -> List[Tuple[float, float, str]]:
96
109
  cues: List[Tuple[float, float, str]] = []
97
110
  cursor = 0.0
98
- lines = read_text_or_die(path, "--text").lstrip("\ufeff").splitlines(True)
111
+ # split at "\n" only: splitlines() also breaks a cue at U+2028, U+0085, \v and \f, which
112
+ # made the rest of the line an auto-timed cue of its own
113
+ lines = read_text_or_die(path, "--text").split("\n")
99
114
  for n, raw in enumerate(lines, 1):
100
- if SRT_TIMING_RE.match(raw.rstrip("\r\n")):
115
+ if SRT_TIMING_RE.match(raw):
101
116
  # a cue file puts the text on the line with its times; this line has the times alone, so
102
117
  # the file is an SRT. Read as a cue file, its numbers and timing lines would be burned in
103
118
  # as captions, auto-timed 3 s apart (#396)
@@ -107,11 +122,10 @@ def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[fl
107
122
  # TIME_RE has no ';', so the line would be burned as caption text, auto-timed
108
123
  die(f"--text: line {n} has a drop-frame timecode (';'), which is not read: write it as "
109
124
  "hh:mm:ss:ff@29.97 or in seconds", kind="input")
110
- for raw in lines:
111
- line = raw.rstrip("\n")
125
+ for line in lines:
112
126
  if not line.strip():
113
127
  continue
114
- m = TIME_RE.match(line)
128
+ m = cue_times_match(line)
115
129
  if m:
116
130
  try:
117
131
  start, end = parse_time(m.group("a"), fps), parse_time(m.group("b"), fps)
@@ -535,7 +549,7 @@ def max_line_em(args, play_w: Optional[int], play_h: Optional[int]) -> Optional[
535
549
  def parse_ass_dialogue(path: str) -> str:
536
550
  """The spoken text of an ASS file, for script detection -- style/override blocks stripped."""
537
551
  text = []
538
- for line in read_text_or_die(path, "--ass").lstrip("\ufeff").splitlines():
552
+ for line in read_text_or_die(path, "--ass").split("\n"):
539
553
  if not line.startswith("Dialogue:"):
540
554
  continue
541
555
  fields = line.split(",", 9)
@@ -557,7 +571,7 @@ def parse_ass_cues(path: str, stats: Optional[dict] = None) -> List[Tuple[float,
557
571
  # the [Events] Format line names the columns; Text is always last (libass reads it the same
558
572
  # way), and a file without one gets the standard ten
559
573
  names = ["layer", "start", "end", "style", "name", "marginl", "marginr", "marginv", "effect", "text"]
560
- for line in read_text_or_die(path, "--ass").lstrip("\ufeff").splitlines():
574
+ for line in read_text_or_die(path, "--ass").split("\n"):
561
575
  if line.startswith("Format:") and "text" in line.lower():
562
576
  names = [n.strip().lower() for n in line.partition(":")[2].split(",")]
563
577
  continue
@@ -601,7 +615,8 @@ def count_visible_cues(cues, duration) -> Tuple[int, int]:
601
615
  def shift_ass_file(src: str, dst: str, offset: float) -> Tuple[int, int]:
602
616
  """Copy an ASS file with every Dialogue start/end moved by `offset` seconds; (lines shifted,
603
617
  Dialogue lines copied unshifted because their timestamps could not be read)."""
604
- text, n, unshifted = shift_ass_text(Path(src).read_text(encoding="utf-8-sig"), offset)
618
+ # read as the dry run reads it: a file that is not UTF-8 is refused as kind input on both
619
+ text, n, unshifted = shift_ass_text(read_text_or_die(src, "--ass"), offset)
605
620
  Path(dst).write_text(text, encoding="utf-8-sig")
606
621
  return n, unshifted
607
622
 
@@ -621,7 +636,9 @@ def shift_ass_text(source: str, offset: float) -> Tuple[str, int, int]:
621
636
 
622
637
  n = unshifted = 0
623
638
  out = []
624
- for line in source.splitlines():
639
+ # lines end at "\n" only (libass reads them so); a U+2028 inside a Dialogue's text is text
640
+ body = normalize_text(source)
641
+ for line in (body[:-1] if body.endswith("\n") else body).split("\n"):
625
642
  if line.startswith("Dialogue:"):
626
643
  head, sep, rest = line.partition(":")
627
644
  fields = rest.split(",")
@@ -929,12 +946,9 @@ def srt_timings_shared_grammar(path: str) -> bool:
929
946
  """True when every timing line of the SRT at `path` reads the same to ffmpeg's SRT demuxer as
930
947
  to parse_srt(). ffmpeg refuses `00:01,000` (no hours) and reads `00:00:01,5` as 1.005 s, where
931
948
  parse_srt() reads 1.5 s (#414); such a file is handed to ffmpeg as a rewritten copy."""
932
- text = read_text_or_die(path, "--srt").lstrip("\ufeff").replace("\r\n", "\n")
933
- # a cue's timing line is its first line with "-->", as parse_srt() reads it (and ffmpeg too);
934
- # a later line holding "-->" is dialogue, not a timing this check should judge
935
- blocks = [[ln for ln in b.split("\n") if ln.strip()] for b in re.split(r"\n\s*\n", text)]
936
- timings = [next((ln for ln in b if "-->" in ln), None) for b in blocks]
937
- return all(_SHARED_SRT_TIMING.match(line) for line in timings if line is not None)
949
+ # the timing lines parse_srt() reads, grouped as it groups them (srt_blocks()): a later line
950
+ # that only holds "-->" is dialogue, not a timing this check should judge
951
+ return all(_SHARED_SRT_TIMING.match(times) for times, _body in srt_blocks(read_text_or_die(path, "--srt")))
938
952
 
939
953
 
940
954
  def mux_subtitle_codec(output: str) -> str:
@@ -1461,7 +1475,7 @@ def main() -> int:
1461
1475
  if args.ass and args.offset and os.path.exists(args.ass):
1462
1476
  shifted = os.path.splitext(output)[0] + "_offset.ass"
1463
1477
  if STATE.dry_run:
1464
- _text, n, unshifted = shift_ass_text(read_text_or_die(args.ass, "--ass").lstrip("\ufeff"), args.offset)
1478
+ _text, n, unshifted = shift_ass_text(read_text_or_die(args.ass, "--ass"), args.offset)
1465
1479
  info(f"[dry-run] would write {shifted} ({n} cues shifted by {args.offset:+g} s)")
1466
1480
  else:
1467
1481
  n, unshifted = shift_ass_file(args.ass, shifted, args.offset)
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
@@ -58,10 +58,11 @@ import tempfile
58
58
  import zlib
59
59
  from typing import List, Tuple
60
60
 
61
- from _common.runner import fail_if_classified
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 "")
@@ -1199,11 +1226,15 @@ def _grid_from_source(path: str, min_confidence: float) -> "dict":
1199
1226
  """The beat grid of `path`: a scenes.py --json document if that is what it is, otherwise a
1200
1227
  media file to measure. Reading a document is how a caller avoids a second decode."""
1201
1228
  try:
1202
- with open(path, "r", encoding="utf-8") as fh:
1203
- doc = json.load(fh)
1229
+ doc = read_json(path) # a media file is not UTF-8 or not JSON: a ValueError, then measured
1230
+ except JsonTooDeep as exc:
1231
+ die(f"--snap-source {path}: {exc}", kind="input")
1204
1232
  except (OSError, ValueError):
1205
1233
  doc = None
1206
1234
  if isinstance(doc, dict) and doc.get("beat_grid"):
1235
+ if not isinstance(doc["beat_grid"], dict) or not isinstance(doc.get("beats") or [], list):
1236
+ die(f"--snap-source {path}: beat_grid must be an object and beats a list "
1237
+ "(regenerate it with `scenes.py MUSIC --beats --json`)", kind="input")
1207
1238
  grid = dict(doc["beat_grid"])
1208
1239
  grid["beats"] = doc.get("beats") or []
1209
1240
  # A scenes.py document carries the supported subset since 1.17; one written by an older
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/join.py CHANGED
@@ -44,6 +44,7 @@ from fractions import Fraction
44
44
  from typing import Any, Dict, List, Optional, Tuple
45
45
 
46
46
  from _common.probe import SILENT_PEAK_DBFS, signatures_match, stream_signature
47
+ from _common.runner import read_text_or_die
47
48
  from _common import STATE, AUDIO_MEDIA_EXT, concat_list_line, guard_sidecar, place_output, refuse, sibling_temp, as_limited, draws_chroma, drawn_chain, label_chain, matrix_chain, range_chain, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, measured_level_dbfs, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
48
49
  from _common.emit import note, warn
49
50
  from _common.decision import av_clip_length, av_trim, stream_starts
@@ -280,13 +281,12 @@ def read_list(path: str) -> List[str]:
280
281
  """--list FILE: one segment per line, in order, relative to the list file's own folder.
281
282
  Blank lines and # comments are ignored, and ffmpeg's concat-demuxer spelling
282
283
  (`file 'part 01.wav'`) is accepted, so a list written for `ffmpeg -f concat` works as is."""
283
- try:
284
- text = open(path, encoding="utf-8").read()
285
- except OSError as exc:
286
- die(f"cannot read --list {path}: {exc}")
284
+ # the shared reader: a BOM, CRLF and a file that is not UTF-8 are read or refused as every
285
+ # other text input is, and a line ends at "\n" only
286
+ text = read_text_or_die(path, "--list")
287
287
  base = os.path.dirname(os.path.abspath(path))
288
288
  entries: List[str] = []
289
- for line in text.splitlines():
289
+ for line in text.split("\n"):
290
290
  line = line.strip()
291
291
  if not line or line.startswith("#"):
292
292
  continue
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)
@@ -43,7 +43,7 @@ def parse_chapters(path: str, duration: float) -> List[Dict[str, Any]]:
43
43
  start inside the file, the last chapter running to the file's end."""
44
44
  text = read_text_or_die(path, "--chapters")
45
45
  entries: List[Dict[str, Any]] = []
46
- for n, raw in enumerate(text.splitlines(), start=1):
46
+ for n, raw in enumerate(text.split("\n"), start=1): # not splitlines(): a title may hold U+2028
47
47
  line = raw.strip()
48
48
  if not line or line.startswith("#"):
49
49
  continue