ffmpeg-skill 3.8.20 → 3.8.22

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/freeze.py CHANGED
@@ -18,8 +18,9 @@ Examples:
18
18
  """
19
19
  import argparse
20
20
  import sys
21
+ from fractions import Fraction
21
22
 
22
- from _common.decision import av_trim, delivered_rate, frame_durations, frame_rate, grid_at_or_after, silence_filter, stream_starts, tpad_length
23
+ from _common.decision import av_trim, cfr_at_input, cfr_filter, delivered_rate, frame_durations, frame_rate, grid_at_or_after, silence_filter, stream_starts, tpad_length
23
24
  from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, time_arg, fmt_secs, run
24
25
  from _common.emit import input_length
25
26
 
@@ -63,6 +64,15 @@ def main() -> int:
63
64
  # starts 0.1 s late 0.1 s early (147 frames for 150).
64
65
  v0 = stream_starts(meta)[0]
65
66
  rate = frame_rate(meta) or fps
67
+ if cfr_filter(meta):
68
+ # A VFR source is put on the delivered grid as it is decoded (the graph's cfr_at_input
69
+ # below), and every frame time is on that grid (#523): on the source's own frames the
70
+ # head ended at its last frame's nominal duration, so with --at inside a gap the hold, and
71
+ # everything after it, started the rest of the gap early against the sound, which is cut
72
+ # at --at (56 ms at --at 0.15, 93 ms at --at 2.2 on a 27 fps average clip with gaps from
73
+ # 0.1 and 2.1 s). A hold is then counted in frames of that rate, the rate its frames are
74
+ # cloned at.
75
+ rate = delivered_rate(meta) or rate
66
76
  cut_at = grid_at_or_after(at, v0, rate)
67
77
  # no frame at or after --at (no --at holds at the clip's end): the insert tail would be empty
68
78
  # and the hold lost, so hold the last frame as --mode extend does (#416 review)
@@ -98,8 +108,11 @@ def main() -> int:
98
108
  # the video actually holds for, a permanent A/V drift from this point on. Pad audio by
99
109
  # the same, frame-rounded duration the video actually gets.
100
110
  actual_hold = n / rate
101
- head_v, head_a = av_trim(v0, cut_at, rate)
102
- tail_v, tail_a = av_trim(cut_at, None, rate)
111
+ # a conformed VFR picture starts at 0 on the graph's clock (cfr_at_input's origin), so its
112
+ # trims are counted from its own start; the sound's stay on the input's clock
113
+ vo = v0 if cfr_filter(meta) else 0.0
114
+ head_v, head_a = av_trim(v0 - vo, cut_at - vo, rate)[0], av_trim(v0, cut_at, rate)[1]
115
+ tail_v, tail_a = av_trim(cut_at - vo, None, rate)[0], av_trim(cut_at, None, rate)[1]
103
116
  # The hold is its own segment: the frame at cut_at, cut on the input's clock and cloned to
104
117
  # n frames there (tpad's stop clone), then shifted like the tail. From FFmpeg 7.0 setpts
105
118
  # leaves its output's frame rate unknown and its frames' durations 0, so the earlier tpad
@@ -114,11 +127,27 @@ def main() -> int:
114
127
  half = 0.5 / rate
115
128
  # the graph closes on the rate the encoder line delivers (a VFR source is conformed to its
116
129
  # average): closed on r_frame_rate, -r then resampled it and the picture ran late (#512)
117
- hold_v = (f"trim=start={cut_at - half:.6f},trim=end_frame=1,"
130
+ hold_v = (f"trim=start={cut_at - vo - half:.6f},trim=end_frame=1,"
118
131
  f"tpad=stop_mode=clone:stop={n - 1},setpts=PTS-STARTPTS")
132
+ # Every segment is a whole count of frames on one grid, so the joined picture's timestamps
133
+ # are rebuilt from its running frame count before the closing fps: FFmpeg 6.1.1's video
134
+ # concat gives a one-frame segment no length (it ends a segment at its last frame's
135
+ # timestamp), so a hold of one frame (--hold 0.04 at 30 fps) was dropped and the rest of
136
+ # the picture ran a frame before its sound, as speedramp.py's one-frame segments did
137
+ grid = frame_durations(meta, delivered_rate(meta) or rate)
138
+ q = Fraction(grid[len("fps="):]) if "/" in grid else Fraction(delivered_rate(meta) or rate).limit_denominator(1001)
119
139
  vf = (f"[0:v]split=3[a][b][c];[a]{head_v}[head];"
120
140
  f"[b]{hold_v}[hold];[c]{tail_v}[tail];"
121
- f"[head][hold][tail]concat=n=3:v=1:a=0,{frame_durations(meta, delivered_rate(meta) or rate)}[outv]")
141
+ f"[head][hold][tail]concat=n=3:v=1:a=0,setpts=N*{q.denominator}/({q.numerator}*TB),{grid}[outv]")
142
+ # the grid is laid from the picture's start (the picture moved to 0 ahead of the fps
143
+ # filter, and the trims above counted from there), on a microsecond time base: laid from
144
+ # the clock's 0, a picture that starts 50 ms in came out a frame early after the hold, and
145
+ # on the fps filter's base of one frame the trim's half-frame bound 3.907407 (--at 3.9)
146
+ # became frame 105 itself, which was cut from the head and held in place of frame 106.
147
+ # It is not moved back after the conform: from FFmpeg 7.0 a setpts leaves its frames no
148
+ # duration and no rate, and the hold's tpad after it cloned nothing (109 frames for 135
149
+ # on 7.1)
150
+ vf = cfr_at_input(vf, meta, origin=v0)
122
151
  cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", vf, "-map", "[outv]"]
123
152
  if has_audio:
124
153
  af = (f"[0:a]asplit[aa][ab];[aa]{head_a}[ahead];"
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
 
@@ -15,7 +15,7 @@ import argparse
15
15
  import math
16
16
  import sys
17
17
 
18
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs
18
+ from _common import add_common, apply_common, aac_args, cfr_args, cfr_filter, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs
19
19
  from _common.emit import warn
20
20
  from _common.decision import (HELD_FRAMES_WARN_BYTES, av_clip_length, av_trim, delivered_rate, frame_durations, frame_rate,
21
21
  held_frames_bytes, stream_starts)
@@ -49,6 +49,17 @@ def main() -> int:
49
49
  f"({vdur:.0f}s of {v['width']}x{v['height']}) -- cut the part to reverse first (cut.py) if RAM is tight",
50
50
  "memory_heavy", duration=meta.get("duration"), bytes=held)
51
51
  cmd = ffmpeg_base() + ["-i", args.input]
52
+ # A VFR source is put on the delivered grid before the reversal (#523). `reverse` hands its
53
+ # frames the forward timestamps in order, so a reversed VFR picture kept its gaps where they
54
+ # were in forward time: every flash of the 27 fps average clip came 0.19 s late against the
55
+ # reversed sound (0.23 s without sound). On a constant grid the reversed picture is the forward
56
+ # one mirrored. Each slot takes the last frame that starts before the slot ends (round=down),
57
+ # which is the frame the reversed slot opens on: the nearest frame to its start (the forward
58
+ # conform's choice) dropped a last frame that starts after the last slot's middle, and the
59
+ # reversal then opened on the frame before it. The conformed picture's time base is one frame,
60
+ # and a trim rescales its bound to it (a half-frame bound can round onto a frame and cut it:
61
+ # freeze.py), so a microsecond base follows.
62
+ reversed_conform = f"{cfr_filter(meta)}:round=down,settb=AVTB" if cfr_filter(meta) else ""
52
63
  if has_audio:
53
64
  # Both streams are reversed about one end (#409); each reversed about its own end put a sound
54
65
  # 0.6 s longer than its picture 0.396 s off. As in a join, both start at the first frame (the
@@ -73,13 +84,14 @@ def main() -> int:
73
84
  vlen = meta["video"].get("duration") or length
74
85
  end = math.ceil(length * rate - 1e-6) / rate
75
86
  vtrim, atrim = av_trim(stream_starts(meta)[0], None)
87
+ conform = f"{reversed_conform}," if reversed_conform else ""
76
88
  cmd += ["-filter_complex",
77
89
  f"[0:v:0]tpad=stop_mode=clone:stop_duration={max(0.0, end - vlen) + 2 / rate:.6f},{vtrim},"
78
- f"trim=end={end - 0.5 / max(rate, frame_rate(meta) or rate):.6f},reverse,{frame_durations(meta, rate)}[v];"
90
+ f"{conform}trim=end={end - 0.5 / max(rate, frame_rate(meta) or rate):.6f},reverse,{frame_durations(meta, rate)}[v];"
79
91
  f"[0:a:0]{atrim},apad=whole_dur={end:.6f},atrim=end={end:.6f},areverse,asetpts=N/SR/TB[a]",
80
92
  "-map", "[v]", "-map", "[a]"]
81
93
  else:
82
- cmd += ["-vf", "reverse"]
94
+ cmd += ["-vf", f"{reversed_conform},reverse" if reversed_conform else "reverse"]
83
95
  cmd += video_args(meta, args.crf, args.preset)
84
96
  cmd += cfr_args(meta)
85
97
  if has_audio:
@@ -18,9 +18,9 @@ Examples:
18
18
  import argparse
19
19
  import sys
20
20
  from fractions import Fraction
21
- from typing import List, Tuple
21
+ from typing import Any, List, Tuple
22
22
 
23
- from _common.decision import av_trim, conform_filter, delivered_rate, frame_durations, frame_rate, grid_at_or_after, stream_starts
23
+ from _common.decision import atempo_chain_with_lead, av_trim, conform_filter, delivered_rate, frame_durations, frame_rate, grid_at_or_after, stream_starts
24
24
  from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs, time_arg
25
25
  from _common.emit import input_length
26
26
 
@@ -28,18 +28,20 @@ MAX_SPEED = 20.0
28
28
  MIN_SPEED = 0.05
29
29
 
30
30
 
31
- def atempo_chain(factor: float) -> str:
32
- """atempo accepts 0.5..100 per instance; chain for factors outside that range."""
31
+ def atempo_chain(factor: float, sample_rate: Any = 0) -> str:
32
+ """atempo accepts 0.5..100 per instance; chain for factors outside that range. The chain is
33
+ led by the samples atempo leaves out (atempo_chain_with_lead, at the track's `sample_rate`):
34
+ without them every segment's sound came out 21 ms early at 1x and 40 ms at 0.5x (#523)."""
33
35
  parts: List[str] = []
34
36
  remaining = factor
35
37
  while remaining < 0.5:
36
- parts.append("atempo=0.5")
38
+ parts.append("0.5")
37
39
  remaining /= 0.5
38
40
  while remaining > 100.0:
39
- parts.append("atempo=100.0")
41
+ parts.append("100.0")
40
42
  remaining /= 100.0
41
- parts.append(f"atempo={remaining:.6f}")
42
- return ",".join(parts)
43
+ parts.append(f"{remaining:.6f}")
44
+ return atempo_chain_with_lead(parts, sample_rate)
43
45
 
44
46
 
45
47
  def parse_segment(raw: str) -> Tuple[float, float, float]:
@@ -118,7 +120,8 @@ def main() -> int:
118
120
  # leaves at the unscaled 1/rate, so every 2x segment pushed the rest of the picture two
119
121
  # frames late against the sound (and added those frames)
120
122
  vchain = f"[0:v]{vtrim},setpts=PTS/{factor:.6f},{frame_durations(meta, out_rate)}"
121
- achain = f"[0:a]{atrim},{atempo_chain(factor)}"
123
+ # a 1x segment is not retimed: atempo would place its sound by windows for nothing (#541)
124
+ achain = f"[0:a]{atrim}" if factor == 1.0 else f"[0:a]{atrim},{atempo_chain(factor, srate)}"
122
125
  if out_rate:
123
126
  # Each segment holds the frames (and samples) between its retimed ends on the whole
124
127
  # output's grid, not its own length rounded: eight 0.5 s segments at 27 fps rounded
@@ -42,6 +42,7 @@ from _common import (add_common, apply_common, aac_args, color_hex, default_outp
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
 
@@ -261,6 +262,10 @@ def main() -> int:
261
262
  drawn_track = audio_streams[args.audio_stream] if audio_streams else meta["audio"]
262
263
  cmd += ["-t", length_output_t(float(meta["duration"]), drawn_track.get("sample_rate"))]
263
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")
264
269
  titled_tmp = sibling_temp(output, "titled")
265
270
  intermediates = [p for p in (render, titled_tmp) if p != output]
266
271
  try: