ffmpeg-skill 0.10.0 → 0.12.5
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 +70 -10
- package/SKILL.md +89 -12
- package/bin/install.js +15 -1
- package/mcp/__pycache__/server.cpython-311.pyc +0 -0
- package/mcp/server.py +2 -0
- package/package.json +2 -2
- package/references/ci-platform-pitfalls.md +111 -0
- package/references/process-pitfalls.md +85 -0
- package/references/scripts.md +139 -12
- package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
- package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
- package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
- package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
- package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check.cpython-311.pyc +0 -0
- package/scripts/__pycache__/color.cpython-311.pyc +0 -0
- package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
- package/scripts/__pycache__/export.cpython-311.pyc +0 -0
- package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
- package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
- package/scripts/__pycache__/join.cpython-311.pyc +0 -0
- package/scripts/__pycache__/look.cpython-311.pyc +0 -0
- package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
- package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
- package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
- package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
- package/scripts/__pycache__/render.cpython-311.pyc +0 -0
- package/scripts/__pycache__/report.cpython-311.pyc +0 -0
- package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
- package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
- package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
- package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
- package/scripts/_common.py +255 -14
- package/scripts/_contract.py +276 -13
- package/scripts/audio.py +1 -1
- package/scripts/background.py +73 -0
- package/scripts/caption.py +106 -24
- package/scripts/color.py +129 -30
- package/scripts/crop.py +79 -0
- package/scripts/cut.py +2 -2
- package/scripts/export.py +1 -1
- package/scripts/fit.py +73 -14
- package/scripts/graphics.py +18 -7
- package/scripts/insert.py +128 -0
- package/scripts/join.py +14 -5
- package/scripts/look.py +13 -8
- package/scripts/loudness.py +3 -3
- package/scripts/multicam.py +1 -1
- package/scripts/overlay.py +78 -12
- package/scripts/proxy.py +82 -0
- package/scripts/reverse.py +56 -0
- package/scripts/scenes.py +8 -2
- package/scripts/sequence.py +124 -0
- package/scripts/silence.py +2 -2
- package/scripts/stabilize.py +101 -0
- package/scripts/sync.py +1 -1
package/scripts/_common.py
CHANGED
|
@@ -41,12 +41,52 @@ INSTALL_HINTS = {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
# `kind` (below) has been the only machine-readable failure axis since 0.1: a flat, 4-value
|
|
45
|
+
# vocabulary (input / missing_tool / ffmpeg / output) set at the ~7 call sites that ever pass one
|
|
46
|
+
# explicitly, defaulting to "input" everywhere else. `ERROR_CODE` is an additive, purely
|
|
47
|
+
# informational refinement layered on top for agents that want a stable enum to switch on instead
|
|
48
|
+
# of pattern-matching `kind` strings -- it is a static 1:1 relabelling of the exact same 4 buckets,
|
|
49
|
+
# not a new taxonomy. It intentionally does NOT introduce categories this codebase cannot actually
|
|
50
|
+
# distinguish today (e.g. a separate ffprobe-vs-ffmpeg code, or an environment-vs-content-cause
|
|
51
|
+
# split of ffmpeg failures): every ffmpeg subprocess failure is currently one undifferentiated
|
|
52
|
+
# bucket regardless of whether ffmpeg rejected a bad filter argument or died from a full disk,
|
|
53
|
+
# and every "kind": "input" failure covers both a missing file and a bad flag value alike. Adding
|
|
54
|
+
# codes for distinctions the code can't actually make would be guessing, not reporting -- if a
|
|
55
|
+
# future call site can genuinely tell capability-missing apart from bad-argument (see doctor()'s
|
|
56
|
+
# available/missing/unknown states, which already model this for detection but aren't wired into
|
|
57
|
+
# any die() call), split ERROR_CODE then, with evidence, not speculatively now.
|
|
58
|
+
ERROR_CODE = {
|
|
59
|
+
"input": "INPUT_INVALID",
|
|
60
|
+
"missing_tool": "DEPENDENCY_MISSING",
|
|
61
|
+
"ffmpeg": "FFMPEG_EXECUTION_FAILED",
|
|
62
|
+
"output": "OUTPUT_INVALID",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# None of the four kinds above are retryable in practice: an "input"/"missing_tool" failure is
|
|
66
|
+
# always deterministic (the same bad path or absent binary fails identically every time), and a
|
|
67
|
+
# "ffmpeg"/"output" failure -- while it COULD in principle be caused by a transient environment
|
|
68
|
+
# condition (full disk, OOM) rather than a bad command -- is never distinguishable from a
|
|
69
|
+
# deterministic content-cause failure without exit-code/stderr sniffing this codebase does not do.
|
|
70
|
+
# Reporting retryable=True for a code we can't actually back up would invite an agent into a blind
|
|
71
|
+
# retry loop against a command that will fail the same way every time; false-for-everything is the
|
|
72
|
+
# honest answer until real sniffing exists to justify anything else.
|
|
73
|
+
ERROR_RETRYABLE = False
|
|
74
|
+
|
|
75
|
+
|
|
44
76
|
def die(msg: str, code: int = 1, kind: str = "input") -> "None":
|
|
45
77
|
"""Exit with a message. Under --json also print a machine-readable failure document
|
|
46
78
|
(status: failed) on stdout so callers get the same shape as a success; exit codes are unchanged."""
|
|
47
79
|
sys.stderr.write(f"error: {msg}\n")
|
|
48
80
|
if STATE.json:
|
|
49
|
-
print_json({
|
|
81
|
+
print_json({
|
|
82
|
+
"status": "failed", "exit_code": code,
|
|
83
|
+
"error": {
|
|
84
|
+
"kind": kind, "message": msg,
|
|
85
|
+
"code": ERROR_CODE.get(kind, "INTERNAL_ERROR"),
|
|
86
|
+
"retryable": ERROR_RETRYABLE,
|
|
87
|
+
},
|
|
88
|
+
"commands": list(STATE.commands),
|
|
89
|
+
})
|
|
50
90
|
sys.exit(code)
|
|
51
91
|
|
|
52
92
|
|
|
@@ -135,10 +175,13 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
135
175
|
|
|
136
176
|
def emit(output: Optional[str], **extra: Any) -> None:
|
|
137
177
|
"""Final stdout line: the output path, or a JSON document with --json."""
|
|
178
|
+
meta: Dict[str, Any] = {}
|
|
179
|
+
if output and not STATE.dry_run:
|
|
180
|
+
meta = verify_output(output) # dies (status: failed, kind: output) if the artifact is unusable
|
|
138
181
|
if STATE.json:
|
|
139
182
|
doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": STATE.dry_run, "commands": list(STATE.commands)}
|
|
140
|
-
if
|
|
141
|
-
doc["probe"] =
|
|
183
|
+
if meta:
|
|
184
|
+
doc["probe"] = meta
|
|
142
185
|
doc.update(extra)
|
|
143
186
|
print_json(doc)
|
|
144
187
|
elif output:
|
|
@@ -153,11 +196,59 @@ def _is_ffmpeg(cmd: Sequence[str]) -> bool:
|
|
|
153
196
|
return os.path.basename(cmd[0]).startswith("ffmpeg")
|
|
154
197
|
|
|
155
198
|
|
|
199
|
+
def _cleanup_partial_output(cmd: Sequence[str]) -> None:
|
|
200
|
+
"""A failed ffmpeg command can still have opened its output container (muxer header
|
|
201
|
+
written) before erroring out mid-stream -- unlike a failure that happens before ffmpeg ever
|
|
202
|
+
touches the output path (a bad filter argument, a missing input), which never creates the
|
|
203
|
+
file at all. Both are reported the same way (status: failed), but only the first case used
|
|
204
|
+
to leave a stray, usually-0-byte file behind: verify_output()'s cleanup only runs on the
|
|
205
|
+
success path, so a failed run() call never routed through it. Remove whatever ffmpeg managed
|
|
206
|
+
to write so a caller scanning the output directory after a failure never mistakes a partial
|
|
207
|
+
artifact for a real (if unverified) one."""
|
|
208
|
+
output = cmd[-1]
|
|
209
|
+
if output in ("-", "pipe:0", "pipe:1") or output.startswith("pipe:") or output.startswith("-"):
|
|
210
|
+
return
|
|
211
|
+
try:
|
|
212
|
+
if os.path.exists(output):
|
|
213
|
+
os.remove(output)
|
|
214
|
+
except OSError:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
|
|
156
218
|
def _fail(cmd: Sequence[str], returncode: int, stderr: str) -> None:
|
|
219
|
+
# Partial-output cleanup already ran in the caller (_run_captured/_run_with_progress) for
|
|
220
|
+
# every failed ffmpeg invocation, not just this check=True path -- see _cleanup_partial_output.
|
|
157
221
|
tail = "\n".join(stderr.strip().splitlines()[-15:])
|
|
158
222
|
die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=returncode or 1, kind="ffmpeg")
|
|
159
223
|
|
|
160
224
|
|
|
225
|
+
def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
|
|
226
|
+
"""Refuse an ffmpeg command whose output path resolves to the same file as one of its
|
|
227
|
+
inputs. ffmpeg's own "Output same as Input" guard only catches byte-identical path
|
|
228
|
+
strings; a relative/absolute pair, a leading "./", a redundant ".." segment, or a symlink
|
|
229
|
+
all resolve to the same file but pass that check, so "-o ./same.mp4" on an input opened as
|
|
230
|
+
"same.mp4" would otherwise silently let ffmpeg's -y clobber the source mid-encode. Every
|
|
231
|
+
write-side script routes through this one run() choke point rather than each computing its
|
|
232
|
+
own output path defensively, so the guard lives here once instead of at 25+ call sites."""
|
|
233
|
+
output = cmd[-1]
|
|
234
|
+
if output in ("-", "pipe:0", "pipe:1") or output.startswith("pipe:") or output.startswith("-"):
|
|
235
|
+
return
|
|
236
|
+
try:
|
|
237
|
+
out_real = os.path.realpath(output)
|
|
238
|
+
except OSError:
|
|
239
|
+
return
|
|
240
|
+
for i, a in enumerate(cmd):
|
|
241
|
+
if a == "-i" and i + 1 < len(cmd):
|
|
242
|
+
inp = cmd[i + 1]
|
|
243
|
+
try:
|
|
244
|
+
if os.path.realpath(inp) == out_real:
|
|
245
|
+
die(f"refusing to run: output {output!r} is the same file as input {inp!r} "
|
|
246
|
+
f"(would overwrite it while ffmpeg is still reading it) -- choose a different --output/-o path",
|
|
247
|
+
kind="input")
|
|
248
|
+
except OSError:
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
|
|
161
252
|
def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subprocess.CompletedProcess:
|
|
162
253
|
"""Run a command, echoing it to stderr unless quiet. Exits on failure when check=True.
|
|
163
254
|
|
|
@@ -167,6 +258,7 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
|
|
|
167
258
|
"""
|
|
168
259
|
is_ffmpeg = _is_ffmpeg(cmd)
|
|
169
260
|
if is_ffmpeg:
|
|
261
|
+
_check_no_overwrite_input(cmd)
|
|
170
262
|
STATE.commands.append(_cmdline(cmd))
|
|
171
263
|
if not quiet:
|
|
172
264
|
info(("[dry-run] $ " if STATE.dry_run and is_ffmpeg else "$ ") + _cmdline(cmd))
|
|
@@ -177,11 +269,34 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
|
|
|
177
269
|
return _run_captured(list(cmd), check)
|
|
178
270
|
|
|
179
271
|
|
|
272
|
+
def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
|
|
273
|
+
"""Run an ffmpeg command that already maps its video/audio, trying first to also
|
|
274
|
+
stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
|
|
275
|
+
there are none). A source whose subtitle codec cannot be copied into the target container
|
|
276
|
+
(e.g. a container change) makes that first attempt fail; retry the same command without the
|
|
277
|
+
extra maps rather than let a tool that never touched subtitles start hard-failing because of
|
|
278
|
+
them. `cmd` is the full argv *without* the output path. Returns True only when the
|
|
279
|
+
retry-without-subtitles path was actually needed (i.e. subtitle/data streams were dropped)."""
|
|
280
|
+
if run(cmd + ["-map", "0:s?", "-map", "0:d?", "-c:s", "copy", "-c:d", "copy", output], check=False).returncode == 0:
|
|
281
|
+
return False
|
|
282
|
+
run(cmd + [output])
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
|
|
180
286
|
def _run_captured(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
|
|
181
287
|
"""Plain run with stdout/stderr captured."""
|
|
182
288
|
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
183
|
-
if
|
|
184
|
-
|
|
289
|
+
if proc.returncode != 0:
|
|
290
|
+
# Cleanup happens for every failed ffmpeg invocation, not just the check=True/_fail()
|
|
291
|
+
# path: a handful of scripts (cut.py, loudness.py, silence.py, sync.py) call run() with
|
|
292
|
+
# check=False so they can compose their own die() message from proc.stderr, but the
|
|
293
|
+
# partial-output risk is identical either way -- and for a script that retries into the
|
|
294
|
+
# same output path after a check=False failure (e.g. color.py's --retag copy-then-
|
|
295
|
+
# reencode fallback), removing the stale partial first is strictly safer than leaving it
|
|
296
|
+
# for -y to overwrite.
|
|
297
|
+
_cleanup_partial_output(cmd)
|
|
298
|
+
if check:
|
|
299
|
+
_fail(cmd, proc.returncode, proc.stderr)
|
|
185
300
|
return proc
|
|
186
301
|
|
|
187
302
|
|
|
@@ -216,13 +331,15 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
|
|
|
216
331
|
_, err = proc.communicate()
|
|
217
332
|
if last:
|
|
218
333
|
sys.stderr.write("\r" + " " * len(last) + "\r")
|
|
219
|
-
if
|
|
220
|
-
|
|
334
|
+
if proc.returncode != 0:
|
|
335
|
+
_cleanup_partial_output(cmd)
|
|
336
|
+
if check:
|
|
337
|
+
_fail(cmd, proc.returncode, err)
|
|
221
338
|
return subprocess.CompletedProcess(full, proc.returncode, "", err)
|
|
222
339
|
|
|
223
340
|
|
|
224
341
|
def shell_quote(s: str) -> str:
|
|
225
|
-
if not s or any(ch in s for ch in " \t
|
|
342
|
+
if not s or any(ch in s for ch in " \t\\\"';|&<>()[]{}$*?"):
|
|
226
343
|
return "'" + s.replace("'", "'\\''") + "'"
|
|
227
344
|
return s
|
|
228
345
|
|
|
@@ -233,14 +350,58 @@ def ffmpeg_base(overwrite: bool = True) -> List[str]:
|
|
|
233
350
|
return cmd
|
|
234
351
|
|
|
235
352
|
|
|
236
|
-
|
|
237
|
-
|
|
353
|
+
MEDIA_EXT = {".mp4", ".mov", ".mkv", ".webm", ".m4v", ".avi", ".ts", ".mts", ".gif", ".wav", ".flac", ".mp3", ".m4a", ".aac", ".ogg", ".opus", ".png", ".jpg", ".jpeg"}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _output_failed(path: str, why: str) -> "None":
|
|
357
|
+
"""An ffmpeg run reported success but the artifact is not usable: say so, and do not leave a
|
|
358
|
+
0-byte file behind that a later step could mistake for a result."""
|
|
359
|
+
try:
|
|
360
|
+
if os.path.exists(path) and os.path.getsize(path) == 0:
|
|
361
|
+
os.remove(path)
|
|
362
|
+
why += " (empty file removed)"
|
|
363
|
+
except OSError:
|
|
364
|
+
pass
|
|
365
|
+
die(f"output verification failed: {path}: {why}", kind="output")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def verify_output(path: str) -> Dict[str, Any]:
|
|
369
|
+
"""The success criterion for every writing tool: the file exists, is not empty and ffprobe
|
|
370
|
+
can read at least one stream from it. Non-media artifacts (srt, edl, html, md) only need to
|
|
371
|
+
exist and be non-empty. Returns the probe (empty dict for non-media)."""
|
|
372
|
+
if not os.path.exists(path):
|
|
373
|
+
_output_failed(path, "not written")
|
|
374
|
+
if os.path.getsize(path) == 0:
|
|
375
|
+
_output_failed(path, "0 bytes")
|
|
376
|
+
if os.path.splitext(path)[1].lower() not in MEDIA_EXT:
|
|
377
|
+
return {}
|
|
378
|
+
meta = probe(path, role="output")
|
|
379
|
+
if not meta.get("video") and not meta.get("audio"):
|
|
380
|
+
_output_failed(path, "no video or audio stream")
|
|
381
|
+
return meta
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
385
|
+
"""Return a compact, script-friendly description of a media file.
|
|
386
|
+
|
|
387
|
+
role="output" marks a file this tool just wrote: a read failure is then reported as an
|
|
388
|
+
output-verification failure (kind "output") instead of an input problem."""
|
|
238
389
|
if not os.path.exists(path):
|
|
390
|
+
if role == "output" and not STATE["dry_run"]:
|
|
391
|
+
_output_failed(path, "not written")
|
|
239
392
|
if STATE["dry_run"]:
|
|
393
|
+
# width/height/fps are honestly 0/0/0.0 -- "not measured", matching duration/size_bytes
|
|
394
|
+
# below -- because this is a dry run: the file doesn't exist yet, so there is nothing to
|
|
395
|
+
# probe. Earlier this stub used plausible-looking placeholders (1920x1080x30.0) instead,
|
|
396
|
+
# which some tools' dry-run summary line echoed verbatim as if it were a real computed
|
|
397
|
+
# preview (#77). That was reverted once, because a couple of call sites divided by these
|
|
398
|
+
# values for aspect-ratio math and crashed on a real 0 (join.py, fit.py); those call
|
|
399
|
+
# sites are now guarded to treat 0 as "unknown" and fall back sanely instead of dividing
|
|
400
|
+
# by it, so the stub can finally report the honest, unknown value.
|
|
240
401
|
return {"file": path, "dry_run": True, "format": None, "duration": 0.0, "size_bytes": 0, "bitrate": None,
|
|
241
|
-
"video": {"codec": None, "width":
|
|
402
|
+
"video": {"codec": None, "width": 0, "height": 0, "fps": 0.0, "pix_fmt": None, "hdr": False,
|
|
242
403
|
"color_transfer": None, "color_primaries": None, "rotation": 0, "variable_frame_rate_suspected": False},
|
|
243
|
-
"audio": {"codec": None, "channels": 0, "sample_rate": 0}, "subtitle_streams": 0}
|
|
404
|
+
"audio": {"codec": None, "channels": 0, "sample_rate": 0}, "subtitle_streams": 0, "data_streams": 0}
|
|
244
405
|
die(f"input not found: {path}")
|
|
245
406
|
ffprobe = require_tool("ffprobe")
|
|
246
407
|
proc = run(
|
|
@@ -249,6 +410,8 @@ def probe(path: str) -> Dict[str, Any]:
|
|
|
249
410
|
check=False,
|
|
250
411
|
)
|
|
251
412
|
if proc.returncode != 0:
|
|
413
|
+
if role == "output":
|
|
414
|
+
_output_failed(path, f"ffprobe cannot read it:\n{proc.stderr.strip()}")
|
|
252
415
|
die(f"ffprobe failed on {path}:\n{proc.stderr.strip()}")
|
|
253
416
|
raw = json.loads(proc.stdout or "{}")
|
|
254
417
|
fmt = raw.get("format", {})
|
|
@@ -256,6 +419,7 @@ def probe(path: str) -> Dict[str, Any]:
|
|
|
256
419
|
video = next((s for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic", 0) == 0), None)
|
|
257
420
|
audio = next((s for s in streams if s.get("codec_type") == "audio"), None)
|
|
258
421
|
subs = [s for s in streams if s.get("codec_type") == "subtitle"]
|
|
422
|
+
data_stream_count = sum(1 for s in streams if s.get("codec_type") in ("data", "attachment"))
|
|
259
423
|
|
|
260
424
|
duration = _to_float(fmt.get("duration"))
|
|
261
425
|
if duration is None and video:
|
|
@@ -274,6 +438,14 @@ def probe(path: str) -> Dict[str, Any]:
|
|
|
274
438
|
"video": None,
|
|
275
439
|
"audio": None,
|
|
276
440
|
"subtitle_streams": len(subs),
|
|
441
|
+
"data_streams": data_stream_count,
|
|
442
|
+
# every subtitle stream in file order: index n here is `-map 0:s:n`
|
|
443
|
+
"subtitle_stream_details": [{
|
|
444
|
+
"index": n,
|
|
445
|
+
"codec": s.get("codec_name"),
|
|
446
|
+
"language": (s.get("tags") or {}).get("language"),
|
|
447
|
+
"title": (s.get("tags") or {}).get("title"),
|
|
448
|
+
} for n, s in enumerate(subs)],
|
|
277
449
|
}
|
|
278
450
|
if video:
|
|
279
451
|
r_rate = _fraction(video.get("r_frame_rate"))
|
|
@@ -351,12 +523,30 @@ def default_output(input_path: str, suffix: str, ext: Optional[str] = None) -> s
|
|
|
351
523
|
return str(p.with_name(f"{p.stem}_{suffix}.{new_ext}"))
|
|
352
524
|
|
|
353
525
|
|
|
354
|
-
|
|
355
|
-
"""
|
|
526
|
+
class MissingFpsError(ValueError):
|
|
527
|
+
"""parse_time() saw an hh:mm:ss:ff SMPTE timecode but no fps was given to convert it -- distinct
|
|
528
|
+
from a plain ValueError so a caller that falls back to treating unparseable text as a literal
|
|
529
|
+
line (e.g. caption.py's free-text cue format) can still fail loudly on this one, instead of
|
|
530
|
+
silently swallowing a mistyped/missing --fps as an auto-timed line of digits."""
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def parse_time(value: str, fps: Optional[float] = None) -> float:
|
|
534
|
+
"""Accept seconds ('12.5'), mm:ss ('1:30'), hh:mm:ss(.ms) ('00:01:30.250'), SRT '00:01:30,250',
|
|
535
|
+
or -- when `fps` is given -- SMPTE non-drop-frame timecode 'hh:mm:ss:ff' ('00:01:30:15')."""
|
|
356
536
|
v = value.strip().replace(",", ".")
|
|
357
537
|
if not v:
|
|
358
538
|
raise ValueError("empty time")
|
|
359
539
|
parts = v.split(":")
|
|
540
|
+
if len(parts) == 4:
|
|
541
|
+
if fps is None or fps <= 0:
|
|
542
|
+
raise MissingFpsError(f"'{value}' looks like an hh:mm:ss:ff SMPTE timecode, but no fps was given to convert its frame count to seconds")
|
|
543
|
+
h, m, s, f = parts
|
|
544
|
+
if "." in f:
|
|
545
|
+
raise ValueError(f"bad SMPTE timecode: {value}")
|
|
546
|
+
frame, whole_fps = int(f), int(round(fps))
|
|
547
|
+
if not (0 <= frame < whole_fps):
|
|
548
|
+
raise ValueError(f"bad SMPTE timecode '{value}': frame {frame} is out of range for {fps:g} fps (0-{whole_fps - 1})")
|
|
549
|
+
return int(h) * 3600 + int(m) * 60 + int(s) + frame / fps
|
|
360
550
|
if len(parts) > 3:
|
|
361
551
|
raise ValueError(f"bad time: {value}")
|
|
362
552
|
total = 0.0
|
|
@@ -375,6 +565,20 @@ def fmt_srt_time(seconds: float) -> str:
|
|
|
375
565
|
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
|
376
566
|
|
|
377
567
|
|
|
568
|
+
def fmt_smpte_time(seconds: float, fps: float) -> str:
|
|
569
|
+
"""SMPTE non-drop-frame timecode 'hh:mm:ss:ff' for a real fps (not the fractional NTSC rates
|
|
570
|
+
-- 29.97/59.94 need drop-frame counting to stay wall-clock accurate, which this does not do)."""
|
|
571
|
+
if seconds < 0:
|
|
572
|
+
seconds = 0.0
|
|
573
|
+
whole_fps = int(round(fps))
|
|
574
|
+
total_frames = int(round(seconds * fps))
|
|
575
|
+
frame = total_frames % whole_fps
|
|
576
|
+
secs_total = total_frames // whole_fps
|
|
577
|
+
h, rem = divmod(secs_total, 3600)
|
|
578
|
+
m, s = divmod(rem, 60)
|
|
579
|
+
return f"{h:02d}:{m:02d}:{s:02d}:{frame:02d}"
|
|
580
|
+
|
|
581
|
+
|
|
378
582
|
def escape_filter_path(path: str) -> str:
|
|
379
583
|
"""Escape a file path for use as a filter option value (subtitles=, ass=, lut3d=file=, fontfile=, fontsdir=).
|
|
380
584
|
|
|
@@ -394,6 +598,43 @@ def escape_filter_path(path: str) -> str:
|
|
|
394
598
|
return p
|
|
395
599
|
|
|
396
600
|
|
|
601
|
+
def default_font_file(font_name: str) -> Optional[str]:
|
|
602
|
+
"""Resolve `font_name` to a concrete on-disk font file, so a caller can tell drawtext
|
|
603
|
+
`fontfile=<path>` instead of `font=<name>`, when possible.
|
|
604
|
+
|
|
605
|
+
On some real Windows ffmpeg builds (winget's gyan.dev 9.x), drawtext's own fontconfig
|
|
606
|
+
resolution crashes with an access violation whenever it has to resolve a font by family name
|
|
607
|
+
-- with or without a valid fonts.conf on FONTCONFIG_FILE. `fontfile=` is the only form
|
|
608
|
+
confirmed not to crash (#100), since it never touches fontconfig at all. `font_name` itself is
|
|
609
|
+
ignored on Windows for that reason: a fixed, near-universally-present system font is used
|
|
610
|
+
instead of trying to resolve the requested family (which would crash the same way).
|
|
611
|
+
|
|
612
|
+
On Linux/macOS this is best-effort and uses the real requested family: `fc-match` reports the
|
|
613
|
+
same file fontconfig would resolve `font_name` to anyway, so a caller gets the identical font,
|
|
614
|
+
just already resolved to a path -- fontfile= skips a redundant fontconfig lookup and equally
|
|
615
|
+
sidesteps the same class of crash if it exists on some build there too, but the fallback below
|
|
616
|
+
(returning None) is exercised routinely there, not just on failure.
|
|
617
|
+
|
|
618
|
+
Returns None when nothing could be resolved (fc-match missing/unavailable, or no well-known
|
|
619
|
+
Windows font file present); the caller falls back to font=<font_name>, the prior behaviour.
|
|
620
|
+
"""
|
|
621
|
+
if platform.system() == "Windows":
|
|
622
|
+
windir = os.environ.get("WINDIR", "C:\\Windows")
|
|
623
|
+
candidate = Path(windir) / "Fonts" / "arial.ttf"
|
|
624
|
+
return str(candidate) if candidate.exists() else None
|
|
625
|
+
exe = shutil.which("fc-match")
|
|
626
|
+
if not exe:
|
|
627
|
+
return None
|
|
628
|
+
try:
|
|
629
|
+
proc = subprocess.run([exe, "--format=%{file}\n", font_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=5)
|
|
630
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
631
|
+
return None
|
|
632
|
+
if proc.returncode != 0:
|
|
633
|
+
return None
|
|
634
|
+
path = proc.stdout.splitlines()[0].strip() if proc.stdout.strip() else ""
|
|
635
|
+
return path if path and os.path.exists(path) else None
|
|
636
|
+
|
|
637
|
+
|
|
397
638
|
def escape_drawtext(text: str) -> str:
|
|
398
639
|
return (
|
|
399
640
|
text.replace("\\", "\\\\")
|