ffmpeg-skill 3.8.7 → 3.8.9

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,25 @@ 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
+
1093
+ def check_not_run(check_result: Optional[Dict[str, Any]]) -> Optional[str]:
1094
+ """Why check.py graded nothing, or None when it did: a document with no FAIL count that is
1095
+ failed or carries an error is a check that could not run (an unparsed document, check.py's own
1096
+ refusal), not a check the delivery failed (#403: both were reported as "check failed")."""
1097
+ if not check_result or check_result.get("failed"):
1098
+ return None
1099
+ err = check_result.get("error")
1100
+ if not err and check_result.get("status") != "failed":
1101
+ return None
1102
+ return str((err.get("message") if isinstance(err, dict) else err) or "check.py reported status failed with no rows")
1103
+
1104
+
1037
1105
  def execute_plan(plan: Dict[str, Any], path: str) -> int:
1038
1106
  """Run a plan written by `<tool> --plan FILE`: refuse if any fingerprinted input changed since
1039
1107
  the plan was made (the plan's commands would then describe a different edit), run the tool
@@ -1093,7 +1161,8 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
1093
1161
  if step.get("tool") == "check" and (step.get("platform") or step.get("content") is True):
1094
1162
  record_skip("[dry-run] the plan's check step is not run: there is no output to check yet",
1095
1163
  "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)
1164
+ emit(_from_plan_cwd(plan.get("output")), plan=path, plan_sha256=digest, tool=tool, stages=[tool], check=None,
1165
+ unbound_inputs=unbound)
1097
1166
  return 0
1098
1167
  info(f"executing plan {path}: {tool} " + " ".join(argv))
1099
1168
  proc = run_tool([str(script)] + argv + child_args() + ["--json"])
@@ -1114,7 +1183,7 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
1114
1183
  code=child_exit_code(doc, proc.returncode), kind=err.get("kind", "ffmpeg"), plan=path, tool=tool,
1115
1184
  error_extra=error_extra, **_stage_failure_hw(doc))
1116
1185
  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")
1186
+ output = _from_plan_cwd(doc.get("output") or plan.get("output"))
1118
1187
  check_result = None
1119
1188
  exit_code = 0
1120
1189
  for step in plan.get("verify") or []:
@@ -1132,7 +1201,9 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
1132
1201
  # same command run by hand)
1133
1202
  if exit_code:
1134
1203
  failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
1135
- info(f"plan done: {output}, but the {check_result.get('platform') or 'content'} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""))
1204
+ unrun = check_not_run(check_result)
1205
+ info(f"plan done: {output}, but the {check_result.get('platform') or 'content'} check "
1206
+ + (f"could not run, so nothing was checked: {unrun}" if unrun else "failed" + (f": {', '.join(failed_rows)}" if failed_rows else "")))
1136
1207
  else:
1137
1208
  advised = guidance_warns(check_result)
1138
1209
  info(f"plan done: {output}" + (" — upload-limit guidance, the uploader checks the current limit: "
@@ -1140,7 +1211,9 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
1140
1211
  # the tool's own steps (export's loudness, ...) come in through absorb_stage() above, tagged
1141
1212
  # with `stage`, so `verified` is theirs and the check's together
1142
1213
  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,
1143
- verification=([{"step": "check", "ok": not exit_code, "platform": check_result.get("platform")}] if check_result else []))
1214
+ verification=([dict({"step": "check", "ok": not exit_code, "platform": check_result.get("platform")},
1215
+ **({"reason": "not_measured: " + check_not_run(check_result)} if check_not_run(check_result) else {}))]
1216
+ if check_result else []))
1144
1217
  return 0
1145
1218
 
1146
1219
 
@@ -1180,6 +1253,8 @@ def export_timeline(proj: Dict[str, Any], rel, dest: str) -> int:
1180
1253
  paths.append(rel(proj["audio"]["music"]))
1181
1254
  probes: Dict[str, Dict[str, Any]] = {}
1182
1255
  for path in paths:
1256
+ if path in probes:
1257
+ continue # one ffprobe per file: ten clips of one recording are one probe (A267)
1183
1258
  if not os.path.exists(path):
1184
1259
  die(f"timeline source not found: {path}")
1185
1260
  probes[path] = probe(path) # a timeline needs real durations and rates, dry run or not
@@ -1197,9 +1272,9 @@ def export_timeline(proj: Dict[str, Any], rel, dest: str) -> int:
1197
1272
  del frame["aspect"]
1198
1273
  try:
1199
1274
  tl = tlmod.build(dict(proj, frame=frame), probes, rel)
1275
+ text = tlmod.WRITERS[fmt](tl) # a writer refuses what its format cannot hold (EDL: 999 events, 24 h)
1200
1276
  except tlmod.TimelineError as exc:
1201
1277
  die(f"--export-timeline: {exc}", kind="input")
1202
- text = tlmod.WRITERS[fmt](tl)
1203
1278
  report = tlmod.summary(tl, fmt)
1204
1279
  _check_output_path(["ffmpeg", dest])
1205
1280
  _check_existing_output(["ffmpeg", dest])
@@ -1234,6 +1309,89 @@ def _has_sound(current: str, proj: Dict[str, Any], rel: Any) -> bool:
1234
1309
  return False
1235
1310
 
1236
1311
 
1312
+ def cache_report(cache_dir: Optional[str]) -> Optional[Dict[str, Any]]:
1313
+ """The result's `cache` object (None without --cache), with its stderr summary line."""
1314
+ if not cache_dir:
1315
+ return None
1316
+ report = {"dir": CACHE["dir"], "ffmpeg": CACHE["ffmpeg"],
1317
+ "hits": CACHE["hits"], "misses": CACHE["misses"], "miss_reasons": CACHE["miss_reasons"],
1318
+ "saved_seconds": round(CACHE["saved_seconds"], 1),
1319
+ "entries": CACHE["entries"]}
1320
+ if STATE.dry_run:
1321
+ report["would_hit"] = CACHE["would_hit"]
1322
+ info(f"cache: {len(CACHE['hits'])} hit(s) ({', '.join(CACHE['hits']) or '-'}), "
1323
+ f"{len(CACHE['misses'])} miss(es) ({', '.join(CACHE['misses']) or '-'})")
1324
+ return report
1325
+
1326
+
1327
+ # One container family: a copy of an MP4 file named .mov (or .m4v, .m4a, .3gp) is the bytes that
1328
+ # name says. Every other pair of extensions names two different containers.
1329
+ ISO_BMFF_EXT = frozenset({".mp4", ".mov", ".m4v", ".m4a", ".3gp"})
1330
+
1331
+
1332
+ def refuse_copied_container(have: str, output: str) -> None:
1333
+ """Refuse delivering a file of container `have` (an extension) by copying it to `output`
1334
+ under another container's name: with no export stage nothing converts it (A84)."""
1335
+ want = Path(output).suffix.lower()
1336
+ if have == want or (have in ISO_BMFF_EXT and want in ISO_BMFF_EXT):
1337
+ return
1338
+ die(f"output {output}: this project has no \"export\" stage, so the delivery is the last stage's "
1339
+ f"{have or 'extension-less'} file copied as it is, which the name {want or '(no extension)'} would mislabel -- "
1340
+ f"name the output {have or 'with that extension'}, or add an \"export\" stage, which writes the container "
1341
+ "its preset names", kind="input", hint=f"\"output\": \"{Path(output).with_suffix(have).name}\"" if have else None)
1342
+
1343
+
1344
+ # A98: the stage files a render with an explicit --work wrote there, {real path: owned_identity()},
1345
+ # kept in <work>/.ffskill-render-stages.json. A later run with the same --work replaces one of them
1346
+ # without --overwrite while it is still that file (the overwrite rule's "this run's own file",
1347
+ # extended to an earlier render of the same work directory); one edited, replaced or never
1348
+ # written by render is refused as before.
1349
+ _OWN_STAGES: Dict[str, Any] = {"dir": None, "files": {}, "manifest": None}
1350
+
1351
+
1352
+ def own_stage_files(work: Path, load: bool) -> None:
1353
+ """Start this run's record of its stage files in `work`, reading an earlier render's when
1354
+ `load` (an explicit --work; the default directory is new every run)."""
1355
+ _OWN_STAGES.update(dir=os.path.realpath(str(work)), files={}, manifest=str(work / ".ffskill-render-stages.json") if load else None)
1356
+ if load:
1357
+ try:
1358
+ doc = json.loads(Path(_OWN_STAGES["manifest"]).read_text(encoding="utf-8"))
1359
+ except (OSError, ValueError):
1360
+ doc = {}
1361
+ files = doc.get("files") if isinstance(doc, dict) else None
1362
+ if isinstance(files, dict):
1363
+ _OWN_STAGES["files"] = {k: v for k, v in files.items() if isinstance(k, str) and isinstance(v, dict)}
1364
+
1365
+
1366
+ def _earlier_own_stage_file(dest: Optional[str]) -> bool:
1367
+ """Whether `dest`, a stage's output in the work directory, is a file an earlier render with
1368
+ this --work wrote there and nothing has changed since."""
1369
+ if not dest or not _OWN_STAGES["manifest"] or not os.path.isfile(dest):
1370
+ return False
1371
+ real = os.path.realpath(dest)
1372
+ if os.path.dirname(real) != _OWN_STAGES["dir"]:
1373
+ return False
1374
+ expected = _OWN_STAGES["files"].get(real)
1375
+ return isinstance(expected, dict) and owned_identity(real) == expected
1376
+
1377
+
1378
+ def _record_own_stage_file(dest: Optional[str]) -> None:
1379
+ """Remember a stage file this run put in the --work directory, as it is now. A manifest that
1380
+ cannot be written only means the next run refuses that file as before."""
1381
+ if STATE.dry_run or not dest or not _OWN_STAGES["manifest"] or not os.path.isfile(dest):
1382
+ return
1383
+ real = os.path.realpath(dest)
1384
+ if os.path.dirname(real) != _OWN_STAGES["dir"]:
1385
+ return
1386
+ ident = owned_identity(real)
1387
+ if ident:
1388
+ _OWN_STAGES["files"][real] = ident
1389
+ try:
1390
+ Path(_OWN_STAGES["manifest"]).write_text(json.dumps({"files": _OWN_STAGES["files"]}, indent=1), encoding="utf-8")
1391
+ except OSError:
1392
+ pass
1393
+
1394
+
1237
1395
  def guidance_warns(check_doc: Optional[Dict[str, Any]]) -> List[str]:
1238
1396
  """The rows of a check result that are over the platform table's upload limit (#485): WARN,
1239
1397
  `basis: guidance`. The check passes with them, so each place that says a check passed names
@@ -1323,7 +1481,8 @@ def main() -> int:
1323
1481
  guard_sidecar(args.init)
1324
1482
  Path(args.init).write_text(json.dumps(TEMPLATE, indent=2) + "\n", encoding="utf-8")
1325
1483
  info(f"wrote {args.init}; edit clips/src and run: render.py {args.init}")
1326
- print(args.init)
1484
+ # the path alone, or under --json the document every other mode prints (A102)
1485
+ emit(args.init, stages=[], check=None)
1327
1486
  return 0
1328
1487
  if not args.project:
1329
1488
  die("give a project.json (or --init FILE, or --template NAME INPUT)")
@@ -1425,25 +1584,48 @@ def main() -> int:
1425
1584
  # fit.mp4, ...) mid-run. An explicit --work is left as given (the caller asked for that exact,
1426
1585
  # shared path, e.g. to inspect intermediates across runs); only the auto-derived default is
1427
1586
  # 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()}")
1587
+ # Intermediates keep the delivery's media kind: a .mp4 project is unchanged (every stage file
1588
+ # is still clipNN.mp4 / fit.mp4 / loudnorm.mp4), while an audio-only delivery (the podcast
1589
+ # template) carries its stages through the audio container instead of a video one.
1590
+ mid = Path(output).suffix.lower() if Path(output).suffix.lower() in AUDIO_EXT else ".mp4"
1591
+ ex_cfg = proj.get("export")
1592
+ if (not args.stop_after or args.stop_after == "export") and not (isinstance(ex_cfg, dict) and ex_cfg.get("preset")):
1593
+ # A84: with no export stage the delivery is the last stage's file copied as it is, so a
1594
+ # .webm/.mkv name was MP4 bytes, completed and verified. Refused before the first stage
1595
+ # when the last file is certainly an intermediate; a single uncut clip that no stage
1596
+ # touches is the source itself, and the copy below checks what it really is.
1597
+ c0 = clips[0] if len(clips) == 1 and isinstance(clips[0], dict) else None
1598
+ passthrough = c0 is not None and c0.get("in") is None and c0.get("out") is None
1599
+ if not passthrough:
1600
+ refuse_copied_container(mid, output)
1601
+ # A77: the default work directory is new for every run (mkdtemp), not named by the PID alone and
1602
+ # reused with exist_ok -- a later run that got the same PID deleted an earlier run's kept preview.
1603
+ # An explicit --work is the caller's: created when missing, never emptied.
1604
+ created_work = bool(args.work) and not Path(args.work).exists()
1429
1605
  try:
1430
- work.mkdir(parents=True, exist_ok=True)
1606
+ if args.work:
1607
+ work = Path(args.work)
1608
+ work.mkdir(parents=True, exist_ok=True)
1609
+ else:
1610
+ import tempfile
1611
+ Path(output).parent.mkdir(parents=True, exist_ok=True)
1612
+ work = Path(tempfile.mkdtemp(prefix=f"{Path(output).with_suffix('').name}_work_{os.getpid()}_",
1613
+ dir=str(Path(output).parent)))
1614
+ created_work = True
1431
1615
  except OSError as e:
1432
- die(f"cannot create the work directory {work}: {e}")
1616
+ die(f"cannot create the work directory {args.work or Path(output).parent}: {e}")
1617
+ own_stage_files(work, load=bool(args.work))
1433
1618
  # --stop-after before the export reports a stage file inside the work directory as its output:
1434
1619
  # removing the directory at exit deleted the file the result named (and its output_identity)
1435
1620
  previewing = bool(args.stop_after) and args.stop_after != "export" and not STATE.dry_run
1436
1621
  if previewing and not args.keep and not args.work:
1437
1622
  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:
1623
+ if (not args.keep and not args.work and not previewing) or (STATE.dry_run and created_work):
1439
1624
  # 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
1625
+ # auto-named directory is ours alone, so remove it on every exit path; a dry run removes
1626
+ # a --work directory it created too, --keep or not (A99: a plan leaves nothing behind)
1441
1627
  import atexit
1442
1628
  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
1629
  frame = dict(proj.get("frame") or {})
1448
1630
  trans = proj.get("transition") or {}
1449
1631
  frame_from_preset(frame, proj.get("export") or {})
@@ -1454,7 +1636,6 @@ def main() -> int:
1454
1636
  # unchanged -- it never gets a --platform it did not ask for.
1455
1637
  dest = str(((proj.get("check") or {}).get("platform") or "")) if proj.get("template") else ""
1456
1638
  platform_args: List[str] = ["--platform", dest] if dest in PLATFORMS and PLATFORMS[dest].get("frame") else []
1457
- ex_cfg = proj.get("export")
1458
1639
  # a malformed "export" is refused by the export stage with its own message; here it only means
1459
1640
  # "no encoding export", never a traceback before that refusal
1460
1641
  ex_preset = str(ex_cfg.get("preset") or "") if isinstance(ex_cfg, dict) else ""
@@ -1468,6 +1649,7 @@ def main() -> int:
1468
1649
 
1469
1650
  CACHE.clear()
1470
1651
  CACHE.update(_fresh_cache())
1652
+ CONTENT_KEYED.clear()
1471
1653
  if args.cache:
1472
1654
  cdir = Path(args.cache)
1473
1655
  try:
@@ -1826,12 +2008,21 @@ def main() -> int:
1826
2008
  sh("export.py", *argv, stage="export")
1827
2009
  stages_done.append("export")
1828
2010
  else:
2011
+ refuse_copied_container(Path(current).suffix.lower(), output) # what the copy really is (A84)
1829
2012
  if not STATE.dry_run:
1830
2013
  place_output(current, output)
1831
2014
  info(("[dry-run] would copy" if STATE.dry_run else "copied") + f" final stage to {output}")
1832
2015
  current = output
1833
2016
  if not STATE.dry_run:
1834
2017
  mark_delivered(output) # the chapters and check stages run on it: a stop from here keeps it, and says so (#415)
2018
+ if args.stop_after == "export":
2019
+ # A87: the chapters and check stages ran after "--stop-after export", and a "check" this
2020
+ # run never reads (validate_project() skips it) was then read with .get(): a traceback
2021
+ if not args.keep and not args.work:
2022
+ shutil.rmtree(work, ignore_errors=True)
2023
+ emit(output, stages=stages_done, check=None, cache=cache_report(args.cache),
2024
+ intermediate_preset=_intermediate_used())
2025
+ return 0
1835
2026
 
1836
2027
  # ---- chapters (metadata.py on the delivered file: streams copied, markers written)
1837
2028
  ch = proj.get("chapters")
@@ -1840,9 +2031,19 @@ def main() -> int:
1840
2031
  # which a dry run has not written either. The plan is the run, so --dry-run shows the
1841
2032
  # command and lists the stage (the child's own --dry-run prints rather than writes).
1842
2033
  if isinstance(ch, list):
1843
- chapter_file = str(work / "chapters.txt")
2034
+ # a dry run writes nothing into the work directory (A99): its metadata.py --dry-run
2035
+ # still reads the list, from a temporary directory removed at exit
2036
+ if STATE.dry_run:
2037
+ import atexit
2038
+ import tempfile
2039
+ tmp_dir = tempfile.mkdtemp(prefix="ffskill_render_chapters_")
2040
+ atexit.register(lambda: shutil.rmtree(tmp_dir, ignore_errors=True))
2041
+ chapter_file = os.path.join(tmp_dir, "chapters.txt")
2042
+ else:
2043
+ chapter_file = str(work / "chapters.txt")
1844
2044
  lines = [f"{entry['at']} {entry['title']}" for entry in ch]
1845
2045
  Path(chapter_file).write_text("\n".join(lines) + "\n", encoding="utf-8")
2046
+ CONTENT_KEYED.add(os.path.abspath(chapter_file)) # rewritten every run: keyed by what it says (A100)
1846
2047
  else:
1847
2048
  chapter_file = rel(ch)
1848
2049
  tagged = str(work / ("chapters" + Path(output).suffix))
@@ -1899,27 +2100,19 @@ def main() -> int:
1899
2100
  # The deliverable is written and verified, but it does not meet the requested platform
1900
2101
  # spec (or the check itself could not run): a failed delivery, reported as one.
1901
2102
  failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
1902
- die(f"rendered {output} but the {check_label} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""),
2103
+ unrun = check_not_run(check_result)
2104
+ die(f"rendered {output} but the {check_label} check "
2105
+ + (f"could not run, so nothing was checked: {unrun}" if unrun else "failed" + (f": {', '.join(failed_rows)}" if failed_rows else "")),
1903
2106
  kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result,
1904
2107
  probe=probe(output, role="output"))
1905
2108
  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
2109
  # One entry per snapped clip, `clip` naming which. A single-clip project keeps the shape a
1917
2110
  # caller reads today by also carrying the first entry's keys at the top level.
1918
2111
  snap_report: Optional[Dict[str, Any]] = None
1919
2112
  if snap_reports:
1920
2113
  snap_report = dict(snap_reports[0])
1921
2114
  snap_report["clips"] = snap_reports
1922
- emit(output, stages=stages_done, check=check_result, snap=snap_report, cache=cache_report,
2115
+ emit(output, stages=stages_done, check=check_result, snap=snap_report, cache=cache_report(args.cache),
1923
2116
  caption=caption_report, intermediate_preset=_intermediate_used(),
1924
2117
  verification=[{"step": "check", "ok": True, "platform": ck.get("platform")}] if check_result else [])
1925
2118
  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")
@@ -121,7 +121,7 @@ def sentence_words(args) -> "Tuple[list, str]":
121
121
  raw = parse_json(read_text_or_die(args.words, "--words"))
122
122
  except ValueError as exc:
123
123
  die(f"--words {args.words}: not readable JSON ({exc})", kind="input")
124
- words, _had_segments, _untimed = _words_from_transcript(raw)
124
+ words, _had_segments, _untimed = _words_from_words_file(args.words, raw)
125
125
  if not words:
126
126
  die(f"no word timings in {args.words}: the sentence flags need "
127
127
  '{"words": [{"word": ..., "start": ..., "end": ...}]} (or the same inside "segments").',
@@ -356,7 +356,15 @@ def resolve_filler(args, meta):
356
356
  except ValueError as exc:
357
357
  die(f"--words {args.words}: not readable JSON ({exc})", kind="input")
358
358
  source = f"whisper-json:{args.words}"
359
- words, had_segments, untimed = _words_from_transcript(raw)
359
+ words, had_segments, untimed = _words_from_words_file(args.words, raw)
360
+ length = float(meta.get("duration") or 0.0)
361
+ late = [w for w in words if w["start"] >= length] if length > 0 else []
362
+ if late:
363
+ # nothing said it: the cuts were planned on times the input does not have (#404)
364
+ warn(f"--words {args.words}: {len(late)} of {len(words)} word timings start at or after the end of the input "
365
+ f"({length:.3f}s; the last at {max(w['start'] for w in late):g}s) -- a transcript of another file, or "
366
+ "times in milliseconds (a whisper JSON's start/end are seconds); no word is cut there",
367
+ "words_outside_input", count=len(late), words=len(words), duration=round(length, 3))
360
368
  if not words:
361
369
  if had_segments:
362
370
  die(f"the transcript in {args.words} has segment timings but no word timings; "
@@ -423,7 +431,11 @@ def resolve_filler(args, meta):
423
431
  if primary in names:
424
432
  return names[primary]
425
433
  return primary[:2] if len(primary) > 3 else primary
426
- spoken = _code((raw or {}).get("language")) if isinstance(raw, dict) else ""
434
+ # openai-whisper's top-level `language`, or whisper.cpp's `result.language` (its
435
+ # --output-json-full document is read for its words too: _words_from_words_file())
436
+ result = raw.get("result") if isinstance(raw, dict) else None
437
+ spoken = _code(raw.get("language") or (result.get("language") if isinstance(result, dict) else None)) \
438
+ if isinstance(raw, dict) else ""
427
439
  spoken = spoken or _code(transcription.get("language") or transcription.get("detected_language"))
428
440
  if spoken and spoken not in FILLER_WORDS and not args.filler_words:
429
441
  die(f"the transcript is in {spoken}, which has no built-in filler list. The languages with one are "
@@ -482,6 +494,18 @@ def resolve_filler(args, meta):
482
494
  return block, [(s["start"], s["end"]) for s in spans]
483
495
 
484
496
 
497
+ def _words_from_words_file(path, data):
498
+ """_words_from_transcript() for a --words file, plus whisper.cpp's own shape: its
499
+ --output-json-full document (transcription[].tokens[], offsets in milliseconds) is what the
500
+ refusals here tell a caller to make, and it was then refused for having no word timings (#404).
501
+ Read as --transcribe reads the same file."""
502
+ words, had_segments, untimed = _words_from_transcript(data)
503
+ if not words and isinstance(data, dict) and isinstance(data.get("transcription"), list):
504
+ words = _asr._words_from_whisper_cpp_json(path)
505
+ had_segments = had_segments or bool(data["transcription"])
506
+ return words, had_segments, untimed
507
+
508
+
485
509
  def _words_from_transcript(data):
486
510
  """([{word,start,end}], whether the document had segments at all, [{word}] of the words
487
511
  that carried no usable timing) from a whisper JSON. The third list lets --filler count a
@@ -28,7 +28,7 @@ import tempfile
28
28
  from pathlib import Path
29
29
 
30
30
  from _common.probe import PROBED_VIDEO
31
- from _common.runner import default_selection_maps
31
+ from _common.runner import ffmpeg_failure_extra, default_selection_maps, require_filter
32
32
  from _common import STATE, add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs, record_fallback, EVEN_SCALE
33
33
 
34
34
 
@@ -57,6 +57,10 @@ def main() -> int:
57
57
  meta = probe(args.input)
58
58
  if not meta.get("video"):
59
59
  die("input has no video stream")
60
+ # pass 1 is a measurement that runs under --dry-run too: refused here, with the reason a run
61
+ # that met "No such filter" gets, instead of as an unclassified pass-1 failure (#402)
62
+ for name in ("vidstabdetect", "vidstabtransform"):
63
+ require_filter(name, "stabilizing (vid.stab)")
60
64
  has_audio = bool(meta.get("audio"))
61
65
  output = args.output or default_output(args.input, "stab")
62
66
 
@@ -89,7 +93,7 @@ def main() -> int:
89
93
  if not dry_run_input_pending(args.input):
90
94
  proc = run_analysis(detect_cmd, check=False, record=True)
91
95
  if proc.returncode != 0:
92
- die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg")
96
+ die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr))
93
97
 
94
98
  crop_mode = {"keep": 0, "black": 1}[args.crop]
95
99
  transform_vf = f"{even}vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=1"