ffmpeg-skill 2.10.11 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/docs/contract.md +107 -11
- package/mcp/server.py +7 -1
- package/package.json +1 -1
- package/references/scripts.md +92 -20
- package/scripts/_common/__init__.py +6 -4
- package/scripts/_common/asr.py +2 -2
- package/scripts/_common/decision.py +57 -11
- package/scripts/_common/emit.py +198 -2
- package/scripts/_common/fonts.py +93 -28
- package/scripts/_common/runner.py +246 -68
- package/scripts/_common/timeline.py +22 -6
- package/scripts/_contract.py +168 -10
- package/scripts/audio.py +2 -1
- package/scripts/batch.py +240 -105
- package/scripts/caption.py +68 -18
- package/scripts/color.py +2 -1
- package/scripts/cropdetect.py +19 -4
- package/scripts/export.py +42 -10
- package/scripts/graphics.py +24 -18
- package/scripts/grid.py +7 -3
- package/scripts/join.py +5 -3
- package/scripts/loop.py +4 -2
- package/scripts/multicam.py +11 -3
- package/scripts/overlay.py +14 -10
- package/scripts/render.py +223 -74
- package/scripts/report.py +77 -23
- package/scripts/reverse.py +4 -2
- package/scripts/sequence.py +4 -2
- package/scripts/silence.py +19 -7
- package/scripts/sync.py +18 -7
- package/scripts/verify.py +98 -24
- package/scripts/waveform.py +3 -2
package/scripts/batch.py
CHANGED
|
@@ -32,9 +32,9 @@ import time
|
|
|
32
32
|
from pathlib import Path
|
|
33
33
|
from typing import Any, Dict, List, Optional
|
|
34
34
|
|
|
35
|
-
from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool,
|
|
36
|
-
from _common.runner import HW_FORCED_ENV, add_hw_orchestrator_args, env_hw
|
|
37
|
-
from _common.emit import absorb_stage_hw, steps_encoder_report
|
|
35
|
+
from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool, MEDIA_EXT as _MEDIA_EXT
|
|
36
|
+
from _common.runner import HW_FORCED_ENV, add_hw_orchestrator_args, env_hw, stop_children
|
|
37
|
+
from _common.emit import absorb_stage_hw, audit, note, steps_encoder_report, warn
|
|
38
38
|
|
|
39
39
|
HERE = Path(__file__).resolve().parent
|
|
40
40
|
MEDIA_EXT = {e for e in _MEDIA_EXT if e not in (".png", ".jpg", ".jpeg", ".webp")} # one list (_common); a batch walks media, not stills
|
|
@@ -70,8 +70,11 @@ def recipe_key(recipe: Dict[str, Any]) -> str:
|
|
|
70
70
|
if recipe.get("project"):
|
|
71
71
|
try:
|
|
72
72
|
project_content = Path(recipe["project"]).read_text(encoding="utf-8")
|
|
73
|
-
except OSError:
|
|
74
|
-
|
|
73
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
74
|
+
# keyed on the recipe alone: an unreadable project cannot invalidate the cache by its
|
|
75
|
+
# content, so say so (each item that needs it then fails on reading it)
|
|
76
|
+
warn(f"recipe.project {recipe['project']} could not be read ({exc}); the cache key covers the recipe only",
|
|
77
|
+
"recipe_project_unreadable", path=str(recipe["project"]), error=str(exc))
|
|
75
78
|
return hashlib.sha1((json.dumps(recipe, sort_keys=True) + "\0" + project_content).encode()).hexdigest()[:12]
|
|
76
79
|
|
|
77
80
|
|
|
@@ -90,6 +93,8 @@ def gpu_key() -> str:
|
|
|
90
93
|
JOBS_CAP = 8 # beyond this, concurrent encodes contend for the same cores and memory
|
|
91
94
|
|
|
92
95
|
_LOG = threading.local()
|
|
96
|
+
# set by batch.py's signal handler: a --jobs worker between two steps must not start the next one
|
|
97
|
+
_STOP = threading.Event()
|
|
93
98
|
|
|
94
99
|
|
|
95
100
|
def log(message: str) -> None:
|
|
@@ -102,16 +107,46 @@ def log(message: str) -> None:
|
|
|
102
107
|
buf.append(message)
|
|
103
108
|
|
|
104
109
|
|
|
110
|
+
class ItemError(Exception):
|
|
111
|
+
"""An item that cannot be processed, with the structured `error` its results row carries.
|
|
112
|
+
process() raises it instead of die(): die() inside an item printed a second JSON document
|
|
113
|
+
and left the row with error "1" (str(SystemExit))."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, error: Dict[str, Any]) -> None:
|
|
116
|
+
super().__init__(error.get("message"))
|
|
117
|
+
self.error = error
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def step_error(index: Optional[int], tool: Optional[str], doc: Any) -> Dict[str, Any]:
|
|
121
|
+
"""A failed step's own failure, for its item's row: {step, tool, kind, message, code, reason,
|
|
122
|
+
details, hint, exit_code} from the step's --json document (keys it did not report are left
|
|
123
|
+
out; step and tool are always there)."""
|
|
124
|
+
err = doc.get("error") if isinstance(doc, dict) and isinstance(doc.get("error"), dict) else {}
|
|
125
|
+
out: Dict[str, Any] = {"step": index, "tool": tool}
|
|
126
|
+
for k in ("kind", "message", "code", "reason", "details", "hint"):
|
|
127
|
+
if err.get(k) is not None:
|
|
128
|
+
out[k] = err[k]
|
|
129
|
+
if isinstance(doc, dict) and doc.get("exit_code") is not None:
|
|
130
|
+
out["exit_code"] = doc["exit_code"]
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
105
134
|
def run_step(argv: List[str], per_call: "Optional[float]" = None) -> "tuple[bool, Optional[dict]]":
|
|
106
|
-
"""Returns (ok, doc): doc is the step's own --json result document
|
|
107
|
-
|
|
135
|
+
"""Returns (ok, doc): doc is the step's own --json result document; a failed step that
|
|
136
|
+
printed none that parses gets one in the failure shape (error.reason step_output_unparsed,
|
|
137
|
+
the last stderr line as the message), so its row still says why. {in}/{out} are already resolved by the caller before argv reaches here, so forcing
|
|
108
138
|
--json on every step (harmless: no step reads another step's stdout, only its output path)
|
|
109
139
|
costs nothing and lets a caller like process() read back tool-specific fields, e.g. cut.py's
|
|
110
140
|
`reencoded`, without adding a bespoke flag per field it might someday want."""
|
|
111
141
|
script = argv[0]
|
|
112
142
|
if script not in ALLOWED_STEP_SCRIPTS:
|
|
113
|
-
|
|
114
|
-
|
|
143
|
+
# main() refuses such a recipe before any item runs; this stays as the last line of defence
|
|
144
|
+
raise ItemError({"step": None, "tool": script, "kind": "input", "reason": "step_script_not_allowed",
|
|
145
|
+
"message": f"recipe step names a script that isn't one of this skill's own tools: {script!r} "
|
|
146
|
+
"(must be a bare filename like 'silence.py', found in scripts/)"})
|
|
147
|
+
if _STOP.is_set():
|
|
148
|
+
return False, {"status": "failed", "exit_code": 130,
|
|
149
|
+
"error": {"kind": "interrupted", "message": f"interrupted before {script} started"}}
|
|
115
150
|
cmd = [str(HERE / script)] + argv[1:] + child_args() + (["--json"] if "--json" not in argv[1:] else [])
|
|
116
151
|
log(" → " + " ".join(os.path.basename(c) if i < 1 else c for i, c in enumerate(cmd)))
|
|
117
152
|
proc = run_tool(cmd, per_call=per_call)
|
|
@@ -121,6 +156,10 @@ def run_step(argv: List[str], per_call: "Optional[float]" = None) -> "tuple[bool
|
|
|
121
156
|
doc = None
|
|
122
157
|
if proc.returncode != 0:
|
|
123
158
|
log(" " + "\n ".join(proc.stderr.strip().splitlines()[-4:]))
|
|
159
|
+
if not isinstance(doc, dict):
|
|
160
|
+
doc = {"status": "failed", "exit_code": proc.returncode,
|
|
161
|
+
"error": {"reason": "step_output_unparsed",
|
|
162
|
+
"message": (proc.stderr.strip().splitlines() or [f"{script} exited {proc.returncode} and printed nothing"])[-1][:300]}}
|
|
124
163
|
return False, doc
|
|
125
164
|
for line in proc.stderr.splitlines():
|
|
126
165
|
if line.startswith("warning:"): # a step's deprecation notice is not swallowed by a success (review 9)
|
|
@@ -159,9 +198,13 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
|
|
|
159
198
|
return max(1.0, deadline - time.monotonic())
|
|
160
199
|
if recipe.get("project"):
|
|
161
200
|
try:
|
|
162
|
-
proj = json.loads(
|
|
163
|
-
except ValueError as e:
|
|
164
|
-
|
|
201
|
+
proj = json.loads(Path(str(recipe["project"])).read_text(encoding="utf-8"))
|
|
202
|
+
except (OSError, ValueError) as e:
|
|
203
|
+
raise ItemError({"step": None, "tool": "render.py", "kind": "input", "reason": "recipe_project_unreadable",
|
|
204
|
+
"message": f"recipe.project: {recipe['project']} could not be read as JSON: {e}"})
|
|
205
|
+
if not isinstance(proj, dict):
|
|
206
|
+
raise ItemError({"step": None, "tool": "render.py", "kind": "input", "reason": "recipe_project_unreadable",
|
|
207
|
+
"message": f"recipe.project: {recipe['project']} is not a JSON object"})
|
|
165
208
|
idx = int(recipe.get("clip_key", 0))
|
|
166
209
|
proj.setdefault("clips", [{}])
|
|
167
210
|
while len(proj["clips"]) <= idx:
|
|
@@ -172,14 +215,17 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
|
|
|
172
215
|
pj.write_text(json.dumps(proj, indent=2), encoding="utf-8")
|
|
173
216
|
ok, _doc = run_step(["render.py", str(pj)], budget())
|
|
174
217
|
step_docs = [("render", _doc)]
|
|
218
|
+
failure = None if ok else step_error(0, "render.py", _doc)
|
|
175
219
|
cut_reencoded: List[bool] = []
|
|
176
220
|
cut_reasons: List[str] = []
|
|
177
221
|
else:
|
|
178
222
|
steps = recipe.get("steps") or []
|
|
179
223
|
if not steps:
|
|
180
|
-
|
|
224
|
+
raise ItemError({"step": None, "tool": None, "kind": "input", "reason": "recipe_has_no_steps",
|
|
225
|
+
"message": "recipe needs steps or project"})
|
|
181
226
|
cur = str(src)
|
|
182
227
|
ok = True
|
|
228
|
+
failure = None
|
|
183
229
|
cut_reencoded = []
|
|
184
230
|
step_docs = []
|
|
185
231
|
cut_reasons = []
|
|
@@ -198,9 +244,30 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
|
|
|
198
244
|
cut_reasons.extend(r for r in (doc.get("reencode_reason") or []) if isinstance(r, str))
|
|
199
245
|
if not step_ok:
|
|
200
246
|
ok = False
|
|
247
|
+
failure = step_error(i, str(argv[0]) if argv else None, doc)
|
|
201
248
|
break
|
|
202
249
|
cur = out
|
|
203
250
|
result: Dict[str, Any] = {"file": str(src), "output": str(final), "ok": ok, "seconds": round(time.time() - t0, 1)}
|
|
251
|
+
if failure:
|
|
252
|
+
result["error"] = failure
|
|
253
|
+
# `ok` is "every step completed" (design-decisions: a spec miss a tool cannot fix is completed +
|
|
254
|
+
# verified false, not a failure); `verified` is whether every step also verified what it wrote,
|
|
255
|
+
# and `unverified` names the steps that did not and which of their checks failed
|
|
256
|
+
unverified = []
|
|
257
|
+
for idx, (name, doc) in enumerate(step_docs):
|
|
258
|
+
if ok and isinstance(doc, dict) and doc.get("verified") is not True:
|
|
259
|
+
unverified.append({"step": idx, "tool": name,
|
|
260
|
+
"failed": [str(v.get("step")) + (f" ({v['stage']})" if v.get("stage") else "")
|
|
261
|
+
for v in doc.get("verification") or [] if isinstance(v, dict) and not v.get("ok")]})
|
|
262
|
+
result["verified"] = bool(ok and not STATE.dry_run and step_docs and not unverified)
|
|
263
|
+
if unverified and not STATE.dry_run:
|
|
264
|
+
result["unverified"] = unverified
|
|
265
|
+
diag: Dict[str, List[Any]] = {}
|
|
266
|
+
for name, doc in step_docs:
|
|
267
|
+
for bucket, entries in ((doc or {}).get("diagnostics") or {}).items() if isinstance(doc, dict) else []:
|
|
268
|
+
diag.setdefault(bucket, []).extend(dict(e, step=name) for e in entries if isinstance(e, dict))
|
|
269
|
+
if diag:
|
|
270
|
+
result["diagnostics"] = diag
|
|
204
271
|
if cut_reencoded:
|
|
205
272
|
result["cut_reencoded"] = cut_reencoded
|
|
206
273
|
result["cut_reencode_reasons"] = cut_reasons
|
|
@@ -210,6 +277,34 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
|
|
|
210
277
|
return result
|
|
211
278
|
|
|
212
279
|
|
|
280
|
+
def output_identity(path: str) -> "Optional[Dict[str, int]]":
|
|
281
|
+
"""What a cache entry remembers of the output it served: size and mtime_ns (a stat, no read)."""
|
|
282
|
+
try:
|
|
283
|
+
st = os.stat(path)
|
|
284
|
+
except OSError:
|
|
285
|
+
return None
|
|
286
|
+
return {"size": st.st_size, "mtime_ns": st.st_mtime_ns}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def cached_output_changed(hit: Dict[str, Any]) -> "Optional[Dict[str, Any]]":
|
|
290
|
+
"""None when the entry's output is still the file this batch wrote; else why it is not served:
|
|
291
|
+
output_missing, output_changed (size or mtime differ: something replaced or edited it) or
|
|
292
|
+
entry_unverifiable (an entry written by 2.10.11 or earlier recorded no identity, so nothing can say
|
|
293
|
+
the file is the one it describes)."""
|
|
294
|
+
out = str(hit.get("output") or "")
|
|
295
|
+
now = output_identity(out) if out else None
|
|
296
|
+
if now is None:
|
|
297
|
+
return {"reason": "output_missing", "output": out, "message": "the output is gone"}
|
|
298
|
+
expected = hit.get("output_identity")
|
|
299
|
+
if not isinstance(expected, dict):
|
|
300
|
+
return {"reason": "entry_unverifiable", "output": out,
|
|
301
|
+
"message": "the cache entry predates output identities, so the file cannot be checked"}
|
|
302
|
+
if expected != now:
|
|
303
|
+
return {"reason": "output_changed", "output": out, "expected": expected, "actual": now,
|
|
304
|
+
"message": "the output changed since the batch wrote it"}
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
|
|
213
308
|
def main() -> int:
|
|
214
309
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
215
310
|
ap.add_argument("folder")
|
|
@@ -235,8 +330,20 @@ def main() -> int:
|
|
|
235
330
|
recipe = json.loads(Path(args.recipe).read_text(encoding="utf-8"))
|
|
236
331
|
except (OSError, ValueError) as exc:
|
|
237
332
|
die(f"cannot read recipe: {exc}")
|
|
333
|
+
if not isinstance(recipe, dict):
|
|
334
|
+
die("cannot read recipe: not a JSON object")
|
|
238
335
|
if recipe.get("project") and not os.path.isabs(recipe["project"]):
|
|
239
336
|
recipe["project"] = str((Path(args.recipe).resolve().parent / recipe["project"]))
|
|
337
|
+
# a recipe error is the same for every item: refuse it once, before any item runs, rather than
|
|
338
|
+
# failing each item with it
|
|
339
|
+
if not recipe.get("project"):
|
|
340
|
+
if not recipe.get("steps"):
|
|
341
|
+
die("recipe needs steps or project")
|
|
342
|
+
for st in recipe["steps"]:
|
|
343
|
+
name = st[0] if isinstance(st, list) and st else st
|
|
344
|
+
if not isinstance(name, str) or name not in ALLOWED_STEP_SCRIPTS:
|
|
345
|
+
die(f"recipe step names a script that isn't one of this skill's own tools: {name!r} "
|
|
346
|
+
f"(must be a bare filename like 'silence.py', found in scripts/)")
|
|
240
347
|
outdir = Path(recipe.get("output_dir") or (folder / "out"))
|
|
241
348
|
if not outdir.is_absolute():
|
|
242
349
|
outdir = folder / outdir
|
|
@@ -263,7 +370,15 @@ def main() -> int:
|
|
|
263
370
|
if cache_path.exists() and not args.force:
|
|
264
371
|
try:
|
|
265
372
|
cache = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
266
|
-
|
|
373
|
+
if not isinstance(cache, dict):
|
|
374
|
+
raise ValueError("not a JSON object")
|
|
375
|
+
except (OSError, ValueError) as exc:
|
|
376
|
+
# every entry is lost: each item runs again, and the next store rewrites the file
|
|
377
|
+
size = cache_path.stat().st_size if cache_path.exists() else None
|
|
378
|
+
warn(f"the batch cache {cache_path} could not be read ({exc}); starting with an empty cache, so every item runs again",
|
|
379
|
+
"batch_cache_reset", path=str(cache_path), size=size, error=str(exc))
|
|
380
|
+
audit("batch_cache_reset", {"path": str(cache_path), "size": size, "error": str(exc)},
|
|
381
|
+
default_path=str(outdir / ".ffskill-audit.jsonl"))
|
|
267
382
|
cache = {}
|
|
268
383
|
rkey = recipe_key(recipe) + gpu_key()
|
|
269
384
|
glob = recipe.get("glob") or "*"
|
|
@@ -294,10 +409,27 @@ def main() -> int:
|
|
|
294
409
|
deadline = time.monotonic() + STATE.timeout if (shared_budget and STATE.timeout) else None
|
|
295
410
|
cache_lock = threading.Lock()
|
|
296
411
|
timed_out = {"hit": False}
|
|
297
|
-
interrupted = {"hit": False}
|
|
412
|
+
interrupted: Dict[str, Any] = {"hit": False, "signal": None}
|
|
413
|
+
_STOP.clear()
|
|
414
|
+
# Ctrl-C (and SIGTERM where it is delivered) stops the running steps and reports the table:
|
|
415
|
+
# the shared handler would exit with "nothing was written" and no rows, and inside an item it
|
|
416
|
+
# surfaced as that item's failure while the batch went on (spec 4.1)
|
|
417
|
+
import signal as _signal
|
|
418
|
+
|
|
419
|
+
def _interrupt(signum: int, frame: Any) -> None:
|
|
420
|
+
interrupted["signal"] = signum
|
|
421
|
+
_STOP.set()
|
|
422
|
+
stop_children()
|
|
423
|
+
raise KeyboardInterrupt
|
|
424
|
+
if threading.current_thread() is threading.main_thread():
|
|
425
|
+
for sig in (getattr(_signal, "SIGINT", None), getattr(_signal, "SIGTERM", None)):
|
|
426
|
+
if sig is not None:
|
|
427
|
+
try:
|
|
428
|
+
_signal.signal(sig, _interrupt)
|
|
429
|
+
except (ValueError, OSError):
|
|
430
|
+
pass
|
|
298
431
|
|
|
299
432
|
def one_pass() -> List[Dict[str, Any]]:
|
|
300
|
-
results = []
|
|
301
433
|
files = sorted(p for p in folder.glob(glob) if p.is_file() and p.suffix.lower() in MEDIA_EXT and outdir not in p.parents)
|
|
302
434
|
# Two different sources can compute the same final path (most often a fixed recipe "ext"
|
|
303
435
|
# collapsing e.g. clip.mp4 and clip.mov to the same clip_out.mp4) -- catch that before
|
|
@@ -332,7 +464,8 @@ def main() -> int:
|
|
|
332
464
|
# the other GPU setting) that names the same file now describes bytes that are gone
|
|
333
465
|
for stale in [k for k, v in cache.items() if k != key and isinstance(v, dict) and v.get("output") == r["output"]]:
|
|
334
466
|
del cache[stale]
|
|
335
|
-
|
|
467
|
+
# the output as this run left it: a later hit is served only while it is unchanged
|
|
468
|
+
cache[key] = dict(r, output_identity=output_identity(r["output"]))
|
|
336
469
|
# write_text isn't atomic -- a process killed mid-write (or a --watch loop racing
|
|
337
470
|
# a concurrent manual run) could leave a truncated file that json.loads() above
|
|
338
471
|
# then silently treats as "no cache" (a ValueError -> {}), discarding every prior
|
|
@@ -349,13 +482,23 @@ def main() -> int:
|
|
|
349
482
|
# warm, which happens at --jobs 1 too and contradicts the order this tool promises.
|
|
350
483
|
slots: "List[Optional[Dict[str, Any]]]" = [None] * len(files)
|
|
351
484
|
pending: List[tuple] = []
|
|
485
|
+
cache_miss: Dict[int, Dict[str, Any]] = {}
|
|
352
486
|
for i, src in enumerate(files):
|
|
353
487
|
key = f"{file_key(src)}:{rkey}"
|
|
354
488
|
hit = cache.get(key)
|
|
355
|
-
if
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
489
|
+
if isinstance(hit, dict) and not args.force:
|
|
490
|
+
why = cached_output_changed(hit)
|
|
491
|
+
if why is None:
|
|
492
|
+
info(f"skip (cached) {src.name}")
|
|
493
|
+
slots[i] = {**{k: v for k, v in hit.items() if k != "output_identity"}, "cached": True}
|
|
494
|
+
continue
|
|
495
|
+
cache_miss[i] = why
|
|
496
|
+
facts = {k: v for k, v in why.items() if k not in ("reason", "message")}
|
|
497
|
+
msg = f"{src.name}: the cached output {hit.get('output')} is not served ({why['message']}); the item runs again"
|
|
498
|
+
if why["reason"] == "output_changed":
|
|
499
|
+
warn(msg, "batch_cache_output_changed", file=str(src), **facts)
|
|
500
|
+
elif why["reason"] == "entry_unverifiable":
|
|
501
|
+
warn(msg, "batch_cache_entry_unverifiable", file=str(src), **facts)
|
|
359
502
|
pending.append((i, src, key))
|
|
360
503
|
|
|
361
504
|
def timed_out_row(src: Path) -> "Dict[str, Any]":
|
|
@@ -367,13 +510,41 @@ def main() -> int:
|
|
|
367
510
|
"""A worker that raised is a failed item, not a dead run. A die() inside a thread
|
|
368
511
|
raises SystemExit through fut.result() and used to take the whole process down
|
|
369
512
|
before the summary and the per-item table were printed -- so the one thing the user
|
|
370
|
-
needed, which item failed and which succeeded, was the thing they did not get.
|
|
371
|
-
|
|
513
|
+
needed, which item failed and which succeeded, was the thing they did not get.
|
|
514
|
+
`error` is structured: an ItemError's own, else what the exception says (its class
|
|
515
|
+
in `exception`), never str(SystemExit) -- which was the exit code, "1"."""
|
|
516
|
+
if isinstance(exc, ItemError):
|
|
517
|
+
error = dict(exc.error)
|
|
518
|
+
elif isinstance(exc, SystemExit):
|
|
519
|
+
error = {"step": None, "tool": None, "exception": "SystemExit", "exit_code": exc.code,
|
|
520
|
+
"message": f"the item stopped with exit code {exc.code}; its message is on stderr"}
|
|
521
|
+
else:
|
|
522
|
+
error = {"step": None, "tool": None, "exception": exc.__class__.__name__,
|
|
523
|
+
"message": (str(exc) or exc.__class__.__name__)[:300]}
|
|
524
|
+
log(f" failed: {error['message']}")
|
|
372
525
|
return {"file": str(src), "output": str(final_path(src, recipe, outdir)),
|
|
373
|
-
"ok": False, "seconds": 0.0, "error":
|
|
526
|
+
"ok": False, "verified": False, "seconds": 0.0, "error": error}
|
|
527
|
+
|
|
528
|
+
def interrupted_row(src: Path, started: bool, r: "Optional[Dict[str, Any]]" = None) -> "Dict[str, Any]":
|
|
529
|
+
"""An item the interrupt reached: `interrupted: true` when it was running (its steps
|
|
530
|
+
were stopped), `skipped: "interrupted"` when it had not started."""
|
|
531
|
+
row = dict(r) if r else {"file": str(src), "output": str(final_path(src, recipe, outdir)), "seconds": 0.0}
|
|
532
|
+
row.update(ok=False, verified=False)
|
|
533
|
+
if started:
|
|
534
|
+
row["interrupted"] = True
|
|
535
|
+
row.setdefault("error", {"step": None, "tool": None, "kind": "interrupted",
|
|
536
|
+
"message": "interrupted while this item was running; its running step was stopped"})
|
|
537
|
+
else:
|
|
538
|
+
row["skipped"] = "interrupted"
|
|
539
|
+
return row
|
|
540
|
+
|
|
541
|
+
def with_miss(i: int, r: Dict[str, Any]) -> Dict[str, Any]:
|
|
542
|
+
if i in cache_miss:
|
|
543
|
+
r["cache_miss"] = {k: v for k, v in cache_miss[i].items() if k != "message"}
|
|
544
|
+
return r
|
|
374
545
|
|
|
375
546
|
if jobs == 1 or len(pending) < 2:
|
|
376
|
-
for i, src, key in pending:
|
|
547
|
+
for n, (i, src, key) in enumerate(pending):
|
|
377
548
|
if deadline is not None and time.monotonic() >= deadline:
|
|
378
549
|
slots[i] = timed_out_row(src)
|
|
379
550
|
continue
|
|
@@ -382,13 +553,17 @@ def main() -> int:
|
|
|
382
553
|
r = process(src, recipe, outdir, item_work(i, src), deadline)
|
|
383
554
|
except KeyboardInterrupt:
|
|
384
555
|
interrupted["hit"] = True
|
|
556
|
+
# the running item, then every one not started: each keeps its row
|
|
557
|
+
slots[i] = interrupted_row(src, True)
|
|
558
|
+
for j, later, _k in pending[n + 1:]:
|
|
559
|
+
slots[j] = interrupted_row(later, False)
|
|
385
560
|
break
|
|
386
561
|
except BaseException as exc: # noqa: BLE001 - reported, never swallowed
|
|
387
562
|
if isinstance(exc, SystemExit) and not exc.code:
|
|
388
563
|
raise
|
|
389
|
-
slots[i] = failed_row(src, exc)
|
|
564
|
+
slots[i] = with_miss(i, failed_row(src, exc))
|
|
390
565
|
continue
|
|
391
|
-
slots[i] = r
|
|
566
|
+
slots[i] = with_miss(i, r)
|
|
392
567
|
store(key, r)
|
|
393
568
|
return [r for r in slots if r is not None]
|
|
394
569
|
|
|
@@ -398,8 +573,9 @@ def main() -> int:
|
|
|
398
573
|
_LOG.buffer = [f"=== {src.name}"]
|
|
399
574
|
try:
|
|
400
575
|
r = process(src, recipe, outdir, item_work(i, src), deadline)
|
|
401
|
-
|
|
402
|
-
|
|
576
|
+
if not interrupted["hit"]: # a step the interrupt stopped is no result to keep
|
|
577
|
+
store(key, r)
|
|
578
|
+
return with_miss(i, r)
|
|
403
579
|
finally:
|
|
404
580
|
r_lines, _LOG.buffer = _LOG.buffer, None
|
|
405
581
|
setattr(_LOG, "lines", r_lines)
|
|
@@ -439,86 +615,34 @@ def main() -> int:
|
|
|
439
615
|
for i, src, key in queue:
|
|
440
616
|
slots[i] = timed_out_row(src)
|
|
441
617
|
except KeyboardInterrupt:
|
|
442
|
-
# Spec 4.1: cancel what has not started,
|
|
443
|
-
#
|
|
444
|
-
#
|
|
618
|
+
# Spec 4.1: cancel what has not started, stop the running steps (the handler
|
|
619
|
+
# already did), and REPORT every item -- exit 130 with the table, never a
|
|
620
|
+
# traceback. The pool's exit waits for the stopped items; they are read below.
|
|
445
621
|
interrupted["hit"] = True
|
|
446
622
|
for fut in futures:
|
|
447
623
|
fut.cancel()
|
|
448
|
-
for fut, (i, src) in futures.items():
|
|
449
|
-
if slots[i] is not None or not fut.done():
|
|
450
|
-
continue
|
|
451
|
-
try:
|
|
452
|
-
slots[i], lines[i] = fut.result()
|
|
453
|
-
except BaseException: # noqa: BLE001
|
|
454
|
-
pass
|
|
455
624
|
info("interrupted: reporting what had already finished")
|
|
625
|
+
if interrupted["hit"]:
|
|
626
|
+
for fut, (i, src) in futures.items():
|
|
627
|
+
if slots[i] is not None:
|
|
628
|
+
continue
|
|
629
|
+
if fut.cancelled():
|
|
630
|
+
slots[i] = interrupted_row(src, False)
|
|
631
|
+
continue
|
|
632
|
+
try:
|
|
633
|
+
r, lines[i] = fut.result()
|
|
634
|
+
except BaseException: # noqa: BLE001 - its row says it was interrupted
|
|
635
|
+
r = None
|
|
636
|
+
# finished in time: kept as it is; stopped by the interrupt: said so
|
|
637
|
+
slots[i] = r if (r and r.get("ok")) else interrupted_row(src, True, r)
|
|
638
|
+
for i, src, _key in queue:
|
|
639
|
+
if slots[i] is None:
|
|
640
|
+
slots[i] = interrupted_row(src, False)
|
|
456
641
|
for i, src in enumerate(files):
|
|
457
642
|
for line in lines.get(i, []):
|
|
458
643
|
info(line)
|
|
459
644
|
return [r for r in slots if r is not None]
|
|
460
645
|
|
|
461
|
-
import concurrent.futures
|
|
462
|
-
|
|
463
|
-
def work_one(i: int, src: Path, key: str) -> Dict[str, Any]:
|
|
464
|
-
_LOG.buffer = [f"=== {src.name}"]
|
|
465
|
-
try:
|
|
466
|
-
r = process(src, recipe, outdir, item_work(i, src), deadline)
|
|
467
|
-
store(key, r)
|
|
468
|
-
return r
|
|
469
|
-
finally:
|
|
470
|
-
r_lines, _LOG.buffer = _LOG.buffer, None
|
|
471
|
-
setattr(_LOG, "lines", r_lines)
|
|
472
|
-
|
|
473
|
-
# Submitted in the existing sorted order and collected into a list indexed by submission
|
|
474
|
-
# order, so the summary and the per-item table are identical to a serial run's whatever
|
|
475
|
-
# order the encodes actually finish in.
|
|
476
|
-
ordered: List[Optional[Dict[str, Any]]] = [None] * len(pending)
|
|
477
|
-
lines: List[List[str]] = [[] for _ in pending]
|
|
478
|
-
with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as pool:
|
|
479
|
-
futures: "Dict[Any, int]" = {}
|
|
480
|
-
queue = list(enumerate(pending))
|
|
481
|
-
in_flight: "set" = set()
|
|
482
|
-
try:
|
|
483
|
-
# The pool is topped up to `jobs` in flight and no further: submitting the whole
|
|
484
|
-
# list up front would put every item past the deadline check before the first one
|
|
485
|
-
# had finished, and the shared budget could then never stop anything.
|
|
486
|
-
while queue or in_flight:
|
|
487
|
-
while queue and len(in_flight) < jobs:
|
|
488
|
-
slot, (i, src, key) = queue[0]
|
|
489
|
-
if deadline is not None and time.monotonic() >= deadline:
|
|
490
|
-
break
|
|
491
|
-
queue.pop(0)
|
|
492
|
-
fut = pool.submit(_run_buffered, work_one, i, src, key)
|
|
493
|
-
futures[fut] = slot
|
|
494
|
-
in_flight.add(fut)
|
|
495
|
-
if not in_flight:
|
|
496
|
-
break
|
|
497
|
-
done_now, in_flight = concurrent.futures.wait(
|
|
498
|
-
in_flight, return_when=concurrent.futures.FIRST_COMPLETED)
|
|
499
|
-
in_flight = set(in_flight)
|
|
500
|
-
for fut in done_now:
|
|
501
|
-
ordered[futures[fut]], lines[futures[fut]] = fut.result()
|
|
502
|
-
for slot, (i, src, key) in queue:
|
|
503
|
-
timed_out["hit"] = True
|
|
504
|
-
ordered[slot] = {"file": str(src),
|
|
505
|
-
"output": str(final_path(src, recipe, outdir)),
|
|
506
|
-
"ok": False, "seconds": 0.0, "skipped": "timeout"}
|
|
507
|
-
except KeyboardInterrupt:
|
|
508
|
-
for fut in futures:
|
|
509
|
-
fut.cancel()
|
|
510
|
-
info("interrupted: finishing what had already started")
|
|
511
|
-
raise
|
|
512
|
-
for slot, (i, src, key) in enumerate(pending):
|
|
513
|
-
for line in lines[slot]:
|
|
514
|
-
info(line)
|
|
515
|
-
if ordered[slot] is None:
|
|
516
|
-
timed_out["hit"] = True
|
|
517
|
-
ordered[slot] = {"file": str(src), "output": str(final_path(src, recipe, outdir)),
|
|
518
|
-
"ok": False, "seconds": 0.0, "skipped": "timeout"}
|
|
519
|
-
results.append(ordered[slot])
|
|
520
|
-
return results
|
|
521
|
-
|
|
522
646
|
results = one_pass()
|
|
523
647
|
if args.watch:
|
|
524
648
|
info(f"watching {folder} every {args.watch:g}s (Ctrl-C to stop)")
|
|
@@ -534,6 +658,8 @@ def main() -> int:
|
|
|
534
658
|
finally:
|
|
535
659
|
signal.signal(signal.SIGINT, previous)
|
|
536
660
|
results = one_pass()
|
|
661
|
+
if interrupted["hit"]:
|
|
662
|
+
break # the pass reported its rows; a watch interrupted mid-pass stops here
|
|
537
663
|
except KeyboardInterrupt:
|
|
538
664
|
info("watch stopped")
|
|
539
665
|
# the items' GPU facts (asked for, fell back and why) reach the top-level `hw`, as render.py's
|
|
@@ -547,10 +673,13 @@ def main() -> int:
|
|
|
547
673
|
print(f"{'OK ' if r['ok'] else 'FAIL'} {r['file']} -> {r['output']}" + (" (cached)" if r.get("cached") else ""))
|
|
548
674
|
if interrupted["hit"]:
|
|
549
675
|
# Ctrl-C on a batch that already produced files: the user needs the partial table, not a
|
|
550
|
-
# traceback and not "nothing was written". Exit 130 with
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
676
|
+
# traceback and not "nothing was written". Exit 130 (143 for SIGTERM) with every item:
|
|
677
|
+
# finished, interrupted while running, or not started.
|
|
678
|
+
stopped = sum(1 for r in results if r.get("interrupted"))
|
|
679
|
+
not_started = sum(1 for r in results if r.get("skipped") == "interrupted")
|
|
680
|
+
die(f"interrupted after {done} of {len(results)} item(s): {stopped} stopped while running and "
|
|
681
|
+
f"{not_started} not started; the finished outputs are kept",
|
|
682
|
+
code=128 + int(interrupted["signal"] or 2), kind="interrupted", output=None, dry_run=STATE.dry_run, results=results,
|
|
554
683
|
processed=done, total=len(results), jobs=jobs, jobs_requested=requested,
|
|
555
684
|
timed_out=timed_out["hit"])
|
|
556
685
|
if timed_out["hit"]:
|
|
@@ -593,7 +722,13 @@ def main() -> int:
|
|
|
593
722
|
why = (" (reencode_reason: " + ", ".join(f"{k} x{v}" for k, v in reasons.items()) + ")") if reasons else ""
|
|
594
723
|
info(f"cut.py: {copied}/{len(all_cuts)} call(s) stayed fully lossless stream-copy "
|
|
595
724
|
f"({cut_summary['stream_copy_rate']:.0%}), {len(all_cuts) - copied} re-encoded a range or its --segments join{why}")
|
|
596
|
-
|
|
725
|
+
unverified = [os.path.basename(r["file"]) for r in results if r.get("ok") and not r.get("verified")]
|
|
726
|
+
if unverified and not STATE.dry_run:
|
|
727
|
+
note(f"{len(unverified)} item(s) completed but did not verify everything they wrote (see each row's unverified): "
|
|
728
|
+
+ ", ".join(unverified[:5]) + (" ..." if len(unverified) > 5 else ""))
|
|
729
|
+
emit(None, verification=[{"step": "item", "file": r["file"], "ok": bool(r.get("verified"))} for r in results]
|
|
730
|
+
if not STATE.dry_run else [],
|
|
731
|
+
results=results, processed=done, total=len(results),
|
|
597
732
|
jobs=jobs, jobs_requested=requested, wall_seconds=round(time.time() - started, 1),
|
|
598
733
|
item_seconds_total=round(sum(float(r.get("seconds") or 0) for r in results), 1),
|
|
599
734
|
timed_out=timed_out["hit"], cut_stream_copy=cut_summary, cut_reencode_reasons=reasons)
|