ffmpeg-skill 2.10.10 → 2.11.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/render.py CHANGED
@@ -72,7 +72,7 @@ from export import PRESETS, PLATFORM_OF
72
72
  from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
73
73
  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
74
  from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args
75
- from _common.emit import absorb_stage_hw
75
+ from _common.emit import absorb_stage, audit, refuse, warn
76
76
  import subprocess
77
77
  from _contract import CONTRACT_VERSION
78
78
  from batch import file_key
@@ -364,7 +364,7 @@ def render_pack(names: List[str], args) -> int:
364
364
  if line.startswith("$ ") or line.startswith("[dry-run]"):
365
365
  STATE.commands.append(line[2:] if line.startswith("$ ") else line)
366
366
  STATE.commands.extend(str(c) for c in (doc.get("commands") or []))
367
- absorb_stage_hw(name, doc)
367
+ absorb_stage(name, doc)
368
368
  out = doc.get("output") or dest_out
369
369
  chk = doc.get("check") or {}
370
370
  ok = proc.returncode == 0 and doc.get("status") == "completed"
@@ -580,26 +580,26 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
580
580
  # needs (a timeout inside audio.py is a timeout, not an "input" error of render.py).
581
581
  err = doc.get("error") or {}
582
582
  extra_fields = {"hint": err["hint"]} if err.get("hint") else {}
583
- extra_fields.update(_stage_failure_hw(Path(script).stem, doc))
583
+ error_extra = absorb_stage(Path(script).stem, doc) # the stage's reason/details and records ride along
584
+ extra_fields.update(_stage_failure_hw(doc))
584
585
  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)
586
+ code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script, error_extra=error_extra, **extra_fields)
586
587
  _LAST_DOC.clear()
587
588
  _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
589
+ 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
590
  out_path = str(doc.get("output") or "")
590
591
  if key:
591
592
  cache_store(stage, key, out_path or (dest or ""), time.time() - started)
592
593
  return out_path
593
594
 
594
595
 
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
596
+ def _stage_failure_hw(doc: Any) -> Dict[str, Any]:
597
+ """A failed stage's `encoder` -- the command that failed -- for render's own failure document
598
+ (absorb_stage() has already joined the stage's `hw` to the earlier stages'; a fallback before
599
+ the failure is not lost). die() then adds `hw` for a kind: ffmpeg failure, as a tool's own
599
600
  run() failure carries it."""
600
601
  if not isinstance(doc, dict):
601
602
  return {}
602
- absorb_stage_hw(stage, doc)
603
603
  return {"encoder": doc["encoder"]} if doc.get("encoder") else {}
604
604
 
605
605
 
@@ -731,40 +731,94 @@ def cache_store(stage: str, key: str, produced: str, seconds: float) -> None:
731
731
  info(f"cache: could not store the {stage} artifact ({exc}); the run is unaffected")
732
732
 
733
733
 
734
+ def _utc_now() -> str:
735
+ import datetime
736
+ return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
737
+
738
+
739
+ def _plan_sha256(path: str) -> Optional[str]:
740
+ try:
741
+ return hashlib.sha256(Path(path).read_bytes()).hexdigest()
742
+ except OSError:
743
+ return None
744
+
745
+
746
+ def _reject_plan(path: str, plan_sha256: Optional[str], reason: str, msg: str, *, hint: Optional[str] = None,
747
+ **details: Any) -> None:
748
+ """Refuse a plan: one audit record (the plan's `<plan>.audit.jsonl`, or --audit-log), then
749
+ the failure document with `error.reason` and `error.details` -- the plan, its sha256, when it
750
+ was checked, the facts below, and where the audit record went."""
751
+ details = dict({"plan": path, "plan_sha256": plan_sha256, "checked_at": _utc_now()}, **details)
752
+ details["audit_log"] = audit("plan_rejected", dict(details, reason=reason), default_path=path + ".audit.jsonl",
753
+ plan_sha256=plan_sha256)
754
+ refuse("input", reason, msg, hint=hint, extra={"plan": path}, **details)
755
+
756
+
757
+ def plan_input_status(inputs: Sequence[Any]) -> List[Dict[str, Any]]:
758
+ """Each fingerprinted plan input against the file as it is now: {path, status: unchanged |
759
+ changed | missing, expected: {size, sha256_head_tail}, actual: {size, sha256_head_tail} or
760
+ "missing"}."""
761
+ rows: List[Dict[str, Any]] = []
762
+ for inp in inputs:
763
+ inp = inp if isinstance(inp, dict) else {"path": inp}
764
+ p = inp.get("path")
765
+ expected = {"size": inp.get("size"), "sha256_head_tail": inp.get("sha256_head_tail")}
766
+ if not p or not os.path.isfile(str(p)):
767
+ rows.append({"path": p, "status": "missing", "expected": expected, "actual": "missing"})
768
+ continue
769
+ try:
770
+ now = fingerprint(str(p))
771
+ except OSError:
772
+ rows.append({"path": p, "status": "missing", "expected": expected, "actual": "missing"})
773
+ continue
774
+ actual = {"size": now["size"], "sha256_head_tail": now["sha256_head_tail"]}
775
+ rows.append({"path": p, "status": "unchanged" if actual == expected else "changed", "expected": expected, "actual": actual})
776
+ return rows
777
+
778
+
734
779
  def execute_plan(plan: Dict[str, Any], path: str) -> int:
735
780
  """Run a plan written by `<tool> --plan FILE`: refuse if any fingerprinted input changed since
736
781
  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."""
782
+ with the planned argv, then the plan's verify steps. --dry-run prints the planned commands.
783
+
784
+ Every refusal is `kind: input` with `error.reason` and `error.details` and appends one audit
785
+ record (docs/design-decisions.md, "Nothing happens silently")."""
786
+ digest = _plan_sha256(path)
738
787
  if plan.get("plan_version") != PLAN_VERSION:
739
- die(f"{path}: plan_version {plan.get('plan_version')!r} is not {PLAN_VERSION}")
788
+ _reject_plan(path, digest, "plan_version_unsupported",
789
+ f"{path}: plan_version {plan.get('plan_version')!r} is not {PLAN_VERSION}",
790
+ plan_version=plan.get("plan_version"), supported=[PLAN_VERSION])
740
791
  tool = str(plan.get("tool") or "")
741
792
  script = HERE / f"{tool}.py"
742
793
  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")
794
+ _reject_plan(path, digest, "plan_unknown_tool", f"{path}: unknown tool {tool!r}", tool=tool)
795
+ unbound = [str(u) for u in (plan.get("unbound_inputs") or [])]
796
+ rows = plan_input_status(plan.get("inputs") or [])
797
+ bad = [r for r in rows if r["status"] != "unchanged"]
798
+ if bad:
799
+ lines = [f"{r['path']}: missing" if r["status"] == "missing" else f"{r['path']}: content changed since the plan was made"
800
+ for r in bad]
801
+ _reject_plan(path, digest, "plan_inputs_changed",
802
+ "plan inputs differ from what was planned; re-run the tool with --plan to make a new plan:\n " + "\n ".join(lines),
803
+ hint="plans are bound to the exact input files they were made from",
804
+ inputs=rows, unbound_inputs=unbound)
805
+ if unbound:
806
+ warn(f"{len(unbound)} plan input(s) did not exist when the plan was made, so nothing checks that they are the files "
807
+ "it was made for: " + ", ".join(unbound), "plan_unbound_inputs", paths=unbound)
756
808
  if plan.get("cwd"):
757
809
  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")
810
+ _reject_plan(path, digest, "plan_cwd_missing",
811
+ f"plan cwd {plan['cwd']} no longer exists; relative paths in the plan would resolve elsewhere -- re-plan",
812
+ cwd=plan["cwd"])
759
813
  os.chdir(plan["cwd"])
760
814
  if not isinstance(plan.get("argv"), list):
761
- die(f"{path}: argv must be a list")
815
+ _reject_plan(path, digest, "plan_argv_invalid", f"{path}: argv must be a list", argv_type=type(plan.get("argv")).__name__)
762
816
  argv = [str(a) for a in plan["argv"]]
763
817
  if STATE.dry_run:
764
818
  for c in plan.get("commands") or []:
765
819
  STATE.commands.append(c)
766
820
  info("[dry-run] " + c)
767
- emit(plan.get("output"), plan=path, tool=tool, stages=[tool], check=None)
821
+ emit(plan.get("output"), plan=path, plan_sha256=digest, tool=tool, stages=[tool], check=None, unbound_inputs=unbound)
768
822
  return 0
769
823
  info(f"executing plan {path}: {tool} " + " ".join(argv))
770
824
  proc = run_tool([str(script)] + argv + child_args() + ["--json"])
@@ -779,9 +833,10 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
779
833
  doc = {}
780
834
  if proc.returncode != 0 or doc.get("status") != "completed":
781
835
  err = doc.get("error") or {}
836
+ error_extra = absorb_stage(tool, doc) # the tool's reason/details and records ride along
782
837
  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
838
+ kind=err.get("kind", "ffmpeg"), plan=path, tool=tool, error_extra=error_extra, **_stage_failure_hw(doc))
839
+ absorb_stage(tool, doc) # the tool's GPU facts (fell back and why, ran on VideoToolbox) and diagnostics reach this result
785
840
  output = doc.get("output") or plan.get("output")
786
841
  check_result = None
787
842
  exit_code = 0
@@ -805,7 +860,7 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
805
860
  info(f"plan done: {output}, but the {check_result.get('platform') or 'content'} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""))
806
861
  else:
807
862
  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,
863
+ 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,
809
864
  verification=([{"step": "check", "ok": not exit_code, "platform": check_result.get("platform")}] if check_result else [])
810
865
  + [s for s in (doc.get("verification") or []) if s.get("step") != "probe"])
811
866
  return 0
@@ -902,6 +957,10 @@ def main() -> int:
902
957
  help="write the project's cut as an editor timeline instead of rendering it: .fcpxml (Final Cut, "
903
958
  "Resolve), .edl (CMX 3600: Premiere, Resolve, Avid) or .otio (OpenTimelineIO); clips, speed, "
904
959
  "transitions, the music bed and chapter markers carry over, everything else is listed as not exported")
960
+ ap.add_argument("--audit-log", metavar="FILE",
961
+ help="append the audit records of this run (a refused plan, a lock taken over, ...) to FILE as JSON lines; "
962
+ "default for a refused plan: <plan>.audit.jsonl next to it. A record that cannot be written is a "
963
+ "warning, never a failure")
905
964
  ap.add_argument("--stop-after", choices=["clips", "join", "silence", "fit", "captions", "graphics", "overlays", "audio", "loudness", "export"], help="stop after this stage (for iterating)")
906
965
  tpl = ap.add_argument_group("delivery templates (one command per destination)")
907
966
  tpl.add_argument("--template", metavar="NAME", help="render INPUT with a shipped template: " + ", ".join(template_names())
@@ -923,6 +982,7 @@ def main() -> int:
923
982
  add_hw_orchestrator_args(ap)
924
983
  args = ap.parse_args()
925
984
  apply_common(args)
985
+ STATE.audit_log = os.path.abspath(args.audit_log) if args.audit_log else None
926
986
 
927
987
  if args.list_templates:
928
988
  list_templates()