ffmpeg-skill 2.10.11 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/docs/contract.md +107 -11
- package/mcp/server.py +7 -1
- package/package.json +1 -1
- package/references/scripts.md +92 -20
- package/scripts/_common/__init__.py +6 -4
- package/scripts/_common/asr.py +2 -2
- package/scripts/_common/decision.py +57 -11
- package/scripts/_common/emit.py +198 -2
- package/scripts/_common/fonts.py +93 -28
- package/scripts/_common/runner.py +246 -68
- package/scripts/_common/timeline.py +22 -6
- package/scripts/_contract.py +168 -10
- package/scripts/audio.py +2 -1
- package/scripts/batch.py +240 -105
- package/scripts/caption.py +68 -18
- package/scripts/color.py +2 -1
- package/scripts/cropdetect.py +19 -4
- package/scripts/export.py +42 -10
- package/scripts/graphics.py +24 -18
- package/scripts/grid.py +7 -3
- package/scripts/join.py +5 -3
- package/scripts/loop.py +4 -2
- package/scripts/multicam.py +11 -3
- package/scripts/overlay.py +14 -10
- package/scripts/render.py +223 -74
- package/scripts/report.py +77 -23
- package/scripts/reverse.py +4 -2
- package/scripts/sequence.py +4 -2
- package/scripts/silence.py +19 -7
- package/scripts/sync.py +18 -7
- package/scripts/verify.py +98 -24
- package/scripts/waveform.py +3 -2
package/scripts/overlay.py
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
245
|
+
warn("emoji: stripped from the drawn text (--emoji none)", "emoji_stripped", mode="none")
|
|
244
246
|
else:
|
|
245
|
-
|
|
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,
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
596
|
-
"""A failed stage's
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
|
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
|
|
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
|
|
696
|
-
return
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
|
718
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
784
|
-
|
|
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
|
-
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
1616
|
+
verification=[{"step": "check", "ok": True, "platform": ck.get("platform")}] if check_result else [])
|
|
1468
1617
|
return 0
|
|
1469
1618
|
|
|
1470
1619
|
|