ffmpeg-skill 3.2.2 → 3.3.0
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 +24 -10
- package/SKILL.md +5 -0
- package/bin/install.js +11 -1
- package/docs/contract.md +33 -7
- package/package.json +2 -1
- package/references/gotchas.md +22 -0
- package/references/scripts.md +109 -6
- package/scripts/_common/__init__.py +4 -4
- package/scripts/_common/alpha.py +465 -0
- package/scripts/_common/color.py +18 -3
- package/scripts/_common/decision.py +19 -7
- package/scripts/_common/emit.py +71 -0
- package/scripts/_common/probe.py +196 -9
- package/scripts/_common/runner.py +11 -5
- package/scripts/_contract.py +65 -14
- package/scripts/_smartcut.py +430 -0
- package/scripts/background.py +33 -6
- package/scripts/caption.py +6 -1
- package/scripts/check.py +64 -1
- package/scripts/color.py +147 -30
- package/scripts/cut.py +94 -6
- package/scripts/export.py +12 -81
- package/scripts/graphics.py +51 -10
- package/scripts/overlay.py +49 -6
- package/scripts/probe.py +47 -1
- package/scripts/stabilize.py +14 -3
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""cut.py --smart: a keyframe-aware cut that re-encodes only the partial GOPs at each cut point.
|
|
2
|
+
|
|
3
|
+
A segment [s, e) on a CFR, progressive H.264 source is split at its keyframes:
|
|
4
|
+
|
|
5
|
+
head [first frame at/after s, K1) re-encoded (K1 = first keyframe at or after s)
|
|
6
|
+
copy [K1, K2) stream-copied, whole closed GOPs
|
|
7
|
+
tail [K2, first frame at/after e) re-encoded (K2 = last keyframe at or before e)
|
|
8
|
+
|
|
9
|
+
A part that is empty (s on a keyframe, e on a keyframe or the end of the video) is left out; a
|
|
10
|
+
segment with no keyframe inside it is one re-encoded part. The re-encoded parts are encoded with
|
|
11
|
+
libx264 matched to the source stream (profile, level, pixel format, reorder depth, frame size,
|
|
12
|
+
SAR, colour tags) and every part is written as MPEG-TS, where each keyframe carries its own
|
|
13
|
+
parameter sets in-band, so the decoder switches between the encoder's SPS/PPS and the source's at
|
|
14
|
+
every join. The parts are joined with the concat demuxer (each part's exact length stated), and
|
|
15
|
+
the sound is cut from the source on the same frame grid and encoded once, in the same command.
|
|
16
|
+
|
|
17
|
+
The result is verified before it is moved into place, and any failure falls back to the full
|
|
18
|
+
re-encode with the reason stated:
|
|
19
|
+
|
|
20
|
+
frames the output's frame count and presentation times: one frame per frame duration from
|
|
21
|
+
0, none missing, none repeated
|
|
22
|
+
packets every copied part's packets against the source's (MD5 of each packet with the
|
|
23
|
+
parameter-set and delimiter NAL units removed, which the in-band switch adds)
|
|
24
|
+
decode framemd5 of each re-encoded part as written against the same frames decoded from
|
|
25
|
+
the output, and of the first DECODE_CHECK_MAX_FRAMES frames of each copied
|
|
26
|
+
part against the source's
|
|
27
|
+
params the re-encoded parts' stream parameters against the source's, and the output's
|
|
28
|
+
time base against the source's
|
|
29
|
+
|
|
30
|
+
Nothing here decides where to cut; it only executes the cut it is given.
|
|
31
|
+
"""
|
|
32
|
+
import os
|
|
33
|
+
from fractions import Fraction
|
|
34
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
35
|
+
|
|
36
|
+
from _common import STATE, aac_args, concat_list_line, ffmpeg_base, require_tool, run
|
|
37
|
+
from _common.runner import fail_if_classified
|
|
38
|
+
|
|
39
|
+
# the stream parameters a re-encoded part must share with the source (ffprobe -show_streams)
|
|
40
|
+
MATCH_FIELDS = ("codec_name", "profile", "level", "pix_fmt", "width", "height", "sample_aspect_ratio",
|
|
41
|
+
"has_b_frames", "color_space", "color_range", "color_transfer", "color_primaries")
|
|
42
|
+
# what libx264 here can encode to match: 8-bit 4:2:0 (full range as yuvj420p)
|
|
43
|
+
SMART_PIX_FMTS = ("yuv420p", "yuvj420p")
|
|
44
|
+
SMART_PROFILES = {"Constrained Baseline": "baseline", "Baseline": "baseline", "Main": "main", "High": "high"}
|
|
45
|
+
SMART_EXTS = (".mp4", ".mov", ".m4v", ".mkv")
|
|
46
|
+
# the first frames of a copied part are decoded and compared against the source's; capped so a long part
|
|
47
|
+
# does not make the check cost a long decode
|
|
48
|
+
DECODE_CHECK_MAX_FRAMES = 300
|
|
49
|
+
# an input -ss lands this far after the keyframe it is asked for (ffprobe prints microseconds)
|
|
50
|
+
_SEEK_PAST = 1e-4
|
|
51
|
+
_DTS_MARGIN = 1e-4
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SmartFallback(Exception):
|
|
55
|
+
"""The smart cut cannot be made or did not verify: `reason` is the stated cause."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, reason: str, code: str):
|
|
58
|
+
super().__init__(reason)
|
|
59
|
+
self.reason, self.code = reason, code
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def stream_params(path: str) -> Optional[Dict[str, Any]]:
|
|
63
|
+
"""The first video stream's parameters (MATCH_FIELDS plus field_order, time_base, r_frame_rate)."""
|
|
64
|
+
import json
|
|
65
|
+
fields = ",".join(MATCH_FIELDS + ("field_order", "time_base", "r_frame_rate"))
|
|
66
|
+
proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "v:0", "-show_entries",
|
|
67
|
+
f"stream={fields}", "-of", "json", path], quiet=True, check=False)
|
|
68
|
+
try:
|
|
69
|
+
streams = json.loads(proc.stdout or "{}").get("streams") or []
|
|
70
|
+
except ValueError:
|
|
71
|
+
return None
|
|
72
|
+
return streams[0] if proc.returncode == 0 and streams else None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _norm(field: str, value: Any) -> Any:
|
|
76
|
+
if field == "profile":
|
|
77
|
+
return SMART_PROFILES.get(str(value), value)
|
|
78
|
+
if field == "sample_aspect_ratio" and value in (None, "0:1", "N/A"):
|
|
79
|
+
return "1:1"
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def mismatches(want: Dict[str, Any], got: Dict[str, Any]) -> List[str]:
|
|
84
|
+
"""`field source/part` for every MATCH_FIELDS value the part does not share."""
|
|
85
|
+
return [f"{f} {want.get(f)}/{got.get(f)}" for f in MATCH_FIELDS if _norm(f, want.get(f)) != _norm(f, got.get(f))]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def ineligible(params: Optional[Dict[str, Any]], meta: Dict[str, Any], ext: str) -> Optional[Tuple[str, str]]:
|
|
89
|
+
"""(reason, code) when the source or the output cannot take a smart cut, else None."""
|
|
90
|
+
if params is None:
|
|
91
|
+
return "the source's video stream parameters could not be read", "params_unreadable"
|
|
92
|
+
if ext.lower() not in SMART_EXTS:
|
|
93
|
+
return f"--smart writes {', '.join(SMART_EXTS)}; {ext} is not one", "container"
|
|
94
|
+
if params.get("codec_name") != "h264":
|
|
95
|
+
return (f"--smart matches the source with libx264, so it takes H.264; this source is "
|
|
96
|
+
f"{params.get('codec_name')}", "codec")
|
|
97
|
+
if params.get("pix_fmt") not in SMART_PIX_FMTS:
|
|
98
|
+
return f"--smart encodes 8-bit 4:2:0; this source is {params.get('pix_fmt')}", "pix_fmt"
|
|
99
|
+
if str(params.get("profile")) not in SMART_PROFILES:
|
|
100
|
+
return f"H.264 profile {params.get('profile')} cannot be matched by libx264 here", "profile"
|
|
101
|
+
if params.get("field_order") not in (None, "progressive", "unknown"):
|
|
102
|
+
return f"the source is interlaced ({params.get('field_order')})", "interlaced"
|
|
103
|
+
if int(params.get("has_b_frames") or 0) > 2:
|
|
104
|
+
return f"a reorder depth of {params.get('has_b_frames')} frames cannot be matched", "reorder_depth"
|
|
105
|
+
if not _rate(params):
|
|
106
|
+
return "the source states no frame rate", "frame_rate"
|
|
107
|
+
if (meta.get("video") or {}).get("bt2020_or_hdr"):
|
|
108
|
+
return "an HDR or BT.2020 source is encoded on the 10-bit HEVC line, not matched", "hdr"
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _rate(params: Dict[str, Any]) -> Optional[Fraction]:
|
|
113
|
+
try:
|
|
114
|
+
q = Fraction(str(params.get("r_frame_rate") or ""))
|
|
115
|
+
except (ValueError, ZeroDivisionError):
|
|
116
|
+
return None
|
|
117
|
+
return q if q > 0 else None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def x264_line(params: Dict[str, Any], crf: int, preset: str) -> List[str]:
|
|
121
|
+
"""libx264 arguments that reproduce the source stream's parameters (MATCH_FIELDS)."""
|
|
122
|
+
level = int(params.get("level") or 0)
|
|
123
|
+
depth = int(params.get("has_b_frames") or 0)
|
|
124
|
+
# the reorder depth decides how far each part's decode times run ahead of its presentation
|
|
125
|
+
# times; parts of different depth would collide in decode order at the join
|
|
126
|
+
b = "bframes=0" if depth == 0 else ("bframes=3:b-pyramid=none" if depth == 1 else "bframes=3:b-pyramid=normal")
|
|
127
|
+
args = ["-c:v", "libx264", "-preset", preset, "-crf", str(crf),
|
|
128
|
+
"-profile:v", SMART_PROFILES[str(params["profile"])], "-pix_fmt", params["pix_fmt"],
|
|
129
|
+
"-x264-params", f"{b}:open-gop=0"]
|
|
130
|
+
if level:
|
|
131
|
+
args += ["-level:v", f"{level // 10}.{level % 10}"]
|
|
132
|
+
for flag, field in (("-colorspace", "color_space"), ("-color_primaries", "color_primaries"),
|
|
133
|
+
("-color_trc", "color_transfer"), ("-color_range", "color_range")):
|
|
134
|
+
value = params.get(field)
|
|
135
|
+
if value and value != "unknown":
|
|
136
|
+
args += [flag, str(value)]
|
|
137
|
+
return args
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def frames_between(frames: List[float], lo: float, hi: Optional[float]) -> List[float]:
|
|
141
|
+
return [p for p in frames if p >= lo - 1e-6 and (hi is None or p < hi - 1e-6)]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def plan_segment(packets: List[Tuple[Any, Any, bool]], s: float, e: float, vend: Optional[float],
|
|
145
|
+
gop_is_open) -> List[Dict[str, Any]]:
|
|
146
|
+
"""The parts of one segment, in order: {kind: reencode|copy, start, end (None: to the end of
|
|
147
|
+
the video), frames}. Raises SmartFallback when a keyframe the copy would start or stop at
|
|
148
|
+
opens its GOP or the packets around a cut were not read."""
|
|
149
|
+
frames = sorted(p[0] for p in packets if p[0] is not None)
|
|
150
|
+
keyidx = [i for i, p in enumerate(packets) if p[2] and p[0] is not None and p[1] is not None]
|
|
151
|
+
seg = frames_between(frames, s, e)
|
|
152
|
+
if not seg:
|
|
153
|
+
raise SmartFallback(f"no frame of the source lies in {s:.3f}-{e:.3f}s", "no_frames")
|
|
154
|
+
first, last = seg[0], seg[-1]
|
|
155
|
+
to_end = vend is not None and e >= vend - 1e-6
|
|
156
|
+
end_bound = None if to_end else next((p for p in frames if p >= e - 1e-6), None)
|
|
157
|
+
if end_bound is None and not to_end:
|
|
158
|
+
raise SmartFallback(f"the frames after {e:.3f}s were not read", "packets")
|
|
159
|
+
k1 = next((i for i in keyidx if packets[i][0] >= first - 1e-6), None)
|
|
160
|
+
if k1 is None or packets[k1][0] > last + 1e-6:
|
|
161
|
+
return [{"kind": "reencode", "start": first, "end": end_bound, "frames": len(seg)}]
|
|
162
|
+
if gop_is_open(packets, k1):
|
|
163
|
+
raise SmartFallback(f"the keyframe at {packets[k1][0]:.3f}s opens its GOP (frames decoded after it are shown "
|
|
164
|
+
"before it), so a copy cannot start there", "open_gop")
|
|
165
|
+
k1_pts = packets[k1][0]
|
|
166
|
+
# where the copy stops: the last keyframe presented at or before the end bound, decoded after K1
|
|
167
|
+
stops = [i for i in keyidx if packets[i][1] > packets[k1][1] + 1e-9
|
|
168
|
+
and (end_bound is None or packets[i][0] <= end_bound + 1e-6)]
|
|
169
|
+
k2 = stops[-1] if stops else None
|
|
170
|
+
if k2 is not None and gop_is_open(packets, k2):
|
|
171
|
+
raise SmartFallback(f"the keyframe at {packets[k2][0]:.3f}s opens its GOP, so the copy cannot stop there",
|
|
172
|
+
"open_gop")
|
|
173
|
+
copy_end = packets[k2][0] if k2 is not None else None
|
|
174
|
+
if copy_end is None and not to_end:
|
|
175
|
+
# no keyframe after K1 inside the segment: the copy would run past the end in its last GOP
|
|
176
|
+
return [{"kind": "reencode", "start": first, "end": end_bound, "frames": len(seg)}]
|
|
177
|
+
parts: List[Dict[str, Any]] = []
|
|
178
|
+
if first < k1_pts - 1e-6:
|
|
179
|
+
parts.append({"kind": "reencode", "start": first, "end": k1_pts,
|
|
180
|
+
"frames": len(frames_between(frames, first, k1_pts))})
|
|
181
|
+
parts.append({"kind": "copy", "start": k1_pts, "end": copy_end, "key_dts": packets[k1][1],
|
|
182
|
+
"end_dts": packets[k2][1] if k2 is not None else None})
|
|
183
|
+
if copy_end is not None and (end_bound is None or copy_end < end_bound - 1e-6):
|
|
184
|
+
tail = frames_between(frames, copy_end, end_bound)
|
|
185
|
+
if tail:
|
|
186
|
+
parts.append({"kind": "reencode", "start": copy_end, "end": end_bound, "frames": len(tail)})
|
|
187
|
+
return parts
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _framehashes(cmd: List[str], code: str) -> List[Tuple[int, str]]:
|
|
191
|
+
"""(size, md5) per line of an ffmpeg -f framemd5 run (packets with -c copy, frames otherwise).
|
|
192
|
+
A run that failed is no evidence, even when the hashes it printed match: a classified failure
|
|
193
|
+
(a full disk) fails the run, any other is a fallback with `code`."""
|
|
194
|
+
proc = run(cmd, quiet=True, check=False)
|
|
195
|
+
fail_if_classified(proc)
|
|
196
|
+
if proc.returncode != 0:
|
|
197
|
+
lines = [ln.strip() for ln in (proc.stderr or "").splitlines() if ln.strip()]
|
|
198
|
+
raise SmartFallback(f"the check could not hash {cmd[cmd.index('-i') + 1]}: "
|
|
199
|
+
f"{lines[-1] if lines else f'exit code {proc.returncode}'}", code)
|
|
200
|
+
out = []
|
|
201
|
+
for line in (proc.stdout or "").splitlines():
|
|
202
|
+
if not line or line.startswith("#"):
|
|
203
|
+
continue
|
|
204
|
+
cols = [c.strip() for c in line.split(",")]
|
|
205
|
+
if len(cols) >= 6:
|
|
206
|
+
try:
|
|
207
|
+
out.append((int(cols[4]), cols[5]))
|
|
208
|
+
except ValueError:
|
|
209
|
+
continue
|
|
210
|
+
return out
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
_NORMALIZE = "h264_mp4toannexb,filter_units=remove_types=7|8|9"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def packet_hashes(path: str, seek: Optional[float] = None, t: Optional[float] = None) -> List[Tuple[int, str]]:
|
|
217
|
+
"""The first video stream's packets as (size, md5) with SPS, PPS and delimiters removed."""
|
|
218
|
+
ff = require_tool("ffmpeg")
|
|
219
|
+
cmd = [ff, "-hide_banner", "-loglevel", "error", "-nostdin"]
|
|
220
|
+
if seek is not None:
|
|
221
|
+
cmd += ["-ss", f"{seek:.6f}"]
|
|
222
|
+
cmd += ["-i", path]
|
|
223
|
+
if t is not None:
|
|
224
|
+
cmd += ["-t", f"{t:.6f}"]
|
|
225
|
+
return _framehashes(cmd + ["-map", "0:v:0", "-c", "copy", "-bsf:v", _NORMALIZE, "-f", "framemd5", "-"], "packets")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def frame_hashes(path: str, at: Optional[float], count: int) -> List[Tuple[int, str]]:
|
|
229
|
+
"""framemd5 of `count` decoded frames from `at` (seconds, an accurate seek; None from the start)."""
|
|
230
|
+
ff = require_tool("ffmpeg")
|
|
231
|
+
cmd = [ff, "-hide_banner", "-loglevel", "error", "-nostdin"]
|
|
232
|
+
if at is not None and at > 0:
|
|
233
|
+
cmd += ["-ss", f"{at:.6f}"]
|
|
234
|
+
return _framehashes(cmd + ["-i", path, "-map", "0:v:0", "-frames:v", str(count), "-flags:v", "+bitexact",
|
|
235
|
+
"-fflags", "+bitexact", "-f", "framemd5", "-"], "decode")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def output_pts(path: str) -> List[float]:
|
|
239
|
+
proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "v:0", "-show_entries", "packet=pts_time",
|
|
240
|
+
"-of", "csv=p=0", path], quiet=True, check=False)
|
|
241
|
+
out = []
|
|
242
|
+
for line in (proc.stdout or "").split():
|
|
243
|
+
try:
|
|
244
|
+
out.append(float(line.strip(",")))
|
|
245
|
+
except ValueError:
|
|
246
|
+
continue
|
|
247
|
+
return sorted(out)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _write(cmd: List[str], what: str) -> None:
|
|
251
|
+
"""Run one of the smart cut's ffmpeg writes. A failure the runner classifies (a full disk, a
|
|
252
|
+
missing input) fails the run as it would anywhere; any other is a fallback with ffmpeg's reason
|
|
253
|
+
(x264 refusing the matched profile at --quality 0, a copy the muxer rejects)."""
|
|
254
|
+
proc = run(cmd, check=False)
|
|
255
|
+
fail_if_classified(proc)
|
|
256
|
+
if proc.returncode != 0:
|
|
257
|
+
lines = [ln.strip() for ln in (proc.stderr or "").splitlines() if ln.strip()]
|
|
258
|
+
why = next((ln for ln in lines if "rror" in ln), lines[-1] if lines else f"exit code {proc.returncode}")
|
|
259
|
+
raise SmartFallback(f"ffmpeg could not write {what}: {why}", "encode")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _count(n_frames: int, rate: Fraction) -> str:
|
|
263
|
+
"""A part's exact length for the concat list's `duration`, in seconds."""
|
|
264
|
+
return f"{float(Fraction(n_frames) / rate):.9f}"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def execute(src: str, segments: List[Tuple[float, float]], dst: str, meta: Dict[str, Any], params: Dict[str, Any],
|
|
268
|
+
packets, crf: int, preset: str, tmp: str, *, vend: Optional[float], gop_is_open, landing,
|
|
269
|
+
origin, declick: float, seek_margin: float) -> Dict[str, Any]:
|
|
270
|
+
"""Write the smart cut of `segments` to `dst` and verify it (module docstring). Returns the
|
|
271
|
+
`smart` document; raises SmartFallback with the reason when it cannot be made or does not verify.
|
|
272
|
+
`landing(src, t)` is cut.seek_keyframe, `origin(path)` cut.file_origin."""
|
|
273
|
+
rate = _rate(params)
|
|
274
|
+
frame = float(1 / rate)
|
|
275
|
+
half = frame / 2
|
|
276
|
+
x264 = x264_line(params, crf, preset)
|
|
277
|
+
plans = [plan_segment(packets, s, e, vend, gop_is_open) for s, e in segments]
|
|
278
|
+
has_a = bool(meta.get("audio"))
|
|
279
|
+
for si, ((s, _), segment_parts) in enumerate(zip(segments, plans)):
|
|
280
|
+
# the sound is trimmed on the picture's grid from the segment's first frame; a picture that
|
|
281
|
+
# starts later than a frame after the segment (video that starts after its audio) would
|
|
282
|
+
# lose the sound before it, which the full re-encode keeps
|
|
283
|
+
late = segment_parts[0]["start"] - s
|
|
284
|
+
if has_a and late > frame + 1e-6:
|
|
285
|
+
raise SmartFallback(f"the picture of segment {si + 1} starts {late:.3f}s after it does, and the sound "
|
|
286
|
+
"before it is kept only by a full re-encode", "av_offset")
|
|
287
|
+
parts: List[Dict[str, Any]] = []
|
|
288
|
+
for si, segment_parts in enumerate(plans):
|
|
289
|
+
for part in segment_parts:
|
|
290
|
+
part = dict(part, segment=si, path=os.path.join(tmp, f"part{len(parts):03d}.ts"))
|
|
291
|
+
if part["kind"] == "reencode":
|
|
292
|
+
m = min(seek_margin, part["start"])
|
|
293
|
+
trim = f"trim=start={m - half:.6f}" + (f":end={m + part['frames'] * frame - half:.6f}")
|
|
294
|
+
_write(ffmpeg_base() + ["-ss", f"{part['start'] - m:.6f}", "-i", src, "-map", "0:v:0", "-an", "-sn", "-dn",
|
|
295
|
+
"-vf", f"{trim},setpts=PTS-STARTPTS", "-frames:v", str(part["frames"]),
|
|
296
|
+
"-fps_mode", "passthrough"] + x264 + ["-f", "mpegts", part["path"]],
|
|
297
|
+
f"the re-encoded part from {part['start']:.3f}s")
|
|
298
|
+
else:
|
|
299
|
+
target = part["start"] + _SEEK_PAST
|
|
300
|
+
landed = None if STATE.dry_run else landing(src, target)
|
|
301
|
+
if not STATE.dry_run and (landed is None or abs(landed[0] - part["start"]) > 1e-4):
|
|
302
|
+
raise SmartFallback(f"a stream copy from the keyframe at {part['start']:.3f}s lands at "
|
|
303
|
+
f"{'an unreadable place' if landed is None else f'{landed[0]:.3f}s'}", "seek")
|
|
304
|
+
part["seek"] = target
|
|
305
|
+
part["t"] = None if part["end_dts"] is None else part["end_dts"] - target - _DTS_MARGIN
|
|
306
|
+
_write(ffmpeg_base() + ["-ss", f"{target:.6f}", "-i", src] + ([] if part["t"] is None else ["-t", f"{part['t']:.6f}"])
|
|
307
|
+
+ ["-map", "0:v:0", "-an", "-sn", "-dn", "-c", "copy", "-f", "mpegts", part["path"]],
|
|
308
|
+
f"the copied part from {part['start']:.3f}s")
|
|
309
|
+
if not STATE.dry_run:
|
|
310
|
+
written = output_pts(part["path"])
|
|
311
|
+
if part["kind"] == "copy":
|
|
312
|
+
expected = None if part["end"] is None else round((part["end"] - part["start"]) * rate)
|
|
313
|
+
if expected is not None and len(written) != expected:
|
|
314
|
+
raise SmartFallback(f"the copy from {part['start']:.3f}s holds {len(written)} frames where the "
|
|
315
|
+
f"source has {expected}", "copy_count")
|
|
316
|
+
part["frames"] = len(written)
|
|
317
|
+
elif len(written) != part["frames"]:
|
|
318
|
+
raise SmartFallback(f"the re-encode from {part['start']:.3f}s holds {len(written)} frames, not "
|
|
319
|
+
f"{part['frames']}", "encode_count")
|
|
320
|
+
if part["kind"] == "reencode":
|
|
321
|
+
got = stream_params(part["path"]) or {}
|
|
322
|
+
diff = mismatches(params, got)
|
|
323
|
+
if diff:
|
|
324
|
+
raise SmartFallback("the re-encoded part does not match the source: " + ", ".join(diff), "params_mismatch")
|
|
325
|
+
parts.append(part)
|
|
326
|
+
listfile = os.path.join(tmp, "smart.txt")
|
|
327
|
+
with open(listfile, "w", encoding="utf-8") as fh:
|
|
328
|
+
for part in parts:
|
|
329
|
+
fh.write(concat_list_line(part["path"]) + "\n")
|
|
330
|
+
if part.get("frames"):
|
|
331
|
+
fh.write(f"duration {_count(part['frames'], rate)}\n")
|
|
332
|
+
ext = os.path.splitext(dst)[1].lower()
|
|
333
|
+
cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile]
|
|
334
|
+
graph: List[str] = []
|
|
335
|
+
if has_a:
|
|
336
|
+
# the sound of each segment from the source, on the picture's frame grid, encoded once
|
|
337
|
+
pads = ""
|
|
338
|
+
for i, (s, e) in enumerate(segments):
|
|
339
|
+
first = plans[i][0]["start"]
|
|
340
|
+
last = plans[i][-1]
|
|
341
|
+
end = last["end"] if last["end"] is not None else e
|
|
342
|
+
m = min(seek_margin, s)
|
|
343
|
+
cmd += ["-ss", f"{s - m:.6f}", "-t", f"{m + (end - s) + 1.0:.6f}", "-i", src]
|
|
344
|
+
a, b = m + (first - s), m + (end - s)
|
|
345
|
+
fade = min(declick, (b - a) / 2)
|
|
346
|
+
fades = (f",afade=t=in:st=0:d={fade:.6f}" if fade > 0 and i > 0 else "")
|
|
347
|
+
fades += (f",afade=t=out:st={b - a - fade:.6f}:d={fade:.6f}" if fade > 0 and i < len(segments) - 1 else "")
|
|
348
|
+
# padded to its picture's length: sound that ends early (a track shorter than the
|
|
349
|
+
# picture) must not pull the next segment's sound forward
|
|
350
|
+
graph.append(f"[{i + 1}:a:0]atrim=start={a:.6f}:end={b:.6f},asetpts=PTS-{a:.6f}/TB,"
|
|
351
|
+
f"aresample=async=1:first_pts=0,apad=whole_dur={b - a:.6f}{fades}[a{i}]")
|
|
352
|
+
pads += f"[a{i}]"
|
|
353
|
+
graph.append(f"{pads}concat=n={len(segments)}:v=0:a=1[a]")
|
|
354
|
+
cmd += ["-filter_complex", ";".join(graph), "-map", "0:v:0", "-map", "[a]"] + aac_args()
|
|
355
|
+
else:
|
|
356
|
+
cmd += ["-map", "0:v:0"]
|
|
357
|
+
cmd += ["-c:v", "copy", "-bsf:v", "filter_units=remove_types=9", "-sn", "-dn"]
|
|
358
|
+
tb = str(params.get("time_base") or "")
|
|
359
|
+
if ext in (".mp4", ".mov", ".m4v"):
|
|
360
|
+
if tb.startswith("1/"):
|
|
361
|
+
cmd += ["-video_track_timescale", tb[2:]]
|
|
362
|
+
cmd += ["-tag:v", "avc1", "-movflags", "+faststart"]
|
|
363
|
+
_write(cmd + [dst], "the joined parts")
|
|
364
|
+
doc: Dict[str, Any] = {
|
|
365
|
+
"applied": True,
|
|
366
|
+
"parts": [{"segment": p["segment"] + 1, "kind": p["kind"], "start": round(p["start"], 6),
|
|
367
|
+
"end": None if p["end"] is None else round(p["end"], 6), "frames": p.get("frames")} for p in parts],
|
|
368
|
+
"reencoded_frames": sum(p.get("frames") or 0 for p in parts if p["kind"] == "reencode"),
|
|
369
|
+
"copied_frames": sum(p.get("frames") or 0 for p in parts if p["kind"] == "copy"),
|
|
370
|
+
"matched": {f: params.get(f) for f in MATCH_FIELDS},
|
|
371
|
+
"encoder": x264,
|
|
372
|
+
}
|
|
373
|
+
if STATE.dry_run:
|
|
374
|
+
doc["checks"] = None
|
|
375
|
+
return doc
|
|
376
|
+
doc["checks"] = verify(src, dst, parts, params, rate, origin)
|
|
377
|
+
doc["time_base"] = doc["checks"]["time_base"]
|
|
378
|
+
return doc
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def verify(src: str, dst: str, parts: List[Dict[str, Any]], params: Dict[str, Any], rate: Fraction, origin) -> Dict[str, Any]:
|
|
382
|
+
"""The checks in the module docstring; raises SmartFallback on the first that fails."""
|
|
383
|
+
frame = float(1 / rate)
|
|
384
|
+
half = frame / 2
|
|
385
|
+
pts = output_pts(dst)
|
|
386
|
+
expected = sum(p["frames"] for p in parts)
|
|
387
|
+
if len(pts) != expected:
|
|
388
|
+
raise SmartFallback(f"the output holds {len(pts)} frames where the parts hold {expected}", "frames")
|
|
389
|
+
worst = max((abs(t - pts[0] - i * frame) for i, t in enumerate(pts)), default=0.0)
|
|
390
|
+
if worst >= half:
|
|
391
|
+
at = next(i for i, t in enumerate(pts) if abs(t - pts[0] - i * frame) >= half)
|
|
392
|
+
raise SmartFallback(f"frame {at} of the output is presented at {pts[at] - pts[0]:.6f}s, off the "
|
|
393
|
+
f"{frame:.6f}s grid (a gap or a repeated frame at a join)", "frames")
|
|
394
|
+
out_packets = packet_hashes(dst)
|
|
395
|
+
if len(out_packets) != expected:
|
|
396
|
+
raise SmartFallback(f"the output holds {len(out_packets)} video packets where the parts hold {expected}", "packets")
|
|
397
|
+
dst_origin = origin(dst)
|
|
398
|
+
offset, copied, decoded = 0, 0, 0
|
|
399
|
+
for p in parts:
|
|
400
|
+
n = p["frames"]
|
|
401
|
+
at_out = pts[offset] - dst_origin - half
|
|
402
|
+
if p["kind"] == "copy":
|
|
403
|
+
source = packet_hashes(src, p["seek"], p["t"])
|
|
404
|
+
if source != out_packets[offset:offset + n]:
|
|
405
|
+
bad = next((i for i, (x, y) in enumerate(zip(source, out_packets[offset:offset + n])) if x != y),
|
|
406
|
+
min(len(source), n))
|
|
407
|
+
raise SmartFallback(f"copied packet {bad} of the part from {p['start']:.3f}s differs from the source's",
|
|
408
|
+
"packets")
|
|
409
|
+
copied += n
|
|
410
|
+
g = min(n, DECODE_CHECK_MAX_FRAMES)
|
|
411
|
+
want = frame_hashes(src, p["start"] - half, g)
|
|
412
|
+
else:
|
|
413
|
+
g = n
|
|
414
|
+
want = frame_hashes(p["path"], None, g)
|
|
415
|
+
got = frame_hashes(dst, at_out, g)
|
|
416
|
+
# a short list on both sides (a frame that did not decode) is not a match of g frames
|
|
417
|
+
if len(want) != g or want != got:
|
|
418
|
+
raise SmartFallback(f"the frames decoded from the output's {p['kind']} part at {pts[offset] - pts[0]:.3f}s "
|
|
419
|
+
f"differ from the {'source' if p['kind'] == 'copy' else 'encoded part'}", "decode")
|
|
420
|
+
decoded += g
|
|
421
|
+
offset += n
|
|
422
|
+
out_params = stream_params(dst) or {}
|
|
423
|
+
if out_params.get("time_base") != params.get("time_base"):
|
|
424
|
+
raise SmartFallback(f"the output's time base is {out_params.get('time_base')}, the source's "
|
|
425
|
+
f"{params.get('time_base')}", "time_base")
|
|
426
|
+
diff = mismatches(params, out_params)
|
|
427
|
+
if diff:
|
|
428
|
+
raise SmartFallback("the output's stream does not match the source: " + ", ".join(diff), "params_mismatch")
|
|
429
|
+
return {"frames": expected, "grid_worst_seconds": round(worst, 6), "copied_packets_equal": copied,
|
|
430
|
+
"frames_decoded_equal": decoded, "time_base": out_params.get("time_base")}
|
package/scripts/background.py
CHANGED
|
@@ -4,17 +4,22 @@
|
|
|
4
4
|
No input file: a silent, fixed-duration, exact-size clip generated entirely
|
|
5
5
|
by ffmpeg's own source filters (`color` for solid, `gradients` for a
|
|
6
6
|
two-colour gradient) -- for a title card background, a placeholder behind a
|
|
7
|
-
logo, or a base layer for overlay.py to composite onto.
|
|
7
|
+
logo, or a base layer for overlay.py to composite onto. --transparent writes a
|
|
8
|
+
fully transparent canvas in an alpha line (ProRes 4444 by default, --alpha-codec),
|
|
9
|
+
the base layer graphics.py and overlay.py --keep-alpha draw on.
|
|
8
10
|
|
|
9
11
|
Examples:
|
|
10
12
|
python3 background.py --duration 3 --width 1920 --height 1080 --color 0x101010
|
|
11
13
|
python3 background.py --duration 5 --width 1080 --height 1920 --gradient 0xff6a00:0x0057ff --angle 45
|
|
14
|
+
python3 background.py --duration 4 --width 1920 --height 1080 --transparent -o canvas.mov
|
|
12
15
|
"""
|
|
13
16
|
import argparse
|
|
14
17
|
import math
|
|
15
18
|
import sys
|
|
16
19
|
|
|
17
|
-
from _common import
|
|
20
|
+
from _common.alpha import add_alpha_args, check_alpha_args, finish_chain, report as alpha_report, video_line
|
|
21
|
+
from _common.emit import note
|
|
22
|
+
from _common import STATE, add_common, apply_common, die, draws_chroma, emit, ffmpeg_base, info, parse_time, probe, rgb_chain_to_bt709, run, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
18
23
|
|
|
19
24
|
|
|
20
25
|
def main() -> int:
|
|
@@ -30,6 +35,7 @@ def main() -> int:
|
|
|
30
35
|
ap.add_argument("--angle", type=float, default=0.0, help="gradient angle in degrees (with --gradient, default 0 = left to right)")
|
|
31
36
|
ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
|
|
32
37
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
38
|
+
add_alpha_args(ap, transparent=True)
|
|
33
39
|
add_common(ap)
|
|
34
40
|
args = ap.parse_args()
|
|
35
41
|
apply_common(args)
|
|
@@ -43,8 +49,17 @@ def main() -> int:
|
|
|
43
49
|
die(f"--width/--height must be > 0, got width={args.width} height={args.height}")
|
|
44
50
|
if args.width % 2 or args.height % 2:
|
|
45
51
|
die(f"--width/--height must be even (4:2:0 chroma), got width={args.width} height={args.height}")
|
|
52
|
+
alpha_codec = check_alpha_args(args, args.output)
|
|
46
53
|
|
|
47
|
-
if
|
|
54
|
+
if alpha_codec:
|
|
55
|
+
# a canvas with nothing on it: alpha 0 everywhere, black colour beneath (what straight and
|
|
56
|
+
# premultiplied colour agree on), in the alpha line's own pixel format
|
|
57
|
+
if args.gradient or args.color != ap.get_default("color"):
|
|
58
|
+
die("--transparent writes a canvas with nothing on it: --color/--gradient do not apply", kind="input")
|
|
59
|
+
src_filter = (f"color=c=black@0:size={args.width}x{args.height}:rate={args.fps:g},format=rgba,"
|
|
60
|
+
+ finish_chain(alpha_codec, args.alpha_mode))
|
|
61
|
+
colours = []
|
|
62
|
+
elif args.gradient:
|
|
48
63
|
try:
|
|
49
64
|
c0, c1 = args.gradient.split(":")
|
|
50
65
|
except ValueError:
|
|
@@ -66,7 +81,7 @@ def main() -> int:
|
|
|
66
81
|
validate_color(args.color, "--color")
|
|
67
82
|
src_filter = f"color=c={args.color}:size={args.width}x{args.height}:rate={args.fps:g}"
|
|
68
83
|
colours = [args.color]
|
|
69
|
-
if draws_chroma(colours, None):
|
|
84
|
+
if colours and draws_chroma(colours, None):
|
|
70
85
|
# the colours are made in RGB and converted to the BT.709 the output is tagged with: left to
|
|
71
86
|
# the encoder line's own conversion (or `color`'s YUV), they were BT.601 under that tag
|
|
72
87
|
# (pure red decoded as (255,24,0)); a grey is the same under every matrix
|
|
@@ -74,14 +89,26 @@ def main() -> int:
|
|
|
74
89
|
|
|
75
90
|
output = args.output
|
|
76
91
|
cmd = ffmpeg_base() + ["-f", "lavfi", "-i", src_filter, "-t", f"{target:.3f}"]
|
|
77
|
-
|
|
92
|
+
if alpha_codec:
|
|
93
|
+
cmd += video_line(alpha_codec)
|
|
94
|
+
if STATE.hw:
|
|
95
|
+
note(f"--hw: --alpha-codec {alpha_codec} stays on the CPU (no VideoToolbox encoder writes it with its alpha)")
|
|
96
|
+
else:
|
|
97
|
+
cmd += video_args(None, args.crf, args.preset)
|
|
78
98
|
cmd += ["-an", output]
|
|
79
99
|
run(cmd)
|
|
80
100
|
|
|
81
101
|
result = probe(output, role="output")
|
|
82
102
|
v = result["video"]
|
|
83
103
|
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['fps']:g}fps)")
|
|
84
|
-
|
|
104
|
+
extra: dict = {}
|
|
105
|
+
if alpha_codec:
|
|
106
|
+
# read back: the written alpha must be 0 in every frame (expected: a canvas of the same
|
|
107
|
+
# frame count, all transparent)
|
|
108
|
+
frames = result["video"].get("nb_frames") or round(target * args.fps)
|
|
109
|
+
clear = None if STATE.dry_run else {"mean": 0.0, "min": 0.0, "frames": int(frames), "per_frame": [0.0] * int(frames)}
|
|
110
|
+
alpha_report(args, alpha_codec, clear, output, extra, composite=False, provenance="specification")
|
|
111
|
+
emit(output, **extra)
|
|
85
112
|
return 0
|
|
86
113
|
|
|
87
114
|
|
package/scripts/caption.py
CHANGED
|
@@ -75,6 +75,11 @@ __all__ = ["parse_srt", "write_srt", "transcribe", "whisper_word_timings", "die_
|
|
|
75
75
|
"text_width_em"]
|
|
76
76
|
|
|
77
77
|
ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
|
|
78
|
+
# libass reads a force_style Alignment in the legacy SSA numbering (1-3 bottom, 5-7 top, 9-11
|
|
79
|
+
# middle), not the v4+ numpad one a Style line uses: only the bottom row is the same number in
|
|
80
|
+
# both. Through 3.2.3 the SRT burn passed the numpad value, so --position top landed mid-left,
|
|
81
|
+
# center top-left, top-left top-right (#436, measured on libass 0.17.1 and FFmpeg 9.0.2).
|
|
82
|
+
FORCE_STYLE_ALIGN = {"bottom": 2, "top": 6, "center": 10, "bottom-left": 1, "bottom-right": 3, "top-left": 5, "top-right": 7}
|
|
78
83
|
|
|
79
84
|
TIME_RE = re.compile(
|
|
80
85
|
r"^\s*(?P<a>[\d:.,@]+)\s*(?:-->|-|–|to)\s*(?P<b>[\d:.,@]+)\s+(?P<text>.+)$" # @ = the 1.9 @fps suffix
|
|
@@ -1750,7 +1755,7 @@ def main() -> int:
|
|
|
1750
1755
|
f"Outline={args.outline:g}",
|
|
1751
1756
|
f"Shadow={args.shadow:g}",
|
|
1752
1757
|
f"Bold={-1 if args.bold else 0}",
|
|
1753
|
-
f"Alignment={
|
|
1758
|
+
f"Alignment={FORCE_STYLE_ALIGN[args.position]}",
|
|
1754
1759
|
f"MarginV={args.margin}",
|
|
1755
1760
|
]
|
|
1756
1761
|
force = ",".join(style).replace("\\", "\\\\").replace("'", "\\'")
|