ffmpeg-skill 2.10.11 → 2.12.0

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/report.py CHANGED
@@ -17,32 +17,57 @@ import os
17
17
  import sys
18
18
  import tempfile
19
19
  from pathlib import Path
20
- from typing import Any, Dict, List, Optional
20
+ from typing import Any, Dict, List, Optional, Tuple
21
21
 
22
22
  from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, read_text_or_die, run_tool
23
+ from _common.emit import record_skip, warn
23
24
 
24
25
  HERE = Path(__file__).resolve().parent
25
26
 
26
27
 
27
- def sheet_b64(path: str, tiles: str = "4x2", width: int = 1200) -> Optional[str]:
28
+ def _child_failure(proc: Any, doc: Any) -> Dict[str, Any]:
29
+ """Why a sibling tool produced nothing usable: its own error (kind, reason, message), or the
30
+ last stderr line and the exit code when it printed no failure document."""
31
+ err = (doc or {}).get("error") if isinstance(doc, dict) and isinstance(doc.get("error"), dict) else {}
32
+ out: Dict[str, Any] = {"exit_code": proc.returncode,
33
+ "message": str(err.get("message") or (proc.stderr.strip().splitlines() or ["?"])[-1])[:300]}
34
+ for k in ("kind", "reason"):
35
+ if err.get(k):
36
+ out[k] = err[k]
37
+ return out
38
+
39
+
40
+ def sheet_b64(path: str, tiles: str = "4x2", width: int = 1200) -> "Tuple[Optional[str], Dict[str, Any]]":
41
+ """(the contact sheet as base64, or None; why there is none)."""
42
+ if STATE.dry_run:
43
+ return None, {"reason": "dry_run", "message": "a dry run draws no contact sheet"}
28
44
  with tempfile.TemporaryDirectory(prefix="ffskill_report_") as tmp:
29
45
  png = os.path.join(tmp, "sheet.png")
30
- proc = run_tool([str(HERE / "look.py"), path, "--tiles", tiles, "--width", str(width), "-o", png] + child_args())
46
+ proc = run_tool([str(HERE / "look.py"), path, "--tiles", tiles, "--width", str(width), "-o", png, "--json"] + child_args())
47
+ try:
48
+ doc = json.loads(proc.stdout)
49
+ except ValueError:
50
+ doc = None
31
51
  if proc.returncode != 0 or not os.path.exists(png):
32
- return None
33
- return base64.b64encode(Path(png).read_bytes()).decode("ascii")
52
+ return None, dict(_child_failure(proc, doc), reason=((doc or {}).get("error") or {}).get("reason") or "look_failed")
53
+ return base64.b64encode(Path(png).read_bytes()).decode("ascii"), {}
34
54
 
35
55
 
36
- def loudness(path: str) -> Dict[str, Any]:
56
+ def loudness(path: str) -> "Tuple[Dict[str, Any], Dict[str, Any]]":
57
+ """(lufs/tp/lra, or {}; why there is no measurement)."""
37
58
  proc = run_tool([str(HERE / "loudness.py"), path, "--measure-only"] + child_args())
38
59
  try:
39
60
  d = json.loads(proc.stdout)
40
- return {"lufs": round(float(d["input_i"]), 1), "tp": round(float(d["input_tp"]), 1), "lra": round(float(d["input_lra"]), 1)}
41
- except (ValueError, KeyError):
42
- return {}
61
+ except ValueError:
62
+ d = None
63
+ try:
64
+ return {"lufs": round(float(d["input_i"]), 1), "tp": round(float(d["input_tp"]), 1), "lra": round(float(d["input_lra"]), 1)}, {}
65
+ except (TypeError, ValueError, KeyError):
66
+ return {}, dict(_child_failure(proc, d), reason="loudness_not_measured")
43
67
 
44
68
 
45
- def check(path: str, platform: str) -> Optional[Dict[str, Any]]:
69
+ def check(path: str, platform: str) -> "Tuple[Optional[Dict[str, Any]], Dict[str, Any]]":
70
+ """(check.py's document, or None when it could not run at all; why)."""
46
71
  proc = run_tool([str(HERE / "check.py"), path, "--platform", platform, "--json"] + child_args())
47
72
  try:
48
73
  doc = json.loads(proc.stdout)
@@ -51,10 +76,10 @@ def check(path: str, platform: str) -> Optional[Dict[str, Any]]:
51
76
  if not isinstance(doc, dict) or "checks" not in doc:
52
77
  # check.py could not run at all (missing ffmpeg, unreadable file): its failure document
53
78
  # has no rows to render. A failed *verification* still carries its rows and is shown.
54
- reason = ((doc or {}).get("error") or {}).get("message") or (proc.stderr.strip().splitlines() or ["?"])[-1]
55
- info(f"check.py could not run: {reason[:200]}")
56
- return None
57
- return doc
79
+ why = dict(_child_failure(proc, doc), reason=((doc or {}).get("error") or {}).get("reason") or "check_failed")
80
+ info(f"check.py could not run: {why['message'][:200]}")
81
+ return None, why
82
+ return doc, {}
58
83
 
59
84
 
60
85
  def fmt_dur(sec: Optional[float]) -> str:
@@ -167,15 +192,44 @@ def main() -> int:
167
192
  die("--after is required (or --pack FILE for a social pack table)")
168
193
  after = probe(args.after)
169
194
  before = probe(args.before) if args.before else None
170
- ld_after = loudness(args.after) if after.get("audio") else {}
171
- ld_before = loudness(args.before) if before and before.get("audio") else {}
172
- chk = check(args.after, args.platform) if args.platform else None
173
- sheets = {}
195
+ # every section the report would show is either in it or accounted for: a section that could
196
+ # not be built is a failing verification step (and a warning), never just absent from the page
197
+ verification: List[Dict[str, Any]] = []
198
+
199
+ def section(step: str, which: Optional[str], why: Dict[str, Any], what: str) -> None:
200
+ entry: Dict[str, Any] = {"step": step, "ok": not why}
201
+ if which:
202
+ entry["file"] = which
203
+ if why:
204
+ if why.get("reason") == "dry_run":
205
+ record_skip(f"{what}: {why['message']}", "report_section_dry_run", section=step, file=which)
206
+ return
207
+ entry.update({k: v for k, v in why.items() if k != "message"}, message=why.get("message"))
208
+ warn(f"the report has no {what}: {why.get('message')}", "report_section_missing", section=step, file=which,
209
+ reason=why.get("reason"))
210
+ verification.append(entry)
211
+
212
+ ld_after: Dict[str, Any] = {}
213
+ ld_before: Dict[str, Any] = {}
214
+ if after.get("audio"):
215
+ ld_after, why = loudness(args.after)
216
+ section("loudness", "after", why, "loudness for --after")
217
+ if before and before.get("audio"):
218
+ ld_before, why = loudness(args.before)
219
+ section("loudness", "before", why, "loudness for --before")
220
+ chk = None
221
+ if args.platform:
222
+ chk, why = check(args.after, args.platform)
223
+ section("compliance", None, why, f"{args.platform} compliance table")
224
+ if chk is not None and verification:
225
+ # ok: the table is in the report; passed: what the table says about the file
226
+ verification[-1].update(platform=args.platform, passed=bool(chk.get("ok")))
227
+ sheets: Dict[str, Optional[str]] = {}
174
228
  if not args.no_sheets:
175
- if before and before.get("video"):
176
- sheets["before"] = sheet_b64(args.before)
177
- if after.get("video"):
178
- sheets["after"] = sheet_b64(args.after)
229
+ for which, path, meta in (("before", args.before, before), ("after", args.after, after)):
230
+ if meta and meta.get("video"):
231
+ sheets[which], why = sheet_b64(path)
232
+ section("contact_sheet", which, why, f"{which} contact sheet")
179
233
  commands = read_text_or_die(args.commands, "--commands").splitlines() if args.commands else []
180
234
  notes = read_text_or_die(args.notes, "--notes") if args.notes else ""
181
235
  title = args.title or f"Delivery report — {Path(args.after).name}"
@@ -235,7 +289,7 @@ def main() -> int:
235
289
  except OSError as e:
236
290
  die(f"cannot write {output}: {e}", kind="output")
237
291
  info(f"wrote {output} ({os.path.getsize(output) / 1024:.0f} KB)")
238
- emit(None, report=output, check=chk, verification=([{"step": "exists", "ok": True}] if not STATE.dry_run else []))
292
+ emit(None, report=output, check=chk, verification=([{"step": "exists", "ok": True}] + verification if not STATE.dry_run else []))
239
293
  if not args.json:
240
294
  print(output)
241
295
  return 0
@@ -15,6 +15,7 @@ import argparse
15
15
  import sys
16
16
 
17
17
  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
18
+ from _common.emit import warn
18
19
 
19
20
 
20
21
  def main() -> int:
@@ -35,8 +36,9 @@ def main() -> int:
35
36
 
36
37
  output = args.output or default_output(args.input, "reverse")
37
38
  if (meta.get("duration") or 0) > 60:
38
- info(f"warning: reverse buffers every decoded frame in memory; {meta['duration']:.0f}s of "
39
- f"{meta['video']['width']}x{meta['video']['height']} can exhaust RAM -- cut the part to reverse first (cut.py)")
39
+ warn(f"reverse buffers every decoded frame in memory; {meta['duration']:.0f}s of "
40
+ f"{meta['video']['width']}x{meta['video']['height']} can exhaust RAM -- cut the part to reverse first (cut.py)",
41
+ "memory_heavy", duration=meta["duration"])
40
42
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", "reverse"]
41
43
  if has_audio:
42
44
  cmd += ["-af", "areverse"]
@@ -21,6 +21,7 @@ import tempfile
21
21
  from pathlib import Path
22
22
 
23
23
  from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, concat_list_line, fmt_secs
24
+ from _common.emit import warn
24
25
 
25
26
 
26
27
  def even(n: float) -> int:
@@ -97,8 +98,9 @@ def main() -> int:
97
98
  # to be dropped silently, giving a shorter video; name the gap and what was left out
98
99
  later = _frames_after(directory, args.pattern, i)
99
100
  if later:
100
- info(f"WARNING: frame {args.pattern % i} is missing; {len(later)} later frame(s) "
101
- f"(from {later[0].name}) are NOT in the sequence -- fill the gap or rename them")
101
+ warn(f"frame {args.pattern % i} is missing; {len(later)} later frame(s) "
102
+ f"(from {later[0].name}) are NOT in the sequence -- fill the gap or rename them", "sequence_gap",
103
+ missing=args.pattern % i, frames_left_out=len(later), first_left_out=later[0].name)
102
104
 
103
105
  frame_meta = probe(str(frames[0]))
104
106
  if not frame_meta.get("video"):
@@ -22,6 +22,7 @@ from typing import List, Tuple
22
22
  from _common import (filler_spans, FILLER_WORDS, FILLER_AMBIGUOUS, FILLER_DISCOURSE_MARKERS,
23
23
  FILLER_PAD, read_text_or_die)
24
24
  from _common import asr as _asr
25
+ from _common.emit import warn
25
26
  from _common import detect_silences as detect, STATE, video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, run, X264_PRESETS, measured_level_dbfs, fmt_secs
26
27
 
27
28
 
@@ -101,13 +102,14 @@ def resolve_filler(args, meta):
101
102
  "timestamps) or --transcribe. There is no way to find a filler word without them -- "
102
103
  "cutting the short quiet blips instead would remove real speech.", kind="input")
103
104
  source, engine, raw, transcription = None, None, None, {}
105
+ untimed: list = [] # transcript words without a usable timing (a --words file can carry them)
104
106
  if args.words:
105
107
  try:
106
108
  raw = json.loads(read_text_or_die(args.words, "--words"))
107
109
  except ValueError as exc:
108
110
  die(f"--words {args.words}: not readable JSON ({exc})", kind="input")
109
111
  source = f"whisper-json:{args.words}"
110
- words, had_segments = _words_from_transcript(raw)
112
+ words, had_segments, untimed = _words_from_transcript(raw)
111
113
  if not words:
112
114
  if had_segments:
113
115
  die(f"the transcript in {args.words} has segment timings but no word timings; "
@@ -186,7 +188,8 @@ def resolve_filler(args, meta):
186
188
  wordlist |= set(_word_list(args.filler_extra))
187
189
  wordlist -= set(_word_list(args.filler_keep))
188
190
 
189
- spans = filler_spans(words, wordlist, pad=args.filler_pad)
191
+ kept: dict = {}
192
+ spans = filler_spans(list(words) + untimed, wordlist, pad=args.filler_pad, stats=kept)
190
193
  removed_words = sorted({s["word"] for s in spans})
191
194
  warnings = []
192
195
  ambiguous = sorted(set(FILLER_AMBIGUOUS.get(lang, ())) & set(
@@ -210,12 +213,21 @@ def resolve_filler(args, meta):
210
213
  "removed_seconds": round(sum(s["end"] - s["start"] for s in spans), 3),
211
214
  "word_timings": len(words), "list": listname, "removed_words": removed_words,
212
215
  "warnings": warnings,
216
+ # listed words this run did not cut, and why: a transcript word with no timing has
217
+ # nothing to cut on, and one longer than a filler is a held vowel someone meant
218
+ "matched_untimed": kept.get("matched_untimed", 0),
219
+ "matched_too_long": kept.get("matched_too_long", 0),
213
220
  }
221
+ if block["matched_untimed"]:
222
+ warn(f"--filler: {block['matched_untimed']} listed word(s) in the transcript carry no usable timing and were "
223
+ "not removed (nothing measured to cut on)", "filler_words_untimed", count=block["matched_untimed"])
214
224
  return block, [(s["start"], s["end"]) for s in spans]
215
225
 
216
226
 
217
227
  def _words_from_transcript(data):
218
- """([{word,start,end}], whether the document had segments at all) from a whisper JSON."""
228
+ """([{word,start,end}], whether the document had segments at all, [{word}] of the words
229
+ that carried no usable timing) from a whisper JSON. The third list lets --filler count a
230
+ listed word it could not cut instead of dropping it unseen."""
219
231
  raw, had_segments = [], False
220
232
  if isinstance(data, dict):
221
233
  raw = list(data.get("words") or [])
@@ -225,7 +237,7 @@ def _words_from_transcript(data):
225
237
  raw.extend((seg or {}).get("words") or [])
226
238
  elif isinstance(data, list):
227
239
  raw = list(data)
228
- out = []
240
+ out, untimed = [], []
229
241
  for w in raw:
230
242
  if not isinstance(w, dict):
231
243
  continue
@@ -233,8 +245,8 @@ def _words_from_transcript(data):
233
245
  out.append({"word": str(w.get("word") or w.get("text") or ""),
234
246
  "start": float(w["start"]), "end": float(w["end"])})
235
247
  except (KeyError, TypeError, ValueError):
236
- continue
237
- return out, had_segments
248
+ untimed.append({"word": str(w.get("word") or w.get("text") or "")})
249
+ return out, had_segments, untimed
238
250
 
239
251
 
240
252
  def main() -> int:
@@ -353,7 +365,7 @@ def main() -> int:
353
365
  f"{filler_info['removed_seconds']:.2f}s, from {filler_info['word_timings']} word timings "
354
366
  f"({filler_info['lang']}, list {filler_info['list']})")
355
367
  for warning in filler_info.get("warnings") or []:
356
- info("warning: " + warning)
368
+ warn(warning, "filler_removal_caution")
357
369
  if getattr(args, "_asr_notes", None):
358
370
  # an English-only transcript made on an assumption (asr.assumed_english_note): the
359
371
  # bridge already logged the warning; the result document carries it at the top level
package/scripts/sync.py CHANGED
@@ -33,6 +33,7 @@ import sys
33
33
  from typing import Dict, List
34
34
 
35
35
  from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args, decode_pcm_mono, rms_envelope
36
+ from _common.emit import record_skip, warn
36
37
 
37
38
  SR = 8000 # decode sample rate
38
39
 
@@ -218,6 +219,9 @@ def measure_one(reference: str, path: str, args) -> Dict:
218
219
  offset, score = measure_offset(reference, path, 0.0, args.analyze_seconds, args.step_ms, args.max_offset, args.fine_ms)
219
220
  drift_ratio = 1.0
220
221
  drift_info = None
222
+ # why a requested --fix-drift was not applied: too_short (no tail window far enough from
223
+ # the head to measure over) or low_confidence (the tail did not correlate); None when applied
224
+ skipped_reason = None
221
225
  if args.fix_drift:
222
226
  ref_dur = probe(reference)["duration"] or 0.0
223
227
  sec_dur = probe(path)["duration"] or 0.0
@@ -225,7 +229,10 @@ def measure_one(reference: str, path: str, args) -> Dict:
225
229
  head_len = min(args.analyze_seconds, overlap_end)
226
230
  tail_start = overlap_end - args.drift_window
227
231
  if tail_start <= head_len / 2 + 5:
228
- info(f"warning: {path} too short to measure drift reliably; skipping drift correction")
232
+ skipped_reason = "too_short"
233
+ record_skip(f"{path} too short to measure drift reliably; skipping drift correction", "drift_correction_skipped",
234
+ path=path, reason=skipped_reason, overlap_seconds=round(overlap_end, 3),
235
+ drift_window=args.drift_window)
229
236
  else:
230
237
  ref_start = tail_start
231
238
  sec_start = tail_start - offset
@@ -251,10 +258,13 @@ def measure_one(reference: str, path: str, args) -> Dict:
251
258
  abs(drift_ratio - 1) * 1e6, "long/slow" if drift_ratio > 1 else "short/fast", abs(residual), elapsed),
252
259
  "confidence": round(end_score, 3)}
253
260
  else:
254
- info(f"warning: could not measure drift with confidence for {path}; skipping drift correction")
261
+ skipped_reason = "low_confidence"
262
+ record_skip(f"could not measure drift with confidence for {path}; skipping drift correction",
263
+ "drift_correction_skipped", path=path, reason=skipped_reason,
264
+ confidence=round(max(0.0, end_score), 3), measured_over_seconds=round(elapsed, 2))
255
265
  return {"path": path, "offset_seconds": round(offset, 4), "offset_s": round(offset, 4),
256
266
  "confidence": round(max(0.0, min(1.0, score)), 3), "drift_ratio": drift_ratio, "drift": drift_info,
257
- "drift_ppm": (drift_info["drift_ppm"] if drift_info else None)}
267
+ "drift_ppm": (drift_info["drift_ppm"] if drift_info else None), "drift_skipped_reason": skipped_reason}
258
268
 
259
269
 
260
270
  def main() -> int:
@@ -301,10 +311,11 @@ def main() -> int:
301
311
  measured = [measure_one(args.reference, p, args) for p in args.sources]
302
312
  for p, m in zip(args.sources, measured):
303
313
  if m["confidence"] < 0.1:
304
- info(f"warning: {p}: low correlation confidence; check that it shares an audio event with the reference")
314
+ warn(f"{p}: low correlation confidence; check that it shares an audio event with the reference",
315
+ "sync_low_confidence", path=p, confidence=m["confidence"])
305
316
 
306
317
  sources_block = [{"path": p, "offset_s": m["offset_s"], "confidence": m["confidence"],
307
- **({"drift_ppm": m["drift_ppm"]} if args.fix_drift else {})}
318
+ **({"drift_ppm": m["drift_ppm"], "drift_skipped_reason": m["drift_skipped_reason"]} if args.fix_drift else {})}
308
319
  for p, m in zip(args.sources, measured)]
309
320
 
310
321
  if n_sources == 1:
@@ -323,8 +334,8 @@ def main() -> int:
323
334
  }
324
335
  if drift_info:
325
336
  result["drift"] = drift_info
326
- if score < 0.1:
327
- info("warning: low correlation confidence; check that both files contain the same audio event")
337
+ if args.fix_drift:
338
+ result["drift_skipped_reason"] = m["drift_skipped_reason"]
328
339
  else:
329
340
  offset = drift_ratio = drift_info = None # not used below; output writing is 1-source only
330
341
  result = {"reference": args.reference, "sources": sources_block}
package/scripts/verify.py CHANGED
@@ -15,6 +15,8 @@ Examples:
15
15
  python3 verify.py clip.mov --keep --out ./verify_out
16
16
  """
17
17
  import argparse
18
+ import contextlib
19
+ import io
18
20
  import json
19
21
  import os
20
22
  import subprocess
@@ -22,7 +24,7 @@ import sys
22
24
  import tempfile
23
25
  import time
24
26
  from pathlib import Path
25
- from typing import Dict, List
27
+ from typing import Any, Dict, List, Optional, Tuple
26
28
 
27
29
  from _common import STATE, add_common, apply_common, die, emit, info, probe
28
30
 
@@ -67,26 +69,77 @@ def collect(paths: List[str]) -> List[Path]:
67
69
  return files
68
70
 
69
71
 
72
+ def _cell(message: str) -> str:
73
+ """A failure message as one Markdown table cell: one line, no pipes, at most 200 characters."""
74
+ return " / ".join(line.strip() for line in str(message).splitlines() if line.strip()).replace("|", "/")[:200]
75
+
76
+
77
+ def _probe_capturing(path: str) -> "Tuple[Optional[Dict[str, Any]], Dict[str, Any]]":
78
+ """probe() in this process, with its failure kept: (the probe, {}) or (None, the failure's
79
+ kind/reason/message). probe() dies on an unreadable file; its failure document is read back
80
+ instead of a fixed string standing in for it (one JSON document per run: it is not printed)."""
81
+ was_json, STATE.json = STATE.json, True
82
+ out, err = io.StringIO(), io.StringIO()
83
+ try:
84
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
85
+ return probe(path), {}
86
+ except SystemExit as exc:
87
+ try:
88
+ doc = json.loads(out.getvalue())
89
+ except ValueError:
90
+ doc = {}
91
+ e = doc.get("error") if isinstance(doc.get("error"), dict) else {}
92
+ failure = {k: e[k] for k in ("kind", "reason", "message") if e.get(k)}
93
+ failure.setdefault("message", (err.getvalue().strip().splitlines() or [f"probe exited {exc.code}"])[-1])
94
+ return None, failure
95
+ finally:
96
+ STATE.json = was_json
97
+
98
+
70
99
  def step(name: str, argv: List[str], timeout: float) -> Dict:
100
+ """Run one step and keep what its tool said: `ok` (it completed), and on a failure the tool's
101
+ own `kind`, `reason` and `message` (its --json failure document), `error` being that message
102
+ for the table. A completed step also carries the tool's `verified`."""
71
103
  t0 = time.time()
72
104
  if argv[0] == "__check_hdr__":
73
- was_json, STATE.json = STATE.json, False # probe()'s die() would print a JSON document of its own
74
- try:
75
- v = probe(argv[1]).get("video") or {}
76
- ok = bool(v.get("bt2020_or_hdr")) and v.get("bit_depth", 8) >= 10
77
- err = "" if ok else f"re-encode lost HDR: {v.get('color_transfer')}/{v.get('pix_fmt')}"
78
- except SystemExit:
79
- ok, err = False, "output missing"
80
- finally:
81
- STATE.json = was_json
82
- return {"step": name, "ok": ok, "seconds": round(time.time() - t0, 1), "error": err}
105
+ meta, failure = _probe_capturing(argv[1])
106
+ if meta is None:
107
+ return dict({"step": name, "ok": False, "seconds": round(time.time() - t0, 1), "error": _cell(failure.get("message", ""))}, **failure)
108
+ v = meta.get("video") or {}
109
+ ok = bool(v.get("bt2020_or_hdr")) and v.get("bit_depth", 8) >= 10
110
+ r: Dict[str, Any] = {"step": name, "ok": ok, "seconds": round(time.time() - t0, 1),
111
+ "error": "" if ok else f"re-encode lost HDR: {v.get('color_transfer')}/{v.get('pix_fmt')}"}
112
+ if not ok:
113
+ r.update(kind="verification", reason="hdr_lost", message=r["error"])
114
+ return r
83
115
  try:
84
- proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=timeout)
85
- ok = proc.returncode == 0
86
- err = "" if ok else (proc.stderr.strip().splitlines() or ["?"])[-1][:200]
116
+ proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:] + ["--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
117
+ text=True, encoding="utf-8", errors="replace", timeout=timeout)
87
118
  except subprocess.TimeoutExpired:
88
- ok, err = False, f"timeout after {timeout:.0f}s"
89
- return {"step": name, "ok": ok, "seconds": round(time.time() - t0, 1), "error": err}
119
+ msg = f"timeout after {timeout:.0f}s"
120
+ return {"step": name, "ok": False, "seconds": round(time.time() - t0, 1), "error": msg,
121
+ "kind": "timeout", "reason": "step_timeout", "message": msg}
122
+ try:
123
+ doc = json.loads(proc.stdout)
124
+ except ValueError:
125
+ doc = None
126
+ doc = doc if isinstance(doc, dict) else {}
127
+ ok = proc.returncode == 0
128
+ r = {"step": name, "ok": ok, "seconds": round(time.time() - t0, 1), "error": ""}
129
+ if ok:
130
+ if "verified" in doc:
131
+ r["verified"] = doc["verified"]
132
+ return r
133
+ e = doc.get("error") if isinstance(doc.get("error"), dict) else {}
134
+ r["message"] = str(e.get("message") or (proc.stderr.strip().splitlines() or [f"exit {proc.returncode}"])[-1])
135
+ r["error"] = _cell(r["message"])
136
+ for k in ("kind", "reason", "details"):
137
+ if e.get(k) is not None:
138
+ r[k] = e[k]
139
+ if not e:
140
+ r["reason"] = "step_output_unparsed"
141
+ r["exit_code"] = proc.returncode
142
+ return r
90
143
 
91
144
 
92
145
  def main() -> int:
@@ -117,16 +170,13 @@ def main() -> int:
117
170
  results = []
118
171
  for f in files:
119
172
  info(f"=== {f}")
120
- entry: Dict = {"file": str(f), "steps": []}
121
- was_json, STATE.json = STATE.json, False # same: one JSON document per run, printed at the end
122
- try:
123
- meta = probe(str(f))
124
- except SystemExit:
125
- entry["steps"].append({"step": "probe", "ok": False, "seconds": 0, "error": "ffprobe failed"})
173
+ entry: Dict = {"file": str(f), "steps": [], "skipped": []}
174
+ meta, failure = _probe_capturing(str(f))
175
+ if meta is None:
176
+ entry["steps"].append(dict({"step": "probe", "ok": False, "seconds": 0, "error": _cell(failure.get("message", ""))}, **failure))
177
+ entry["skipped"].append({"step": "*", "reason": "input_unreadable", "message": "no other step runs on a file probe cannot read"})
126
178
  results.append(entry)
127
179
  continue
128
- finally:
129
- STATE.json = was_json
130
180
  entry["probe"] = {k: meta.get(k) for k in ("duration", "format")}
131
181
  entry["probe"]["video"] = {k: (meta.get("video") or {}).get(k) for k in ("codec", "width", "height", "fps", "pix_fmt", "hdr_format", "rotation", "variable_frame_rate_suspected")}
132
182
  entry["probe"]["audio"] = {k: (meta.get("audio") or {}).get(k) for k in ("codec", "channels", "sample_rate")}
@@ -138,6 +188,10 @@ def main() -> int:
138
188
  seg_end = min(dur, args.seconds) if dur else args.seconds
139
189
  fast = ["--fast"]
140
190
  plan = []
191
+ skipped: List[Dict[str, str]] = entry["skipped"]
192
+
193
+ def skip(step_name: str, reason: str, message: str) -> None:
194
+ skipped.append({"step": step_name, "reason": reason, "message": message})
141
195
  plan.append(("cut copy", ["cut.py", str(f), "--start", "0", "--end", f"{seg_end:.2f}", "-o", cut]))
142
196
  if has_v:
143
197
  plan.append(("cut accurate", ["cut.py", str(f), "--start", "0", "--end", f"{seg_end:.2f}", "--accurate", "-o", f"{stem}_acc.mp4"] + fast))
@@ -152,12 +206,30 @@ def main() -> int:
152
206
  plan.append(("color to-sdr", ["color.py", f"{stem}_acc.mp4", "--to-sdr", "-o", f"{stem}_sdr.mp4"] + fast))
153
207
  plan.append(("hdr preserved", ["__check_hdr__", f"{stem}_acc.mp4"]))
154
208
  plan.append(("probe analyze", ["probe.py", cut, "--analyze"]))
209
+ else:
210
+ for n in ("overlay text", "look sheet", "color to-sdr", "hdr preserved", "probe analyze"):
211
+ skip(n, "quick", "--quick runs probe, copy cut, fit, caption and export only")
212
+ if not args.quick and not (meta.get("video") or {}).get("bt2020_or_hdr"):
213
+ for n in ("color to-sdr", "hdr preserved"):
214
+ skip(n, "not_hdr", "the file is not HDR")
155
215
  plan.append(("export x", ["export.py", cut, "--preset", "x", "-o", f"{stem}_x.mp4"]))
216
+ else:
217
+ for n in ("cut accurate", "fit 9:16", "caption", "overlay text", "look sheet", "color to-sdr", "hdr preserved",
218
+ "probe analyze", "export x"):
219
+ skip(n, "no_video", "the file has no video stream")
156
220
  if has_a and not args.quick:
157
221
  plan.append(("loudness measure", ["loudness.py", str(f), "--measure-only"]))
158
222
  plan.append(("silence list", ["silence.py", cut, "--list"]))
159
223
  if (meta.get("audio") or {}).get("channels", 0) > 2:
160
224
  plan.append(("audio downmix", ["audio.py", cut, "--downmix", "-o", f"{stem}_st.mp4"]))
225
+ else:
226
+ skip("audio downmix", "not_multichannel", "the audio has 2 channels or fewer")
227
+ elif not has_a:
228
+ for n in ("loudness measure", "silence list", "audio downmix"):
229
+ skip(n, "no_audio", "the file has no audio stream")
230
+ else:
231
+ for n in ("loudness measure", "silence list", "audio downmix"):
232
+ skip(n, "quick", "--quick runs probe, copy cut, fit, caption and export only")
161
233
  for name, argv in plan:
162
234
  r = step(name, argv, args.timeout)
163
235
  entry["steps"].append(r)
@@ -179,6 +251,8 @@ def main() -> int:
179
251
  lines.append("|---|---|---|---|")
180
252
  for s in e["steps"]:
181
253
  lines.append(f"| {s['step']} | {'PASS' if s['ok'] else 'FAIL'} | {s['seconds']}s | {s['error']} |")
254
+ for s in e.get("skipped") or []:
255
+ lines.append(f"| {s['step']} | SKIP | - | {s['message']} |")
182
256
  lines.append("")
183
257
  report = "\n".join(lines)
184
258
  if args.report:
@@ -38,7 +38,7 @@ from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
38
38
  from _common import (add_common, apply_common, aac_args, default_output, die, draws_chroma, emit, ffmpeg_base,
39
39
  info, load_brand, pad_filters, probe, rgb_chain_to_bt709, run, STATE, validate_color, video_args, X264_PRESETS,
40
40
  fmt_secs, child_args, run_tool, measured_level_dbfs)
41
- from _common.emit import absorb_stage_hw
41
+ from _common.emit import absorb_stage_hw, warn
42
42
 
43
43
  WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
44
44
 
@@ -272,7 +272,8 @@ def main() -> int:
272
272
  f"{overlap}px: the captions or the app's own UI will sit over the waveform "
273
273
  f"(lower --vis-height to {max(0.05, (args.height - safe_px) / args.height):.2f})")
274
274
  if silent_peak is not None:
275
- info(f"warning: {args.input} is silent (peak {silent_peak:g} dBFS); the visualisation is a flat line")
275
+ warn(f"{args.input} is silent (peak {silent_peak:g} dBFS); the visualisation is a flat line", "silent_input",
276
+ path=args.input, peak_db=silent_peak)
276
277
  notes.append(f"the input audio is silent (peak {silent_peak:g} dBFS, at or below --silence-threshold "
277
278
  f"{args.silence_threshold:g}): the visualisation is a flat line; pass --on-silent fail to refuse instead")
278
279
  duration_ok = True