ffmpeg-skill 1.14.0 → 1.15.1

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.
@@ -0,0 +1,1056 @@
1
+ """Process execution for ffmpeg-skill: locating the tools, running them under a wall-clock
2
+ ceiling, signal handling, output locking and staging, and the drawtext text-file spool.
3
+
4
+ Nothing here decides *what* to encode -- that is decision.py -- and nothing here formats a result
5
+ document -- that is emit.py. Imported through the `_common` facade by every script.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import platform
12
+ import argparse
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
18
+
19
+
20
+ INSTALL_HINTS = {
21
+ "Darwin": " brew install ffmpeg-full (the plain ffmpeg formula lacks subtitles/drawtext/zscale)",
22
+ "Linux": (
23
+ " Debian/Ubuntu: sudo apt install ffmpeg\n"
24
+ " Fedora: sudo dnf install ffmpeg\n"
25
+ " Arch: sudo pacman -S ffmpeg"
26
+ ),
27
+ "Windows": (
28
+ " winget install Gyan.FFmpeg\n"
29
+ " or: choco install ffmpeg\n"
30
+ " or download a build from https://ffmpeg.org/download.html and add it to PATH"
31
+ ),
32
+ }
33
+
34
+
35
+ # `kind` (below) is the machine-readable failure axis: input / missing_tool / ffmpeg / output
36
+ # since 0.1, plus timeout (1.3), verification (1.4.3) and interrupted (1.4.10). `ERROR_CODE` is an
37
+ # additive, purely informational refinement layered on top for agents that want a stable enum to
38
+ # switch on instead of pattern-matching `kind` strings -- a static 1:1 relabelling of the same
39
+ # buckets, not a new taxonomy. It intentionally does NOT introduce categories this codebase cannot actually
40
+ # distinguish today (e.g. a separate ffprobe-vs-ffmpeg code, or an environment-vs-content-cause
41
+ # split of ffmpeg failures): every ffmpeg subprocess failure is currently one undifferentiated
42
+ # bucket regardless of whether ffmpeg rejected a bad filter argument or died from a full disk,
43
+ # and every "kind": "input" failure covers both a missing file and a bad flag value alike. Adding
44
+ # codes for distinctions the code can't actually make would be guessing, not reporting -- if a
45
+ # future call site can genuinely tell capability-missing apart from bad-argument (see doctor()'s
46
+ # available/missing/unknown states, which already model this for detection but aren't wired into
47
+ # any die() call), split ERROR_CODE then, with evidence, not speculatively now.
48
+ ERROR_CODE = {
49
+ "input": "INPUT_INVALID",
50
+ "missing_tool": "DEPENDENCY_MISSING",
51
+ "ffmpeg": "FFMPEG_EXECUTION_FAILED",
52
+ "output": "OUTPUT_INVALID",
53
+ "timeout": "TIMEOUT",
54
+ "verification": "VERIFICATION_FAILED",
55
+ "interrupted": "INTERRUPTED",
56
+ }
57
+
58
+
59
+ # Wall-clock ceiling for one ffmpeg/ffprobe invocation, in seconds. A hung ffmpeg (a build
60
+ # that deadlocks on a filter combination, a stalled network mount, an input that never ends)
61
+ # used to hang the calling agent with it, with no error document and no way out short of
62
+ # killing the process by hand. The ceiling is generous on purpose: it exists to turn a hang
63
+ # into a reported failure, not to police slow encodes. --timeout and FFMPEG_SKILL_TIMEOUT
64
+ # override it; 0 disables it.
65
+ DEFAULT_TIMEOUT = 1800.0
66
+
67
+
68
+ PROBE_TIMEOUT = 120.0
69
+
70
+
71
+ def _env_timeout() -> float:
72
+ try:
73
+ return max(0.0, float(os.environ.get("FFMPEG_SKILL_TIMEOUT", DEFAULT_TIMEOUT)))
74
+ except ValueError:
75
+ return DEFAULT_TIMEOUT
76
+
77
+
78
+ # None of the four kinds above are retryable in practice: an "input"/"missing_tool" failure is
79
+ # always deterministic (the same bad path or absent binary fails identically every time), and a
80
+ # "ffmpeg"/"output" failure -- while it COULD in principle be caused by a transient environment
81
+ # condition (full disk, OOM) rather than a bad command -- is never distinguishable from a
82
+ # deterministic content-cause failure without exit-code/stderr sniffing this codebase does not do.
83
+ # Reporting retryable=True for a code we can't actually back up would invite an agent into a blind
84
+ # retry loop against a command that will fail the same way every time; false-for-everything is the
85
+ # honest answer until real sniffing exists to justify anything else.
86
+ ERROR_RETRYABLE = False
87
+
88
+
89
+ _FFMPEG_VERSION: "Optional[Tuple[int, int]]" = None
90
+
91
+
92
+ def ffmpeg_version() -> "Tuple[int, int]":
93
+ """(major, minor) of the FFmpeg build on PATH, parsed once from `ffprobe -version`; (0, 0)
94
+ when it cannot be read. ffprobe rather than ffmpeg because --dry-run promises never to run
95
+ ffmpeg (docs/contract.md: ffmpeg_execution "none") while ffprobe always may, and the two
96
+ ship from the same build. Used only to pick between two spellings of an option where FFmpeg
97
+ changed behaviour between releases (the tools otherwise never branch on the version: doctor's
98
+ capability listing is the source of truth for what a build can do)."""
99
+ global _FFMPEG_VERSION
100
+ if _FFMPEG_VERSION is None:
101
+ _FFMPEG_VERSION = (0, 0)
102
+ try:
103
+ out = subprocess.run(["ffprobe", "-version"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
104
+ timeout=PROBE_TIMEOUT).stdout
105
+ m = re.search(r"ffprobe version\s+n?(\d+)\.(\d+)", out)
106
+ if m:
107
+ _FFMPEG_VERSION = (int(m.group(1)), int(m.group(2)))
108
+ else:
109
+ # git / vendor builds print "N-115000-g..." or a date, never major.minor; the
110
+ # libavutil major is still there and maps one-to-one onto the FFmpeg major
111
+ # (56=4, 57=5, 58=6, 59=7, 60=8). Without this every version branch took the
112
+ # oldest spelling on such builds: on 7.1 that skipped bt709_tag_args()'s
113
+ # workaround and an untagged source got a real matrix conversion.
114
+ m = re.search(r"^libavutil\s+(\d+)\.", out, re.M)
115
+ if m:
116
+ major = int(m.group(1)) - 52
117
+ if major >= 4:
118
+ _FFMPEG_VERSION = (major, 0)
119
+ except (OSError, subprocess.TimeoutExpired):
120
+ # (0, 0) = unknown: every version branch then takes the older, universally accepted
121
+ # spelling, the same "unknown is not missing" stance doctor takes.
122
+ pass
123
+ return _FFMPEG_VERSION
124
+
125
+
126
+ def require_tool(name: str) -> str:
127
+ """Return the absolute path of ffmpeg/ffprobe or exit with install steps."""
128
+ path = shutil.which(name)
129
+ if path:
130
+ return path
131
+ system = platform.system()
132
+ hint = INSTALL_HINTS.get(system, " See https://ffmpeg.org/download.html")
133
+ die(
134
+ f"'{name}' was not found on PATH.\n"
135
+ f"Install FFmpeg (which includes ffprobe) for {system}:\n{hint}",
136
+ code=127, kind="missing_tool",
137
+ )
138
+ return "" # unreachable
139
+
140
+
141
+ X264_PRESETS = ("ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow", "placebo")
142
+
143
+
144
+ CODECS = ("h264", "hevc", "av1", "prores")
145
+
146
+
147
+ _ENCODERS: Optional[set] = None
148
+
149
+
150
+ class Context:
151
+ """Per-process settings that the shared flags (--dry-run, --json, --progress, --fast) set once.
152
+
153
+ Scripts read it as attributes (``STATE.dry_run``); the dict-style shims that once served
154
+ older call sites are gone. Keeping it a single explicit object rather than module globals
155
+ makes it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
156
+ """
157
+
158
+ __slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec")
159
+
160
+ def __init__(self) -> None:
161
+ self.reset()
162
+
163
+ def reset(self) -> None:
164
+ self.dry_run = False # print ffmpeg commands, run nothing (ffprobe still runs)
165
+ self.json = False # emit() prints a JSON document instead of the output path
166
+ self.json_brief = False # --json-brief: the same document trimmed to the fields a caller acts on
167
+ self.progress = False # run() streams percent / ETA to stderr for ffmpeg
168
+ self.fast = False # x264 preset forced to veryfast
169
+ self.duration_hint: Optional[float] = None # expected output length, for the progress percent
170
+ self.commands: List[str] = [] # every ffmpeg command line, for --json and --dry-run
171
+ self.timeout: float = _env_timeout() # seconds per ffmpeg invocation, 0 = none
172
+ self.overwrite = False # --overwrite: an existing output may be replaced
173
+ self.written: set = set() # output paths this process has written itself
174
+ self.preexisting: dict = {} # output path -> (size, mtime_ns) of a file that was there before we ran
175
+ self.plan: Optional[str] = None # --plan FILE: write the dry-run as a plan document (implies --dry-run)
176
+ self.plan_written = False # write_plan() ran (emit or the exit hook), so the hook does not write twice
177
+ self.plan_inputs: List[str] = [] # side inputs (srt/ass/lut/font files) a tool named through escape_filter_path
178
+ self.codec: Optional[str] = None # --codec: encoder for the re-encode (None = x264 for SDR, x265 for HDR)
179
+
180
+
181
+ STATE = Context()
182
+
183
+
184
+ def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
185
+ """Add the flags every script shares. `codec=False` is for a tool that re-encodes but whose
186
+ preset decides the encoder (export.py): it must not advertise --codec/--quality in its schema."""
187
+ g = ap.add_argument_group("agent options")
188
+ g.add_argument("--dry-run", action="store_true", help="print the ffmpeg commands that would run, run nothing")
189
+ g.add_argument("--json", action="store_true", help="print a JSON result (output, probe, commands) on stdout instead of the path")
190
+ g.add_argument("--json-brief", action="store_true",
191
+ help="like --json but trimmed: status, output, dry_run, verified, a compact summary of the output probe, this tool's own keys, and the command count instead of the command lines (failures print the full failure document, unchanged)")
192
+ g.add_argument("--progress", action="store_true", help="show percent / ETA on stderr while ffmpeg encodes")
193
+ g.add_argument("--fast", action="store_true", help="preview quality: x264 preset veryfast (overrides --preset) for quick iterations")
194
+ if "--timeout" not in ap._option_string_actions: # verify.py defines its own per-step --timeout; apply_common reads either
195
+ g.add_argument("--timeout", type=float, default=None, metavar="SECONDS",
196
+ help=f"kill an ffmpeg run past this many seconds, kind=timeout (default {DEFAULT_TIMEOUT:.0f}; 0 = no limit)")
197
+ g.add_argument("--overwrite", action="store_true",
198
+ help="allow replacing an existing output (warned today, refused from 2.0)")
199
+ g.add_argument("--plan", metavar="FILE",
200
+ help="write the dry run as a plan (inputs fingerprinted, commands, expected output, verify steps) that render.py FILE executes later; implies --dry-run")
201
+ if codec and "--crf" in ap._option_string_actions:
202
+ # --crf became an alias of --quality in 1.8; 1.10 deprecates it (removed in 2.0, see the
203
+ # `deprecated` list in `contract --json` and docs/contract.md "What 2.0 changes"). Marked
204
+ # here, once, rather than in each re-encoding tool's own parser.
205
+ crf = ap._option_string_actions["--crf"]
206
+ # The flag's own default moves aside so apply_common() can tell an explicit --crf (in any
207
+ # spelling argparse accepts, including the --cr / --c abbreviations) from the default;
208
+ # apply_common() puts _CRF_DEFAULT back when the flag was absent.
209
+ global _CRF_DEFAULT
210
+ _CRF_DEFAULT = crf.default
211
+ crf.deprecated_default = crf.default # the schema still advertises it (_contract._json_type)
212
+ crf.default = None
213
+ if "deprecated" not in (crf.help or ""):
214
+ # the nine tools that declare --crf with no help string used to fall through this and
215
+ # never show the mark at all (review 9)
216
+ crf.help = (crf.help or "x264 CRF when re-encoding (default 18)") + " (deprecated: use --quality)"
217
+ # only the tools that re-encode (they declare --crf before add_common): one encoder choice
218
+ # resolved in video_args(), the 2.0 encoder abstraction pre-shipped in 1.8 (docs/roadmap.md)
219
+ g.add_argument("--codec", choices=CODECS, default=None,
220
+ help="video encoder for the re-encode: h264 (x264, the default for SDR), hevc (x265, the default for HDR), av1 (SVT-AV1 or libaom), prores (422 HQ, needs a .mov/.mkv output); HDR sources keep their colour on hevc/av1/prores")
221
+ g.add_argument("--quality", type=int, default=None, metavar="N",
222
+ help="encoder quality on the CRF scale (lower = better; 18 visually lossless for x264/x265, up to 63 for av1); overrides --crf, ignored by prores")
223
+
224
+
225
+ # The declared default of a deprecated --crf, parked by add_common() (one parser per process).
226
+ _CRF_DEFAULT: Optional[int] = None
227
+
228
+
229
+ def apply_common(args: "argparse.Namespace") -> None:
230
+ # Was --crf typed? add_common() parked the flag's default (None in its place) on every tool
231
+ # whose --crf is deprecated, i.e. the ones that also have --quality; export.py's --crf is not
232
+ # an alias and keeps its own default. Scanning sys.argv for "--crf" instead missed the unique
233
+ # prefixes argparse accepts (--cr, --c) and never ran for batch.py's recipe steps (review 9).
234
+ crf_explicit = hasattr(args, "quality") and getattr(args, "crf", None) is not None
235
+ if hasattr(args, "quality") and hasattr(args, "crf") and args.crf is None:
236
+ args.crf = _CRF_DEFAULT
237
+ STATE.plan = getattr(args, "plan", None) or None
238
+ STATE.dry_run = bool(getattr(args, "dry_run", False)) or bool(STATE.plan)
239
+ if STATE.plan:
240
+ # tools that print their document instead of calling emit() (probe, and the analysis
241
+ # tools without --json) still get their plan written, at exit, unless die() ran (review 6)
242
+ import atexit
243
+ atexit.register(_plan_at_exit)
244
+ STATE.json_brief = bool(getattr(args, "json_brief", False))
245
+ # --json-brief is a shorter --json, not a second output mode: it implies it, so a caller that
246
+ # passes only --json-brief still gets a JSON document (and --json --json-brief is the brief one).
247
+ STATE.json = bool(getattr(args, "json", False)) or STATE.json_brief
248
+ STATE.progress = bool(getattr(args, "progress", False))
249
+ STATE.fast = bool(getattr(args, "fast", False))
250
+ STATE.overwrite = bool(getattr(args, "overwrite", False))
251
+ if getattr(args, "timeout", None) is not None:
252
+ STATE.timeout = max(0.0, float(args.timeout))
253
+ if STATE.fast and getattr(args, "preset", None) in X264_PRESETS:
254
+ args.preset = "veryfast"
255
+ STATE.codec = getattr(args, "codec", None) or None
256
+ quality = getattr(args, "quality", None)
257
+ if quality is not None:
258
+ top = 63 if STATE.codec == "av1" else 51
259
+ if not 0 <= int(quality) <= top:
260
+ die(f"--quality must be between 0 and {top} for {STATE.codec or 'h264'} (CRF scale; 18 is visually lossless), got {quality}")
261
+ args.crf = int(quality) # every tool reads args.crf; --quality is the codec-neutral spelling of it
262
+ if STATE.codec == "prores" and hasattr(args, "output"):
263
+ out = getattr(args, "output", None)
264
+ if not out:
265
+ # every tool defaults its output to the source's extension (or .mp4): ProRes in an .mp4
266
+ # fails inside ffmpeg with "codec not currently supported in container" (review 7)
267
+ die("--codec prores needs an explicit -o NAME.mov (or .mkv): the default output name keeps the source's container, which cannot hold ProRes",
268
+ hint="give -o NAME.mov")
269
+ if os.path.splitext(str(out))[1].lower() not in (".mov", ".mkv"):
270
+ die(f"--codec prores needs a .mov (or .mkv) output; {os.path.basename(str(out))} cannot hold ProRes",
271
+ hint="give -o NAME.mov")
272
+ crf = getattr(args, "crf", None)
273
+ # The warning the deprecation policy asks for, only when the caller typed the flag (see
274
+ # crf_explicit above). export.py has no --quality (its preset chooses the encoder), so its
275
+ # --crf is not an alias and is not deprecated: warn only where --quality exists.
276
+ if crf is not None and crf_explicit:
277
+ info("warning: --crf is deprecated since 1.10.0; use --quality N (the same CRF scale, codec-neutral). --crf is removed in 2.0.")
278
+ top = 63 if STATE.codec == "av1" else 51
279
+ if crf is not None and not 0 <= int(crf) <= top:
280
+ die(f"--crf must be between 0 and {top} ({'SVT-AV1' if STATE.codec == 'av1' else 'x264/x265'} scale; 18 is visually lossless), got {crf}")
281
+ install_signal_handlers()
282
+
283
+
284
+ # The child processes this tool is waiting on right now (an ffmpeg, or a sibling script under
285
+ # run_tool), with the command whose partial output would need removing. A signal handler
286
+ # reads it; the runners keep it current. Before 1.4.9 a SIGTERM to the tool (a cancelled MCP
287
+ # call, a supervisor's stop, a closed terminal) killed only the Python parent: ffmpeg carried on
288
+ # as an orphan, finished a file nobody verified, and the caller got no JSON at all; SIGINT was a
289
+ # KeyboardInterrupt traceback with the partial left on disk.
290
+ _CHILDREN: List[Tuple[subprocess.Popen, Sequence[str]]] = []
291
+
292
+
293
+ _SIGNALS_INSTALLED = False
294
+
295
+
296
+ def _on_signal(signum: int, frame: Any) -> None:
297
+ import signal as _signal
298
+ name = {getattr(_signal, "SIGINT", None): "SIGINT", getattr(_signal, "SIGTERM", None): "SIGTERM"}.get(signum, str(signum))
299
+ for proc, cmd in list(_CHILDREN):
300
+ try:
301
+ proc.terminate() # ffmpeg exits promptly on SIGTERM; a sibling script runs this same handler
302
+ try:
303
+ proc.wait(timeout=5)
304
+ except subprocess.TimeoutExpired:
305
+ proc.kill()
306
+ proc.wait()
307
+ except OSError:
308
+ pass
309
+ if cmd:
310
+ _cleanup_partial_output(cmd)
311
+ _CHILDREN.clear()
312
+ die(f"interrupted by {name}: the running command was stopped and its partial output removed; nothing was written",
313
+ code=128 + signum, kind="interrupted")
314
+
315
+
316
+ def install_signal_handlers() -> None:
317
+ """SIGINT/SIGTERM stop the child, remove its partial output and exit with a failure document
318
+ (kind: interrupted, exit 130/143). Main thread only; on Windows SIGTERM is never delivered,
319
+ SIGINT (Ctrl-C) is."""
320
+ global _SIGNALS_INSTALLED
321
+ if _SIGNALS_INSTALLED:
322
+ return
323
+ import signal as _signal
324
+ import threading
325
+ if threading.current_thread() is not threading.main_thread():
326
+ return
327
+ for sig in (getattr(_signal, "SIGINT", None), getattr(_signal, "SIGTERM", None)):
328
+ if sig is None:
329
+ continue
330
+ try:
331
+ _signal.signal(sig, _on_signal)
332
+ except (ValueError, OSError):
333
+ pass
334
+ _SIGNALS_INSTALLED = True
335
+
336
+
337
+ def _watch(proc: subprocess.Popen, cmd: Sequence[str]) -> None:
338
+ _CHILDREN.append((proc, cmd))
339
+
340
+
341
+ def _unwatch(proc: subprocess.Popen) -> None:
342
+ _CHILDREN[:] = [(p, c) for p, c in _CHILDREN if p is not proc]
343
+
344
+
345
+ def _cmdline(cmd: Sequence[str]) -> str:
346
+ return " ".join(shell_quote(c) for c in cmd)
347
+
348
+
349
+ def _is_ffmpeg(cmd: Sequence[str]) -> bool:
350
+ return os.path.basename(cmd[0]).startswith("ffmpeg")
351
+
352
+
353
+ def _cleanup_partial_output(cmd: Sequence[str]) -> None:
354
+ """A failed ffmpeg command can still have opened its output container (muxer header
355
+ written) before erroring out mid-stream -- unlike a failure that happens before ffmpeg ever
356
+ touches the output path (a bad filter argument, a missing input), which never creates the
357
+ file at all. Both are reported the same way (status: failed), but only the first case used
358
+ to leave a stray, usually-0-byte file behind: verify_output()'s cleanup only runs on the
359
+ success path, so a failed run() call never routed through it. Remove whatever ffmpeg managed
360
+ to write so a caller scanning the output directory after a failure never mistakes a partial
361
+ artifact for a real (if unverified) one."""
362
+ # run() also executes ffprobe, whose last argument is an INPUT. Never
363
+ # interpret a read-only tool's failure as permission to remove that file.
364
+ if not _is_ffmpeg(cmd):
365
+ return
366
+ output = cmd[-1]
367
+ if output in ("-", "pipe:0", "pipe:1") or output.startswith("pipe:") or output.startswith("-"):
368
+ return
369
+ try:
370
+ if not os.path.exists(output):
371
+ return
372
+ # A file that was already there before this command ran is someone's deliverable, not
373
+ # our partial. If ffmpeg died before opening it (bad filter argument, unreadable input:
374
+ # the common case) it is byte-for-byte what it was, so leave it alone. Only when ffmpeg
375
+ # did open and truncate it (size or mtime changed) is what remains a partial of ours,
376
+ # and the original is already gone either way; then removing it is still right.
377
+ before = STATE.preexisting.get(os.path.realpath(output))
378
+ if before is not None:
379
+ st = os.stat(output)
380
+ if (st.st_size, st.st_mtime_ns) == before:
381
+ return
382
+ os.remove(output)
383
+ except OSError:
384
+ pass
385
+
386
+
387
+ def _fail(cmd: Sequence[str], returncode: int, stderr: str) -> None:
388
+ # Partial-output cleanup already ran in the caller (_run_captured/_run_with_progress) for
389
+ # every failed ffmpeg invocation, not just this check=True path -- see _cleanup_partial_output.
390
+ # The process exit code is always 1 for an ffmpeg failure: ffmpeg's own code (1, 69, 218, 234,
391
+ # a negative signal number...) varies by build and by the failing stage, and 124/127/130/143
392
+ # are reserved for timeout, missing tool and interrupts. The raw code is kept in the JSON
393
+ # document as `ffmpeg_returncode` for a caller that wants it. docs/design-decisions.md.
394
+ tail = "\n".join(stderr.strip().splitlines()[-15:])
395
+ die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=1, kind="ffmpeg", ffmpeg_returncode=returncode)
396
+
397
+
398
+ def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
399
+ """Refuse an ffmpeg command whose output path resolves to the same file as one of its
400
+ inputs. ffmpeg's own "Output same as Input" guard only catches byte-identical path
401
+ strings; a relative/absolute pair, a leading "./", a redundant ".." segment, or a symlink
402
+ all resolve to the same file but pass that check, so "-o ./same.mp4" on an input opened as
403
+ "same.mp4" would otherwise silently let ffmpeg's -y clobber the source mid-encode. Every
404
+ write-side script routes through this one run() choke point rather than each computing its
405
+ own output path defensively, so the guard lives here once instead of at 25+ call sites."""
406
+ output = cmd[-1]
407
+ if output in ("-", "pipe:0", "pipe:1") or output.startswith("pipe:") or output.startswith("-"):
408
+ return
409
+ try:
410
+ out_real = os.path.realpath(output)
411
+ except OSError:
412
+ return
413
+ for i, a in enumerate(cmd):
414
+ if a == "-i" and i + 1 < len(cmd):
415
+ inp = cmd[i + 1]
416
+ try:
417
+ if os.path.realpath(inp) == out_real:
418
+ die(f"refusing to run: output {output!r} is the same file as input {inp!r} "
419
+ f"(would overwrite it while ffmpeg is still reading it) -- choose a different --output/-o path",
420
+ kind="input")
421
+ except OSError:
422
+ continue
423
+
424
+
425
+ def refuse_output_is_input(output: str, *inputs: str) -> None:
426
+ """Tool-level twin of the run() guard, for tools whose final ffmpeg command does not name
427
+ the user's input at all. `cut.py --segments` cuts each part into a temp dir and then concats
428
+ a list file: the last command's only `-i` is that list, so `-o` equal to the input sailed
429
+ through _check_no_overwrite_input() and replaced the source with the join (fourth audit,
430
+ P0). Call it once the output path is known, before any part of the input is consumed."""
431
+ try:
432
+ out_real = os.path.realpath(output)
433
+ except OSError:
434
+ return
435
+ for inp in inputs:
436
+ try:
437
+ same = os.path.realpath(inp) == out_real
438
+ except OSError:
439
+ continue
440
+ if same:
441
+ die(f"refusing to run: output {output!r} is the same file as input {inp!r} "
442
+ f"(the result would replace the source) -- choose a different --output/-o path", kind="input")
443
+
444
+
445
+ def _check_output_path(cmd: Sequence[str]) -> None:
446
+ """An output whose directory does not exist, or that names a directory, is a caller mistake:
447
+ say so as `kind: input` before ffmpeg runs, instead of the muxer's "No such file or directory"
448
+ as `kind: ffmpeg` (which reads as an encoder failure) or an `OUTPUT_INVALID` after the fact."""
449
+ output = cmd[-1]
450
+ if output == "-" or output.startswith("pipe:") or output.startswith("-"):
451
+ return
452
+ if os.path.isdir(output):
453
+ die(f"output {output!r} is a directory; pass a file path (e.g. {os.path.join(output, 'result.mp4')!r})")
454
+ parent = os.path.dirname(os.path.abspath(output))
455
+ if not os.path.isdir(parent):
456
+ die(f"output directory {parent!r} does not exist; create it first (this tool never creates directories)")
457
+ if not os.access(parent, os.W_OK):
458
+ die(f"output directory {parent!r} is not writable")
459
+
460
+
461
+ EVEN_SCALE = "scale=trunc(iw/2)*2:trunc(ih/2)*2"
462
+
463
+
464
+ def _pid_dead(pid: int) -> bool:
465
+ """True only when the process is known not to exist. POSIX: signal 0. Windows: OpenProcess
466
+ fails with ERROR_INVALID_PARAMETER (87) for a pid that is not in use; any other outcome
467
+ (a handle, or access denied) means it is live. Unknown is treated as live."""
468
+ if os.name != "nt":
469
+ try:
470
+ os.kill(pid, 0)
471
+ except ProcessLookupError:
472
+ return True
473
+ except OSError:
474
+ pass
475
+ return False
476
+ try:
477
+ import ctypes
478
+ k32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
479
+ handle = k32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
480
+ if handle:
481
+ k32.CloseHandle(handle)
482
+ return False
483
+ return k32.GetLastError() == 87
484
+ except Exception:
485
+ return False
486
+
487
+
488
+ class _OutputLock:
489
+ """Two runs writing the same output at once used to both report `completed` while one of
490
+ them described the other's file (sweep F1). A lock file next to the output, created with
491
+ O_EXCL and holding the writer's pid, makes the second run refuse as `kind: input`. A lock
492
+ whose pid is dead (POSIX) or older than an hour is stale and taken over."""
493
+ def __init__(self, output: str) -> None:
494
+ self.path: Optional[str] = None
495
+ self.fd: Optional[int] = None
496
+ if output == "-" or output.startswith("pipe:") or output.startswith("-"):
497
+ return
498
+ d, base = os.path.split(os.path.abspath(output))
499
+ self.path = os.path.join(d, f".{base}.ffskill-lock")
500
+
501
+ def __enter__(self) -> "_OutputLock":
502
+ if not self.path:
503
+ return self
504
+ for attempt in (0, 1):
505
+ try:
506
+ self.fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
507
+ os.write(self.fd, str(os.getpid()).encode())
508
+ return self
509
+ except FileExistsError:
510
+ if attempt == 0 and self._stale():
511
+ try:
512
+ os.remove(self.path)
513
+ except OSError:
514
+ pass
515
+ continue
516
+ die(f"another run is writing {os.path.basename(self.path)[1:-len('.ffskill-lock')]!r} right now "
517
+ f"(lock {self.path}); wait for it or choose a different --output/-o path")
518
+ except OSError:
519
+ return self # unlockable location (read-only dir surfaces elsewhere): proceed without a lock
520
+ return self
521
+
522
+ def _stale(self) -> bool:
523
+ try:
524
+ pid = int(open(self.path).read().strip() or "0")
525
+ if pid > 0 and _pid_dead(pid):
526
+ return True
527
+ import time
528
+ return time.time() - os.path.getmtime(self.path) > 3600
529
+ except (OSError, ValueError):
530
+ return True
531
+
532
+ def __exit__(self, *exc: Any) -> None:
533
+ if self.fd is not None:
534
+ try:
535
+ os.close(self.fd)
536
+ except OSError:
537
+ pass
538
+ if self.path:
539
+ try:
540
+ os.remove(self.path)
541
+ except OSError:
542
+ pass
543
+
544
+
545
+ def _odd_dimension_retry(cmd: List[str], stderr: str) -> Optional[List[str]]:
546
+ """An odd-sized source (641x359 screen captures, some 4:4:4 masters) fails every yuv420p
547
+ encode with "width/height not divisible by 2" (sweep F8, 15 tools). Return the same command
548
+ with an even-dimension scale prepended to its -vf chain (or a new -vf when the command had
549
+ none); None when the failure is something else or the graph is a -filter_complex the
550
+ caller has to fix itself."""
551
+ if "not divisible by 2" not in stderr or EVEN_SCALE in cmd or any(EVEN_SCALE in a for a in cmd):
552
+ return None
553
+ if "-filter_complex" in cmd:
554
+ return None
555
+ new = list(cmd)
556
+ if "-vf" in new:
557
+ i = new.index("-vf") + 1
558
+ new[i] = EVEN_SCALE + "," + new[i]
559
+ return new
560
+ if "-c:v" in new and new[new.index("-c:v") + 1] == "copy":
561
+ return None
562
+ return new[:-1] + ["-vf", EVEN_SCALE, new[-1]]
563
+
564
+
565
+ def _check_existing_output(cmd: Sequence[str]) -> None:
566
+ """An output path that already exists is someone's file: a previous result, a source the
567
+ agent mis-named, a deliverable from another run. ffmpeg's -y (which every command carries so
568
+ a run never blocks on a y/N prompt) would replace it without a word. Until 2.0 this only
569
+ warns, per docs/contract.md's deprecation policy; FFMPEG_SKILL_NO_OVERWRITE=1 opts into the
570
+ 2.0 behaviour (refuse) today, and --overwrite is the explicit consent either way. Paths this
571
+ process wrote itself (a two-pass tool, a copy-then-re-encode fallback) are never in question."""
572
+ output = cmd[-1]
573
+ if output in ("-",) or output.startswith("pipe:") or output.startswith("-"):
574
+ return
575
+ try:
576
+ exists = os.path.isfile(output)
577
+ real = os.path.realpath(output)
578
+ except OSError:
579
+ return
580
+ if not exists or real in STATE.written:
581
+ return
582
+ try:
583
+ st = os.stat(output)
584
+ STATE.preexisting[real] = (st.st_size, st.st_mtime_ns)
585
+ except OSError:
586
+ pass
587
+ if STATE.overwrite:
588
+ return
589
+ if os.environ.get("FFMPEG_SKILL_NO_OVERWRITE", "") not in ("", "0"):
590
+ die(f"refusing to overwrite existing output {output!r}: pass --overwrite to replace it, or choose another -o path", kind="input")
591
+ info(f"warning: {output} already exists and will be overwritten (pass --overwrite to confirm; "
592
+ f"from 2.0 an existing output is refused without it, FFMPEG_SKILL_NO_OVERWRITE=1 enables that now)")
593
+
594
+
595
+ def _remember_output(cmd: Sequence[str]) -> None:
596
+ output = cmd[-1]
597
+ if output == "-" or output.startswith("pipe:") or output.startswith("-"):
598
+ return
599
+ try:
600
+ STATE.written.add(os.path.realpath(output))
601
+ except OSError:
602
+ pass
603
+
604
+
605
+ def _timed_out(cmd: Sequence[str], seconds: float) -> "None":
606
+ _cleanup_partial_output(cmd)
607
+ die(f"{os.path.basename(cmd[0])} exceeded the {seconds:.0f} s time limit and was killed; nothing was written. "
608
+ f"Raise --timeout (or FFMPEG_SKILL_TIMEOUT) if the job is genuinely that long, or check the input for a stall",
609
+ code=124, kind="timeout")
610
+
611
+
612
+ def _stage_existing_output(cmd: Sequence[str]) -> Tuple[List[str], Optional[str], Optional[str]]:
613
+ """When the output path already holds someone's file, run ffmpeg against a hidden sibling
614
+ temp path and move it over the original only on success.
615
+
616
+ ffmpeg's -y truncates the output the moment it opens it, and *when* it opens it depends on
617
+ the version: 6.1+ initialises the filter graph first (a bad LUT fails before the file is
618
+ touched), 5.x opens the output during option parsing, before any filter runs, so the same
619
+ bad LUT leaves a 0-byte file where the deliverable was. No amount of post-failure cleanup
620
+ can undo that; the only way to keep an existing file safe across a failed run is for ffmpeg
621
+ never to write to it. Same directory, same extension (the muxer is chosen by it), hidden
622
+ name, so nothing else changes for the encoder. Returns (command to execute, final path,
623
+ temp path); (cmd, None, None) when no staging is needed."""
624
+ output = cmd[-1]
625
+ if output == "-" or output.startswith("pipe:") or output.startswith("-"):
626
+ return list(cmd), None, None
627
+ try:
628
+ if not os.path.isfile(output) or os.path.realpath(output) in STATE.written:
629
+ return list(cmd), None, None
630
+ except OSError:
631
+ return list(cmd), None, None
632
+ d, base = os.path.split(output)
633
+ stem, ext = os.path.splitext(base)
634
+ tmp = os.path.join(d, f".{stem}.ffskill-{os.getpid()}{ext}")
635
+ return list(cmd[:-1]) + [tmp], output, tmp
636
+
637
+
638
+ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Optional[Context]" = None) -> subprocess.CompletedProcess:
639
+ """Run a command, echoing it to stderr unless quiet. Exits on failure when check=True.
640
+
641
+ ffmpeg invocations are recorded in STATE.commands (for --json), skipped under --dry-run
642
+ (a fake successful CompletedProcess is returned so scripts can keep planning), and run
643
+ with a progress readout under --progress. ffprobe and other tools always run. An output
644
+ path that already exists is written through a temp file and replaced only on success
645
+ (see _stage_existing_output), so a failed run never costs the caller the file that was there.
646
+
647
+ `ctx` is the optional per-request Context added in 1.10 (2.0 makes it required, issue #189 B);
648
+ omitted, the commands and flags are read from the process-global STATE as before.
649
+ """
650
+ ctx = ctx or STATE
651
+ is_ffmpeg = _is_ffmpeg(cmd)
652
+ if is_ffmpeg:
653
+ _check_no_overwrite_input(cmd)
654
+ _check_output_path(cmd)
655
+ _check_existing_output(cmd)
656
+ ctx.commands.append(_cmdline(cmd))
657
+ if not quiet:
658
+ info(("[dry-run] $ " if ctx.dry_run and is_ffmpeg else "$ ") + _cmdline(cmd), ctx=ctx)
659
+ if ctx.dry_run and is_ffmpeg:
660
+ return subprocess.CompletedProcess(list(cmd), 0, "", "")
661
+ if is_ffmpeg:
662
+ flush_drawtext_textfiles(cmd)
663
+ with _OutputLock(cmd[-1] if is_ffmpeg else "-"):
664
+ exec_cmd, final, tmp = _stage_existing_output(cmd) if is_ffmpeg else (list(cmd), None, None)
665
+ proc = _execute(exec_cmd)
666
+ if proc.returncode != 0 and is_ffmpeg:
667
+ retry = _odd_dimension_retry(exec_cmd, proc.stderr or "")
668
+ if retry is not None:
669
+ info("source has odd dimensions; scaling to even before encoding (yuv420p needs it)")
670
+ ctx.commands[-1] = _cmdline(retry[:-1] + [cmd[-1]])
671
+ proc = _execute(retry)
672
+ elif "not divisible by 2" in (proc.stderr or ""):
673
+ die("the source has odd dimensions (width or height not divisible by 2) and this tool's filter graph "
674
+ "cannot pad them itself; make them even first, e.g. fit.py --width/--height, then retry",
675
+ kind="input")
676
+ if proc.returncode != 0 and check:
677
+ _fail(exec_cmd, proc.returncode, proc.stderr or "")
678
+ if final and tmp:
679
+ if proc.returncode == 0:
680
+ try:
681
+ os.replace(tmp, final)
682
+ except OSError as e:
683
+ _cleanup_partial_output(exec_cmd)
684
+ die(f"could not replace {final} with the new output: {e}", kind="output")
685
+ _remember_output(cmd)
686
+ else:
687
+ _cleanup_partial_output(exec_cmd)
688
+ return proc
689
+
690
+
691
+ def _execute(exec_cmd: List[str]) -> subprocess.CompletedProcess:
692
+ """One attempt, never exiting on failure (run() decides after its retries)."""
693
+ if STATE.progress and _is_ffmpeg(exec_cmd) and exec_cmd[-1] != "-":
694
+ return _run_with_progress(exec_cmd, False)
695
+ return _run_captured(exec_cmd, False)
696
+
697
+
698
+ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, record: bool = False) -> subprocess.CompletedProcess:
699
+ """Run an ffmpeg *measurement* (scene scores, crop rectangles, decoded PCM, signal stats,
700
+ silence detection, loudness, stabilisation pass 1): output to `-f null`, a pipe or a temp
701
+ file, no deliverable written. These are not run() calls -- they run under --dry-run too,
702
+ since a plan built on a fake measurement is not a plan (silence.py used to report "0
703
+ silences" and loudness.py a made-up -20 LUFS under --dry-run) -- but they get the same
704
+ wall-clock limit as any other ffmpeg invocation and, with check=True, the same `kind: ffmpeg`
705
+ failure instead of an exit-0 "0 scenes found" over a file ffmpeg could not read. record=True
706
+ lists the command in the --json `commands` like run() does."""
707
+ if record:
708
+ STATE.commands.append(_cmdline(cmd))
709
+ limit = _limit_for(cmd)
710
+ try:
711
+ proc = subprocess.run(list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=text, timeout=limit)
712
+ except subprocess.TimeoutExpired:
713
+ _timed_out(cmd, limit or 0)
714
+ if check and proc.returncode != 0:
715
+ err = proc.stderr if text else proc.stderr.decode(errors="replace")
716
+ _fail(cmd, proc.returncode, err)
717
+ return proc
718
+
719
+
720
+ def dry_run_input_pending(path: str) -> bool:
721
+ """True when a measurement cannot run because its input does not exist yet under --dry-run:
722
+ in a render.py/batch.py plan each stage's input is the previous stage's output, which a dry
723
+ run never wrote. The measurement is then skipped (with a note) rather than failing the plan;
724
+ on a real file the measurement runs even under --dry-run."""
725
+ if STATE.dry_run and not os.path.exists(path):
726
+ info(f"[dry-run] {path} does not exist yet (an earlier dry-run stage would write it); measurement skipped")
727
+ return True
728
+ return False
729
+
730
+
731
+ def child_limit(per_call: Optional[float] = None) -> Optional[float]:
732
+ """Wall-clock ceiling for running one sibling script as a subprocess (render/batch/report
733
+ stages, the MCP server's dispatch). A tool runs a handful of ffmpeg/ffprobe calls, each
734
+ under its own --timeout, so the outer ceiling is a multiple of that plus a margin: it never
735
+ fires first on a healthy run, and it is the only thing that ends a child hung for a reason
736
+ that is not ffmpeg (a stuck import, a wedged pipe). None when the per-call limit is 0."""
737
+ limit = STATE.timeout if per_call is None else per_call
738
+ return (limit * 4 + 60) if limit else None
739
+
740
+
741
+ def run_tool(argv: Sequence[str], *, per_call: Optional[float] = None) -> subprocess.CompletedProcess:
742
+ """Run a sibling script (`argv[0]` is the script path) under child_limit(). On overrun the
743
+ child is killed and a CompletedProcess is returned whose stdout is this skill's own failure
744
+ document (kind timeout, exit 124), so callers that parse the child's --json see a timeout
745
+ exactly as they would from the child itself."""
746
+ limit = child_limit(per_call)
747
+ child = subprocess.Popen([sys.executable] + list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
748
+ _watch(child, []) # a sibling script removes its own partial output; there is none of ours to clean
749
+ try:
750
+ out, err = child.communicate(timeout=limit)
751
+ _unwatch(child)
752
+ return subprocess.CompletedProcess(child.args, child.returncode, out, err)
753
+ except subprocess.TimeoutExpired as e:
754
+ child.kill()
755
+ child.communicate()
756
+ _unwatch(child)
757
+ name = os.path.basename(str(argv[0]))
758
+ msg = f"{name} did not finish within {limit:.0f} s (4x the per-ffmpeg --timeout plus 60 s) and was killed"
759
+ doc = {"status": "failed", "exit_code": 124,
760
+ "error": {"kind": "timeout", "message": msg, "code": ERROR_CODE["timeout"], "retryable": ERROR_RETRYABLE},
761
+ "commands": []}
762
+ partial = e.stderr.decode(errors="replace") if isinstance(e.stderr, bytes) else (e.stderr or "")
763
+ return subprocess.CompletedProcess(list(argv), 124, json.dumps(doc), partial + f"\nerror: {msg}\n")
764
+
765
+
766
+ def child_args() -> List[str]:
767
+ """The shared flags a tool that runs sibling scripts (render.py, batch.py) forwards to them,
768
+ so one `--timeout`/`--overwrite`/`--fast`/`--dry-run` on the outer command governs every
769
+ stage. Before 1.4.3 only --fast and --dry-run were forwarded; a --timeout given to render.py
770
+ stopped at render.py."""
771
+ args: List[str] = []
772
+ if STATE.fast:
773
+ args.append("--fast")
774
+ if STATE.dry_run:
775
+ args.append("--dry-run")
776
+ if STATE.overwrite:
777
+ args.append("--overwrite")
778
+ args += ["--timeout", f"{STATE.timeout:g}"]
779
+ return args
780
+
781
+
782
+ def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
783
+ """Run an ffmpeg command that already maps its video/audio, trying first to also
784
+ stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
785
+ there are none). A source whose subtitle codec cannot be copied into the target container
786
+ (e.g. a container change) makes that first attempt fail; retry the same command without the
787
+ extra maps rather than let a tool that never touched subtitles start hard-failing because of
788
+ them. `cmd` is the full argv *without* the output path. Returns True only when the
789
+ retry-without-subtitles path was actually needed (i.e. subtitle/data streams were dropped)."""
790
+ if run(cmd + ["-map", "0:s?", "-map", "0:d?", "-c:s", "copy", "-c:d", "copy", output], check=False).returncode == 0:
791
+ return False
792
+ run(cmd + [output])
793
+ return True
794
+
795
+
796
+ def _limit_for(cmd: Sequence[str]) -> Optional[float]:
797
+ """The wall-clock ceiling for this command: ffprobe (and other read-only probes) get a fixed
798
+ short one, ffmpeg the configured one; None means unlimited."""
799
+ if not _is_ffmpeg(cmd):
800
+ return PROBE_TIMEOUT if STATE.timeout else None
801
+ return STATE.timeout or None
802
+
803
+
804
+ def _run_captured(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
805
+ """Plain run with stdout/stderr captured."""
806
+ limit = _limit_for(cmd)
807
+ child = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
808
+ _watch(child, cmd)
809
+ try:
810
+ out, err = child.communicate(timeout=limit)
811
+ except subprocess.TimeoutExpired:
812
+ child.kill()
813
+ child.communicate()
814
+ _unwatch(child)
815
+ _timed_out(cmd, limit or 0)
816
+ finally:
817
+ _unwatch(child)
818
+ proc = subprocess.CompletedProcess(list(cmd), child.returncode, out, err)
819
+ if proc.returncode == 0 and _is_ffmpeg(cmd):
820
+ _remember_output(cmd)
821
+ if proc.returncode != 0:
822
+ # Cleanup happens for every failed ffmpeg invocation, not just the check=True/_fail()
823
+ # path: a handful of scripts (cut.py, loudness.py, silence.py, sync.py) call run() with
824
+ # check=False so they can compose their own die() message from proc.stderr, but the
825
+ # partial-output risk is identical either way -- and for a script that retries into the
826
+ # same output path after a check=False failure (e.g. color.py's --retag copy-then-
827
+ # reencode fallback), removing the stale partial first is strictly safer than leaving it
828
+ # for -y to overwrite.
829
+ _cleanup_partial_output(cmd)
830
+ if check:
831
+ _fail(cmd, proc.returncode, proc.stderr)
832
+ return proc
833
+
834
+
835
+ def _progress_line(done: float, total: float, elapsed: float) -> str:
836
+ if total > 0:
837
+ pct = min(99.9, done / total * 100)
838
+ eta = (elapsed / pct * (100 - pct)) if pct > 0.5 else 0
839
+ return f"\r {pct:5.1f}% {done:7.1f}s / {total:.1f}s ETA {eta:4.0f}s"
840
+ return f"\r {done:7.1f}s encoded"
841
+
842
+
843
+ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
844
+ """Run ffmpeg with -progress on a pipe and print percent/ETA to stderr.
845
+
846
+ The time limit is checked on a clock, not per progress line: a deadlocked ffmpeg (the very
847
+ case --timeout exists for) prints nothing, so a loop that only looked at the deadline when a
848
+ line arrived waited on it forever. Reader threads drain both pipes; the main loop wakes at
849
+ least twice a second to compare the clock against the limit."""
850
+ import queue
851
+ import threading
852
+ import time
853
+ total = STATE.duration_hint or 0.0
854
+ full = cmd[:1] + ["-progress", "pipe:1", "-nostats"] + cmd[1:]
855
+ t0 = time.time()
856
+ limit = _limit_for(cmd)
857
+ proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
858
+ _watch(proc, cmd)
859
+ assert proc.stdout is not None and proc.stderr is not None
860
+ lines: "queue.Queue[Optional[str]]" = queue.Queue()
861
+ err_chunks: List[str] = []
862
+
863
+ def pump_out() -> None:
864
+ for line in proc.stdout: # type: ignore[union-attr]
865
+ lines.put(line)
866
+ lines.put(None)
867
+
868
+ def pump_err() -> None:
869
+ err_chunks.append(proc.stderr.read()) # type: ignore[union-attr]
870
+
871
+ threading.Thread(target=pump_out, daemon=True).start()
872
+ err_thread = threading.Thread(target=pump_err, daemon=True)
873
+ err_thread.start()
874
+ last = ""
875
+
876
+ def clear_line() -> None:
877
+ if last:
878
+ sys.stderr.write("\r" + " " * len(last) + "\r")
879
+
880
+ def timed_out() -> None:
881
+ proc.kill()
882
+ proc.wait()
883
+ clear_line()
884
+ _timed_out(cmd, limit or 0)
885
+
886
+ while True:
887
+ remaining = (limit - (time.time() - t0)) if limit else None
888
+ if remaining is not None and remaining <= 0:
889
+ timed_out()
890
+ try:
891
+ line = lines.get(timeout=min(0.5, remaining) if remaining is not None else 0.5)
892
+ except queue.Empty:
893
+ continue
894
+ if line is None:
895
+ break
896
+ if line.startswith("out_time_us=") or line.startswith("out_time_ms="):
897
+ try:
898
+ done = int(line.split("=")[1]) / 1_000_000
899
+ except ValueError:
900
+ continue
901
+ msg = _progress_line(done, total, time.time() - t0)
902
+ if msg != last:
903
+ sys.stderr.write(msg)
904
+ sys.stderr.flush()
905
+ last = msg
906
+ try:
907
+ proc.wait(timeout=(max(5.0, limit - (time.time() - t0)) if limit else None))
908
+ except subprocess.TimeoutExpired:
909
+ timed_out()
910
+ _unwatch(proc)
911
+ err_thread.join()
912
+ err = "".join(err_chunks)
913
+ clear_line()
914
+ if proc.returncode == 0:
915
+ _remember_output(cmd)
916
+ if proc.returncode != 0:
917
+ _cleanup_partial_output(cmd)
918
+ if check:
919
+ _fail(cmd, proc.returncode, err)
920
+ return subprocess.CompletedProcess(full, proc.returncode, "", err)
921
+
922
+
923
+ def shell_quote(s: str) -> str:
924
+ if not s or any(ch in s for ch in " \t\n\r\\\"';|&<>()[]{}$*?"):
925
+ return "'" + s.replace("'", "'\\''") + "'"
926
+ return s
927
+
928
+
929
+ def ffmpeg_base(overwrite: bool = True) -> List[str]:
930
+ cmd = [require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin"]
931
+ cmd.append("-y" if overwrite else "-n")
932
+ return cmd
933
+
934
+
935
+ def place_output(src: str, dst: str) -> None:
936
+ """Deliver an already-rendered file to `dst` under the same rules as an ffmpeg output:
937
+ the path is checked, an existing file is only replaced through a sibling temp so a
938
+ failed copy never costs the caller what was there, and the result is remembered as ours.
939
+ render.py's final `copyfile()` used to bypass all three."""
940
+ import shutil
941
+ cmd = ["ffmpeg", dst]
942
+ _check_output_path(cmd)
943
+ _check_existing_output(cmd)
944
+ d, base = os.path.split(dst)
945
+ stem, ext = os.path.splitext(base)
946
+ tmp = os.path.join(d, f".{stem}.ffskill-{os.getpid()}{ext}")
947
+ try:
948
+ shutil.copyfile(src, tmp)
949
+ os.replace(tmp, dst)
950
+ except OSError as e:
951
+ try:
952
+ os.remove(tmp)
953
+ except OSError:
954
+ pass
955
+ die(f"could not place {dst}: {e}", kind="output")
956
+ _remember_output(cmd)
957
+
958
+
959
+ _DRAWTEXT_TMPDIR: "Optional[str]" = None
960
+
961
+
962
+ _DRAWTEXT_PENDING: "Dict[str, str]" = {}
963
+
964
+
965
+ def _drawtext_tmpdir(create: bool = True) -> str:
966
+ """The private, per-run directory drawn-text files live in.
967
+
968
+ tempfile.mkdtemp() creates it 0700 under a name nobody can guess, which is the whole point:
969
+ the 1.15.0 shape (a fixed, world-writable `/tmp/ffmpeg-skill-text` entered with
970
+ makedirs(exist_ok=True) and content-addressed filenames) let any other user on the machine
971
+ pre-create the directory or plant a symlink at a predictable name, and handed the second
972
+ user of a shared box a PermissionError out of filter construction instead of a `kind: input`
973
+ refusal. The directory is removed when the process ends, whether it succeeded or failed.
974
+ """
975
+ global _DRAWTEXT_TMPDIR
976
+ import tempfile
977
+ if _DRAWTEXT_TMPDIR and os.path.isdir(_DRAWTEXT_TMPDIR):
978
+ return _DRAWTEXT_TMPDIR
979
+ if not create:
980
+ # --dry-run names the path it WOULD use and creates nothing (a dry run writes nothing).
981
+ return os.path.join(tempfile.gettempdir(), "ffmpeg-skill-text-%d" % os.getpid())
982
+ import atexit
983
+ _DRAWTEXT_TMPDIR = tempfile.mkdtemp(prefix="ffmpeg-skill-text-")
984
+ atexit.register(shutil.rmtree, _DRAWTEXT_TMPDIR, True)
985
+ return _DRAWTEXT_TMPDIR
986
+
987
+
988
+ def flush_drawtext_textfiles(cmd: "Sequence[str]") -> "List[str]":
989
+ """Write the drawn-text files this command actually names, and return their paths.
990
+
991
+ The text is registered when the filter STRING is built, but a filter string is not a run:
992
+ graphics.py builds the drawtext graph even on a job that is finally rendered through libass,
993
+ and every tool builds one under --dry-run. Writing here -- from run(), past the dry-run
994
+ return, against the command that is about to be executed -- is what keeps both of those from
995
+ leaving a file behind.
996
+ """
997
+ if not _DRAWTEXT_PENDING:
998
+ return []
999
+ joined = " ".join(str(a) for a in cmd)
1000
+ written = []
1001
+ for path, body in list(_DRAWTEXT_PENDING.items()):
1002
+ # match on the unique file name, not the full path: inside a filter string the path is
1003
+ # escaped (a Windows drive colon becomes `C\\:`, and the separators are forward slashes),
1004
+ # so the registered spelling never appears verbatim in the command
1005
+ if os.path.basename(path) not in joined or os.path.exists(path):
1006
+ continue
1007
+ # O_NOFOLLOW exists on POSIX only; the directory is private (mkdtemp, 0700) so the
1008
+ # symlink guard is belt and braces there and unavailable on Windows
1009
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
1010
+ fd = os.open(path, flags, 0o600)
1011
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
1012
+ fh.write(body)
1013
+ written.append(path)
1014
+ return written
1015
+
1016
+
1017
+ def ffmpeg_encoders() -> set:
1018
+ """Names from `ffmpeg -encoders`, read once; empty when ffmpeg is missing. Used only to pick
1019
+ an AV1 encoder and to refuse --codec av1 / prores before ffmpeg would."""
1020
+ global _ENCODERS
1021
+ if _ENCODERS is None:
1022
+ _ENCODERS = set()
1023
+ try:
1024
+ out = subprocess.run([shutil.which("ffmpeg") or "ffmpeg", "-hide_banner", "-encoders"], stdout=subprocess.PIPE,
1025
+ stderr=subprocess.DEVNULL, text=True, timeout=PROBE_TIMEOUT).stdout
1026
+ _ENCODERS = set(re.findall(r"^\s*[VAS][.\w]{5}\s+(\S+)", out, re.M))
1027
+ except (OSError, subprocess.SubprocessError):
1028
+ pass
1029
+ return _ENCODERS
1030
+
1031
+
1032
+ def read_text_or_die(path: str, flag: str) -> str:
1033
+ """Read a caller-supplied UTF-8 text file (a cue list, chapters, notes) or fail as kind input
1034
+ with the flag named, instead of a FileNotFoundError / UnicodeDecodeError traceback."""
1035
+ if os.path.isdir(path):
1036
+ # checked first: Windows raises PermissionError, not IsADirectoryError, for a directory
1037
+ die(f"{flag}: {path} is a directory, not a text file")
1038
+ try:
1039
+ with open(path, "r", encoding="utf-8") as fh:
1040
+ return fh.read()
1041
+ except FileNotFoundError:
1042
+ die(f"{flag}: {path} does not exist")
1043
+ except IsADirectoryError:
1044
+ die(f"{flag}: {path} is a directory, not a text file")
1045
+ except UnicodeDecodeError as e:
1046
+ die(f"{flag}: {path} is not UTF-8 text ({e.reason} at byte {e.start}); save it as UTF-8")
1047
+ except OSError as e:
1048
+ die(f"{flag}: cannot read {path}: {e.strerror}")
1049
+ return "" # unreachable
1050
+
1051
+
1052
+ # Deferred to the foot of the module on purpose. runner is the first module the package loads and
1053
+ # emit needs runner's Context/STATE/ERROR_CODE, so the two form a cycle that has to be cut
1054
+ # somewhere: by the time this line runs every name emit reads from runner above is defined, and
1055
+ # every name runner reads from emit is only ever read inside a function body, never at import.
1056
+ from _common.emit import _plan_at_exit, die, info # noqa: E402