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/README.md +1 -1
- package/docs/contract.md +15 -4
- package/package.json +1 -1
- package/references/gotchas.md +5 -3
- package/references/scripts.md +69 -17
- package/scripts/_common/asr.py +12 -1
- package/scripts/_common/drawtext.py +11 -0
- package/scripts/_common/emit.py +3 -1
- package/scripts/_common/emoji.py +28 -5
- package/scripts/_common/probe.py +27 -7
- package/scripts/_common/runner.py +146 -5
- package/scripts/_common/timeline.py +45 -12
- package/scripts/_contract.py +19 -11
- package/scripts/batch.py +91 -8
- package/scripts/caption.py +39 -3
- package/scripts/color.py +36 -0
- package/scripts/cropdetect.py +24 -6
- package/scripts/cut.py +13 -5
- package/scripts/export.py +3 -2
- package/scripts/graphics.py +31 -12
- package/scripts/join.py +20 -2
- package/scripts/loudness.py +2 -1
- package/scripts/metadata.py +15 -1
- package/scripts/overlay.py +11 -6
- package/scripts/probe.py +8 -0
- package/scripts/render.py +232 -39
- package/scripts/report.py +27 -1
- package/scripts/sequence.py +5 -1
- package/scripts/silence.py +27 -3
- package/scripts/stabilize.py +6 -2
- package/scripts/sync.py +8 -3
- package/scripts/waveform.py +13 -6
- 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,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,
|
|
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
|
-
|
|
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")}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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/scripts/silence.py
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
package/scripts/stabilize.py
CHANGED
|
@@ -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"
|