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/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", kept_deliverables()[-1])
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 + child_args() + ["--json"])
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
 
@@ -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: