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.
@@ -71,10 +71,26 @@ PROBE_TIMEOUT = 120.0
71
71
 
72
72
 
73
73
  def _env_timeout() -> float:
74
+ return _read_env_timeout()[0]
75
+
76
+
77
+ def _read_env_timeout() -> "Tuple[float, Optional[str]]":
78
+ """(the per-call limit FFMPEG_SKILL_TIMEOUT sets, why its value was not taken as written or
79
+ None). Unset or blank is the default with nothing to say. A value that is not a number (or is
80
+ NaN) is the default, and a negative one is 0 (no limit) as it always was; both used to pass
81
+ without a word, and apply_common() now records them (`timeout_env_invalid`)."""
82
+ raw = os.environ.get("FFMPEG_SKILL_TIMEOUT")
83
+ if raw is None or not raw.strip():
84
+ return DEFAULT_TIMEOUT, None
74
85
  try:
75
- return max(0.0, float(os.environ.get("FFMPEG_SKILL_TIMEOUT", DEFAULT_TIMEOUT)))
86
+ value = float(raw)
87
+ if value != value:
88
+ raise ValueError("NaN")
76
89
  except ValueError:
77
- return DEFAULT_TIMEOUT
90
+ return DEFAULT_TIMEOUT, f"FFMPEG_SKILL_TIMEOUT={raw!r} is not a number of seconds; using the default {DEFAULT_TIMEOUT:g} s"
91
+ if value < 0:
92
+ return 0.0, f"FFMPEG_SKILL_TIMEOUT={raw!r} is negative; read as 0, which means no time limit"
93
+ return value, None
78
94
 
79
95
 
80
96
  # None of the four kinds above are retryable in practice: an "input"/"missing_tool" failure is
@@ -217,7 +233,8 @@ class Context:
217
233
  """
218
234
 
219
235
  __slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec",
220
- "hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto")
236
+ "hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto",
237
+ "notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification")
221
238
 
222
239
  def __init__(self) -> None:
223
240
  self.reset()
@@ -246,6 +263,19 @@ class Context:
246
263
  self.hw_env_ignored = False # $FFMPEG_SKILL_HW=1 was set but this tool's delivery preset does not take it (export.py)
247
264
  self.hw_stages: List[Dict[str, Any]] = [] # render.py: the `hw` facts each stage (a child process) reported
248
265
  self.hw_auto: List[str] = [] # CPU encoders (libx264/libx265) this ffmpeg build lacks, whose encodes ran on VideoToolbox without --hw
266
+ # The collector (docs/design-decisions.md, "Nothing happens silently"): what this run
267
+ # decided on its own -- a warning, a fallback, a step it skipped -- recorded as it happens
268
+ # by warn()/record_fallback()/record_skip() (stderr too) and merged by emit()/die() into
269
+ # the document: `notes` (strings) and `diagnostics: {warnings, fallbacks, skipped}`.
270
+ self.notes: List[str] = []
271
+ self.warnings: List[Dict[str, Any]] = []
272
+ self.fallbacks: List[Dict[str, Any]] = []
273
+ self.skipped: List[Dict[str, Any]] = []
274
+ self.audit_log: Optional[str] = None # render.py --audit-log FILE: where audit() appends; None = the event's own default
275
+ # render.py (stages, an executed plan, a pack's renders): the verification steps each child
276
+ # tool reported, tagged with `stage`, which emit() adds to this result's own so that
277
+ # `verified` is the conjunction of every stage's (absorb_stage())
278
+ self.stage_verification: List[Dict[str, Any]] = []
249
279
 
250
280
 
251
281
  STATE = Context()
@@ -320,6 +350,10 @@ def apply_common(args: "argparse.Namespace") -> None:
320
350
  STATE.overwrite = bool(getattr(args, "overwrite", False))
321
351
  if getattr(args, "timeout", None) is not None:
322
352
  STATE.timeout = max(0.0, float(args.timeout))
353
+ else:
354
+ env_limit, env_problem = _read_env_timeout()
355
+ if env_problem:
356
+ warn(env_problem, "timeout_env_invalid", value=os.environ.get("FFMPEG_SKILL_TIMEOUT"), timeout=env_limit)
323
357
  if STATE.fast and getattr(args, "preset", None) in X264_PRESETS:
324
358
  args.preset = "veryfast"
325
359
  STATE.codec = getattr(args, "codec", None) or None
@@ -377,9 +411,10 @@ _CHILDREN: List[Tuple[subprocess.Popen, Sequence[str]]] = []
377
411
  _SIGNALS_INSTALLED = False
378
412
 
379
413
 
380
- def _on_signal(signum: int, frame: Any) -> None:
381
- import signal as _signal
382
- name = {getattr(_signal, "SIGINT", None): "SIGINT", getattr(_signal, "SIGTERM", None): "SIGTERM"}.get(signum, str(signum))
414
+ def stop_children() -> None:
415
+ """Stop every child this tool is waiting on and remove each one's partial output: what the
416
+ signal handler does before it exits, for a tool that reports more than "interrupted" after a
417
+ signal (batch.py's partial table)."""
383
418
  for proc, cmd in list(_CHILDREN):
384
419
  try:
385
420
  proc.terminate() # ffmpeg exits promptly on SIGTERM; a sibling script runs this same handler
@@ -393,6 +428,12 @@ def _on_signal(signum: int, frame: Any) -> None:
393
428
  if cmd:
394
429
  _cleanup_partial_output(cmd)
395
430
  _CHILDREN.clear()
431
+
432
+
433
+ def _on_signal(signum: int, frame: Any) -> None:
434
+ import signal as _signal
435
+ name = {getattr(_signal, "SIGINT", None): "SIGINT", getattr(_signal, "SIGTERM", None): "SIGTERM"}.get(signum, str(signum))
436
+ stop_children()
396
437
  die(f"interrupted by {name}: the running command was stopped and its partial output removed; nothing was written",
397
438
  code=128 + signum, kind="interrupted")
398
439
 
@@ -531,12 +572,21 @@ def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
531
572
  if a == "-i" and i + 1 < len(cmd):
532
573
  inp = cmd[i + 1]
533
574
  try:
534
- if os.path.realpath(inp) == out_real:
535
- die(f"refusing to run: output {output!r} is the same file as input {inp!r} "
536
- f"(would overwrite it while ffmpeg is still reading it) -- choose a different --output/-o path",
537
- kind="input")
575
+ same = os.path.realpath(inp) == out_real
538
576
  except OSError:
539
577
  continue
578
+ if same:
579
+ _refuse_output_is_input(output, inp, out_real, "would overwrite it while ffmpeg is still reading it")
580
+
581
+
582
+ def _refuse_output_is_input(output: str, inp: str, real: str, why: str) -> None:
583
+ """The one refusal both output-is-input guards end in: structured, and in the audit log when
584
+ one is configured (render.py --audit-log)."""
585
+ details = {"output": output, "input": inp, "resolved": real}
586
+ audit("output_is_input_refused", details)
587
+ refuse("input", "output_is_input",
588
+ f"refusing to run: output {output!r} is the same file as input {inp!r} ({why}) -- choose a different --output/-o path",
589
+ **details)
540
590
 
541
591
 
542
592
  def refuse_output_is_input(output: str, *inputs: str) -> None:
@@ -555,8 +605,7 @@ def refuse_output_is_input(output: str, *inputs: str) -> None:
555
605
  except OSError:
556
606
  continue
557
607
  if same:
558
- die(f"refusing to run: output {output!r} is the same file as input {inp!r} "
559
- f"(the result would replace the source) -- choose a different --output/-o path", kind="input")
608
+ _refuse_output_is_input(output, inp, out_real, "the result would replace the source")
560
609
 
561
610
 
562
611
  def _check_output_path(cmd: Sequence[str]) -> None:
@@ -567,12 +616,15 @@ def _check_output_path(cmd: Sequence[str]) -> None:
567
616
  if output == "-" or output.startswith("pipe:") or output.startswith("-"):
568
617
  return
569
618
  if os.path.isdir(output):
570
- die(f"output {output!r} is a directory; pass a file path (e.g. {os.path.join(output, 'result.mp4')!r})")
619
+ refuse("input", "output_is_directory",
620
+ f"output {output!r} is a directory; pass a file path (e.g. {os.path.join(output, 'result.mp4')!r})", output=output)
571
621
  parent = os.path.dirname(os.path.abspath(output))
572
622
  if not os.path.isdir(parent):
573
- die(f"output directory {parent!r} does not exist; create it first (this tool never creates directories)")
623
+ refuse("input", "output_dir_missing",
624
+ f"output directory {parent!r} does not exist; create it first (this tool never creates directories)",
625
+ output=output, directory=parent)
574
626
  if not os.access(parent, os.W_OK):
575
- die(f"output directory {parent!r} is not writable")
627
+ refuse("input", "output_dir_not_writable", f"output directory {parent!r} is not writable", output=output, directory=parent)
576
628
 
577
629
 
578
630
  EVEN_SCALE = "scale=trunc(iw/2)*2:trunc(ih/2)*2"
@@ -602,14 +654,29 @@ def _pid_dead(pid: int) -> bool:
602
654
  return False
603
655
 
604
656
 
657
+ LOCK_HOUR = 3600.0
658
+ LOCK_UNLIMITED = 10 * 365 * 86400.0 # --timeout 0: the deadline is pushed past any run (and kept short of 2038)
659
+
660
+
605
661
  class _OutputLock:
606
662
  """Two runs writing the same output at once used to both report `completed` while one of
607
663
  them described the other's file (sweep F1). A lock file next to the output, created with
608
- O_EXCL and holding the writer's pid, makes the second run refuse as `kind: input`. A lock
609
- whose pid is dead (POSIX) or older than an hour is stale and taken over."""
664
+ O_EXCL and holding the writer's pid, makes the second run refuse (`kind: input`,
665
+ `reason: output_locked`, the holder in `details`).
666
+
667
+ Stale locks: a lock whose pid is dead is taken over. A lock whose pid is alive is taken over
668
+ only once its holder's own time limit has passed: the writer sets the lock's mtime to that
669
+ deadline minus an hour, so "older than an hour" -- the rule every version since 1.x applies,
670
+ which this keeps compatible in both directions -- means "past the deadline". The deadline is
671
+ child_limit() from now (4x --timeout + 60 s, the ceiling on everything one run() does under
672
+ the lock); with --timeout 0 there is none and a live holder is never taken over. Through 2.10
673
+ the hour ran from the lock's creation, so a legitimate run longer than an hour lost its lock
674
+ to a second writer. A takeover is recorded (`diagnostics.fallbacks`, stderr, and the audit
675
+ log); so is running without a lock where none can be created (`diagnostics.warnings`)."""
610
676
  def __init__(self, output: str) -> None:
611
677
  self.path: Optional[str] = None
612
678
  self.fd: Optional[int] = None
679
+ self.output = output
613
680
  if output == "-" or output.startswith("pipe:") or output.startswith("-"):
614
681
  return
615
682
  d, base = os.path.split(os.path.abspath(output))
@@ -618,33 +685,95 @@ class _OutputLock:
618
685
  def __enter__(self) -> "_OutputLock":
619
686
  if not self.path:
620
687
  return self
688
+ taken: Optional[Dict[str, Any]] = None
621
689
  for attempt in (0, 1):
622
690
  try:
623
691
  self.fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
624
692
  os.write(self.fd, str(os.getpid()).encode())
693
+ self._set_deadline()
694
+ if taken is not None:
695
+ self._record_takeover(taken)
625
696
  return self
626
697
  except FileExistsError:
627
- if attempt == 0 and self._stale():
698
+ holder = self._holder()
699
+ if attempt == 0 and holder["stale"]:
628
700
  try:
629
701
  os.remove(self.path)
630
- except OSError:
631
- pass
632
- continue
633
- die(f"another run is writing {os.path.basename(self.path)[1:-len('.ffskill-lock')]!r} right now "
634
- f"(lock {self.path}); wait for it or choose a different --output/-o path")
635
- except OSError:
636
- return self # unlockable location (read-only dir surfaces elsewhere): proceed without a lock
702
+ except OSError as exc:
703
+ # Windows will not delete a file another process holds open, and a live
704
+ # holder keeps its lock open until it finishes: the takeover fails and
705
+ # the run is refused below, with the reason, instead of passing silently
706
+ holder["takeover_error"] = str(exc)
707
+ holder["stale"] = False
708
+ else:
709
+ taken = holder
710
+ continue
711
+ name = os.path.basename(self.path)[1:-len(".ffskill-lock")]
712
+ refuse("input", "output_locked",
713
+ f"another run is writing {name!r} right now (lock {self.path}"
714
+ + (f", pid {holder['pid']}" if holder.get("pid") else "") + "); wait for it or choose a different --output/-o path",
715
+ hint=("wait for that run, or choose another -o path; if pid "
716
+ f"{holder.get('pid')} is not an ffmpeg-skill run, delete {self.path}"),
717
+ lock=self.path, output=os.path.abspath(self.output), **{k: v for k, v in holder.items() if k != "stale"})
718
+ except OSError as exc:
719
+ # an unlockable location (a read-only directory surfaces elsewhere): the run goes
720
+ # on without a lock, and says so -- two runs could now both write this output
721
+ details = {"lock": self.path, "output": os.path.abspath(self.output), "error": str(exc)}
722
+ warn(f"could not create the output lock {self.path} ({exc}); running without one, so a second run "
723
+ "writing the same output at the same time would not be refused", "output_unlocked", **details)
724
+ audit("output_unlocked", details, default_path=self._audit_default())
725
+ return self
637
726
  return self
638
727
 
639
- def _stale(self) -> bool:
728
+ def _audit_default(self) -> Optional[str]:
729
+ if not self.path:
730
+ return None
731
+ d, base = os.path.split(os.path.abspath(self.output))
732
+ return os.path.join(d, f".{base}.ffskill-audit.jsonl")
733
+
734
+ def _set_deadline(self) -> None:
735
+ import time
736
+ limit = child_limit()
737
+ deadline = time.time() + (limit if limit else LOCK_UNLIMITED)
738
+ try:
739
+ os.utime(self.path, (deadline - LOCK_HOUR, deadline - LOCK_HOUR))
740
+ except (OSError, OverflowError, ValueError) as exc:
741
+ warn(f"could not date the output lock {self.path} ({exc}); another run may take it over an hour from now",
742
+ "lock_deadline_unset", lock=self.path, error=str(exc))
743
+
744
+ def _holder(self) -> Dict[str, Any]:
745
+ """Who holds the lock and whether it is stale: {pid, pid_alive, deadline (UTC, the
746
+ mtime plus an hour), why: pid_dead | past_deadline | unreadable | held, stale}."""
747
+ import datetime
748
+ import time
749
+ out: Dict[str, Any] = {"pid": None, "pid_alive": None, "deadline": None, "why": "unreadable", "stale": True}
640
750
  try:
641
- pid = int(open(self.path).read().strip() or "0")
642
- if pid > 0 and _pid_dead(pid):
643
- return True
644
- import time
645
- return time.time() - os.path.getmtime(self.path) > 3600
751
+ with open(self.path, encoding="utf-8", errors="replace") as f:
752
+ text = f.read().strip()
753
+ deadline = os.path.getmtime(self.path) + LOCK_HOUR
754
+ pid = int(text or "0")
646
755
  except (OSError, ValueError):
647
- return True
756
+ return out
757
+ out["deadline"] = datetime.datetime.fromtimestamp(deadline, datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
758
+ out["pid"] = pid if pid > 0 else None
759
+ if pid > 0 and _pid_dead(pid):
760
+ out.update(pid_alive=False, why="pid_dead")
761
+ return out
762
+ out["pid_alive"] = pid > 0 or None
763
+ if time.time() > deadline:
764
+ out["why"] = "past_deadline"
765
+ return out
766
+ out.update(why="held", stale=False)
767
+ return out
768
+
769
+ def _record_takeover(self, holder: Dict[str, Any]) -> None:
770
+ details = {"lock": self.path, "output": os.path.abspath(self.output),
771
+ **{k: v for k, v in holder.items() if k != "stale"}}
772
+ why = {"pid_dead": f"its pid {holder.get('pid')} no longer runs",
773
+ "past_deadline": f"its holder (pid {holder.get('pid')}, still running) is past its own time limit",
774
+ }.get(holder.get("why") or "", "it could not be read")
775
+ details["audit_log"] = audit("lock_takeover", details, default_path=self._audit_default())
776
+ record_fallback(f"took over the stale output lock {self.path}: {why}", "stale_lock_taken_over", **details)
648
777
 
649
778
  def __exit__(self, *exc: Any) -> None:
650
779
  if self.fd is not None:
@@ -698,6 +827,7 @@ def refuse_existing_outputs(paths: Sequence[str]) -> None:
698
827
  .srt/.ass sidecars) calls this up front so a hand-edited sidecar is refused like the video
699
828
  is, and a dry run predicts the same refusal."""
700
829
  existing: List[str] = []
830
+ facts: List[Dict[str, Any]] = []
701
831
  for output in paths:
702
832
  if not output:
703
833
  continue
@@ -708,21 +838,25 @@ def refuse_existing_outputs(paths: Sequence[str]) -> None:
708
838
  continue
709
839
  if not exists or real in STATE.written:
710
840
  continue
841
+ size = mtime_ns = None
711
842
  try:
712
843
  st = os.stat(output)
713
844
  STATE.preexisting[real] = (st.st_size, st.st_mtime_ns)
845
+ size, mtime_ns = st.st_size, st.st_mtime_ns
714
846
  except OSError:
715
847
  pass
716
848
  if output not in existing:
717
849
  existing.append(output)
850
+ facts.append({"path": output, "resolved": real, "size": size, "mtime_ns": mtime_ns})
718
851
  if not existing or STATE.overwrite:
719
852
  return
853
+ hint = "pass --overwrite, or choose another -o path"
720
854
  if len(existing) == 1:
721
- die(f"refusing to overwrite existing output {existing[0]!r}: pass --overwrite to replace it, or choose another -o path",
722
- kind="input", hint="pass --overwrite, or choose another -o path")
723
- die("refusing to overwrite existing outputs " + ", ".join(repr(p) for p in existing)
724
- + ": pass --overwrite to replace them, or choose another -o path",
725
- kind="input", hint="pass --overwrite, or choose another -o path")
855
+ refuse("input", "output_exists",
856
+ f"refusing to overwrite existing output {existing[0]!r}: pass --overwrite to replace it, or choose another -o path",
857
+ hint=hint, outputs=facts)
858
+ refuse("input", "output_exists", "refusing to overwrite existing outputs " + ", ".join(repr(p) for p in existing)
859
+ + ": pass --overwrite to replace them, or choose another -o path", hint=hint, outputs=facts)
726
860
 
727
861
 
728
862
  def _remember_output(cmd: Sequence[str]) -> None:
@@ -928,7 +1062,10 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
928
1062
  return proc, ran
929
1063
  retry = _odd_dimension_retry(ran, proc.stderr or "")
930
1064
  if retry is not None:
931
- info("source has odd dimensions; scaling to even before encoding (yuv420p needs it)")
1065
+ # recorded, not only said: the output's frame is a pixel narrower or shorter than
1066
+ # the source's, which a caller comparing sizes has to be able to read
1067
+ record_fallback("source has odd dimensions; scaling to even before encoding (yuv420p needs it)",
1068
+ "odd_dimensions_scaled_even", ctx=ctx, output=cmd[-1], scale=EVEN_SCALE)
932
1069
  ctx.commands[-1] = _cmdline(retry[:-1] + [cmd[-1]])
933
1070
  return _execute(retry), retry
934
1071
  if "not divisible by 2" in (proc.stderr or ""):
@@ -1027,10 +1164,12 @@ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, r
1027
1164
  def dry_run_input_pending(path: str) -> bool:
1028
1165
  """True when a measurement cannot run because its input does not exist yet under --dry-run:
1029
1166
  in a render.py/batch.py plan each stage's input is the previous stage's output, which a dry
1030
- run never wrote. The measurement is then skipped (with a note) rather than failing the plan;
1031
- on a real file the measurement runs even under --dry-run."""
1167
+ run never wrote. The measurement is then skipped rather than failing the plan, and the skip is
1168
+ recorded (`diagnostics.skipped`, `measurement_input_pending`, once per path) beside its stderr
1169
+ line; on a real file the measurement runs even under --dry-run."""
1032
1170
  if STATE.dry_run and not os.path.exists(path):
1033
- info(f"[dry-run] {path} does not exist yet (an earlier dry-run stage would write it); measurement skipped")
1171
+ record_skip(f"[dry-run] {path} does not exist yet (an earlier dry-run stage would write it); measurement skipped",
1172
+ "measurement_input_pending", path=path)
1034
1173
  return True
1035
1174
  return False
1036
1175
 
@@ -1093,9 +1232,19 @@ def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
1093
1232
  (e.g. a container change) makes that first attempt fail; retry the same command without the
1094
1233
  extra maps rather than let a tool that never touched subtitles start hard-failing because of
1095
1234
  them. `cmd` is the full argv *without* the output path. Returns True only when the
1096
- retry-without-subtitles path was actually needed (i.e. subtitle/data streams were dropped)."""
1097
- if run(cmd + ["-map", "0:s?", "-map", "0:d?", "-c:s", "copy", "-c:d", "copy", output], check=False).returncode == 0:
1235
+ retry-without-subtitles path was actually needed (i.e. subtitle/data streams were dropped).
1236
+
1237
+ The retry is recorded with the first attempt's error (`diagnostics.fallbacks`,
1238
+ `non_av_streams_dropped`): the caller's `dropped_non_av_streams` says that streams went, the
1239
+ record says why, which is the only way to tell a container that cannot hold them from a first
1240
+ attempt that failed for another reason and happened to succeed the second time."""
1241
+ first = run(cmd + ["-map", "0:s?", "-map", "0:d?", "-c:s", "copy", "-c:d", "copy", output], check=False)
1242
+ if first.returncode == 0:
1098
1243
  return False
1244
+ error = " | ".join([ln.strip() for ln in (first.stderr or "").splitlines() if ln.strip()][-3:])[-500:]
1245
+ record_fallback(f"the subtitle/data streams could not be copied into {os.path.basename(output)} "
1246
+ f"({error or 'ffmpeg exit ' + str(first.returncode)}); encoding again without them",
1247
+ "non_av_streams_dropped", output=output, returncode=first.returncode, error=error)
1099
1248
  run(cmd + [output])
1100
1249
  return True
1101
1250
 
@@ -1356,41 +1505,70 @@ def ffmpeg_encoders() -> set:
1356
1505
  ffmpeg_version(). That is the one case for ffprobe. On a mixed install (a Homebrew ffprobe next
1357
1506
  to a static ffmpeg) its list is another build's, so a real run never uses it: the AV1/ProRes
1358
1507
  refusals and the VideoToolbox check must describe the binary that encodes, and a plan that
1359
- read ffprobe's list is re-decided against ffmpeg's when it runs."""
1508
+ read ffprobe's list is re-decided against ffmpeg's when it runs.
1509
+
1510
+ An empty list is not "this build has none": when the list could not be read (the binary did
1511
+ not run, timed out, exited non-zero, or printed nothing a list is made of), the reason is kept
1512
+ (ffmpeg_list_error("encoders")) and recorded once as a `ffmpeg_list_unreadable` warning, so a
1513
+ refusal or a CPU note built on the empty set names the read failure, not a missing encoder."""
1360
1514
  global _ENCODERS
1361
1515
  if _ENCODERS is None:
1362
- _ENCODERS = set()
1363
- tool = "ffprobe" if STATE.dry_run else "ffmpeg"
1364
- # --timeout bounds this read too: on Apple Silicon every CPU re-encode asks for the list
1365
- # (#314), so a deadlocked ffmpeg must not outlive the limit the caller set before the encode
1366
- limit = min(PROBE_TIMEOUT, STATE.timeout) if STATE.timeout else PROBE_TIMEOUT
1367
- try:
1368
- out = subprocess.run([ffmpeg_exe(tool)[0] or tool, "-hide_banner", "-encoders"], stdout=subprocess.PIPE,
1369
- stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=limit).stdout
1370
- _ENCODERS = set(re.findall(r"^\s*[VAS][.\w]{5}\s+(\S+)", out, re.M))
1371
- except (OSError, subprocess.SubprocessError):
1372
- pass
1516
+ _ENCODERS = _read_ff_list("-encoders", r"^\s*[VAS][.\w]{5}\s+(\S+)")
1373
1517
  return _ENCODERS
1374
1518
 
1375
1519
 
1376
1520
  def ffmpeg_bsfs() -> set:
1377
- """Names from `ffmpeg -bsfs`, read once, empty when it cannot be read; from ffprobe under
1378
- --dry-run, for the reason ffmpeg_encoders() gives. color.py --retag rewrites an H.264/HEVC
1379
- stream's colour description with h264_metadata/hevc_metadata only when the build has it."""
1521
+ """Names from `ffmpeg -bsfs`, read once, empty when it cannot be read (the reason is kept and
1522
+ recorded as ffmpeg_encoders() keeps it); from ffprobe under --dry-run, for the reason
1523
+ ffmpeg_encoders() gives. color.py --retag rewrites an H.264/HEVC stream's colour description
1524
+ with h264_metadata/hevc_metadata only when the build has it."""
1380
1525
  global _BSFS
1381
1526
  if _BSFS is None:
1382
- _BSFS = set()
1383
- tool = "ffprobe" if STATE.dry_run else "ffmpeg"
1384
- limit = min(PROBE_TIMEOUT, STATE.timeout) if STATE.timeout else PROBE_TIMEOUT
1385
- try:
1386
- out = subprocess.run([ffmpeg_exe(tool)[0] or tool, "-hide_banner", "-bsfs"], stdout=subprocess.PIPE,
1387
- stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=limit).stdout
1388
- _BSFS = set(re.findall(r"^\s*([a-z0-9_]+)\s*$", out, re.M))
1389
- except (OSError, subprocess.SubprocessError):
1390
- pass
1527
+ _BSFS = _read_ff_list("-bsfs", r"^\s*([a-z0-9_]+)\s*$")
1391
1528
  return _BSFS
1392
1529
 
1393
1530
 
1531
+ # why the last read of a capability list came back empty: "-encoders"/"-bsfs" -> reason
1532
+ _LIST_ERRORS: Dict[str, str] = {}
1533
+
1534
+
1535
+ def ffmpeg_list_error(flag: str) -> Optional[str]:
1536
+ """Why `ffmpeg -encoders` / `-bsfs` (flag "encoders" or "bsfs") could not be read, or None when
1537
+ it was read (or not yet asked for). Only while the cached list is empty: a list a test or a
1538
+ later read filled in has nothing to explain."""
1539
+ flag = "-" + flag.lstrip("-")
1540
+ cache = _ENCODERS if flag == "-encoders" else _BSFS if flag == "-bsfs" else None
1541
+ return None if cache else _LIST_ERRORS.get(flag)
1542
+
1543
+
1544
+ def _read_ff_list(flag: str, pattern: str) -> set:
1545
+ tool = "ffprobe" if STATE.dry_run else "ffmpeg"
1546
+ # --timeout bounds this read too: on Apple Silicon every CPU re-encode asks for the list
1547
+ # (#314), so a deadlocked ffmpeg must not outlive the limit the caller set before the encode
1548
+ limit = min(PROBE_TIMEOUT, STATE.timeout) if STATE.timeout else PROBE_TIMEOUT
1549
+ _LIST_ERRORS.pop(flag, None)
1550
+ names: set = set()
1551
+ why = None
1552
+ try:
1553
+ proc = subprocess.run([ffmpeg_exe(tool)[0] or tool, "-hide_banner", flag], stdout=subprocess.PIPE,
1554
+ stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=limit)
1555
+ names = set(re.findall(pattern, proc.stdout or "", re.M))
1556
+ if not names:
1557
+ tail = [ln.strip() for ln in (proc.stderr or "").splitlines() if ln.strip()][-1:]
1558
+ why = (f"{tool} {flag} exited {proc.returncode}" if proc.returncode else f"{tool} {flag} listed nothing") + \
1559
+ (f": {tail[0][:200]}" if tail else "")
1560
+ except subprocess.TimeoutExpired:
1561
+ why = f"{tool} {flag} did not answer within {limit:g} s"
1562
+ except (OSError, subprocess.SubprocessError) as exc:
1563
+ why = f"{tool} {flag} could not run: {exc}"
1564
+ if why:
1565
+ _LIST_ERRORS[flag] = why
1566
+ what = "encoder" if flag == "-encoders" else "bitstream filter"
1567
+ warn(f"could not read this ffmpeg build's {what} list ({why}); nothing that needs it can be confirmed, which is not the same as the build lacking it",
1568
+ "ffmpeg_list_unreadable", list=flag.lstrip("-"), tool=tool, error=why)
1569
+ return names
1570
+
1571
+
1394
1572
  def read_text_or_die(path: str, flag: str) -> str:
1395
1573
  """Read a caller-supplied UTF-8 text file (a cue list, chapters, notes) or fail as kind input
1396
1574
  with the flag named, instead of a FileNotFoundError / UnicodeDecodeError traceback."""
@@ -1415,4 +1593,4 @@ def read_text_or_die(path: str, flag: str) -> str:
1415
1593
  # emit needs runner's Context/STATE/ERROR_CODE, so the two form a cycle that has to be cut
1416
1594
  # somewhere: by the time this line runs every name emit reads from runner above is defined, and
1417
1595
  # every name runner reads from emit is only ever read inside a function body, never at import.
1418
- from _common.emit import _plan_at_exit, die, hw_report, info # noqa: E402
1596
+ from _common.emit import _plan_at_exit, audit, die, hw_report, info, record_fallback, record_skip, refuse, warn # noqa: E402
@@ -29,6 +29,7 @@ from typing import Any, Dict, List, Optional, Tuple
29
29
  from xml.sax.saxutils import escape as _xml_escape
30
30
 
31
31
  from _common.decision import MissingFpsError, aspect_ratio, frame_size, parse_time
32
+ from _common.emit import warn
32
33
 
33
34
  FORMATS = {".edl": "edl", ".fcpxml": "fcpxml", ".otio": "otio"}
34
35
  # Common NTSC rates are stored as the exact ratio an editor expects, not as 29.97.
@@ -482,17 +483,32 @@ WRITERS = {"edl": to_edl, "fcpxml": to_fcpxml, "otio": to_otio}
482
483
 
483
484
 
484
485
  def parses(path: str, fmt: str) -> bool:
485
- """The verification step: the written file reads back as what it claims to be."""
486
+ """The verification step: the written file reads back as what it claims to be. A file that
487
+ does not is recorded with the reason (`diagnostics.warnings`, `timeline_parse_failed`: the
488
+ exception, or what was read instead), beside the step's `ok: false`."""
489
+ why = parse_error(path, fmt)
490
+ if why:
491
+ warn(f"{path} does not read back as {fmt}: {why}", "timeline_parse_failed", path=path, format=fmt, error=why)
492
+ return why is None
493
+
494
+
495
+ def parse_error(path: str, fmt: str) -> Optional[str]:
496
+ """Why `path` does not read back as `fmt`, or None when it does (parses() without the record)."""
486
497
  try:
487
498
  text = Path(path).read_text(encoding="utf-8")
488
499
  if fmt == "fcpxml":
489
500
  import xml.etree.ElementTree as ET
490
- return ET.fromstring(text.encode("utf-8")).tag == "fcpxml"
501
+ tag = ET.fromstring(text.encode("utf-8")).tag
502
+ return None if tag == "fcpxml" else f"the root element is <{tag}>, not <fcpxml>"
491
503
  if fmt == "otio":
492
- return json.loads(text).get("OTIO_SCHEMA") == "Timeline.1"
493
- return text.startswith("TITLE:") and any(line[:3].isdigit() for line in text.splitlines())
494
- except (OSError, ValueError, SyntaxError):
495
- return False
504
+ doc = json.loads(text)
505
+ schema = doc.get("OTIO_SCHEMA") if isinstance(doc, dict) else None
506
+ return None if schema == "Timeline.1" else f"OTIO_SCHEMA is {schema!r}, not 'Timeline.1'"
507
+ if not text.startswith("TITLE:"):
508
+ return "the first line is not TITLE:"
509
+ return None if any(line[:3].isdigit() for line in text.splitlines()) else "no numbered event line"
510
+ except (OSError, ValueError, SyntaxError) as exc:
511
+ return f"{type(exc).__name__}: {exc}"
496
512
 
497
513
 
498
514
  def summary(tl: Dict[str, Any], fmt: str) -> Dict[str, Any]: