ffmpeg-skill 2.11.0 → 2.12.1

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.
@@ -32,6 +32,7 @@ import sys
32
32
  from typing import Dict, List, Tuple
33
33
 
34
34
  from _common import label_chain, matrix_chain, video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, x264_args, X264_PRESETS, fmt_secs, decode_pcm_mono, rms_envelope, STATE
35
+ from _common.emit import record_fallback, warn
35
36
  from sync import measure_offset
36
37
 
37
38
  ENERGY_WINDOW_S = 0.25 # --switch energy: loudness measured over this window on the reference timeline
@@ -199,7 +200,8 @@ def main() -> int:
199
200
  conf.append(score)
200
201
  info(f"{p}: offset {off:+.3f}s (confidence {score:.2f})" + (f", drift {(ratio - 1) * 1e6:+.0f} ppm" if args.fix_drift else ""))
201
202
  if score < 0.1:
202
- info(f"warning: {p} has low correlation confidence ({score:.2f}); check that it shares an audio event with the reference before trusting this offset")
203
+ warn(f"{p} has low correlation confidence ({score:.2f}); check that it shares an audio event with the reference before "
204
+ "trusting this offset", "sync_low_confidence", path=p, confidence=round(score, 3))
203
205
 
204
206
  report = {"inputs": args.inputs, "offsets_seconds": [round(o, 4) for o in offsets],
205
207
  "confidence": [round(c, 3) for c in conf]}
@@ -275,13 +277,19 @@ def main() -> int:
275
277
  convert = [matrix_chain(m, metas[0]) for m in metas]
276
278
  if any(convert):
277
279
  convert = [c or label_chain(m, metas[0]) for c, m in zip(convert, metas)]
280
+ # what is actually rendered: a range whose camera has not started yet plays camera 0, and the
281
+ # result says so (`cuts` names the camera on screen, diagnostics.fallbacks the one asked for)
282
+ rendered: List[Tuple[float, float, int]] = []
278
283
  for i, (s, e, c) in enumerate(filled):
279
284
  # reference time t maps to source time (t - offset_c) * ratio_c
280
285
  src_s = (s - offsets[c]) * ratios[c]
281
286
  src_e = (e - offsets[c]) * ratios[c]
282
287
  if src_s < 0:
283
- info(f"warning: camera {c} has not started at reference {s:.2f}s; using camera 0 for that range")
288
+ record_fallback(f"camera {c} has not started at reference {s:.2f}s; using camera 0 for that range "
289
+ f"({s:.3f}-{e:.3f}s)", "camera_not_started", start=round(s, 3), end=round(e, 3),
290
+ requested_camera=c, camera=0, starts_at=round(offsets[c], 3))
284
291
  c, src_s, src_e = 0, s, e
292
+ rendered.append((s, e, c))
285
293
  parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts=PTS-STARTPTS,{convert[c]}{geo}[v{i}]")
286
294
  labels.append(f"[v{i}]")
287
295
  project_clips.append({"src": args.inputs[c], "in": round(src_s, 3), "out": round(src_e, 3)})
@@ -321,7 +329,7 @@ def main() -> int:
321
329
  r = probe(output, role="output")
322
330
  info(f"wrote {output} ({fmt_secs(r['duration'])}, {len(filled)} cuts, audio from input {a})")
323
331
  extra = {"switch_mode": "energy", "min_shot": args.min_shot} if energy_mode else {}
324
- emit(output, cuts=[[round(s, 3), round(e, 3), c] for s, e, c in filled], **report, **extra)
332
+ emit(output, cuts=[[round(s, 3), round(e, 3), c] for s, e, c in rendered], **report, **extra)
325
333
  return 0
326
334
 
327
335
 
@@ -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, audit, refuse, warn
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
@@ -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])))
@@ -589,7 +594,7 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
589
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
590
595
  out_path = str(doc.get("output") or "")
591
596
  if key:
592
- 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)
593
598
  return out_path
594
599
 
595
600
 
@@ -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,10 +780,11 @@ 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))
732
788
 
733
789
 
734
790
  def _utc_now() -> str:
@@ -776,6 +832,28 @@ def plan_input_status(inputs: Sequence[Any]) -> List[Dict[str, Any]]:
776
832
  return rows
777
833
 
778
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}}
855
+
856
+
779
857
  def execute_plan(plan: Dict[str, Any], path: str) -> int:
780
858
  """Run a plan written by `<tool> --plan FILE`: refuse if any fingerprinted input changed since
781
859
  the plan was made (the plan's commands would then describe a different edit), run the tool
@@ -818,6 +896,10 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
818
896
  for c in plan.get("commands") or []:
819
897
  STATE.commands.append(c)
820
898
  info("[dry-run] " + c)
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)
821
903
  emit(plan.get("output"), plan=path, plan_sha256=digest, tool=tool, stages=[tool], check=None, unbound_inputs=unbound)
822
904
  return 0
823
905
  info(f"executing plan {path}: {tool} " + " ".join(argv))
@@ -844,14 +926,12 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
844
926
  # a check step names a platform, asks for check.py --content's black/frozen/silence rows
845
927
  # ("content": true), or both -- the same keys a project's "check" stage takes
846
928
  if step.get("tool") == "check" and (step.get("platform") or step.get("content") is True) and output:
847
- cp = run_tool([str(HERE / "check.py"), output] + (["--platform", str(step["platform"])] if step.get("platform") else [])
848
- + (["--content"] if step.get("content") is True else []) + ["--json"] + child_args())
849
- try:
850
- check_result = json.loads(cp.stdout)
851
- except ValueError:
852
- check_result = {"error": cp.stderr.strip()[-300:]}
929
+ check_result = run_check(output, step.get("platform"), step.get("content") is True)
853
930
  if check_result.get("failed") or check_result.get("status") == "failed" or check_result.get("error"):
854
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"))
855
935
  # a failed platform check is reported the way the direct path reports it: the tool completed,
856
936
  # verified is false, the rows say what to fix (review 6: a plan must not fail harder than the
857
937
  # same command run by hand)
@@ -860,9 +940,10 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
860
940
  info(f"plan done: {output}, but the {check_result.get('platform') or 'content'} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""))
861
941
  else:
862
942
  info(f"plan done: {output}")
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
863
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,
864
- verification=([{"step": "check", "ok": not exit_code, "platform": check_result.get("platform")}] if check_result else [])
865
- + [s for s in (doc.get("verification") or []) if s.get("step") != "probe"])
946
+ verification=([{"step": "check", "ok": not exit_code, "platform": check_result.get("platform")}] if check_result else []))
866
947
  return 0
867
948
 
868
949
 
@@ -886,7 +967,8 @@ def frame_from_preset(frame: Dict[str, Any], export: Dict[str, Any]) -> None:
886
967
  if abs(float(m.group(1)) / float(m.group(2)) - preset["w"] / preset["h"]) > 0.01:
887
968
  return
888
969
  frame["width"], frame["height"] = preset["w"], preset["h"]
889
- 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"])
890
972
 
891
973
 
892
974
  def export_timeline(proj: Dict[str, Any], rel, dest: str) -> int:
@@ -1441,7 +1523,8 @@ def main() -> int:
1441
1523
  # was rendered again; a platform preset with no loudness stage of its own gets the
1442
1524
  # one-export behaviour by default ("normalize": false opts out)
1443
1525
  normalize = True
1444
- 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"])
1445
1528
  if normalize:
1446
1529
  argv += ["--normalize"] # one export that meets the platform's loudness (export.py --normalize)
1447
1530
  sh("export.py", *argv, stage="export")
@@ -1474,22 +1557,28 @@ def main() -> int:
1474
1557
  ck = proj.get("check")
1475
1558
  check_result = None
1476
1559
  exit_code = 0
1477
- if ck and ck.get("platform") and not STATE.dry_run:
1478
- proc = run_tool([str(HERE / "check.py"), output, "--platform", ck["platform"], "--json"]
1479
- + (["--content"] if ck.get("content") is True else []) + child_args())
1480
- try:
1481
- check_result = json.loads(proc.stdout)
1482
- except ValueError:
1483
- 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)
1484
1569
  if check_result.get("failed"):
1485
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"))
1486
1571
  exit_code = 1
1487
1572
  elif check_result.get("error") or check_result.get("status") == "failed":
1488
- 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}")
1489
1575
  exit_code = 1
1490
1576
  else:
1491
- info(f"check: OK for {ck['platform']}")
1577
+ info(f"check: OK for {check_label}")
1492
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 {}))
1493
1582
 
1494
1583
  if not args.keep and not args.work:
1495
1584
  # Also clean up on --dry-run: a dry run still creates this directory (and some steps,
@@ -1502,14 +1591,14 @@ def main() -> int:
1502
1591
  # The deliverable is written and verified, but it does not meet the requested platform
1503
1592
  # spec (or the check itself could not run): a failed delivery, reported as one.
1504
1593
  failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
1505
- 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 ""),
1506
1595
  kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result,
1507
1596
  probe=probe(output, role="output"))
1508
1597
  info(f"rendered {output} via {' → '.join(stages_done)}")
1509
1598
  cache_report = None
1510
1599
  if args.cache:
1511
1600
  cache_report = {"dir": CACHE["dir"], "ffmpeg": CACHE["ffmpeg"],
1512
- "hits": CACHE["hits"], "misses": CACHE["misses"],
1601
+ "hits": CACHE["hits"], "misses": CACHE["misses"], "miss_reasons": CACHE["miss_reasons"],
1513
1602
  "saved_seconds": round(CACHE["saved_seconds"], 1),
1514
1603
  "entries": CACHE["entries"]}
1515
1604
  if STATE.dry_run:
@@ -1524,7 +1613,7 @@ def main() -> int:
1524
1613
  snap_report["clips"] = snap_reports
1525
1614
  emit(output, stages=stages_done, check=check_result, snap=snap_report, cache=cache_report,
1526
1615
  caption=caption_report,
1527
- 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 [])
1528
1617
  return 0
1529
1618
 
1530
1619
 
package/scripts/report.py CHANGED
@@ -17,32 +17,57 @@ import os
17
17
  import sys
18
18
  import tempfile
19
19
  from pathlib import Path
20
- from typing import Any, Dict, List, Optional
20
+ from typing import Any, Dict, List, Optional, Tuple
21
21
 
22
22
  from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, read_text_or_die, run_tool
23
+ from _common.emit import record_skip, warn
23
24
 
24
25
  HERE = Path(__file__).resolve().parent
25
26
 
26
27
 
27
- def sheet_b64(path: str, tiles: str = "4x2", width: int = 1200) -> Optional[str]:
28
+ def _child_failure(proc: Any, doc: Any) -> Dict[str, Any]:
29
+ """Why a sibling tool produced nothing usable: its own error (kind, reason, message), or the
30
+ last stderr line and the exit code when it printed no failure document."""
31
+ err = (doc or {}).get("error") if isinstance(doc, dict) and isinstance(doc.get("error"), dict) else {}
32
+ out: Dict[str, Any] = {"exit_code": proc.returncode,
33
+ "message": str(err.get("message") or (proc.stderr.strip().splitlines() or ["?"])[-1])[:300]}
34
+ for k in ("kind", "reason"):
35
+ if err.get(k):
36
+ out[k] = err[k]
37
+ return out
38
+
39
+
40
+ def sheet_b64(path: str, tiles: str = "4x2", width: int = 1200) -> "Tuple[Optional[str], Dict[str, Any]]":
41
+ """(the contact sheet as base64, or None; why there is none)."""
42
+ if STATE.dry_run:
43
+ return None, {"reason": "dry_run", "message": "a dry run draws no contact sheet"}
28
44
  with tempfile.TemporaryDirectory(prefix="ffskill_report_") as tmp:
29
45
  png = os.path.join(tmp, "sheet.png")
30
- proc = run_tool([str(HERE / "look.py"), path, "--tiles", tiles, "--width", str(width), "-o", png] + child_args())
46
+ proc = run_tool([str(HERE / "look.py"), path, "--tiles", tiles, "--width", str(width), "-o", png, "--json"] + child_args())
47
+ try:
48
+ doc = json.loads(proc.stdout)
49
+ except ValueError:
50
+ doc = None
31
51
  if proc.returncode != 0 or not os.path.exists(png):
32
- return None
33
- return base64.b64encode(Path(png).read_bytes()).decode("ascii")
52
+ return None, dict(_child_failure(proc, doc), reason=((doc or {}).get("error") or {}).get("reason") or "look_failed")
53
+ return base64.b64encode(Path(png).read_bytes()).decode("ascii"), {}
34
54
 
35
55
 
36
- def loudness(path: str) -> Dict[str, Any]:
56
+ def loudness(path: str) -> "Tuple[Dict[str, Any], Dict[str, Any]]":
57
+ """(lufs/tp/lra, or {}; why there is no measurement)."""
37
58
  proc = run_tool([str(HERE / "loudness.py"), path, "--measure-only"] + child_args())
38
59
  try:
39
60
  d = json.loads(proc.stdout)
40
- return {"lufs": round(float(d["input_i"]), 1), "tp": round(float(d["input_tp"]), 1), "lra": round(float(d["input_lra"]), 1)}
41
- except (ValueError, KeyError):
42
- return {}
61
+ except ValueError:
62
+ d = None
63
+ try:
64
+ return {"lufs": round(float(d["input_i"]), 1), "tp": round(float(d["input_tp"]), 1), "lra": round(float(d["input_lra"]), 1)}, {}
65
+ except (TypeError, ValueError, KeyError):
66
+ return {}, dict(_child_failure(proc, d), reason="loudness_not_measured")
43
67
 
44
68
 
45
- def check(path: str, platform: str) -> Optional[Dict[str, Any]]:
69
+ def check(path: str, platform: str) -> "Tuple[Optional[Dict[str, Any]], Dict[str, Any]]":
70
+ """(check.py's document, or None when it could not run at all; why)."""
46
71
  proc = run_tool([str(HERE / "check.py"), path, "--platform", platform, "--json"] + child_args())
47
72
  try:
48
73
  doc = json.loads(proc.stdout)
@@ -51,10 +76,10 @@ def check(path: str, platform: str) -> Optional[Dict[str, Any]]:
51
76
  if not isinstance(doc, dict) or "checks" not in doc:
52
77
  # check.py could not run at all (missing ffmpeg, unreadable file): its failure document
53
78
  # has no rows to render. A failed *verification* still carries its rows and is shown.
54
- reason = ((doc or {}).get("error") or {}).get("message") or (proc.stderr.strip().splitlines() or ["?"])[-1]
55
- info(f"check.py could not run: {reason[:200]}")
56
- return None
57
- return doc
79
+ why = dict(_child_failure(proc, doc), reason=((doc or {}).get("error") or {}).get("reason") or "check_failed")
80
+ info(f"check.py could not run: {why['message'][:200]}")
81
+ return None, why
82
+ return doc, {}
58
83
 
59
84
 
60
85
  def fmt_dur(sec: Optional[float]) -> str:
@@ -167,15 +192,44 @@ def main() -> int:
167
192
  die("--after is required (or --pack FILE for a social pack table)")
168
193
  after = probe(args.after)
169
194
  before = probe(args.before) if args.before else None
170
- ld_after = loudness(args.after) if after.get("audio") else {}
171
- ld_before = loudness(args.before) if before and before.get("audio") else {}
172
- chk = check(args.after, args.platform) if args.platform else None
173
- sheets = {}
195
+ # every section the report would show is either in it or accounted for: a section that could
196
+ # not be built is a failing verification step (and a warning), never just absent from the page
197
+ verification: List[Dict[str, Any]] = []
198
+
199
+ def section(step: str, which: Optional[str], why: Dict[str, Any], what: str) -> None:
200
+ entry: Dict[str, Any] = {"step": step, "ok": not why}
201
+ if which:
202
+ entry["file"] = which
203
+ if why:
204
+ if why.get("reason") == "dry_run":
205
+ record_skip(f"{what}: {why['message']}", "report_section_dry_run", section=step, file=which)
206
+ return
207
+ entry.update({k: v for k, v in why.items() if k != "message"}, message=why.get("message"))
208
+ warn(f"the report has no {what}: {why.get('message')}", "report_section_missing", section=step, file=which,
209
+ reason=why.get("reason"))
210
+ verification.append(entry)
211
+
212
+ ld_after: Dict[str, Any] = {}
213
+ ld_before: Dict[str, Any] = {}
214
+ if after.get("audio"):
215
+ ld_after, why = loudness(args.after)
216
+ section("loudness", "after", why, "loudness for --after")
217
+ if before and before.get("audio"):
218
+ ld_before, why = loudness(args.before)
219
+ section("loudness", "before", why, "loudness for --before")
220
+ chk = None
221
+ if args.platform:
222
+ chk, why = check(args.after, args.platform)
223
+ section("compliance", None, why, f"{args.platform} compliance table")
224
+ if chk is not None and verification:
225
+ # ok: the table is in the report; passed: what the table says about the file
226
+ verification[-1].update(platform=args.platform, passed=bool(chk.get("ok")))
227
+ sheets: Dict[str, Optional[str]] = {}
174
228
  if not args.no_sheets:
175
- if before and before.get("video"):
176
- sheets["before"] = sheet_b64(args.before)
177
- if after.get("video"):
178
- sheets["after"] = sheet_b64(args.after)
229
+ for which, path, meta in (("before", args.before, before), ("after", args.after, after)):
230
+ if meta and meta.get("video"):
231
+ sheets[which], why = sheet_b64(path)
232
+ section("contact_sheet", which, why, f"{which} contact sheet")
179
233
  commands = read_text_or_die(args.commands, "--commands").splitlines() if args.commands else []
180
234
  notes = read_text_or_die(args.notes, "--notes") if args.notes else ""
181
235
  title = args.title or f"Delivery report — {Path(args.after).name}"
@@ -235,7 +289,7 @@ def main() -> int:
235
289
  except OSError as e:
236
290
  die(f"cannot write {output}: {e}", kind="output")
237
291
  info(f"wrote {output} ({os.path.getsize(output) / 1024:.0f} KB)")
238
- emit(None, report=output, check=chk, verification=([{"step": "exists", "ok": True}] if not STATE.dry_run else []))
292
+ emit(None, report=output, check=chk, verification=([{"step": "exists", "ok": True}] + verification if not STATE.dry_run else []))
239
293
  if not args.json:
240
294
  print(output)
241
295
  return 0
@@ -15,6 +15,7 @@ import argparse
15
15
  import sys
16
16
 
17
17
  from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs
18
+ from _common.emit import warn
18
19
 
19
20
 
20
21
  def main() -> int:
@@ -35,8 +36,9 @@ def main() -> int:
35
36
 
36
37
  output = args.output or default_output(args.input, "reverse")
37
38
  if (meta.get("duration") or 0) > 60:
38
- info(f"warning: reverse buffers every decoded frame in memory; {meta['duration']:.0f}s of "
39
- f"{meta['video']['width']}x{meta['video']['height']} can exhaust RAM -- cut the part to reverse first (cut.py)")
39
+ warn(f"reverse buffers every decoded frame in memory; {meta['duration']:.0f}s of "
40
+ f"{meta['video']['width']}x{meta['video']['height']} can exhaust RAM -- cut the part to reverse first (cut.py)",
41
+ "memory_heavy", duration=meta["duration"])
40
42
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", "reverse"]
41
43
  if has_audio:
42
44
  cmd += ["-af", "areverse"]
@@ -21,6 +21,7 @@ import tempfile
21
21
  from pathlib import Path
22
22
 
23
23
  from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, concat_list_line, fmt_secs
24
+ from _common.emit import warn
24
25
 
25
26
 
26
27
  def even(n: float) -> int:
@@ -97,8 +98,9 @@ def main() -> int:
97
98
  # to be dropped silently, giving a shorter video; name the gap and what was left out
98
99
  later = _frames_after(directory, args.pattern, i)
99
100
  if later:
100
- info(f"WARNING: frame {args.pattern % i} is missing; {len(later)} later frame(s) "
101
- f"(from {later[0].name}) are NOT in the sequence -- fill the gap or rename them")
101
+ warn(f"frame {args.pattern % i} is missing; {len(later)} later frame(s) "
102
+ f"(from {later[0].name}) are NOT in the sequence -- fill the gap or rename them", "sequence_gap",
103
+ missing=args.pattern % i, frames_left_out=len(later), first_left_out=later[0].name)
102
104
 
103
105
  frame_meta = probe(str(frames[0]))
104
106
  if not frame_meta.get("video"):