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/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/decision.py +86 -2
- package/scripts/_common/runner.py +105 -7
- package/scripts/_contract.py +12 -5
- package/scripts/audio.py +4 -2
- package/scripts/batch.py +259 -24
- package/scripts/cut.py +44 -4
- package/scripts/fit.py +12 -9
- package/scripts/freeze.py +34 -5
- package/scripts/render.py +107 -6
- package/scripts/reverse.py +15 -3
- package/scripts/speedramp.py +12 -9
- package/scripts/waveform.py +5 -0
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
|
-
|
|
102
|
-
|
|
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,{
|
|
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",
|
|
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/reverse.py
CHANGED
|
@@ -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:
|
package/scripts/speedramp.py
CHANGED
|
@@ -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("
|
|
38
|
+
parts.append("0.5")
|
|
37
39
|
remaining /= 0.5
|
|
38
40
|
while remaining > 100.0:
|
|
39
|
-
parts.append("
|
|
41
|
+
parts.append("100.0")
|
|
40
42
|
remaining /= 100.0
|
|
41
|
-
parts.append(f"
|
|
42
|
-
return
|
|
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
|
-
|
|
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
|
package/scripts/waveform.py
CHANGED
|
@@ -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:
|