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.
@@ -26,7 +26,9 @@ import sys
26
26
  from typing import List, Optional
27
27
 
28
28
  from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
29
- from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, matrix_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
29
+ from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, matrix_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
30
+ from _common.emit import warn
31
+ from _common.fonts import font_choice, record_font_choice
30
32
 
31
33
  # Per-edge margins: a platform's UI does not cover the same fraction of every edge (TikTok's
32
34
  # like column is 14 % of the width, its description block 22 % of the height), so a position
@@ -240,20 +242,22 @@ def main() -> int:
240
242
  if args.emoji == "none":
241
243
  for _i, _cl in list(reversed(emoji_clusters(args.text))):
242
244
  args.text = args.text[:_i] + args.text[_i + len(_cl):]
243
- info("emoji: stripped from the drawn text (--emoji none)")
245
+ warn("emoji: stripped from the drawn text (--emoji none)", "emoji_stripped", mode="none")
244
246
  else:
245
- info("warning: emoji are drawn by the text font here (monochrome at best); "
246
- "caption.py/graphics.py composite colour PNGs with --emoji-assets")
247
+ warn("emoji are drawn by the text font here (monochrome at best); "
248
+ "caption.py/graphics.py composite colour PNGs with --emoji-assets", "emoji_monochrome")
249
+ script_file = script_family = None
247
250
  if args.text and not args.font_file:
248
251
  # 1.12: non-Latin overlay text picks a font by script, so a title in Japanese, Korean,
249
252
  # Arabic ... draws glyphs instead of boxes. drawtext does not shape or reorder RTL text --
250
253
  # see references/gotchas.md#fonts-by-script.
251
- _script, script_file, _family = script_font_for_text(
254
+ _script, script_file, script_family = script_font_for_text(
252
255
  args.text, font=args.font, font_explicit=args.font != ap.get_default("font"), font_file=args.font_file)
253
- if script_file:
254
- args.font_file = script_file
255
- if not args.font_file:
256
- args.font_file = default_font_file(args.font)
256
+ # the font drawtext gets, reported as `font_used` and recorded when it is not the one asked for
257
+ font_used = font_choice(args.font, font_file=args.font_file, script_file=script_file, script_family=script_family)
258
+ args.font_file = font_used["file"]
259
+ if args.text:
260
+ record_font_choice(font_used)
257
261
  meta = probe(args.input)
258
262
  if not meta.get("video"):
259
263
  die("input has no video stream")
@@ -411,7 +415,7 @@ def main() -> int:
411
415
  if not STATE.dry_run:
412
416
  result = probe(output, role="output")
413
417
  info(f"wrote {output} ({fmt_secs(result['duration'])})")
414
- emit(output, dropped_non_av_streams=dropped_streams)
418
+ emit(output, dropped_non_av_streams=dropped_streams, **({"font_used": font_used} if args.text else {}))
415
419
  return 0
416
420
 
417
421
 
package/scripts/render.py CHANGED
@@ -48,7 +48,11 @@ is "font_size", and "scale" on a text entry is refused.
48
48
  itself failing to run) exits 1, same as running check.py directly would --
49
49
  the render is not silently reported as successful just because every stage
50
50
  up to it completed. The output file is still written and `--json`'s
51
- `check` field still carries the full row-by-row result either way.
51
+ `check` field still carries the full row-by-row result either way. A check
52
+ stage with only "content": true runs check.py --content without a platform.
53
+ `verified` is every stage's: a stage that completed unverified (an export that
54
+ missed its loudness) leaves the render verified false, its steps and notes in
55
+ the result.
52
56
 
53
57
  Examples:
54
58
  python3 render.py --init project.json # write a commented starter project
@@ -72,7 +76,7 @@ from export import PRESETS, PLATFORM_OF
72
76
  from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
73
77
  from _common import STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, ffmpeg_exe
74
78
  from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args
75
- from _common.emit import absorb_stage_hw
79
+ from _common.emit import absorb_stage, audit, record_fallback, record_skip, refuse, warn
76
80
  import subprocess
77
81
  from _contract import CONTRACT_VERSION
78
82
  from batch import file_key
@@ -364,7 +368,7 @@ def render_pack(names: List[str], args) -> int:
364
368
  if line.startswith("$ ") or line.startswith("[dry-run]"):
365
369
  STATE.commands.append(line[2:] if line.startswith("$ ") else line)
366
370
  STATE.commands.extend(str(c) for c in (doc.get("commands") or []))
367
- absorb_stage_hw(name, doc)
371
+ absorb_stage(name, doc)
368
372
  out = doc.get("output") or dest_out
369
373
  chk = doc.get("check") or {}
370
374
  ok = proc.returncode == 0 and doc.get("status") == "completed"
@@ -550,7 +554,7 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
550
554
  # would make a second run with the first run's output already on disk miss every time.
551
555
  key_args = ["<out>" if a == dest else a for a in full]
552
556
  key = cache_key(stage, script, key_args, inputs, dest)
553
- if cache_lookup(stage, key, dest):
557
+ if cache_lookup(stage, key, dest, Path(script).stem):
554
558
  CACHE["hits"].append(stage)
555
559
  info(f"→ {script} {stage}: served from --cache")
556
560
  # No child ran, so there is no document: say so rather than leaving the PREVIOUS
@@ -562,6 +566,7 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
562
566
  _refuse_uncached_earlier_stage(stage)
563
567
  elif stage and CACHE.get("dir"):
564
568
  CACHE["misses"].append(stage)
569
+ _cache_miss(stage, "no_output_path") # nothing names where the artifact goes, so there is nothing to keep
565
570
  started = time.time()
566
571
  cmd = [str(HERE / script)] + [str(a) for a in argv] + (extra or []) + child_args() + ["--json"]
567
572
  info("→ " + " ".join(os.path.basename(c) if i < 1 else c for i, c in enumerate(cmd[:-1])))
@@ -580,26 +585,26 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
580
585
  # needs (a timeout inside audio.py is a timeout, not an "input" error of render.py).
581
586
  err = doc.get("error") or {}
582
587
  extra_fields = {"hint": err["hint"]} if err.get("hint") else {}
583
- extra_fields.update(_stage_failure_hw(Path(script).stem, doc))
588
+ error_extra = absorb_stage(Path(script).stem, doc) # the stage's reason/details and records ride along
589
+ extra_fields.update(_stage_failure_hw(doc))
584
590
  die(f"{script} failed: {err.get('message') or (proc.stderr.strip().splitlines() or ['?'])[-1][:300]}",
585
- code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script, **extra_fields)
591
+ code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script, error_extra=error_extra, **extra_fields)
586
592
  _LAST_DOC.clear()
587
593
  _LAST_DOC.update(doc if isinstance(doc, dict) else {})
588
- absorb_stage_hw(Path(script).stem, doc) # a stage's GPU facts (ran on VideoToolbox, fell back and why) reach this result
594
+ absorb_stage(Path(script).stem, doc) # a stage's GPU facts (ran on VideoToolbox, fell back and why) and its diagnostics reach this result
589
595
  out_path = str(doc.get("output") or "")
590
596
  if key:
591
- cache_store(stage, key, out_path or (dest or ""), time.time() - started)
597
+ cache_store(stage, key, out_path or (dest or ""), time.time() - started, doc)
592
598
  return out_path
593
599
 
594
600
 
595
- def _stage_failure_hw(stage: str, doc: Any) -> Dict[str, Any]:
596
- """A failed stage's GPU facts, for render's own failure document: the stage's `hw` joins the
597
- earlier stages' (a fallback before the failure is not lost), and its `encoder` -- the command
598
- that failed -- is forwarded. die() then adds `hw` for a kind: ffmpeg failure, as a tool's own
601
+ def _stage_failure_hw(doc: Any) -> Dict[str, Any]:
602
+ """A failed stage's `encoder` -- the command that failed -- for render's own failure document
603
+ (absorb_stage() has already joined the stage's `hw` to the earlier stages'; a fallback before
604
+ the failure is not lost). die() then adds `hw` for a kind: ffmpeg failure, as a tool's own
599
605
  run() failure carries it."""
600
606
  if not isinstance(doc, dict):
601
607
  return {}
602
- absorb_stage_hw(stage, doc)
603
608
  return {"encoder": doc["encoder"]} if doc.get("encoder") else {}
604
609
 
605
610
 
@@ -613,7 +618,7 @@ STAGE_ORDER = ("clips", "audiogram", "join", "silence", "fit", "captions", "grap
613
618
  "overlays", "audio", "loudness", "export", "chapters")
614
619
 
615
620
  def _fresh_cache() -> "Dict[str, Any]":
616
- return {"dir": None, "ffmpeg": None, "hits": [], "misses": [], "saved_seconds": 0.0,
621
+ return {"dir": None, "ffmpeg": None, "hits": [], "misses": [], "miss_reasons": [], "saved_seconds": 0.0,
617
622
  "entries": 0, "would_hit": [], "from": None}
618
623
 
619
624
 
@@ -625,10 +630,16 @@ CACHE: Dict[str, Any] = _fresh_cache()
625
630
 
626
631
  def _content_hash(path: str) -> str:
627
632
  """The same cheap content fingerprint batch.py caches on: name, size, mtime, first and last
628
- MB. Reused rather than reinvented so one file has one identity across the skill."""
633
+ MB. Reused rather than reinvented so one file has one identity across the skill. A file that
634
+ cannot be read keys as "missing" -- recorded (cache_input_unreadable), since two unreadable
635
+ files then share that part of the key."""
629
636
  try:
630
637
  return file_key(Path(path))
631
- except (OSError, ValueError):
638
+ except (OSError, ValueError) as exc:
639
+ entry = {"code": "cache_input_unreadable", "path": str(path)}
640
+ if not any(all(w.get(k) == v for k, v in entry.items()) for w in STATE.warnings):
641
+ warn(f"cache: could not fingerprint {path} ({exc}); it is keyed as \"missing\"", "cache_input_unreadable",
642
+ path=str(path), error=str(exc))
632
643
  return "missing"
633
644
 
634
645
 
@@ -646,9 +657,14 @@ def ffmpeg_banner() -> str:
646
657
  first = (out or "").strip().splitlines()
647
658
  if first:
648
659
  return first[0].strip()
649
- except (OSError, subprocess.SubprocessError):
650
- pass
651
- return ".".join(str(n) for n in ffmpeg_version())
660
+ why = "ffprobe -version printed nothing"
661
+ except (OSError, subprocess.SubprocessError) as exc:
662
+ why = str(exc)
663
+ pair = ".".join(str(n) for n in ffmpeg_version())
664
+ # two builds of one major.minor now share cache entries: say so
665
+ record_fallback(f"cache: the ffmpeg build banner could not be read ({why}); keying on ffmpeg {pair} instead, so builds "
666
+ "of the same major.minor share entries", "cache_ffmpeg_banner_unreadable", used=pair, error=why)
667
+ return pair
652
668
 
653
669
 
654
670
  def cache_key(stage: str, script: str, argv: "Sequence[Any]", inputs: "Sequence[str]",
@@ -684,22 +700,46 @@ def cache_key(stage: str, script: str, argv: "Sequence[Any]", inputs: "Sequence[
684
700
  return hashlib.sha1(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
685
701
 
686
702
 
687
- def cache_lookup(stage: str, key: str, dest: str) -> bool:
703
+ def _cache_miss(stage: str, reason: str, **facts: Any) -> bool:
704
+ """Record why `stage` was not served from the cache (`cache.miss_reasons`); a damaged entry
705
+ (unreadable sidecar, artifact of the wrong size) is also a warning and an audit line in the
706
+ cache directory, because something rewrote or truncated a file this tool wrote."""
707
+ CACHE["miss_reasons"].append(dict({"stage": stage, "reason": reason}, **facts))
708
+ if reason in ("sidecar_corrupt", "artifact_truncated"):
709
+ msg = f"cache: the {stage} entry is damaged ({reason.replace('_', ' ')}); the stage runs again"
710
+ warn(msg, "cache_entry_damaged", stage=stage, reason=reason, **facts)
711
+ audit("cache_entry_damaged", dict({"stage": stage, "reason": reason}, **facts),
712
+ default_path=str(Path(CACHE["dir"]) / ".ffskill-audit.jsonl"))
713
+ return False
714
+
715
+
716
+ def cache_lookup(stage: str, key: str, dest: str, tool: Optional[str] = None) -> bool:
688
717
  """Put the cached artifact for `key` at `dest` and return True, or return False on any
689
- mismatch -- silently, because a miss is not an error, it is just work to do."""
718
+ mismatch. A miss is not an error, it is work to do, but it is not silent either: why each
719
+ stage missed is in `cache.miss_reasons` (not_cached, sidecar_missing, artifact_missing,
720
+ sidecar_corrupt, artifact_truncated). A hit replays the stage's recorded result (its
721
+ verification, notes and diagnostics) into this run, as a stage that ran would have."""
690
722
  cdir = CACHE.get("dir")
691
723
  if not cdir:
692
724
  return False
693
725
  side = Path(cdir) / f"{key}.json"
694
726
  art = Path(cdir) / f"{key}{Path(dest).suffix or '.bin'}"
695
- if not (side.exists() and art.exists()):
696
- return False
727
+ if not side.exists() and not art.exists():
728
+ return _cache_miss(stage, "not_cached", key=key)
729
+ if not side.exists():
730
+ return _cache_miss(stage, "sidecar_missing", key=key, artifact=str(art))
731
+ if not art.exists():
732
+ return _cache_miss(stage, "artifact_missing", key=key, sidecar=str(side))
697
733
  try:
698
734
  meta = json.loads(side.read_text(encoding="utf-8"))
699
- except (OSError, ValueError):
700
- return False
701
- if int(meta.get("size") or -1) != art.stat().st_size:
702
- return False
735
+ if not isinstance(meta, dict):
736
+ raise ValueError("not a JSON object")
737
+ expected = int(meta.get("size") or -1)
738
+ except (OSError, ValueError, TypeError) as exc:
739
+ return _cache_miss(stage, "sidecar_corrupt", key=key, sidecar=str(side), error=str(exc))
740
+ actual = art.stat().st_size
741
+ if expected != actual:
742
+ return _cache_miss(stage, "artifact_truncated", key=key, artifact=str(art), expected_size=expected, actual_size=actual)
703
743
  if STATE.dry_run:
704
744
  CACHE["would_hit"].append(stage)
705
745
  return True
@@ -711,11 +751,26 @@ def cache_lookup(stage: str, key: str, dest: str) -> bool:
711
751
  except OSError:
712
752
  shutil.copy2(art, dest) # ... and a copy where it does not. Never a move: the cache
713
753
  CACHE["saved_seconds"] += float(meta.get("seconds") or 0.0)
754
+ result = meta.get("result")
755
+ if isinstance(result, dict):
756
+ absorb_stage(tool or stage, dict(result, status="completed", dry_run=False)) # tagged as the stage's tool, as a live run is
757
+ else:
758
+ # an entry written before the stage's result was kept: what the stage verified is unknown
759
+ record_skip(f"cache: the {stage} entry predates recorded results, so what that stage verified is not "
760
+ "known; rerun without --cache to see it", "cache_result_unrecorded", stage=stage)
714
761
  return True
715
762
 
716
763
 
717
- def cache_store(stage: str, key: str, produced: str, seconds: float) -> None:
718
- """Keep `produced` for the next run. Never under --dry-run: a plan writes nothing."""
764
+ def _stage_result(doc: Any) -> Dict[str, Any]:
765
+ """What a cache entry keeps of the stage's own --json document, replayed on a hit."""
766
+ if not isinstance(doc, dict):
767
+ return {}
768
+ return {k: doc[k] for k in ("verified", "verification", "notes", "diagnostics") if k in doc}
769
+
770
+
771
+ def cache_store(stage: str, key: str, produced: str, seconds: float, doc: Any = None) -> None:
772
+ """Keep `produced` for the next run. Never under --dry-run: a plan writes nothing. A store
773
+ that fails is a warning (cache_store_failed), never a failure of the run."""
719
774
  cdir = CACHE.get("dir")
720
775
  if not cdir or STATE.dry_run or not produced or not os.path.exists(produced):
721
776
  return
@@ -725,46 +780,127 @@ def cache_store(stage: str, key: str, produced: str, seconds: float) -> None:
725
780
  Path(cdir, f"{key}.json").write_text(json.dumps({
726
781
  "stage": stage, "ffmpeg": CACHE.get("ffmpeg"), "skill": SKILL_VERSION,
727
782
  "created": time.time(), "size": art.stat().st_size, "seconds": round(seconds, 2),
728
- "artifact": art.name}, indent=2), encoding="utf-8")
783
+ "artifact": art.name, "result": _stage_result(doc)}, indent=2), encoding="utf-8")
729
784
  CACHE["entries"] += 1
730
785
  except OSError as exc:
731
- info(f"cache: could not store the {stage} artifact ({exc}); the run is unaffected")
786
+ warn(f"cache: could not store the {stage} artifact ({exc}); the run is unaffected", "cache_store_failed",
787
+ stage=stage, error=str(exc))
788
+
789
+
790
+ def _utc_now() -> str:
791
+ import datetime
792
+ return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
793
+
794
+
795
+ def _plan_sha256(path: str) -> Optional[str]:
796
+ try:
797
+ return hashlib.sha256(Path(path).read_bytes()).hexdigest()
798
+ except OSError:
799
+ return None
800
+
801
+
802
+ def _reject_plan(path: str, plan_sha256: Optional[str], reason: str, msg: str, *, hint: Optional[str] = None,
803
+ **details: Any) -> None:
804
+ """Refuse a plan: one audit record (the plan's `<plan>.audit.jsonl`, or --audit-log), then
805
+ the failure document with `error.reason` and `error.details` -- the plan, its sha256, when it
806
+ was checked, the facts below, and where the audit record went."""
807
+ details = dict({"plan": path, "plan_sha256": plan_sha256, "checked_at": _utc_now()}, **details)
808
+ details["audit_log"] = audit("plan_rejected", dict(details, reason=reason), default_path=path + ".audit.jsonl",
809
+ plan_sha256=plan_sha256)
810
+ refuse("input", reason, msg, hint=hint, extra={"plan": path}, **details)
811
+
812
+
813
+ def plan_input_status(inputs: Sequence[Any]) -> List[Dict[str, Any]]:
814
+ """Each fingerprinted plan input against the file as it is now: {path, status: unchanged |
815
+ changed | missing, expected: {size, sha256_head_tail}, actual: {size, sha256_head_tail} or
816
+ "missing"}."""
817
+ rows: List[Dict[str, Any]] = []
818
+ for inp in inputs:
819
+ inp = inp if isinstance(inp, dict) else {"path": inp}
820
+ p = inp.get("path")
821
+ expected = {"size": inp.get("size"), "sha256_head_tail": inp.get("sha256_head_tail")}
822
+ if not p or not os.path.isfile(str(p)):
823
+ rows.append({"path": p, "status": "missing", "expected": expected, "actual": "missing"})
824
+ continue
825
+ try:
826
+ now = fingerprint(str(p))
827
+ except OSError:
828
+ rows.append({"path": p, "status": "missing", "expected": expected, "actual": "missing"})
829
+ continue
830
+ actual = {"size": now["size"], "sha256_head_tail": now["sha256_head_tail"]}
831
+ rows.append({"path": p, "status": "unchanged" if actual == expected else "changed", "expected": expected, "actual": actual})
832
+ return rows
833
+
834
+
835
+ def run_check(output: str, platform: Optional[str], content: bool) -> Dict[str, Any]:
836
+ """check.py on `output` for a project's "check" stage or a plan's check step: `platform`
837
+ (or none: check.py's youtube assumption, judgement rows WARN) and `content` (--content).
838
+ Returns check.py's own document; when check.py printed none that parses, a failure document
839
+ in its shape -- status failed, error {kind, reason: check_output_unparsed, message,
840
+ exit_code, stderr_tail} -- so a caller never reads a bare string."""
841
+ cp = run_tool([str(HERE / "check.py"), output] + (["--platform", str(platform)] if platform else [])
842
+ + (["--content"] if content else []) + ["--json"] + child_args())
843
+ try:
844
+ doc = json.loads(cp.stdout)
845
+ except ValueError:
846
+ doc = None
847
+ if isinstance(doc, dict):
848
+ return doc
849
+ tail = cp.stderr.strip()[-300:]
850
+ msg = "check.py printed no JSON document" + (f": {tail.splitlines()[-1]}" if tail else "")
851
+ warn(f"check: {msg}", "check_output_unparsed", exit_code=cp.returncode)
852
+ return {"status": "failed", "platform": platform, "ok": False,
853
+ "error": {"kind": "timeout" if cp.returncode == 124 else "verification", "reason": "check_output_unparsed",
854
+ "message": msg, "exit_code": cp.returncode, "stderr_tail": tail}}
732
855
 
733
856
 
734
857
  def execute_plan(plan: Dict[str, Any], path: str) -> int:
735
858
  """Run a plan written by `<tool> --plan FILE`: refuse if any fingerprinted input changed since
736
859
  the plan was made (the plan's commands would then describe a different edit), run the tool
737
- with the planned argv, then the plan's verify steps. --dry-run prints the planned commands."""
860
+ with the planned argv, then the plan's verify steps. --dry-run prints the planned commands.
861
+
862
+ Every refusal is `kind: input` with `error.reason` and `error.details` and appends one audit
863
+ record (docs/design-decisions.md, "Nothing happens silently")."""
864
+ digest = _plan_sha256(path)
738
865
  if plan.get("plan_version") != PLAN_VERSION:
739
- die(f"{path}: plan_version {plan.get('plan_version')!r} is not {PLAN_VERSION}")
866
+ _reject_plan(path, digest, "plan_version_unsupported",
867
+ f"{path}: plan_version {plan.get('plan_version')!r} is not {PLAN_VERSION}",
868
+ plan_version=plan.get("plan_version"), supported=[PLAN_VERSION])
740
869
  tool = str(plan.get("tool") or "")
741
870
  script = HERE / f"{tool}.py"
742
871
  if not re.fullmatch(r"[a-z][a-z0-9_]*", tool) or not script.exists() or tool == "render":
743
- die(f"{path}: unknown tool {tool!r}")
744
- changed = []
745
- for inp in plan.get("inputs") or []:
746
- p = inp.get("path")
747
- if not p or not os.path.isfile(p):
748
- changed.append(f"{p}: missing")
749
- continue
750
- now = fingerprint(p)
751
- if now["size"] != inp.get("size") or now["sha256_head_tail"] != inp.get("sha256_head_tail"):
752
- changed.append(f"{p}: content changed since the plan was made")
753
- if changed:
754
- die("plan inputs differ from what was planned; re-run the tool with --plan to make a new plan:\n " + "\n ".join(changed),
755
- hint="plans are bound to the exact input files they were made from")
872
+ _reject_plan(path, digest, "plan_unknown_tool", f"{path}: unknown tool {tool!r}", tool=tool)
873
+ unbound = [str(u) for u in (plan.get("unbound_inputs") or [])]
874
+ rows = plan_input_status(plan.get("inputs") or [])
875
+ bad = [r for r in rows if r["status"] != "unchanged"]
876
+ if bad:
877
+ lines = [f"{r['path']}: missing" if r["status"] == "missing" else f"{r['path']}: content changed since the plan was made"
878
+ for r in bad]
879
+ _reject_plan(path, digest, "plan_inputs_changed",
880
+ "plan inputs differ from what was planned; re-run the tool with --plan to make a new plan:\n " + "\n ".join(lines),
881
+ hint="plans are bound to the exact input files they were made from",
882
+ inputs=rows, unbound_inputs=unbound)
883
+ if unbound:
884
+ warn(f"{len(unbound)} plan input(s) did not exist when the plan was made, so nothing checks that they are the files "
885
+ "it was made for: " + ", ".join(unbound), "plan_unbound_inputs", paths=unbound)
756
886
  if plan.get("cwd"):
757
887
  if not os.path.isdir(plan["cwd"]):
758
- die(f"plan cwd {plan['cwd']} no longer exists; relative paths in the plan would resolve elsewhere -- re-plan")
888
+ _reject_plan(path, digest, "plan_cwd_missing",
889
+ f"plan cwd {plan['cwd']} no longer exists; relative paths in the plan would resolve elsewhere -- re-plan",
890
+ cwd=plan["cwd"])
759
891
  os.chdir(plan["cwd"])
760
892
  if not isinstance(plan.get("argv"), list):
761
- die(f"{path}: argv must be a list")
893
+ _reject_plan(path, digest, "plan_argv_invalid", f"{path}: argv must be a list", argv_type=type(plan.get("argv")).__name__)
762
894
  argv = [str(a) for a in plan["argv"]]
763
895
  if STATE.dry_run:
764
896
  for c in plan.get("commands") or []:
765
897
  STATE.commands.append(c)
766
898
  info("[dry-run] " + c)
767
- emit(plan.get("output"), plan=path, tool=tool, stages=[tool], check=None)
899
+ for step in plan.get("verify") or []:
900
+ if step.get("tool") == "check" and (step.get("platform") or step.get("content") is True):
901
+ record_skip("[dry-run] the plan's check step is not run: there is no output to check yet",
902
+ "check_dry_run", platform=step.get("platform"), content=step.get("content") is True)
903
+ emit(plan.get("output"), plan=path, plan_sha256=digest, tool=tool, stages=[tool], check=None, unbound_inputs=unbound)
768
904
  return 0
769
905
  info(f"executing plan {path}: {tool} " + " ".join(argv))
770
906
  proc = run_tool([str(script)] + argv + child_args() + ["--json"])
@@ -779,9 +915,10 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
779
915
  doc = {}
780
916
  if proc.returncode != 0 or doc.get("status") != "completed":
781
917
  err = doc.get("error") or {}
918
+ error_extra = absorb_stage(tool, doc) # the tool's reason/details and records ride along
782
919
  die(f"{tool} failed while executing the plan: {err.get('message') or proc.stderr.strip()[-300:]}",
783
- kind=err.get("kind", "ffmpeg"), plan=path, tool=tool, **_stage_failure_hw(tool, doc))
784
- absorb_stage_hw(tool, doc) # the tool's GPU facts (fell back and why, ran on VideoToolbox) reach this result
920
+ kind=err.get("kind", "ffmpeg"), plan=path, tool=tool, error_extra=error_extra, **_stage_failure_hw(doc))
921
+ absorb_stage(tool, doc) # the tool's GPU facts (fell back and why, ran on VideoToolbox) and diagnostics reach this result
785
922
  output = doc.get("output") or plan.get("output")
786
923
  check_result = None
787
924
  exit_code = 0
@@ -789,14 +926,12 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
789
926
  # a check step names a platform, asks for check.py --content's black/frozen/silence rows
790
927
  # ("content": true), or both -- the same keys a project's "check" stage takes
791
928
  if step.get("tool") == "check" and (step.get("platform") or step.get("content") is True) and output:
792
- cp = run_tool([str(HERE / "check.py"), output] + (["--platform", str(step["platform"])] if step.get("platform") else [])
793
- + (["--content"] if step.get("content") is True else []) + ["--json"] + child_args())
794
- try:
795
- check_result = json.loads(cp.stdout)
796
- except ValueError:
797
- check_result = {"error": cp.stderr.strip()[-300:]}
929
+ check_result = run_check(output, step.get("platform"), step.get("content") is True)
798
930
  if check_result.get("failed") or check_result.get("status") == "failed" or check_result.get("error"):
799
931
  exit_code = 1
932
+ elif step.get("tool") == "check" and not output:
933
+ record_skip("the plan's check step is not run: the tool reported no output", "check_no_output",
934
+ platform=step.get("platform"))
800
935
  # a failed platform check is reported the way the direct path reports it: the tool completed,
801
936
  # verified is false, the rows say what to fix (review 6: a plan must not fail harder than the
802
937
  # same command run by hand)
@@ -805,9 +940,10 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
805
940
  info(f"plan done: {output}, but the {check_result.get('platform') or 'content'} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""))
806
941
  else:
807
942
  info(f"plan done: {output}")
808
- emit(output, plan=path, tool=tool, stages=[tool] + (["check"] if check_result else []), check=check_result, tool_result=doc,
809
- verification=([{"step": "check", "ok": not exit_code, "platform": check_result.get("platform")}] if check_result else [])
810
- + [s for s in (doc.get("verification") or []) if s.get("step") != "probe"])
943
+ # the tool's own steps (export's loudness, ...) come in through absorb_stage() above, tagged
944
+ # with `stage`, so `verified` is theirs and the check's together
945
+ emit(output, plan=path, plan_sha256=digest, unbound_inputs=unbound, tool=tool, stages=[tool] + (["check"] if check_result else []), check=check_result, tool_result=doc,
946
+ verification=([{"step": "check", "ok": not exit_code, "platform": check_result.get("platform")}] if check_result else []))
811
947
  return 0
812
948
 
813
949
 
@@ -831,7 +967,8 @@ def frame_from_preset(frame: Dict[str, Any], export: Dict[str, Any]) -> None:
831
967
  if abs(float(m.group(1)) / float(m.group(2)) - preset["w"] / preset["h"]) > 0.01:
832
968
  return
833
969
  frame["width"], frame["height"] = preset["w"], preset["h"]
834
- info(f"frame: {preset['w']}x{preset['h']} from the {export['preset']} export preset (captions and overlays are sized for delivery)")
970
+ warn(f"frame: {preset['w']}x{preset['h']} from the {export['preset']} export preset (captions and overlays are sized for delivery)",
971
+ "frame_from_export_preset", preset=export["preset"], width=preset["w"], height=preset["h"])
835
972
 
836
973
 
837
974
  def export_timeline(proj: Dict[str, Any], rel, dest: str) -> int:
@@ -902,6 +1039,10 @@ def main() -> int:
902
1039
  help="write the project's cut as an editor timeline instead of rendering it: .fcpxml (Final Cut, "
903
1040
  "Resolve), .edl (CMX 3600: Premiere, Resolve, Avid) or .otio (OpenTimelineIO); clips, speed, "
904
1041
  "transitions, the music bed and chapter markers carry over, everything else is listed as not exported")
1042
+ ap.add_argument("--audit-log", metavar="FILE",
1043
+ help="append the audit records of this run (a refused plan, a lock taken over, ...) to FILE as JSON lines; "
1044
+ "default for a refused plan: <plan>.audit.jsonl next to it. A record that cannot be written is a "
1045
+ "warning, never a failure")
905
1046
  ap.add_argument("--stop-after", choices=["clips", "join", "silence", "fit", "captions", "graphics", "overlays", "audio", "loudness", "export"], help="stop after this stage (for iterating)")
906
1047
  tpl = ap.add_argument_group("delivery templates (one command per destination)")
907
1048
  tpl.add_argument("--template", metavar="NAME", help="render INPUT with a shipped template: " + ", ".join(template_names())
@@ -923,6 +1064,7 @@ def main() -> int:
923
1064
  add_hw_orchestrator_args(ap)
924
1065
  args = ap.parse_args()
925
1066
  apply_common(args)
1067
+ STATE.audit_log = os.path.abspath(args.audit_log) if args.audit_log else None
926
1068
 
927
1069
  if args.list_templates:
928
1070
  list_templates()
@@ -1381,7 +1523,8 @@ def main() -> int:
1381
1523
  # was rendered again; a platform preset with no loudness stage of its own gets the
1382
1524
  # one-export behaviour by default ("normalize": false opts out)
1383
1525
  normalize = True
1384
- info(f"export: --normalize on by default for the {ex['preset']} preset (set \"normalize\": false to skip)")
1526
+ warn(f"export: --normalize on by default for the {ex['preset']} preset (set \"normalize\": false to skip)",
1527
+ "normalize_on_by_default", preset=ex["preset"])
1385
1528
  if normalize:
1386
1529
  argv += ["--normalize"] # one export that meets the platform's loudness (export.py --normalize)
1387
1530
  sh("export.py", *argv, stage="export")
@@ -1414,22 +1557,28 @@ def main() -> int:
1414
1557
  ck = proj.get("check")
1415
1558
  check_result = None
1416
1559
  exit_code = 0
1417
- if ck and ck.get("platform") and not STATE.dry_run:
1418
- proc = run_tool([str(HERE / "check.py"), output, "--platform", ck["platform"], "--json"]
1419
- + (["--content"] if ck.get("content") is True else []) + child_args())
1420
- try:
1421
- check_result = json.loads(proc.stdout)
1422
- except ValueError:
1423
- check_result = {"error": proc.stderr.strip()[-300:]}
1560
+ # a platform, --content's rows, or both: the same rule a plan's check step follows (a
1561
+ # {"content": true} stage without a platform used to be skipped without a word)
1562
+ wants_check = bool(ck) and (bool(ck.get("platform")) or ck.get("content") is True)
1563
+ check_label = ck.get("platform") if ck and ck.get("platform") else "content"
1564
+ if wants_check and STATE.dry_run:
1565
+ record_skip("[dry-run] the check stage is not run: a dry run writes no output to check",
1566
+ "check_dry_run", platform=ck.get("platform"), content=ck.get("content") is True)
1567
+ elif wants_check:
1568
+ check_result = run_check(output, ck.get("platform"), ck.get("content") is True)
1424
1569
  if check_result.get("failed"):
1425
1570
  info(f"check: {check_result['failed']} FAIL — " + "; ".join(f"{r['check']}={r['value']} ({r['fix']})" for r in check_result["checks"] if r["status"] == "FAIL"))
1426
1571
  exit_code = 1
1427
1572
  elif check_result.get("error") or check_result.get("status") == "failed":
1428
- info(f"check: could not run check.py — {check_result.get('error')}")
1573
+ err = check_result.get("error")
1574
+ info(f"check: could not run check.py — {err.get('message') if isinstance(err, dict) else err}")
1429
1575
  exit_code = 1
1430
1576
  else:
1431
- info(f"check: OK for {ck['platform']}")
1577
+ info(f"check: OK for {check_label}")
1432
1578
  stages_done.append("check")
1579
+ elif "check" in proj:
1580
+ record_skip('the check stage names neither "platform" nor "content": true, so nothing was checked',
1581
+ "check_nothing_to_check", keys=sorted(ck or {}))
1433
1582
 
1434
1583
  if not args.keep and not args.work:
1435
1584
  # Also clean up on --dry-run: a dry run still creates this directory (and some steps,
@@ -1442,14 +1591,14 @@ def main() -> int:
1442
1591
  # The deliverable is written and verified, but it does not meet the requested platform
1443
1592
  # spec (or the check itself could not run): a failed delivery, reported as one.
1444
1593
  failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
1445
- die(f"rendered {output} but the {ck['platform']} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""),
1594
+ die(f"rendered {output} but the {check_label} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""),
1446
1595
  kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result,
1447
1596
  probe=probe(output, role="output"))
1448
1597
  info(f"rendered {output} via {' → '.join(stages_done)}")
1449
1598
  cache_report = None
1450
1599
  if args.cache:
1451
1600
  cache_report = {"dir": CACHE["dir"], "ffmpeg": CACHE["ffmpeg"],
1452
- "hits": CACHE["hits"], "misses": CACHE["misses"],
1601
+ "hits": CACHE["hits"], "misses": CACHE["misses"], "miss_reasons": CACHE["miss_reasons"],
1453
1602
  "saved_seconds": round(CACHE["saved_seconds"], 1),
1454
1603
  "entries": CACHE["entries"]}
1455
1604
  if STATE.dry_run:
@@ -1464,7 +1613,7 @@ def main() -> int:
1464
1613
  snap_report["clips"] = snap_reports
1465
1614
  emit(output, stages=stages_done, check=check_result, snap=snap_report, cache=cache_report,
1466
1615
  caption=caption_report,
1467
- verification=[{"step": "check", "ok": True, "platform": ck["platform"]}] if check_result else [])
1616
+ verification=[{"step": "check", "ok": True, "platform": ck.get("platform")}] if check_result else [])
1468
1617
  return 0
1469
1618
 
1470
1619