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.
- package/README.md +12 -4
- package/SKILL.md +4 -4
- package/docs/contract.md +32 -11
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +6 -1
- package/references/gotchas.md +70 -1
- package/references/scripts.md +49 -7
- package/scripts/_ass_overlay.py +155 -0
- package/scripts/_common/__init__.py +187 -0
- package/scripts/_common/color.py +69 -0
- package/scripts/_common/decision.py +415 -0
- package/scripts/_common/emit.py +287 -0
- package/scripts/_common/probe.py +382 -0
- package/scripts/_common/runner.py +1056 -0
- package/scripts/_common/text.py +980 -0
- package/scripts/_contract.py +19 -4
- package/scripts/caption.py +326 -92
- package/scripts/graphics.py +295 -19
- package/scripts/overlay.py +35 -2
- package/scripts/_common.py +0 -2516
|
@@ -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
|