ffmpeg-skill 3.8.6 → 3.8.8
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 +3 -3
- package/SKILL.md +3 -3
- package/docs/contract.md +29 -20
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +9 -6
- package/references/flags.md +2 -2
- package/references/gotchas.md +10 -4
- package/references/scripts.md +59 -12
- package/references/workflow.md +2 -2
- package/scripts/_common/runner.py +33 -3
- package/scripts/_common/timeline.py +45 -12
- package/scripts/_contract.py +88 -8
- package/scripts/batch.py +91 -8
- package/scripts/export.py +1 -1
- package/scripts/graphics.py +14 -1
- package/scripts/metadata.py +4 -0
- package/scripts/render.py +211 -36
- package/scripts/report.py +27 -1
- package/scripts/sequence.py +5 -1
- package/templates/audiogram.json +9 -0
package/scripts/render.py
CHANGED
|
@@ -75,7 +75,7 @@ from typing import Any, Dict, List, Optional, Sequence
|
|
|
75
75
|
from export import PRESETS, PLATFORM_OF
|
|
76
76
|
from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
|
|
77
77
|
from _common import guard_sidecar, STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, option_args, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, X264_PRESETS
|
|
78
|
-
from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args, child_exit_code, found_exe, kept_clause, kept_deliverables, mark_delivered, read_json, remember_written
|
|
78
|
+
from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args, child_exit_code, found_exe, kept_clause, kept_deliverables, mark_delivered, owned_identity, read_json, remember_written
|
|
79
79
|
from _common.emit import absorb_stage, audit, record_fallback, record_skip, refuse, warn
|
|
80
80
|
from _common.decision import rebase_project_paths
|
|
81
81
|
import subprocess
|
|
@@ -304,8 +304,11 @@ def template_output(input_path: str, name: str, dest: str) -> Path:
|
|
|
304
304
|
|
|
305
305
|
|
|
306
306
|
def list_templates() -> None:
|
|
307
|
-
"""The templates, the destination each delivers to, and the zones its UI covers
|
|
308
|
-
|
|
307
|
+
"""The templates, the destination each delivers to, and the zones its UI covers: a table, or
|
|
308
|
+
under --json one row per template in `templates` (A102: the table was printed under --json)."""
|
|
309
|
+
rows: List[Dict[str, Any]] = []
|
|
310
|
+
if not STATE.json:
|
|
311
|
+
print("%-15s %-11s %-5s %7s %-10s %s" % ("template", "frame", "fit", "max", "loudness", "safe zones (fraction of the frame)"))
|
|
309
312
|
for name in template_names():
|
|
310
313
|
tpl = load_template(name)
|
|
311
314
|
dest = str((tpl.get("check") or {}).get("platform") or name)
|
|
@@ -318,7 +321,13 @@ def list_templates() -> None:
|
|
|
318
321
|
size = "%dx%d" % (frame["w"], frame["h"]) if frame else "audio"
|
|
319
322
|
fit = str((tpl.get("frame") or {}).get("fit") or "-")
|
|
320
323
|
loud = ("%g LUFS" % spec["lufs"]) if spec.get("lufs") is not None else "-"
|
|
321
|
-
|
|
324
|
+
rows.append({"template": name, "destination": dest, "frame": {"width": frame["w"], "height": frame["h"]} if frame else None,
|
|
325
|
+
"fit": None if fit == "-" else fit, "max_duration": dur, "lufs": spec.get("lufs"),
|
|
326
|
+
"safe": {edge: safe.get(edge, 0) for edge in ("top", "bottom", "left", "right")}})
|
|
327
|
+
if not STATE.json:
|
|
328
|
+
print("%-15s %-11s %-5s %7s %-10s %s" % (name, size, fit, ("%gs" % dur) if dur else "-", loud, zones or "none"))
|
|
329
|
+
if STATE.json:
|
|
330
|
+
emit(None, templates=rows, stages=[], check=None)
|
|
322
331
|
|
|
323
332
|
|
|
324
333
|
# `--template all`: every destination a single edit is normally posted to. The podcast template
|
|
@@ -346,6 +355,19 @@ def expand_templates(value: str) -> List[str]:
|
|
|
346
355
|
return seen
|
|
347
356
|
|
|
348
357
|
|
|
358
|
+
def md_line(text: Any) -> str:
|
|
359
|
+
"""Caller text on one Markdown line: each line break (any str.splitlines() boundary) and other
|
|
360
|
+
control character becomes a space, so a file name cannot start a line of its own (A263)."""
|
|
361
|
+
return "".join(" " if (ch in "\x85\u2028\u2029" or ord(ch) < 32 or ord(ch) == 127) else ch for ch in str(text))
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def md_cell(text: Any) -> str:
|
|
365
|
+
"""Caller text in one cell of a GFM table row: on one line (md_line()), a backslash doubled
|
|
366
|
+
and a '|' escaped as '\\|', so `a | b.mp4` stays in its column. report.py --pack reads the
|
|
367
|
+
escapes back (A263: the columns shifted and passing destinations were reported failed)."""
|
|
368
|
+
return md_line(text).replace("\\", "\\\\").replace("|", "\\|")
|
|
369
|
+
|
|
370
|
+
|
|
349
371
|
def render_pack(names: List[str], args) -> int:
|
|
350
372
|
"""The social pack: one edit delivered to every named destination, plus a table of what
|
|
351
373
|
was written. Each destination is a full render (its own frame, captions, loudness, export
|
|
@@ -415,14 +437,14 @@ def render_pack(names: List[str], args) -> int:
|
|
|
415
437
|
if err.get("kind") in ("timeout", "interrupted"):
|
|
416
438
|
stopped.append({"kind": err["kind"], "exit_code": child_exit_code(doc, proc.returncode)})
|
|
417
439
|
pack = str(outdir / f"{stem}_pack.md")
|
|
418
|
-
lines = [f"# Social pack — {stem}", "",
|
|
440
|
+
lines = [f"# Social pack — {md_line(stem)}", "",
|
|
419
441
|
"| platform | file | size | duration | check |", "|---|---|---|---|---|"]
|
|
420
442
|
for r in rows:
|
|
421
443
|
dur = f"{r['duration']:.2f} s" if r.get("duration") else "-"
|
|
422
444
|
mb = f"{r['size_bytes'] / 1024 / 1024:.1f} MB" if r.get("size_bytes") else "-"
|
|
423
445
|
verdict = r["check"] + (f" (upload-limit guidance: {'; '.join(r['guidance'])})" if r.get("guidance") else "")
|
|
424
|
-
lines.append(
|
|
425
|
-
lines += ["", f"{len(rows)} destinations from one edit ({os.path.basename(args.input)}).",
|
|
446
|
+
lines.append("| " + " | ".join(md_cell(c) for c in (r["platform"], r["file"], mb, dur, verdict)) + " |")
|
|
447
|
+
lines += ["", f"{len(rows)} destinations from one edit ({md_line(os.path.basename(args.input))}).",
|
|
426
448
|
"Rendered by ffmpeg-skill; `report.py --pack` turns this table into an HTML report."]
|
|
427
449
|
if not STATE.dry_run:
|
|
428
450
|
if not outdir.is_dir():
|
|
@@ -501,7 +523,8 @@ def project_input_files(proj: Dict[str, Any], rel: Any) -> List[str]:
|
|
|
501
523
|
if not must_exist or os.path.isfile(p):
|
|
502
524
|
found.append(p)
|
|
503
525
|
|
|
504
|
-
|
|
526
|
+
clips = proj.get("clips")
|
|
527
|
+
for c in clips if isinstance(clips, list) else []: # a wrong type is validate_project()'s to refuse
|
|
505
528
|
if isinstance(c, dict):
|
|
506
529
|
add(c.get("src"))
|
|
507
530
|
if isinstance(c.get("snap"), dict):
|
|
@@ -604,6 +627,13 @@ def validate_project(proj: Dict[str, Any], timeline: bool = False, stop_after: O
|
|
|
604
627
|
check_keys(item, "chapters[]", f"chapters[{i}]")
|
|
605
628
|
if not isinstance(item, dict) or item.get("at") is None or not str(item.get("title") or "").strip():
|
|
606
629
|
die(f'chapters[{i}]: needs {{"at": TIME, "title": STR}}')
|
|
630
|
+
# the chapters stage writes one `TIME TITLE` line per chapter for metadata.py, which
|
|
631
|
+
# reads a line break as the end of the chapter (a CR too: the file is read as text) and
|
|
632
|
+
# FFmpeg ends a title at NUL: "Intro\n0:01 Evil" was two chapters, verified (A260)
|
|
633
|
+
bad = next((ch for ch in str(item["title"]) if ch in "\n\r\x00"), None)
|
|
634
|
+
if bad is not None:
|
|
635
|
+
die(f"chapters[{i}].title: holds U+{ord(bad):04X} ({'NUL' if bad == chr(0) else 'a line break'}), which "
|
|
636
|
+
"a chapter title cannot carry -- a title is one line of text; remove it", kind="input")
|
|
607
637
|
for name in ("clips", "graphics", "overlays"):
|
|
608
638
|
items = proj.get(name)
|
|
609
639
|
if name == "clips" or (not timeline and _reaches(READ_AT[name], stop_after)): # the export reads only the clips
|
|
@@ -707,6 +737,7 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
|
|
|
707
737
|
_LAST_DOC["cached"] = True
|
|
708
738
|
if not STATE.dry_run:
|
|
709
739
|
remember_written(dest) # placed by this run, as a stage's own write would be (#415)
|
|
740
|
+
_record_own_stage_file(dest)
|
|
710
741
|
return dest
|
|
711
742
|
CACHE["misses"].append(stage)
|
|
712
743
|
_refuse_uncached_earlier_stage(stage)
|
|
@@ -714,7 +745,12 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
|
|
|
714
745
|
CACHE["misses"].append(stage)
|
|
715
746
|
_cache_miss(stage, "no_output_path") # nothing names where the artifact goes, so there is nothing to keep
|
|
716
747
|
started = time.time()
|
|
717
|
-
|
|
748
|
+
# an earlier render's own stage file in this --work (A98): replaced as this run's own would be.
|
|
749
|
+
# After the cache key, which this does not change.
|
|
750
|
+
own = ["--overwrite"] if not STATE.overwrite and _earlier_own_stage_file(dest) else []
|
|
751
|
+
if own:
|
|
752
|
+
info(f" {os.path.basename(str(dest))} is an earlier render's own stage file in {_OWN_STAGES['dir']}, unchanged since: replaced")
|
|
753
|
+
cmd = [str(HERE / script)] + [str(a) for a in argv] + (extra or []) + own + child_args() + ["--json"]
|
|
718
754
|
info("→ " + " ".join(os.path.basename(c) if i < 1 else c for i, c in enumerate(cmd[:-1])))
|
|
719
755
|
proc = run_tool(cmd)
|
|
720
756
|
for line in proc.stderr.splitlines():
|
|
@@ -754,6 +790,7 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
|
|
|
754
790
|
# places its tagged copy over the export's delivery, which the output guard would
|
|
755
791
|
# otherwise refuse as an existing file without --overwrite (#415).
|
|
756
792
|
remember_written(out_path, dest)
|
|
793
|
+
_record_own_stage_file(dest)
|
|
757
794
|
if key:
|
|
758
795
|
cache_store(stage, key, out_path or (dest or ""), time.time() - started, doc)
|
|
759
796
|
return out_path
|
|
@@ -808,12 +845,20 @@ def _intermediate_used() -> Optional[str]:
|
|
|
808
845
|
return INTERMEDIATE["preset"] if INTERMEDIATE["applied"] else None
|
|
809
846
|
|
|
810
847
|
|
|
848
|
+
# Small files this run writes itself and hands to a stage (the inline chapters list's chapters.txt):
|
|
849
|
+
# keyed by their whole content, not by file_key()'s mtime, since a new run writes them anew and
|
|
850
|
+
# their stage could never hit the cache (A100).
|
|
851
|
+
CONTENT_KEYED: set = set()
|
|
852
|
+
|
|
853
|
+
|
|
811
854
|
def _content_hash(path: str) -> str:
|
|
812
855
|
"""The same cheap content fingerprint batch.py caches on: name, size, mtime, first and last
|
|
813
856
|
MB. Reused rather than reinvented so one file has one identity across the skill. A file that
|
|
814
857
|
cannot be read keys as "missing" -- recorded (cache_input_unreadable), since two unreadable
|
|
815
|
-
files then share that part of the key."""
|
|
858
|
+
files then share that part of the key. A file in CONTENT_KEYED is keyed by its bytes."""
|
|
816
859
|
try:
|
|
860
|
+
if os.path.abspath(path) in CONTENT_KEYED:
|
|
861
|
+
return "content:" + hashlib.sha1(Path(path).read_bytes(), usedforsecurity=False).hexdigest()
|
|
817
862
|
return file_key(Path(path))
|
|
818
863
|
except (OSError, ValueError) as exc:
|
|
819
864
|
entry = {"code": "cache_input_unreadable", "path": str(path)}
|
|
@@ -923,6 +968,10 @@ def cache_lookup(stage: str, key: str, dest: str, tool: Optional[str] = None) ->
|
|
|
923
968
|
if STATE.dry_run:
|
|
924
969
|
CACHE["would_hit"].append(stage)
|
|
925
970
|
return True
|
|
971
|
+
if Path(dest).exists() and not STATE.overwrite and not _earlier_own_stage_file(dest):
|
|
972
|
+
# a file in --work that is not this render's own (the caller's, or one they edited): a hit
|
|
973
|
+
# would unlink it without --overwrite. Run the stage instead, which refuses it by its rule
|
|
974
|
+
return _cache_miss(stage, "destination_not_owned", key=key, destination=str(dest))
|
|
926
975
|
Path(dest).parent.mkdir(parents=True, exist_ok=True)
|
|
927
976
|
try:
|
|
928
977
|
if Path(dest).exists():
|
|
@@ -1034,6 +1083,13 @@ def run_check(output: str, platform: Optional[str], content: bool) -> Dict[str,
|
|
|
1034
1083
|
"message": msg, "exit_code": cp.returncode, "stderr_tail": tail}}
|
|
1035
1084
|
|
|
1036
1085
|
|
|
1086
|
+
def _from_plan_cwd(output: Any) -> Any:
|
|
1087
|
+
"""A plan's output as an absolute path. execute_plan() has moved into the plan's cwd, so a
|
|
1088
|
+
relative one named a file there, and the result reported it to a caller in another directory
|
|
1089
|
+
as if it were in theirs (A96)."""
|
|
1090
|
+
return os.path.abspath(str(output)) if isinstance(output, str) and output else output
|
|
1091
|
+
|
|
1092
|
+
|
|
1037
1093
|
def execute_plan(plan: Dict[str, Any], path: str) -> int:
|
|
1038
1094
|
"""Run a plan written by `<tool> --plan FILE`: refuse if any fingerprinted input changed since
|
|
1039
1095
|
the plan was made (the plan's commands would then describe a different edit), run the tool
|
|
@@ -1093,7 +1149,8 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
|
|
|
1093
1149
|
if step.get("tool") == "check" and (step.get("platform") or step.get("content") is True):
|
|
1094
1150
|
record_skip("[dry-run] the plan's check step is not run: there is no output to check yet",
|
|
1095
1151
|
"check_dry_run", platform=step.get("platform"), content=step.get("content") is True)
|
|
1096
|
-
emit(plan.get("output"), plan=path, plan_sha256=digest, tool=tool, stages=[tool], check=None,
|
|
1152
|
+
emit(_from_plan_cwd(plan.get("output")), plan=path, plan_sha256=digest, tool=tool, stages=[tool], check=None,
|
|
1153
|
+
unbound_inputs=unbound)
|
|
1097
1154
|
return 0
|
|
1098
1155
|
info(f"executing plan {path}: {tool} " + " ".join(argv))
|
|
1099
1156
|
proc = run_tool([str(script)] + argv + child_args() + ["--json"])
|
|
@@ -1114,7 +1171,7 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
|
|
|
1114
1171
|
code=child_exit_code(doc, proc.returncode), kind=err.get("kind", "ffmpeg"), plan=path, tool=tool,
|
|
1115
1172
|
error_extra=error_extra, **_stage_failure_hw(doc))
|
|
1116
1173
|
absorb_stage(tool, doc) # the tool's GPU facts (fell back and why, ran on VideoToolbox) and diagnostics reach this result
|
|
1117
|
-
output = doc.get("output") or plan.get("output")
|
|
1174
|
+
output = _from_plan_cwd(doc.get("output") or plan.get("output"))
|
|
1118
1175
|
check_result = None
|
|
1119
1176
|
exit_code = 0
|
|
1120
1177
|
for step in plan.get("verify") or []:
|
|
@@ -1180,6 +1237,8 @@ def export_timeline(proj: Dict[str, Any], rel, dest: str) -> int:
|
|
|
1180
1237
|
paths.append(rel(proj["audio"]["music"]))
|
|
1181
1238
|
probes: Dict[str, Dict[str, Any]] = {}
|
|
1182
1239
|
for path in paths:
|
|
1240
|
+
if path in probes:
|
|
1241
|
+
continue # one ffprobe per file: ten clips of one recording are one probe (A267)
|
|
1183
1242
|
if not os.path.exists(path):
|
|
1184
1243
|
die(f"timeline source not found: {path}")
|
|
1185
1244
|
probes[path] = probe(path) # a timeline needs real durations and rates, dry run or not
|
|
@@ -1197,9 +1256,9 @@ def export_timeline(proj: Dict[str, Any], rel, dest: str) -> int:
|
|
|
1197
1256
|
del frame["aspect"]
|
|
1198
1257
|
try:
|
|
1199
1258
|
tl = tlmod.build(dict(proj, frame=frame), probes, rel)
|
|
1259
|
+
text = tlmod.WRITERS[fmt](tl) # a writer refuses what its format cannot hold (EDL: 999 events, 24 h)
|
|
1200
1260
|
except tlmod.TimelineError as exc:
|
|
1201
1261
|
die(f"--export-timeline: {exc}", kind="input")
|
|
1202
|
-
text = tlmod.WRITERS[fmt](tl)
|
|
1203
1262
|
report = tlmod.summary(tl, fmt)
|
|
1204
1263
|
_check_output_path(["ffmpeg", dest])
|
|
1205
1264
|
_check_existing_output(["ffmpeg", dest])
|
|
@@ -1234,6 +1293,89 @@ def _has_sound(current: str, proj: Dict[str, Any], rel: Any) -> bool:
|
|
|
1234
1293
|
return False
|
|
1235
1294
|
|
|
1236
1295
|
|
|
1296
|
+
def cache_report(cache_dir: Optional[str]) -> Optional[Dict[str, Any]]:
|
|
1297
|
+
"""The result's `cache` object (None without --cache), with its stderr summary line."""
|
|
1298
|
+
if not cache_dir:
|
|
1299
|
+
return None
|
|
1300
|
+
report = {"dir": CACHE["dir"], "ffmpeg": CACHE["ffmpeg"],
|
|
1301
|
+
"hits": CACHE["hits"], "misses": CACHE["misses"], "miss_reasons": CACHE["miss_reasons"],
|
|
1302
|
+
"saved_seconds": round(CACHE["saved_seconds"], 1),
|
|
1303
|
+
"entries": CACHE["entries"]}
|
|
1304
|
+
if STATE.dry_run:
|
|
1305
|
+
report["would_hit"] = CACHE["would_hit"]
|
|
1306
|
+
info(f"cache: {len(CACHE['hits'])} hit(s) ({', '.join(CACHE['hits']) or '-'}), "
|
|
1307
|
+
f"{len(CACHE['misses'])} miss(es) ({', '.join(CACHE['misses']) or '-'})")
|
|
1308
|
+
return report
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
# One container family: a copy of an MP4 file named .mov (or .m4v, .m4a, .3gp) is the bytes that
|
|
1312
|
+
# name says. Every other pair of extensions names two different containers.
|
|
1313
|
+
ISO_BMFF_EXT = frozenset({".mp4", ".mov", ".m4v", ".m4a", ".3gp"})
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
def refuse_copied_container(have: str, output: str) -> None:
|
|
1317
|
+
"""Refuse delivering a file of container `have` (an extension) by copying it to `output`
|
|
1318
|
+
under another container's name: with no export stage nothing converts it (A84)."""
|
|
1319
|
+
want = Path(output).suffix.lower()
|
|
1320
|
+
if have == want or (have in ISO_BMFF_EXT and want in ISO_BMFF_EXT):
|
|
1321
|
+
return
|
|
1322
|
+
die(f"output {output}: this project has no \"export\" stage, so the delivery is the last stage's "
|
|
1323
|
+
f"{have or 'extension-less'} file copied as it is, which the name {want or '(no extension)'} would mislabel -- "
|
|
1324
|
+
f"name the output {have or 'with that extension'}, or add an \"export\" stage, which writes the container "
|
|
1325
|
+
"its preset names", kind="input", hint=f"\"output\": \"{Path(output).with_suffix(have).name}\"" if have else None)
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
# A98: the stage files a render with an explicit --work wrote there, {real path: owned_identity()},
|
|
1329
|
+
# kept in <work>/.ffskill-render-stages.json. A later run with the same --work replaces one of them
|
|
1330
|
+
# without --overwrite while it is still that file (the overwrite rule's "this run's own file",
|
|
1331
|
+
# extended to an earlier render of the same work directory); one edited, replaced or never
|
|
1332
|
+
# written by render is refused as before.
|
|
1333
|
+
_OWN_STAGES: Dict[str, Any] = {"dir": None, "files": {}, "manifest": None}
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
def own_stage_files(work: Path, load: bool) -> None:
|
|
1337
|
+
"""Start this run's record of its stage files in `work`, reading an earlier render's when
|
|
1338
|
+
`load` (an explicit --work; the default directory is new every run)."""
|
|
1339
|
+
_OWN_STAGES.update(dir=os.path.realpath(str(work)), files={}, manifest=str(work / ".ffskill-render-stages.json") if load else None)
|
|
1340
|
+
if load:
|
|
1341
|
+
try:
|
|
1342
|
+
doc = json.loads(Path(_OWN_STAGES["manifest"]).read_text(encoding="utf-8"))
|
|
1343
|
+
except (OSError, ValueError):
|
|
1344
|
+
doc = {}
|
|
1345
|
+
files = doc.get("files") if isinstance(doc, dict) else None
|
|
1346
|
+
if isinstance(files, dict):
|
|
1347
|
+
_OWN_STAGES["files"] = {k: v for k, v in files.items() if isinstance(k, str) and isinstance(v, dict)}
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
def _earlier_own_stage_file(dest: Optional[str]) -> bool:
|
|
1351
|
+
"""Whether `dest`, a stage's output in the work directory, is a file an earlier render with
|
|
1352
|
+
this --work wrote there and nothing has changed since."""
|
|
1353
|
+
if not dest or not _OWN_STAGES["manifest"] or not os.path.isfile(dest):
|
|
1354
|
+
return False
|
|
1355
|
+
real = os.path.realpath(dest)
|
|
1356
|
+
if os.path.dirname(real) != _OWN_STAGES["dir"]:
|
|
1357
|
+
return False
|
|
1358
|
+
expected = _OWN_STAGES["files"].get(real)
|
|
1359
|
+
return isinstance(expected, dict) and owned_identity(real) == expected
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
def _record_own_stage_file(dest: Optional[str]) -> None:
|
|
1363
|
+
"""Remember a stage file this run put in the --work directory, as it is now. A manifest that
|
|
1364
|
+
cannot be written only means the next run refuses that file as before."""
|
|
1365
|
+
if STATE.dry_run or not dest or not _OWN_STAGES["manifest"] or not os.path.isfile(dest):
|
|
1366
|
+
return
|
|
1367
|
+
real = os.path.realpath(dest)
|
|
1368
|
+
if os.path.dirname(real) != _OWN_STAGES["dir"]:
|
|
1369
|
+
return
|
|
1370
|
+
ident = owned_identity(real)
|
|
1371
|
+
if ident:
|
|
1372
|
+
_OWN_STAGES["files"][real] = ident
|
|
1373
|
+
try:
|
|
1374
|
+
Path(_OWN_STAGES["manifest"]).write_text(json.dumps({"files": _OWN_STAGES["files"]}, indent=1), encoding="utf-8")
|
|
1375
|
+
except OSError:
|
|
1376
|
+
pass
|
|
1377
|
+
|
|
1378
|
+
|
|
1237
1379
|
def guidance_warns(check_doc: Optional[Dict[str, Any]]) -> List[str]:
|
|
1238
1380
|
"""The rows of a check result that are over the platform table's upload limit (#485): WARN,
|
|
1239
1381
|
`basis: guidance`. The check passes with them, so each place that says a check passed names
|
|
@@ -1323,7 +1465,8 @@ def main() -> int:
|
|
|
1323
1465
|
guard_sidecar(args.init)
|
|
1324
1466
|
Path(args.init).write_text(json.dumps(TEMPLATE, indent=2) + "\n", encoding="utf-8")
|
|
1325
1467
|
info(f"wrote {args.init}; edit clips/src and run: render.py {args.init}")
|
|
1326
|
-
|
|
1468
|
+
# the path alone, or under --json the document every other mode prints (A102)
|
|
1469
|
+
emit(args.init, stages=[], check=None)
|
|
1327
1470
|
return 0
|
|
1328
1471
|
if not args.project:
|
|
1329
1472
|
die("give a project.json (or --init FILE, or --template NAME INPUT)")
|
|
@@ -1425,25 +1568,48 @@ def main() -> int:
|
|
|
1425
1568
|
# fit.mp4, ...) mid-run. An explicit --work is left as given (the caller asked for that exact,
|
|
1426
1569
|
# shared path, e.g. to inspect intermediates across runs); only the auto-derived default is
|
|
1427
1570
|
# made unique per process, since it's the one that's also auto-deleted at the end.
|
|
1428
|
-
|
|
1571
|
+
# Intermediates keep the delivery's media kind: a .mp4 project is unchanged (every stage file
|
|
1572
|
+
# is still clipNN.mp4 / fit.mp4 / loudnorm.mp4), while an audio-only delivery (the podcast
|
|
1573
|
+
# template) carries its stages through the audio container instead of a video one.
|
|
1574
|
+
mid = Path(output).suffix.lower() if Path(output).suffix.lower() in AUDIO_EXT else ".mp4"
|
|
1575
|
+
ex_cfg = proj.get("export")
|
|
1576
|
+
if (not args.stop_after or args.stop_after == "export") and not (isinstance(ex_cfg, dict) and ex_cfg.get("preset")):
|
|
1577
|
+
# A84: with no export stage the delivery is the last stage's file copied as it is, so a
|
|
1578
|
+
# .webm/.mkv name was MP4 bytes, completed and verified. Refused before the first stage
|
|
1579
|
+
# when the last file is certainly an intermediate; a single uncut clip that no stage
|
|
1580
|
+
# touches is the source itself, and the copy below checks what it really is.
|
|
1581
|
+
c0 = clips[0] if len(clips) == 1 and isinstance(clips[0], dict) else None
|
|
1582
|
+
passthrough = c0 is not None and c0.get("in") is None and c0.get("out") is None
|
|
1583
|
+
if not passthrough:
|
|
1584
|
+
refuse_copied_container(mid, output)
|
|
1585
|
+
# A77: the default work directory is new for every run (mkdtemp), not named by the PID alone and
|
|
1586
|
+
# reused with exist_ok -- a later run that got the same PID deleted an earlier run's kept preview.
|
|
1587
|
+
# An explicit --work is the caller's: created when missing, never emptied.
|
|
1588
|
+
created_work = bool(args.work) and not Path(args.work).exists()
|
|
1429
1589
|
try:
|
|
1430
|
-
work
|
|
1590
|
+
if args.work:
|
|
1591
|
+
work = Path(args.work)
|
|
1592
|
+
work.mkdir(parents=True, exist_ok=True)
|
|
1593
|
+
else:
|
|
1594
|
+
import tempfile
|
|
1595
|
+
Path(output).parent.mkdir(parents=True, exist_ok=True)
|
|
1596
|
+
work = Path(tempfile.mkdtemp(prefix=f"{Path(output).with_suffix('').name}_work_{os.getpid()}_",
|
|
1597
|
+
dir=str(Path(output).parent)))
|
|
1598
|
+
created_work = True
|
|
1431
1599
|
except OSError as e:
|
|
1432
|
-
die(f"cannot create the work directory {work}: {e}")
|
|
1600
|
+
die(f"cannot create the work directory {args.work or Path(output).parent}: {e}")
|
|
1601
|
+
own_stage_files(work, load=bool(args.work))
|
|
1433
1602
|
# --stop-after before the export reports a stage file inside the work directory as its output:
|
|
1434
1603
|
# removing the directory at exit deleted the file the result named (and its output_identity)
|
|
1435
1604
|
previewing = bool(args.stop_after) and args.stop_after != "export" and not STATE.dry_run
|
|
1436
1605
|
if previewing and not args.keep and not args.work:
|
|
1437
1606
|
info(f"--stop-after {args.stop_after}: keeping the work directory {work}, since the result's output is in it")
|
|
1438
|
-
if not args.keep and not args.work and not previewing:
|
|
1607
|
+
if (not args.keep and not args.work and not previewing) or (STATE.dry_run and created_work):
|
|
1439
1608
|
# a failed or dry run used to leave <output>_work_<pid>/ behind (sweep F15): the
|
|
1440
|
-
# auto-named directory is ours alone, so remove it on every exit path
|
|
1609
|
+
# auto-named directory is ours alone, so remove it on every exit path; a dry run removes
|
|
1610
|
+
# a --work directory it created too, --keep or not (A99: a plan leaves nothing behind)
|
|
1441
1611
|
import atexit
|
|
1442
1612
|
atexit.register(lambda: shutil.rmtree(work, ignore_errors=True))
|
|
1443
|
-
# Intermediates keep the delivery's media kind: a .mp4 project is unchanged (every stage file
|
|
1444
|
-
# is still clipNN.mp4 / fit.mp4 / loudnorm.mp4), while an audio-only delivery (the podcast
|
|
1445
|
-
# template) carries its stages through the audio container instead of a video one.
|
|
1446
|
-
mid = Path(output).suffix.lower() if Path(output).suffix.lower() in AUDIO_EXT else ".mp4"
|
|
1447
1613
|
frame = dict(proj.get("frame") or {})
|
|
1448
1614
|
trans = proj.get("transition") or {}
|
|
1449
1615
|
frame_from_preset(frame, proj.get("export") or {})
|
|
@@ -1454,7 +1620,6 @@ def main() -> int:
|
|
|
1454
1620
|
# unchanged -- it never gets a --platform it did not ask for.
|
|
1455
1621
|
dest = str(((proj.get("check") or {}).get("platform") or "")) if proj.get("template") else ""
|
|
1456
1622
|
platform_args: List[str] = ["--platform", dest] if dest in PLATFORMS and PLATFORMS[dest].get("frame") else []
|
|
1457
|
-
ex_cfg = proj.get("export")
|
|
1458
1623
|
# a malformed "export" is refused by the export stage with its own message; here it only means
|
|
1459
1624
|
# "no encoding export", never a traceback before that refusal
|
|
1460
1625
|
ex_preset = str(ex_cfg.get("preset") or "") if isinstance(ex_cfg, dict) else ""
|
|
@@ -1468,6 +1633,7 @@ def main() -> int:
|
|
|
1468
1633
|
|
|
1469
1634
|
CACHE.clear()
|
|
1470
1635
|
CACHE.update(_fresh_cache())
|
|
1636
|
+
CONTENT_KEYED.clear()
|
|
1471
1637
|
if args.cache:
|
|
1472
1638
|
cdir = Path(args.cache)
|
|
1473
1639
|
try:
|
|
@@ -1826,12 +1992,21 @@ def main() -> int:
|
|
|
1826
1992
|
sh("export.py", *argv, stage="export")
|
|
1827
1993
|
stages_done.append("export")
|
|
1828
1994
|
else:
|
|
1995
|
+
refuse_copied_container(Path(current).suffix.lower(), output) # what the copy really is (A84)
|
|
1829
1996
|
if not STATE.dry_run:
|
|
1830
1997
|
place_output(current, output)
|
|
1831
1998
|
info(("[dry-run] would copy" if STATE.dry_run else "copied") + f" final stage to {output}")
|
|
1832
1999
|
current = output
|
|
1833
2000
|
if not STATE.dry_run:
|
|
1834
2001
|
mark_delivered(output) # the chapters and check stages run on it: a stop from here keeps it, and says so (#415)
|
|
2002
|
+
if args.stop_after == "export":
|
|
2003
|
+
# A87: the chapters and check stages ran after "--stop-after export", and a "check" this
|
|
2004
|
+
# run never reads (validate_project() skips it) was then read with .get(): a traceback
|
|
2005
|
+
if not args.keep and not args.work:
|
|
2006
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
2007
|
+
emit(output, stages=stages_done, check=None, cache=cache_report(args.cache),
|
|
2008
|
+
intermediate_preset=_intermediate_used())
|
|
2009
|
+
return 0
|
|
1835
2010
|
|
|
1836
2011
|
# ---- chapters (metadata.py on the delivered file: streams copied, markers written)
|
|
1837
2012
|
ch = proj.get("chapters")
|
|
@@ -1840,9 +2015,19 @@ def main() -> int:
|
|
|
1840
2015
|
# which a dry run has not written either. The plan is the run, so --dry-run shows the
|
|
1841
2016
|
# command and lists the stage (the child's own --dry-run prints rather than writes).
|
|
1842
2017
|
if isinstance(ch, list):
|
|
1843
|
-
|
|
2018
|
+
# a dry run writes nothing into the work directory (A99): its metadata.py --dry-run
|
|
2019
|
+
# still reads the list, from a temporary directory removed at exit
|
|
2020
|
+
if STATE.dry_run:
|
|
2021
|
+
import atexit
|
|
2022
|
+
import tempfile
|
|
2023
|
+
tmp_dir = tempfile.mkdtemp(prefix="ffskill_render_chapters_")
|
|
2024
|
+
atexit.register(lambda: shutil.rmtree(tmp_dir, ignore_errors=True))
|
|
2025
|
+
chapter_file = os.path.join(tmp_dir, "chapters.txt")
|
|
2026
|
+
else:
|
|
2027
|
+
chapter_file = str(work / "chapters.txt")
|
|
1844
2028
|
lines = [f"{entry['at']} {entry['title']}" for entry in ch]
|
|
1845
2029
|
Path(chapter_file).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
2030
|
+
CONTENT_KEYED.add(os.path.abspath(chapter_file)) # rewritten every run: keyed by what it says (A100)
|
|
1846
2031
|
else:
|
|
1847
2032
|
chapter_file = rel(ch)
|
|
1848
2033
|
tagged = str(work / ("chapters" + Path(output).suffix))
|
|
@@ -1903,23 +2088,13 @@ def main() -> int:
|
|
|
1903
2088
|
kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result,
|
|
1904
2089
|
probe=probe(output, role="output"))
|
|
1905
2090
|
info(f"rendered {output} via {' → '.join(stages_done)}")
|
|
1906
|
-
cache_report = None
|
|
1907
|
-
if args.cache:
|
|
1908
|
-
cache_report = {"dir": CACHE["dir"], "ffmpeg": CACHE["ffmpeg"],
|
|
1909
|
-
"hits": CACHE["hits"], "misses": CACHE["misses"], "miss_reasons": CACHE["miss_reasons"],
|
|
1910
|
-
"saved_seconds": round(CACHE["saved_seconds"], 1),
|
|
1911
|
-
"entries": CACHE["entries"]}
|
|
1912
|
-
if STATE.dry_run:
|
|
1913
|
-
cache_report["would_hit"] = CACHE["would_hit"]
|
|
1914
|
-
info(f"cache: {len(CACHE['hits'])} hit(s) ({', '.join(CACHE['hits']) or '-'}), "
|
|
1915
|
-
f"{len(CACHE['misses'])} miss(es) ({', '.join(CACHE['misses']) or '-'})")
|
|
1916
2091
|
# One entry per snapped clip, `clip` naming which. A single-clip project keeps the shape a
|
|
1917
2092
|
# caller reads today by also carrying the first entry's keys at the top level.
|
|
1918
2093
|
snap_report: Optional[Dict[str, Any]] = None
|
|
1919
2094
|
if snap_reports:
|
|
1920
2095
|
snap_report = dict(snap_reports[0])
|
|
1921
2096
|
snap_report["clips"] = snap_reports
|
|
1922
|
-
emit(output, stages=stages_done, check=check_result, snap=snap_report, cache=cache_report,
|
|
2097
|
+
emit(output, stages=stages_done, check=check_result, snap=snap_report, cache=cache_report(args.cache),
|
|
1923
2098
|
caption=caption_report, intermediate_preset=_intermediate_used(),
|
|
1924
2099
|
verification=[{"step": "check", "ok": True, "platform": ck.get("platform")}] if check_result else [])
|
|
1925
2100
|
return 0
|
package/scripts/report.py
CHANGED
|
@@ -119,6 +119,32 @@ PACK_CSS = """
|
|
|
119
119
|
"""
|
|
120
120
|
|
|
121
121
|
|
|
122
|
+
def md_cells(line: str) -> List[str]:
|
|
123
|
+
"""The cells of one GFM table row, as render.py's md_cell() writes them: a '|' preceded by a
|
|
124
|
+
backslash is part of the cell, and a doubled backslash is one. Splitting on every '|' moved a
|
|
125
|
+
file name such as `a | b.mp4` into the next column (A263)."""
|
|
126
|
+
cells: List[str] = []
|
|
127
|
+
cur: List[str] = []
|
|
128
|
+
body = line.strip()
|
|
129
|
+
body = body[1:] if body.startswith("|") else body
|
|
130
|
+
i = 0
|
|
131
|
+
while i < len(body):
|
|
132
|
+
ch = body[i]
|
|
133
|
+
if ch == "\\" and i + 1 < len(body) and body[i + 1] in "\\|":
|
|
134
|
+
cur.append(body[i + 1])
|
|
135
|
+
i += 2
|
|
136
|
+
continue
|
|
137
|
+
if ch == "|":
|
|
138
|
+
cells.append("".join(cur).strip())
|
|
139
|
+
cur = []
|
|
140
|
+
else:
|
|
141
|
+
cur.append(ch)
|
|
142
|
+
i += 1
|
|
143
|
+
if "".join(cur).strip():
|
|
144
|
+
cells.append("".join(cur).strip())
|
|
145
|
+
return cells
|
|
146
|
+
|
|
147
|
+
|
|
122
148
|
def pack_report(args) -> int:
|
|
123
149
|
"""render.py --template all writes <stem>_pack.md: one row per destination. This renders the
|
|
124
150
|
same rows as HTML, so a pack can be handed over the way a single delivery report is -- no
|
|
@@ -130,7 +156,7 @@ def pack_report(args) -> int:
|
|
|
130
156
|
line = line.strip()
|
|
131
157
|
if not line.startswith("|"):
|
|
132
158
|
continue
|
|
133
|
-
cells =
|
|
159
|
+
cells = md_cells(line)
|
|
134
160
|
if all(set(c) <= set("-: ") for c in cells):
|
|
135
161
|
continue
|
|
136
162
|
if not header:
|
package/scripts/sequence.py
CHANGED
|
@@ -156,7 +156,11 @@ def main() -> int:
|
|
|
156
156
|
else:
|
|
157
157
|
out_w, out_h = even(sw), even(sh)
|
|
158
158
|
|
|
159
|
-
|
|
159
|
+
# beside the folder, named after it: `--dir .` (run inside the frames folder) or `--dir ..` has
|
|
160
|
+
# no name of its own, so the folder's resolved name is used (A108: "." was a ValueError
|
|
161
|
+
# traceback); the filesystem root, which has none either, still names it sequence_sequence.mp4
|
|
162
|
+
named = directory if directory.name not in ("", ".", "..") else directory.resolve()
|
|
163
|
+
output = args.output or default_output(str(named).rstrip("/\\") or "sequence", "sequence", "mp4")
|
|
160
164
|
with tempfile.TemporaryDirectory(prefix="ffmpeg-skill-sequence-") as tmp:
|
|
161
165
|
list_path = Path(tmp) / "frames.txt"
|
|
162
166
|
list_path.write_text(concat_list([f.resolve() for f in frames], rate), encoding="utf-8")
|
package/templates/audiogram.json
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
"src": "$INPUT"
|
|
7
7
|
}
|
|
8
8
|
],
|
|
9
|
+
"brand": "$BRAND",
|
|
9
10
|
"audiogram": {
|
|
10
11
|
"image": "$IMAGE",
|
|
11
12
|
"style": "waveform",
|
|
@@ -17,6 +18,14 @@
|
|
|
17
18
|
"position": "bottom",
|
|
18
19
|
"margin": 14
|
|
19
20
|
},
|
|
21
|
+
"graphics": [
|
|
22
|
+
{
|
|
23
|
+
"template": "lower-third",
|
|
24
|
+
"name": "$TITLE",
|
|
25
|
+
"start": 1,
|
|
26
|
+
"end": 6
|
|
27
|
+
}
|
|
28
|
+
],
|
|
20
29
|
"loudness": {
|
|
21
30
|
"lufs": -14,
|
|
22
31
|
"tp": -1
|