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/README.md +4 -3
- package/SKILL.md +1 -1
- package/docs/contract.md +53 -10
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +9 -1
- package/references/scripts.md +34 -8
- package/scripts/_common/__init__.py +9 -7
- package/scripts/_common/drawtext.py +65 -6
- package/scripts/_common/emit.py +154 -2
- package/scripts/_common/runner.py +155 -33
- package/scripts/_common/text.py +1 -1
- package/scripts/_contract.py +59 -8
- package/scripts/color.py +142 -41
- package/scripts/overlay.py +66 -22
- package/scripts/render.py +91 -31
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
596
|
-
"""A failed stage's
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
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
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
784
|
-
|
|
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()
|