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/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
- print("%-15s %-11s %-5s %7s %-10s %s" % ("template", "frame", "fit", "max", "loudness", "safe zones (fraction of the frame)"))
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
- print("%-15s %-11s %-5s %7s %-10s %s" % (name, size, fit, ("%gs" % dur) if dur else "-", loud, zones or "none"))
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(f"| {r['platform']} | {r['file']} | {mb} | {dur} | {verdict} |")
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
- for c in proj.get("clips") or []:
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
- cmd = [str(HERE / script)] + [str(a) for a in argv] + (extra or []) + child_args() + ["--json"]
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, unbound_inputs=unbound)
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
- print(args.init)
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
- work = Path(args.work) if args.work else Path(f"{Path(output).with_suffix('')}_work_{os.getpid()}")
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.mkdir(parents=True, exist_ok=True)
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
- chapter_file = str(work / "chapters.txt")
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 = [c.strip() for c in line.strip("|").split("|")]
159
+ cells = md_cells(line)
134
160
  if all(set(c) <= set("-: ") for c in cells):
135
161
  continue
136
162
  if not header:
@@ -156,7 +156,11 @@ def main() -> int:
156
156
  else:
157
157
  out_w, out_h = even(sw), even(sh)
158
158
 
159
- output = args.output or default_output(str(directory).rstrip("/\\") or "sequence", "sequence", "mp4")
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")
@@ -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