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.
- package/README.md +1 -1
- package/docs/contract.md +69 -13
- package/mcp/server.py +7 -1
- package/package.json +2 -1
- package/references/scripts.md +79 -14
- package/scripts/_common/__init__.py +2 -2
- package/scripts/_common/asr.py +2 -2
- package/scripts/_common/decision.py +57 -11
- package/scripts/_common/emit.py +49 -5
- package/scripts/_common/fonts.py +93 -28
- package/scripts/_common/runner.py +112 -37
- package/scripts/_common/timeline.py +22 -6
- package/scripts/_contract.py +122 -12
- 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/look.py +10 -3
- package/scripts/loop.py +4 -2
- package/scripts/multicam.py +11 -3
- package/scripts/overlay.py +14 -10
- package/scripts/render.py +133 -44
- 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/multicam.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 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
|
-
|
|
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
55
|
-
info(f"check.py could not run: {
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
package/scripts/reverse.py
CHANGED
|
@@ -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
|
-
|
|
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"]
|
package/scripts/sequence.py
CHANGED
|
@@ -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
|
-
|
|
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"):
|