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/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.4`) | 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.4", "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.4",
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": [
@@ -9,7 +9,7 @@ why it is its own file.
9
9
  """
10
10
  from __future__ import annotations
11
11
 
12
- import json
12
+ import math
13
13
  import os
14
14
  import re
15
15
  import unicodedata
@@ -18,7 +18,7 @@ from typing import Any, Dict, List, NamedTuple, Optional, Tuple
18
18
 
19
19
  from _common.decision import _unspaced_script, fmt_srt_time, parse_time
20
20
  from _common.emit import die, info, warn
21
- from _common.runner import read_text_or_die
21
+ from _common.runner import parse_json, read_json, read_text_or_die
22
22
 
23
23
  # The engines this skill knows how to drive, and how to install each -- one string, so every
24
24
  # tool that needs a transcript refuses in the same words. The two Parakeet engines are
@@ -85,26 +85,60 @@ class Transcription:
85
85
  f"facts={self.facts!r}, notes={self.notes!r})")
86
86
 
87
87
 
88
+ # A line that starts a cue even with no blank line before it: a clock time, "-->", a clock time,
89
+ # then nothing or whitespace (ffmpeg's position fields, X1:.. Y1:..). ffmpeg's SRT demuxer starts
90
+ # an event at every such line; the first line of a block holding "-->" is a timing line whatever
91
+ # its shape.
92
+ _SRT_CUE_START = re.compile(r"^\s*\d+:\d+[\d:.,]*\s*-->\s*\d+:\d+[\d:.,]*(\s|$)")
93
+
94
+
95
+ def srt_blocks(text: str) -> List[Tuple[str, List[str]]]:
96
+ """(timing line, text lines) of every cue in SRT text whose line ends are already `\\n`.
97
+
98
+ A blank (or whitespace-only) line ends a cue, and the first line holding "-->" in a run of
99
+ lines is its timing line; the lines before it (the index) are not text. Inside a run, a later
100
+ line shaped like a timing line (_SRT_CUE_START) starts the next cue, and a bare number just
101
+ before it is that cue's index, so an SRT written without blank lines between cues reads as
102
+ ffmpeg reads it rather than as one cue. A later line that merely contains "-->" is text."""
103
+ out: List[Tuple[str, List[str]]] = []
104
+ for run in re.split(r"\n\s*\n", text):
105
+ lines = [ln for ln in run.split("\n") if ln.strip()]
106
+ first = next((i for i, ln in enumerate(lines) if "-->" in ln), None)
107
+ if first is None:
108
+ continue
109
+ timing, body = lines[first], []
110
+ for ln in lines[first + 1:]:
111
+ if _SRT_CUE_START.match(ln):
112
+ if body and body[-1].strip().isdigit():
113
+ body.pop()
114
+ out.append((timing, body))
115
+ timing, body = ln, []
116
+ else:
117
+ body.append(ln)
118
+ out.append((timing, body))
119
+ return out
120
+
121
+
122
+ def srt_timing_times(line: str) -> Tuple[str, str]:
123
+ """The start and end tokens of an SRT timing line: what is before the first "-->", and the
124
+ first word after it. ffmpeg reads the two times and ignores the rest (position fields
125
+ `X1:.. X2:.. Y1:.. Y2:..`, or a stray second "-->")."""
126
+ a, _sep, b = line.partition("-->")
127
+ words = b.split()
128
+ return a.strip(), words[0] if words else ""
129
+
130
+
88
131
  def parse_srt(path: str) -> List[Tuple[float, float, str]]:
89
132
  cues: List[Tuple[float, float, str]] = []
90
- block: List[str] = []
91
- content = read_text_or_die(path, "--srt").lstrip("\ufeff").replace("\r\n", "\n") + "\n\n"
92
- for line in content.split("\n"):
93
- if line.strip():
94
- block.append(line)
95
- continue
96
- if block:
97
- times = next((b for b in block if "-->" in b), None)
98
- if times:
99
- a, b = times.split("-->")
100
- text = "\n".join(block[block.index(times) + 1:]).strip()
101
- try:
102
- cues.append((parse_time(a), parse_time(b), text))
103
- except ValueError as e: # includes MissingFpsError: SRT timings are hh:mm:ss,ms, never frames
104
- die(f"{path}: cannot read the timing line {times.strip()!r}: {e}")
105
- block = []
133
+ for times, body in srt_blocks(read_text_or_die(path, "--srt")):
134
+ a, b = srt_timing_times(times)
135
+ text = "\n".join(body).strip()
136
+ try:
137
+ cues.append((parse_time(a), parse_time(b), text))
138
+ except ValueError as e: # includes MissingFpsError: SRT timings are hh:mm:ss,ms, never frames
139
+ die(f"{path}: cannot read the timing line {times.strip()!r}: {e}", kind="input")
106
140
  if not cues:
107
- die(f"no cues found in {path}")
141
+ die(f"no cues found in {path}", kind="input")
108
142
  return cues
109
143
 
110
144
 
@@ -204,8 +238,9 @@ def write_srt(cues: List[Tuple[float, float, str]], path: str) -> None:
204
238
  # line with two adjacent pipes ("a||b") becomes "a\n\nb" -- and writing that blank line
205
239
  # raw would split one cue into two malformed half-blocks (the second missing its own
206
240
  # index/timecode). Collapse any run of blank lines within the cue text to a single
207
- # newline so the cue's own text can never fake the format's block boundary.
208
- t = re.sub(r"\n{2,}", "\n", t).strip("\n")
241
+ # newline so the cue's own text can never fake the format's block boundary. A line of
242
+ # only spaces or tabs is blank to every SRT reader too, so it goes the same way.
243
+ t = "\n".join(ln for ln in t.split("\n") if ln.strip())
209
244
  fh.write(f"{i}\n{fmt_srt_time(s)} --> {fmt_srt_time(e)}\n{t}\n\n")
210
245
  # under batch.py: this SRT as written is the run's own (an edit made after it is not)
211
246
  from _common.runner import report_committed
@@ -476,20 +511,24 @@ def whisper_word_timings(srt_path: Optional[str]) -> List[Tuple[float, float, st
476
511
  if not os.path.exists(cand):
477
512
  continue
478
513
  try:
479
- data = json.loads(Path(cand).read_text(encoding="utf-8"))
514
+ data = read_json(cand)
480
515
  except (OSError, ValueError):
481
516
  continue
517
+ # the shape is not checked first: a segment that is a string or a list, or "segments" that
518
+ # is a number, is simply no words, as _dicts() reads every engine's JSON
482
519
  raw = []
483
520
  if isinstance(data, dict):
484
- raw = list(data.get("words") or [])
485
- for seg in data.get("segments") or []:
486
- raw.extend((seg or {}).get("words") or [])
521
+ raw = _dicts(data.get("words"))
522
+ for seg in _dicts(data.get("segments")):
523
+ raw.extend(_dicts(seg.get("words")))
487
524
  words = []
488
525
  for w in raw:
489
526
  try:
490
527
  text = str(w.get("word") or w.get("text") or "").strip()
491
- if text:
492
- words.append((float(w["start"]), float(w["end"]), text))
528
+ start, end = float(w["start"]), float(w["end"])
529
+ # NaN / Infinity are valid to json.loads and place no word anywhere
530
+ if text and math.isfinite(start) and math.isfinite(end):
531
+ words.append((start, end, text))
493
532
  except (AttributeError, KeyError, TypeError, ValueError):
494
533
  continue
495
534
  if words:
@@ -519,12 +558,12 @@ def _words_from_whisper_cpp_json(path: str) -> "List[Dict[str, Any]]":
519
558
  boundaries). A token that is part of a character (U+FFFD) is still glued.
520
559
  """
521
560
  try:
522
- doc = json.loads(Path(path).read_text(encoding="utf-8"))
561
+ doc = read_json(path)
523
562
  except (OSError, ValueError):
524
563
  return []
525
564
  out: "List[Dict[str, Any]]" = []
526
- for seg in (doc.get("transcription") or []):
527
- for tok in (seg.get("tokens") or []):
565
+ for seg in _dicts(doc.get("transcription")) if isinstance(doc, dict) else []:
566
+ for tok in _dicts(seg.get("tokens")):
528
567
  text = str(tok.get("text") or "")
529
568
  if not text.strip() or text.strip().startswith("[_"):
530
569
  continue
@@ -552,7 +591,7 @@ def _whisper_json_language(path: str) -> Optional[str]:
552
591
  """The language a Whisper engine's JSON says it decoded: whisper.cpp's `result.language`, or
553
592
  openai-whisper's top-level `language`. None when the file has neither."""
554
593
  try:
555
- doc = json.loads(Path(path).read_text(encoding="utf-8")) or {}
594
+ doc = read_json(path) or {}
556
595
  lang = str(((doc.get("result") or {}).get("language")) or doc.get("language") or "").strip().lower()
557
596
  except (OSError, ValueError, AttributeError):
558
597
  return None
@@ -568,12 +607,12 @@ def _heard_language(heard: list) -> Optional[str]:
568
607
  def _words_from_openai_whisper_json(path: str) -> "List[Dict[str, Any]]":
569
608
  """openai-whisper --word_timestamps True --output_format json: segments[].words[]."""
570
609
  try:
571
- doc = json.loads(Path(path).read_text(encoding="utf-8"))
610
+ doc = read_json(path)
572
611
  except (OSError, ValueError):
573
612
  return []
574
613
  out: "List[Dict[str, Any]]" = []
575
- for seg in (doc.get("segments") or []):
576
- for w in (seg.get("words") or []):
614
+ for seg in _dicts(doc.get("segments")) if isinstance(doc, dict) else []:
615
+ for w in _dicts(seg.get("words")):
577
616
  try:
578
617
  out.append({"word": str(w.get("word") or w.get("text") or "").strip(),
579
618
  "start": float(w["start"]), "end": float(w["end"])})
@@ -592,7 +631,7 @@ def _words_from_parakeet_mlx_json(path: str) -> "List[Dict[str, Any]]":
592
631
  after it starts a word too (" m" "is" "ter" " " "Q" "u" "il" "ter" -> "mister", "Quilter").
593
632
  """
594
633
  try:
595
- doc = json.loads(Path(path).read_text(encoding="utf-8"))
634
+ doc = read_json(path)
596
635
  except (OSError, ValueError):
597
636
  return []
598
637
  out: "List[Dict[str, Any]]" = []
@@ -619,7 +658,7 @@ def _words_from_parakeet_mlx_json(path: str) -> "List[Dict[str, Any]]":
619
658
  def _cues_from_parakeet_mlx_json(path: str) -> List[Tuple[float, float, str]]:
620
659
  """parakeet-mlx's own sentence segmentation, as cues."""
621
660
  try:
622
- doc = json.loads(Path(path).read_text(encoding="utf-8"))
661
+ doc = read_json(path)
623
662
  except (OSError, ValueError):
624
663
  return []
625
664
  cues: List[Tuple[float, float, str]] = []
@@ -642,7 +681,7 @@ def _dicts(value: Any) -> "List[Dict[str, Any]]":
642
681
  def _words_from_parakeet_cpp_json(text: str) -> "List[Dict[str, Any]]":
643
682
  """parakeet-cli transcribe --json: {"words": [{"w", "start", "end", "conf"}]}, times in seconds."""
644
683
  try:
645
- doc = json.loads(text)
684
+ doc = parse_json(text)
646
685
  except ValueError:
647
686
  return []
648
687
  out: "List[Dict[str, Any]]" = []
@@ -978,7 +1017,7 @@ def _heard_nothing(text: str, key: str) -> bool:
978
1017
  Anything else that yields no words -- not JSON, the wrong shape, entries that do not parse --
979
1018
  is output that could not be read, not a finding that nobody spoke."""
980
1019
  try:
981
- doc = json.loads(text)
1020
+ doc = parse_json(text)
982
1021
  except ValueError:
983
1022
  return False
984
1023
  return isinstance(doc, dict) and doc.get(key) == []
@@ -990,7 +1029,7 @@ def _whisper_heard_nothing(path: str, key: str) -> bool:
990
1029
  no speech (#414). A document with text and no word timings is a build without them, not
991
1030
  silence, and stays the "no word-level timings" refusal."""
992
1031
  try:
993
- doc = json.loads(Path(path).read_text(encoding="utf-8"))
1032
+ doc = read_json(path)
994
1033
  except (OSError, ValueError):
995
1034
  return False
996
1035
  if not isinstance(doc, dict) or not isinstance(doc.get(key), list):
@@ -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)
@@ -6,7 +6,6 @@ and encoder-selection rules testable on their own.
6
6
  """
7
7
  from __future__ import annotations
8
8
 
9
- import json
10
9
  import math
11
10
  import os
12
11
  import re
@@ -17,6 +16,7 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
17
16
  from _common.color import (as_limited, bt709_label_filter, bt709_matrix_args, bt709_matrix_filter, bt709_tag_args, drawn_stream_format,
18
17
  is_neutral_color, limited_range_filter, rgb_island, rgb_to_bt709_filter, _sdr_bt709)
19
18
  from _common.emit import die, record_fallback, refuse, warn
19
+ from _common.runner import read_json
20
20
  from _common.runner import CODECS, STATE, ffmpeg_encoders, ffmpeg_list_error, ffmpeg_version, hw_platform_reason
21
21
 
22
22
 
@@ -1025,13 +1025,29 @@ def load_brand(path: Optional[str]) -> Dict[str, Any]:
1025
1025
  if not path:
1026
1026
  return brand
1027
1027
  if not os.path.exists(path):
1028
- die(f"brand file not found: {path}")
1028
+ die(f"brand file not found: {path}", kind="input")
1029
1029
  try:
1030
- data = json.loads(Path(path).read_text(encoding="utf-8"))
1031
- except ValueError as exc:
1032
- die(f"brand file is not valid JSON: {exc}")
1030
+ data = read_json(path)
1031
+ except OSError as exc:
1032
+ die(f"brand file {path} cannot be read: {exc.strerror or exc}", kind="input")
1033
+ except ValueError as exc: # malformed, not UTF-8, or nested too deep (read_json)
1034
+ die(f"brand file is not valid JSON: {exc}", kind="input")
1035
+ if not isinstance(data, dict):
1036
+ die(f"brand file {path} must be a JSON object {{...}}, got {type(data).__name__}", kind="input")
1037
+ # the shapes the readers below and brand_caption_style() walk: a block that is not an object,
1038
+ # and a path that is not a string, were a traceback there
1039
+ for key, value in (*data.items(), ("styles.caption", (data.get("styles") or {}).get("caption")
1040
+ if isinstance(data.get("styles"), dict) else None)):
1041
+ if value is None:
1042
+ continue
1043
+ if (isinstance(BRAND_DEFAULTS.get(key), dict) or key == "styles.caption") and not isinstance(value, dict):
1044
+ die(f'brand file {path}: "{key}" must be an object {{...}}, got {type(value).__name__}', kind="input")
1045
+ if key in ("logo", "font_file", "font") and not isinstance(value, str):
1046
+ die(f'brand file {path}: "{key}" must be a string, got {type(value).__name__}', kind="input")
1033
1047
  base = Path(path).resolve().parent
1034
1048
  for k, v in data.items():
1049
+ if v is None and isinstance(brand.get(k), dict):
1050
+ continue # a null block keeps its defaults: readers walk it as an object (#483 review)
1035
1051
  if isinstance(v, dict) and isinstance(brand.get(k), dict):
1036
1052
  brand[k].update(v)
1037
1053
  else:
@@ -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
@@ -2487,15 +2507,27 @@ def _read_ff_list(flag: str, pattern: str) -> set:
2487
2507
  return names
2488
2508
 
2489
2509
 
2510
+ _LINE_END = re.compile(r"\r*\n|\r")
2511
+
2512
+
2513
+ def normalize_text(text: str) -> str:
2514
+ """A caller's text as every reader here takes it: a leading UTF-8 BOM dropped, and every line
2515
+ ending `\\n`. `\\r\\r\\n` is one line end, as ffmpeg's text demuxers read it (a CRLF file passed
2516
+ through a second CRLF conversion); a lone `\\r` (classic Mac) is one too. Only these: a line is
2517
+ never split at U+2028, U+0085, \\v or \\f, which str.splitlines() also splits at."""
2518
+ return _LINE_END.sub("\n", text.lstrip("\ufeff"))
2519
+
2520
+
2490
2521
  def read_text_or_die(path: str, flag: str) -> str:
2491
2522
  """Read a caller-supplied UTF-8 text file (a cue list, chapters, notes) or fail as kind input
2492
- with the flag named, instead of a FileNotFoundError / UnicodeDecodeError traceback."""
2523
+ with the flag named, instead of a FileNotFoundError / UnicodeDecodeError traceback. The text
2524
+ comes back through normalize_text(): no BOM, `\\n` line ends."""
2493
2525
  if os.path.isdir(path):
2494
2526
  # checked first: Windows raises PermissionError, not IsADirectoryError, for a directory
2495
2527
  die(f"{flag}: {path} is a directory, not a text file")
2496
2528
  try:
2497
- with open(path, "r", encoding="utf-8") as fh:
2498
- return fh.read()
2529
+ with open(path, "r", encoding="utf-8", newline="") as fh:
2530
+ return normalize_text(fh.read())
2499
2531
  except FileNotFoundError:
2500
2532
  die(f"{flag}: {path} does not exist")
2501
2533
  except IsADirectoryError:
@@ -2507,6 +2539,44 @@ def read_text_or_die(path: str, flag: str) -> str:
2507
2539
  return "" # unreachable
2508
2540
 
2509
2541
 
2542
+ # Deeper than any project, recipe, brand, plan or transcript this skill reads (those are four or
2543
+ # five levels); a file nested past it is refused before code that walks it recursively
2544
+ # (copy.deepcopy, json.dumps) can raise RecursionError.
2545
+ JSON_MAX_DEPTH = 64
2546
+
2547
+
2548
+ class JsonTooDeep(ValueError):
2549
+ """parse_json()'s refusal of a document nested past JSON_MAX_DEPTH: a ValueError, so callers
2550
+ that catch ValueError refuse it as malformed JSON; one that falls back on a non-JSON file (a
2551
+ media file) can tell this apart, since the file was JSON."""
2552
+
2553
+
2554
+ def parse_json(text: str) -> Any:
2555
+ """json.loads for a caller's file: a BOM is dropped, and every way the text can fail to be a
2556
+ document this skill can walk raises ValueError -- malformed JSON (json's own ValueError),
2557
+ nesting too deep for the parser (RecursionError) or deeper than JSON_MAX_DEPTH. Callers that
2558
+ already catch ValueError therefore refuse all three as they refuse malformed JSON."""
2559
+ try:
2560
+ data = json.loads(text.lstrip("\ufeff"))
2561
+ except RecursionError:
2562
+ raise JsonTooDeep(f"nested more than {JSON_MAX_DEPTH} levels deep") from None
2563
+ stack = [(data, 1)]
2564
+ while stack:
2565
+ value, depth = stack.pop()
2566
+ if isinstance(value, (dict, list)):
2567
+ if depth > JSON_MAX_DEPTH:
2568
+ raise JsonTooDeep(f"nested more than {JSON_MAX_DEPTH} levels deep")
2569
+ stack.extend((v, depth + 1) for v in (value.values() if isinstance(value, dict) else value))
2570
+ return data
2571
+
2572
+
2573
+ def read_json(path: "os.PathLike[str] | str") -> Any:
2574
+ """parse_json() of a UTF-8 file. Raises OSError for a file that cannot be read and ValueError
2575
+ (UnicodeDecodeError is one) for anything else, so `except (OSError, ValueError)` covers it."""
2576
+ with open(path, "r", encoding="utf-8") as fh:
2577
+ return parse_json(fh.read())
2578
+
2579
+
2510
2580
  # Deferred to the foot of the module on purpose. runner is the first module the package loads and
2511
2581
  # emit needs runner's Context/STATE/ERROR_CODE, so the two form a cycle that has to be cut
2512
2582
  # somewhere: by the time this line runs every name emit reads from runner above is defined, and
@@ -443,6 +443,8 @@ ERROR_REASONS = {
443
443
  "plan_unknown_tool": "render.py PLAN: the plan names no tool render.py can run; details: {plan, plan_sha256, checked_at, tool, audit_log}",
444
444
  "plan_cwd_missing": "render.py PLAN: the directory the plan was made in is gone; details: {plan, plan_sha256, checked_at, cwd, audit_log}",
445
445
  "plan_argv_invalid": "render.py PLAN: argv is not a list; details: {plan, plan_sha256, checked_at, argv_type, audit_log}",
446
+ "plan_field_invalid": "render.py PLAN: inputs, unbound_inputs, verify (objects), commands (strings) or cwd has the wrong JSON type; "
447
+ "details: {plan, plan_sha256, checked_at, field, field_type, audit_log}",
446
448
  "decoder_missing": "color.py / graphics.py / overlay.py: a VP9 source or --video layer stored with an alpha layer, on a build with no libvpx-vp9 decoder (FFmpeg's own VP9 decoder drops the alpha), kind missing_tool; details: {name}",
447
449
  "encoder_missing": "--codec av1/prores: the build's encoder list was read and has no encoder for the codec (kind missing_tool; details: {codec, encoders}); or ffmpeg itself reported \"Unknown encoder\" for the encoder the command named (kind ffmpeg; details: {name}; the hint points at doctor)",
448
450
  "asr_engine_missing": "kind missing_tool: --transcribe (caption.py; silence.py with --filler or a sentence flag) found no local speech-to-text engine; the message lists the install lines; details: {}",
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: