ffmpeg-skill 3.8.19 → 3.8.21
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 +2 -2
- package/docs/contract.md +27 -2
- package/mcp/server.py +1 -1
- package/package.json +1 -1
- package/references/scripts.md +31 -4
- package/scripts/_common/color.py +10 -5
- package/scripts/_common/decision.py +24 -0
- package/scripts/_common/runner.py +105 -7
- package/scripts/_contract.py +12 -5
- package/scripts/batch.py +259 -24
- package/scripts/cut.py +44 -4
- package/scripts/render.py +107 -6
- package/scripts/waveform.py +10 -3
package/scripts/render.py
CHANGED
|
@@ -75,9 +75,9 @@ 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 as _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, owned_identity, 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, refuse_missing_components_first, remember_written, with_flags, kept_output, unwatch_destination, watch_destination
|
|
79
79
|
from _common.emit import absorb_stage, audit, is_output_shape_flag, record_fallback, record_skip, refuse, stderr_records, warn
|
|
80
|
-
from _common.decision import rebase_project_paths
|
|
80
|
+
from _common.decision import brand_asset_files, rebase_project_paths
|
|
81
81
|
import subprocess
|
|
82
82
|
from _contract import CONTRACT_VERSION
|
|
83
83
|
from batch import file_key
|
|
@@ -803,6 +803,14 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
|
|
|
803
803
|
info(f" {os.path.basename(str(dest))} is an earlier render's own stage file in {_OWN_STAGES['dir']}, unchanged since: replaced")
|
|
804
804
|
cmd = [str(HERE / script)] + [str(a) for a in argv] + (extra or []) + own + child_args() + ["--json"]
|
|
805
805
|
info("→ " + " ".join(os.path.basename(c) if i < 1 else c for i, c in enumerate(cmd[:-1])))
|
|
806
|
+
# a stage that writes outside the work directory (the export writes the delivery itself): what
|
|
807
|
+
# is there now, so a stop while it runs reports a file the stage wrote as kept, not "nothing
|
|
808
|
+
# was written", and never claims the caller's own file (#510)
|
|
809
|
+
# the delivery itself is watched wherever --work is (a --work that holds the output's folder
|
|
810
|
+
# made the export look like an intermediate, #510 review)
|
|
811
|
+
outside = bool(dest) and (not _in_work(dest) or _is_delivery(dest))
|
|
812
|
+
if outside:
|
|
813
|
+
watch_destination(dest)
|
|
806
814
|
proc = run_tool(cmd)
|
|
807
815
|
for is_command, line in stderr_records(proc.stderr):
|
|
808
816
|
if is_command:
|
|
@@ -825,7 +833,7 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
|
|
|
825
833
|
# a stage after the delivery (chapters, check): the stage's own "nothing was written"
|
|
826
834
|
# is about its own file, while the delivery is on disk (#415)
|
|
827
835
|
why = why.replace("; nothing was written", "") + f" ({kept_clause()})"
|
|
828
|
-
extra_fields.setdefault("output",
|
|
836
|
+
extra_fields.setdefault("output", kept_output())
|
|
829
837
|
elif err.get("kind") in ("timeout", "interrupted") and doc.get("output") and os.path.isfile(str(doc["output"])):
|
|
830
838
|
# the stage was stopped after writing its own deliverable and kept it (export.py's
|
|
831
839
|
# --normalize pass): its message says so, and the file is this run's output too
|
|
@@ -843,11 +851,64 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
|
|
|
843
851
|
# otherwise refuse as an existing file without --overwrite (#415).
|
|
844
852
|
remember_written(out_path, dest)
|
|
845
853
|
_record_own_stage_file(dest)
|
|
854
|
+
if outside:
|
|
855
|
+
unwatch_destination(dest)
|
|
846
856
|
if key:
|
|
847
857
|
cache_store(stage, key, out_path or (dest or ""), time.time() - started, doc)
|
|
848
858
|
return out_path
|
|
849
859
|
|
|
850
860
|
|
|
861
|
+
WORK_DIR: Dict[str, Optional[str]] = {"path": None}
|
|
862
|
+
DELIVERY: Dict[str, Optional[str]] = {"path": None} # the render's output path, set in main()
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _is_delivery(path: str) -> bool:
|
|
866
|
+
return bool(DELIVERY["path"]) and os.path.realpath(path) == os.path.realpath(str(DELIVERY["path"]))
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _in_work(path: str) -> bool:
|
|
870
|
+
"""Whether `path` is inside this render's work directory."""
|
|
871
|
+
work = WORK_DIR["path"]
|
|
872
|
+
if not work:
|
|
873
|
+
return False
|
|
874
|
+
return os.path.realpath(path).startswith(os.path.realpath(work) + os.sep)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
SRT_TRIM_TOLERANCE = 0.25 # seconds the delivery may be shorter than the captioned stage before its SRT is called off-timeline
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def deliver_caption_srt(target: Optional[str], made: Optional[Dict[str, Any]], output: str) -> Optional[str]:
|
|
881
|
+
"""Put the captions stage's generated SRT at `target` (<output>.srt, guarded before the first
|
|
882
|
+
stage) and return the path, or None with a skip record when it is not delivered: the stage was
|
|
883
|
+
served from the cache (the cache keeps the video only), or a later stage changed the timeline
|
|
884
|
+
(the export trimmed the delivery to a platform's maximum), where the SRT's times are no longer
|
|
885
|
+
the delivery's (#510). Under --dry-run the planned path."""
|
|
886
|
+
if not target or not made:
|
|
887
|
+
return None
|
|
888
|
+
if STATE.dry_run:
|
|
889
|
+
info(f"[dry-run] would deliver the captions' SRT as {target}")
|
|
890
|
+
return target
|
|
891
|
+
if made.get("cached") or not os.path.isfile(made["srt"]):
|
|
892
|
+
record_skip(f"the captions stage's SRT is not delivered: the stage was served from --cache, which keeps its video only",
|
|
893
|
+
"caption_srt_not_delivered", reason="cached", path=target)
|
|
894
|
+
return None
|
|
895
|
+
trimmed = _LAST_DOC.get("trimmed") if _LAST_DOC.get("trimmed") else None
|
|
896
|
+
if not trimmed:
|
|
897
|
+
before = (probe(made["video"], role="output").get("duration") if os.path.isfile(made["video"]) else None) or 0.0
|
|
898
|
+
after = probe(output, role="output").get("duration") or 0.0
|
|
899
|
+
if before and after and before - after > SRT_TRIM_TOLERANCE:
|
|
900
|
+
trimmed = {"from": round(before, 3), "to": round(after, 3)}
|
|
901
|
+
if trimmed:
|
|
902
|
+
record_skip(f"the captions stage's SRT is not delivered: a later stage changed the timeline (the delivery was trimmed: "
|
|
903
|
+
f"{trimmed}), so its cue times are not the delivery's", "caption_srt_not_delivered",
|
|
904
|
+
reason="timeline_changed", path=target, trimmed=trimmed)
|
|
905
|
+
return None
|
|
906
|
+
place_output(made["srt"], target)
|
|
907
|
+
mark_delivered(target, side=True) # kept after a stop, but not the run's `output` (#510 review)
|
|
908
|
+
info(f"delivered the captions' SRT as {target}")
|
|
909
|
+
return target
|
|
910
|
+
|
|
911
|
+
|
|
851
912
|
def _stage_failure_hw(doc: Any) -> Dict[str, Any]:
|
|
852
913
|
"""A failed stage's `encoder` -- the command that failed -- for render's own failure document
|
|
853
914
|
(absorb_stage() has already joined the stage's `hw` to the earlier stages'; a fallback before
|
|
@@ -974,9 +1035,26 @@ def cache_key(stage: str, script: str, argv: "Sequence[Any]", inputs: "Sequence[
|
|
|
974
1035
|
"hw": [os.environ.get(HW_ENV, "").strip().lower() in ("1", "true", "yes", "on"), os.environ.get(HW_FORCED_ENV) == "1"],
|
|
975
1036
|
"ffmpeg": CACHE.get("ffmpeg"), "skill": SKILL_VERSION, "contract": CONTRACT_VERSION,
|
|
976
1037
|
}
|
|
1038
|
+
named = _brand_files_key(argv)
|
|
1039
|
+
if named:
|
|
1040
|
+
payload["brand_files"] = named # only with --brand: every other stage's key is unchanged
|
|
977
1041
|
return hashlib.sha1(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode(), usedforsecurity=False).hexdigest()
|
|
978
1042
|
|
|
979
1043
|
|
|
1044
|
+
def _brand_files_key(argv: "Sequence[Any]") -> List[Any]:
|
|
1045
|
+
"""The files a stage's --brand file names (its logo and font_file, resolved as load_brand()
|
|
1046
|
+
resolves them): the key hashes the brand file's own bytes, so a font or a logo replaced under
|
|
1047
|
+
the same name was served from the cache with the old one drawn (#510's sweep)."""
|
|
1048
|
+
out: List[Any] = []
|
|
1049
|
+
args = [str(a) for a in argv]
|
|
1050
|
+
for i, a in enumerate(args[:-1]):
|
|
1051
|
+
if a != "--brand" or not os.path.isfile(args[i + 1]):
|
|
1052
|
+
continue
|
|
1053
|
+
for key, path in brand_asset_files(args[i + 1]):
|
|
1054
|
+
out.append([key, _content_hash(path) if os.path.isfile(path) else None])
|
|
1055
|
+
return out
|
|
1056
|
+
|
|
1057
|
+
|
|
980
1058
|
def _cache_miss(stage: str, reason: str, **facts: Any) -> bool:
|
|
981
1059
|
"""Record why `stage` was not served from the cache (`cache.miss_reasons`); a damaged entry
|
|
982
1060
|
(unreadable sidecar, artifact of the wrong size) is also a warning and an audit line in the
|
|
@@ -1225,7 +1303,7 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
|
|
|
1225
1303
|
unbound_inputs=unbound)
|
|
1226
1304
|
return 0
|
|
1227
1305
|
info(f"executing plan {path}: {tool} " + " ".join(argv))
|
|
1228
|
-
proc = run_tool([str(script)] + argv
|
|
1306
|
+
proc = run_tool([str(script)] + with_flags(argv, child_args() + ["--json"])) # before a `--` in the plan's argv
|
|
1229
1307
|
for is_command, line in stderr_records(proc.stderr):
|
|
1230
1308
|
if is_command and not line.startswith("[dry-run]"):
|
|
1231
1309
|
STATE.commands.append(line)
|
|
@@ -1545,6 +1623,8 @@ def main() -> int:
|
|
|
1545
1623
|
args = ap.parse_args()
|
|
1546
1624
|
apply_common(args)
|
|
1547
1625
|
PROJECT_SOURCE.update(src=None, timecode=[])
|
|
1626
|
+
WORK_DIR["path"] = None
|
|
1627
|
+
DELIVERY["path"] = None
|
|
1548
1628
|
STATE.audit_log = os.path.abspath(args.audit_log) if args.audit_log else None
|
|
1549
1629
|
|
|
1550
1630
|
if args.list_templates:
|
|
@@ -1675,6 +1755,9 @@ def main() -> int:
|
|
|
1675
1755
|
passthrough = c0 is not None and c0.get("in") is None and c0.get("out") is None
|
|
1676
1756
|
if not passthrough:
|
|
1677
1757
|
refuse_copied_container(mid, output)
|
|
1758
|
+
# #510: a filter or an encoder this build lacks is found by the stage that needs it; the dry run
|
|
1759
|
+
# finds it before anything is encoded, so a real render runs its own dry run first
|
|
1760
|
+
refuse_missing_components_first(str(HERE / "render.py"), "stage's filters and encoders")
|
|
1678
1761
|
# A77: the default work directory is new for every run (mkdtemp), not named by the PID alone and
|
|
1679
1762
|
# reused with exist_ok -- a later run that got the same PID deleted an earlier run's kept preview.
|
|
1680
1763
|
# An explicit --work is the caller's: created when missing, never emptied.
|
|
@@ -1703,6 +1786,19 @@ def main() -> int:
|
|
|
1703
1786
|
# a --work directory it created too, --keep or not (A99: a plan leaves nothing behind)
|
|
1704
1787
|
import atexit
|
|
1705
1788
|
atexit.register(lambda: shutil.rmtree(work, ignore_errors=True))
|
|
1789
|
+
STATE.temp_dirs.append(str(work)) # removed at exit: a stop does not call its files kept (#510)
|
|
1790
|
+
WORK_DIR["path"] = str(work)
|
|
1791
|
+
DELIVERY["path"] = output
|
|
1792
|
+
# #510: the captions stage's generated SRT is delivered beside the output, so its guards are
|
|
1793
|
+
# the output's, once the output's folder exists (created above, as for the output) and
|
|
1794
|
+
# before the first stage: an existing one, an input, the output itself
|
|
1795
|
+
caption_srt = None
|
|
1796
|
+
cap_cfg = proj.get("captions")
|
|
1797
|
+
if (isinstance(cap_cfg, dict) and cap_cfg.get("text") and _reaches("captions", args.stop_after)
|
|
1798
|
+
and (not args.stop_after or args.stop_after == "export")):
|
|
1799
|
+
caption_srt = os.path.splitext(output)[0] + ".srt"
|
|
1800
|
+
guard_sidecar(caption_srt, *([os.path.abspath(args.project)] if args.project else []), *project_input_files(proj, rel),
|
|
1801
|
+
siblings=(output,))
|
|
1706
1802
|
frame = dict(proj.get("frame") or {})
|
|
1707
1803
|
trans = proj.get("transition") or {}
|
|
1708
1804
|
frame_from_preset(frame, proj.get("export") or {})
|
|
@@ -1720,6 +1816,7 @@ def main() -> int:
|
|
|
1720
1816
|
and args.stop_after in (None, "export") and args.intermediate_preset != "medium" and not STATE.fast):
|
|
1721
1817
|
INTERMEDIATE["preset"] = args.intermediate_preset
|
|
1722
1818
|
stages_done: List[str] = []
|
|
1819
|
+
captioned_srt: Optional[Dict[str, Any]] = None
|
|
1723
1820
|
# what the caption stage reported (wrapped/split counts and the fit-size keys), so a caller
|
|
1724
1821
|
# reading render.py's JSON can see whether the type was shrunk to fit or a cue was split.
|
|
1725
1822
|
caption_report: Optional[Dict[str, Any]] = None
|
|
@@ -1948,6 +2045,9 @@ def main() -> int:
|
|
|
1948
2045
|
if cap.get(k):
|
|
1949
2046
|
argv.append(flag)
|
|
1950
2047
|
sh("caption.py", *(argv + brand_args + platform_args), stage="captions")
|
|
2048
|
+
# the SRT caption.py generated from the cue file, beside its intermediate (#510); a cache hit
|
|
2049
|
+
# places the video only, so there is none then
|
|
2050
|
+
captioned_srt = {"srt": os.path.splitext(nxt)[0] + ".srt", "video": nxt, "cached": bool(_LAST_DOC.get("cached"))}
|
|
1951
2051
|
if isinstance(_LAST_DOC.get("caption"), dict):
|
|
1952
2052
|
caption_report = dict(_LAST_DOC["caption"])
|
|
1953
2053
|
current = nxt
|
|
@@ -2093,13 +2193,14 @@ def main() -> int:
|
|
|
2093
2193
|
current = output
|
|
2094
2194
|
if not STATE.dry_run:
|
|
2095
2195
|
mark_delivered(output) # the chapters and check stages run on it: a stop from here keeps it, and says so (#415)
|
|
2196
|
+
caption_srt = deliver_caption_srt(caption_srt, captioned_srt, output)
|
|
2096
2197
|
if args.stop_after == "export":
|
|
2097
2198
|
# A87: the chapters and check stages ran after "--stop-after export", and a "check" this
|
|
2098
2199
|
# run never reads (validate_project() skips it) was then read with .get(): a traceback
|
|
2099
2200
|
if not args.keep and not args.work:
|
|
2100
2201
|
shutil.rmtree(work, ignore_errors=True)
|
|
2101
2202
|
emit(output, stages=stages_done, check=None, cache=cache_report(args.cache),
|
|
2102
|
-
intermediate_preset=_intermediate_used())
|
|
2203
|
+
intermediate_preset=_intermediate_used(), caption_srt=caption_srt)
|
|
2103
2204
|
return 0
|
|
2104
2205
|
|
|
2105
2206
|
# ---- chapters (metadata.py on the delivered file: streams copied, markers written)
|
|
@@ -2191,7 +2292,7 @@ def main() -> int:
|
|
|
2191
2292
|
snap_report = dict(snap_reports[0])
|
|
2192
2293
|
snap_report["clips"] = snap_reports
|
|
2193
2294
|
emit(output, stages=stages_done, check=check_result, snap=snap_report, cache=cache_report(args.cache),
|
|
2194
|
-
caption=caption_report, intermediate_preset=_intermediate_used(),
|
|
2295
|
+
caption=caption_report, intermediate_preset=_intermediate_used(), caption_srt=caption_srt,
|
|
2195
2296
|
verification=[{"step": "check", "ok": True, "platform": ck.get("platform")}] if check_result else [])
|
|
2196
2297
|
return 0
|
|
2197
2298
|
|
package/scripts/waveform.py
CHANGED
|
@@ -38,10 +38,11 @@ import sys
|
|
|
38
38
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
|
|
39
39
|
from _common.probe import SILENT_PEAK_DBFS, refuse_not_a_still, square_pixels, square_pixels_note
|
|
40
40
|
from _common import still_input_args # noqa: E402
|
|
41
|
-
from _common import (add_common, apply_common, aac_args, default_output, die, draws_chroma, emit, ffmpeg_base,
|
|
41
|
+
from _common import (add_common, apply_common, aac_args, color_hex, default_output, die, draws_chroma, emit, ffmpeg_base,
|
|
42
42
|
info, load_brand, pad_filters, probe, rgb_chain_to_bt709, run, STATE, validate_color, video_args, X264_PRESETS,
|
|
43
43
|
fmt_secs, child_args, run_tool, measured_level_dbfs, sibling_temp)
|
|
44
44
|
from _common.emit import absorb_stage_hw, note, warn
|
|
45
|
+
from _common.runner import refuse_missing_components_first
|
|
45
46
|
from _common.decision import length_output_t
|
|
46
47
|
from _common.probe import AUDIO_MEDIA_EXT
|
|
47
48
|
|
|
@@ -110,10 +111,12 @@ def main() -> int:
|
|
|
110
111
|
brand = load_brand(args.brand) if args.brand else None
|
|
111
112
|
if brand:
|
|
112
113
|
bc = brand.get("colors") or {}
|
|
114
|
+
# read as caption.py, graphics.py and overlay.py read a brand colour (#535): a brand file
|
|
115
|
+
# writes six hex digits with or without # or 0x
|
|
113
116
|
if args.color == "lime" and bc.get("primary"):
|
|
114
|
-
args.color = bc["primary"]
|
|
117
|
+
args.color = color_hex(bc["primary"])
|
|
115
118
|
if args.background == "black" and bc.get("background"):
|
|
116
|
-
args.background = bc["background"]
|
|
119
|
+
args.background = color_hex(bc["background"])
|
|
117
120
|
if args.image:
|
|
118
121
|
if "://" in args.image:
|
|
119
122
|
die("--image must be a readable local file; this skill has no network access, so save "
|
|
@@ -259,6 +262,10 @@ def main() -> int:
|
|
|
259
262
|
drawn_track = audio_streams[args.audio_stream] if audio_streams else meta["audio"]
|
|
260
263
|
cmd += ["-t", length_output_t(float(meta["duration"]), drawn_track.get("sample_rate"))]
|
|
261
264
|
cmd += ["-shortest", render]
|
|
265
|
+
if args.title or args.srt or args.text:
|
|
266
|
+
# #510: the title and captions are later stages; one that needs a filter this build lacks
|
|
267
|
+
# (drawtext, subtitles) is refused before the visualisation is encoded, as the dry run is
|
|
268
|
+
refuse_missing_components_first(os.path.abspath(__file__), "stage's filters and encoders")
|
|
262
269
|
titled_tmp = sibling_temp(output, "titled")
|
|
263
270
|
intermediates = [p for p in (render, titled_tmp) if p != output]
|
|
264
271
|
try:
|