ffmpeg-skill 1.16.0 → 1.17.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 +13 -8
- package/SKILL.md +5 -5
- package/docs/contract.md +28 -10
- package/package.json +1 -1
- package/references/gotchas.md +4 -0
- package/references/scripts.md +264 -4
- package/scripts/_common/__init__.py +25 -3
- package/scripts/_common/asr.py +369 -0
- package/scripts/_common/decision.py +346 -0
- package/scripts/_common/text.py +142 -2
- package/scripts/_contract.py +4 -2
- package/scripts/batch.py +287 -22
- package/scripts/caption.py +150 -198
- package/scripts/cut.py +136 -1
- package/scripts/render.py +289 -24
- package/scripts/scenes.py +81 -7
- package/scripts/silence.py +207 -5
package/scripts/batch.py
CHANGED
|
@@ -27,9 +27,10 @@ import hashlib
|
|
|
27
27
|
import json
|
|
28
28
|
import os
|
|
29
29
|
import sys
|
|
30
|
+
import threading
|
|
30
31
|
import time
|
|
31
32
|
from pathlib import Path
|
|
32
|
-
from typing import Any, Dict, List
|
|
33
|
+
from typing import Any, Dict, List, Optional
|
|
33
34
|
|
|
34
35
|
from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool, read_text_or_die, MEDIA_EXT as _MEDIA_EXT
|
|
35
36
|
|
|
@@ -72,23 +73,44 @@ def recipe_key(recipe: Dict[str, Any]) -> str:
|
|
|
72
73
|
return hashlib.sha1((json.dumps(recipe, sort_keys=True) + "\0" + project_content).encode()).hexdigest()[:12]
|
|
73
74
|
|
|
74
75
|
|
|
75
|
-
|
|
76
|
+
JOBS_CAP = 8 # beyond this, concurrent encodes contend for the same cores and memory
|
|
77
|
+
|
|
78
|
+
_LOG = threading.local()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def log(message: str) -> None:
|
|
82
|
+
"""info(), unless this thread is a --jobs worker -- then the line is buffered and flushed in
|
|
83
|
+
file order when the item finishes, so a parallel run's log reads exactly like a serial one."""
|
|
84
|
+
buf = getattr(_LOG, "buffer", None)
|
|
85
|
+
if buf is None:
|
|
86
|
+
info(message)
|
|
87
|
+
else:
|
|
88
|
+
buf.append(message)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run_step(argv: List[str], per_call: "Optional[float]" = None) -> bool:
|
|
76
92
|
script = argv[0]
|
|
77
93
|
if script not in ALLOWED_STEP_SCRIPTS:
|
|
78
94
|
die(f"recipe step names a script that isn't one of this skill's own tools: {script!r} "
|
|
79
95
|
f"(must be a bare filename like 'silence.py', found in scripts/)")
|
|
80
96
|
cmd = [str(HERE / script)] + argv[1:] + child_args()
|
|
81
|
-
|
|
82
|
-
proc = run_tool(cmd)
|
|
97
|
+
log(" → " + " ".join(os.path.basename(c) if i < 1 else c for i, c in enumerate(cmd)))
|
|
98
|
+
proc = run_tool(cmd, per_call=per_call)
|
|
83
99
|
if proc.returncode != 0:
|
|
84
|
-
|
|
100
|
+
log(" " + "\n ".join(proc.stderr.strip().splitlines()[-4:]))
|
|
85
101
|
return False
|
|
86
102
|
for line in proc.stderr.splitlines():
|
|
87
103
|
if line.startswith("warning:"): # a step's deprecation notice is not swallowed by a success (review 9)
|
|
88
|
-
|
|
104
|
+
log(" " + line)
|
|
89
105
|
return True
|
|
90
106
|
|
|
91
107
|
|
|
108
|
+
def _run_buffered(fn, *a):
|
|
109
|
+
"""Run a worker and hand back (its result, the log lines it produced)."""
|
|
110
|
+
r = fn(*a)
|
|
111
|
+
return r, list(getattr(_LOG, "lines", None) or [])
|
|
112
|
+
|
|
113
|
+
|
|
92
114
|
def final_path(src: Path, recipe: Dict[str, Any], outdir: Path) -> Path:
|
|
93
115
|
suffix = recipe.get("suffix", "_out")
|
|
94
116
|
# By default final_ext falls back to each source's OWN extension, so files that only differ
|
|
@@ -101,9 +123,17 @@ def final_path(src: Path, recipe: Dict[str, Any], outdir: Path) -> Path:
|
|
|
101
123
|
return outdir / f"{src.stem}{suffix}.{final_ext}"
|
|
102
124
|
|
|
103
125
|
|
|
104
|
-
def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path
|
|
126
|
+
def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
|
|
127
|
+
deadline: "Optional[float]" = None) -> Dict[str, Any]:
|
|
105
128
|
final = final_path(src, recipe, outdir)
|
|
106
129
|
t0 = time.time()
|
|
130
|
+
|
|
131
|
+
def budget() -> "Optional[float]":
|
|
132
|
+
"""What is left of the BATCH's time limit -- not a fresh one per item. A --timeout is a
|
|
133
|
+
promise about the whole run, so a queue of 40 files cannot quietly take 40 timeouts."""
|
|
134
|
+
if deadline is None:
|
|
135
|
+
return None
|
|
136
|
+
return max(1.0, deadline - time.monotonic())
|
|
107
137
|
if recipe.get("project"):
|
|
108
138
|
try:
|
|
109
139
|
proj = json.loads(read_text_or_die(str(recipe["project"]), "recipe.project"))
|
|
@@ -117,7 +147,7 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path) -> Dict
|
|
|
117
147
|
proj["output"] = str(final.resolve())
|
|
118
148
|
pj = work / f"{src.stem}_project.json"
|
|
119
149
|
pj.write_text(json.dumps(proj, indent=2), encoding="utf-8")
|
|
120
|
-
ok = run_step(["render.py", str(pj)])
|
|
150
|
+
ok = run_step(["render.py", str(pj)], budget())
|
|
121
151
|
else:
|
|
122
152
|
steps = recipe.get("steps") or []
|
|
123
153
|
if not steps:
|
|
@@ -128,7 +158,7 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path) -> Dict
|
|
|
128
158
|
last = i == len(steps) - 1
|
|
129
159
|
out = str(final) if last else str(work / f"{src.stem}_step{i}.{'mp4' if src.suffix.lower() not in ('.wav', '.mp3', '.m4a', '.flac') else src.suffix.lstrip('.')}")
|
|
130
160
|
argv = [str(a).replace("{in}", cur).replace("{out}", out) for a in step]
|
|
131
|
-
if not run_step(argv):
|
|
161
|
+
if not run_step(argv, budget()):
|
|
132
162
|
ok = False
|
|
133
163
|
break
|
|
134
164
|
cur = out
|
|
@@ -141,11 +171,17 @@ def main() -> int:
|
|
|
141
171
|
ap.add_argument("--recipe", required=True, help="batch.json")
|
|
142
172
|
ap.add_argument("--force", action="store_true", help="ignore the cache and redo everything")
|
|
143
173
|
ap.add_argument("--watch", type=float, help="keep polling the folder every N seconds")
|
|
174
|
+
ap.add_argument("--jobs", default="1", metavar="N",
|
|
175
|
+
help="process N files at once, or 'auto' for min(cpu_count, 4). Capped at "
|
|
176
|
+
"min(N, cpu_count, 8): every item is already an ffmpeg that threads "
|
|
177
|
+
"across cores, so more than a few contend rather than go faster. "
|
|
178
|
+
"Default 1, which is 1.16's behaviour exactly.")
|
|
144
179
|
ap.add_argument("--work", help="work directory for intermediates (default: <output_dir>/.work)")
|
|
145
180
|
add_common(ap)
|
|
146
181
|
args = ap.parse_args()
|
|
147
182
|
apply_common(args)
|
|
148
183
|
|
|
184
|
+
started = time.time()
|
|
149
185
|
folder = Path(args.folder).resolve() # relative 'bdir' used to become bdir/bdir/out once joined with the default outdir
|
|
150
186
|
if not folder.is_dir():
|
|
151
187
|
die(f"not a folder: {folder}")
|
|
@@ -186,6 +222,34 @@ def main() -> int:
|
|
|
186
222
|
rkey = recipe_key(recipe)
|
|
187
223
|
glob = recipe.get("glob") or "*"
|
|
188
224
|
|
|
225
|
+
# --jobs: every item is itself an ffmpeg that already threads across cores, so beyond a few
|
|
226
|
+
# concurrent encodes the jobs contend and wall-clock stops improving while memory does not.
|
|
227
|
+
# A number above the cap is clamped with a note, not refused: an optimistic number is not an
|
|
228
|
+
# error, and refusing one helps nobody.
|
|
229
|
+
cpus = os.cpu_count() or 1
|
|
230
|
+
requested = min(cpus, 4) if str(args.jobs).lower() == "auto" else None
|
|
231
|
+
if requested is None:
|
|
232
|
+
try:
|
|
233
|
+
requested = int(args.jobs)
|
|
234
|
+
except ValueError:
|
|
235
|
+
die(f"--jobs {args.jobs!r}: a whole number, or 'auto'", kind="input")
|
|
236
|
+
if requested < 1:
|
|
237
|
+
die("--jobs must be at least 1", kind="input")
|
|
238
|
+
jobs = max(1, min(requested, cpus, JOBS_CAP))
|
|
239
|
+
if jobs != requested:
|
|
240
|
+
info(f"--jobs {requested} capped to {jobs} (min of the request, {cpus} CPU(s) and the "
|
|
241
|
+
f"{JOBS_CAP}-job ceiling): each item is already a multi-threaded encode")
|
|
242
|
+
# One budget for the whole batch, not one per item -- but only where that cannot change what
|
|
243
|
+
# 1.16 did. A stated --timeout is a statement about this run, and asking for --jobs > 1 is
|
|
244
|
+
# asking for the batch to be treated as one piece of work; the default sequential path with
|
|
245
|
+
# the default timeout keeps 1.16's behaviour exactly, where each item got its own ceiling and
|
|
246
|
+
# a long folder was never cut off part-way.
|
|
247
|
+
shared_budget = bool(args.timeout) or jobs > 1
|
|
248
|
+
deadline = time.monotonic() + STATE.timeout if (shared_budget and STATE.timeout) else None
|
|
249
|
+
cache_lock = threading.Lock()
|
|
250
|
+
timed_out = {"hit": False}
|
|
251
|
+
interrupted = {"hit": False}
|
|
252
|
+
|
|
189
253
|
def one_pass() -> List[Dict[str, Any]]:
|
|
190
254
|
results = []
|
|
191
255
|
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)
|
|
@@ -201,17 +265,23 @@ def main() -> int:
|
|
|
201
265
|
detail = "; ".join(f"{dst.name} <- {', '.join(s.name for s in srcs)}" for dst, srcs in collisions.items())
|
|
202
266
|
die(f"{len(collisions)} output filename collision(s) in this batch -- rename the sources, "
|
|
203
267
|
f"or add a distinguishing \"suffix\"/\"ext\" per run, or split into separate globs: {detail}")
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
268
|
+
def item_work(i: int, src: Path) -> Path:
|
|
269
|
+
"""Where this item's intermediates go. Parallel items must not share one work dir:
|
|
270
|
+
the step file names are stem-derived, so two globs holding the same stem would write
|
|
271
|
+
over each other. Serial runs keep the flat layout 1.16 used, byte for byte."""
|
|
272
|
+
if jobs == 1:
|
|
273
|
+
return work
|
|
274
|
+
sub = work / f"{i}-{src.stem}"
|
|
275
|
+
sub.mkdir(parents=True, exist_ok=True)
|
|
276
|
+
return sub
|
|
277
|
+
|
|
278
|
+
def store(key: str, r: Dict[str, Any]) -> None:
|
|
279
|
+
if not (r["ok"] and not STATE.dry_run):
|
|
280
|
+
return
|
|
281
|
+
# The read-modify-write of the in-memory dict needs the lock even though the file
|
|
282
|
+
# write is already atomic: two finishers could otherwise serialise from two different
|
|
283
|
+
# snapshots and lose an entry.
|
|
284
|
+
with cache_lock:
|
|
215
285
|
cache[key] = r
|
|
216
286
|
# write_text isn't atomic -- a process killed mid-write (or a --watch loop racing
|
|
217
287
|
# a concurrent manual run) could leave a truncated file that json.loads() above
|
|
@@ -219,9 +289,184 @@ def main() -> int:
|
|
|
219
289
|
# entry. Write to a sibling temp file and rename into place: same-directory
|
|
220
290
|
# renames are atomic on POSIX and os.replace() is atomic on Windows too, so a
|
|
221
291
|
# reader only ever sees the old complete file or the new complete file.
|
|
222
|
-
tmp = cache_path.parent / f"{cache_path.name}.tmp{os.getpid()}"
|
|
292
|
+
tmp = cache_path.parent / f"{cache_path.name}.tmp{os.getpid()}.{threading.get_ident()}"
|
|
223
293
|
tmp.write_text(json.dumps(cache, indent=2), encoding="utf-8")
|
|
224
294
|
os.replace(tmp, cache_path)
|
|
295
|
+
|
|
296
|
+
# ONE list, indexed by each file's position in the sorted `files`. A cached hit goes
|
|
297
|
+
# into its own slot rather than being appended ahead of the items that still have to run:
|
|
298
|
+
# appending in two passes reordered the per-item table whenever the cache was partially
|
|
299
|
+
# warm, which happens at --jobs 1 too and contradicts the order this tool promises.
|
|
300
|
+
slots: "List[Optional[Dict[str, Any]]]" = [None] * len(files)
|
|
301
|
+
pending: List[tuple] = []
|
|
302
|
+
for i, src in enumerate(files):
|
|
303
|
+
key = f"{file_key(src)}:{rkey}"
|
|
304
|
+
hit = cache.get(key)
|
|
305
|
+
if hit and Path(hit.get("output", "")).exists() and not args.force:
|
|
306
|
+
info(f"skip (cached) {src.name}")
|
|
307
|
+
slots[i] = {**hit, "cached": True}
|
|
308
|
+
continue
|
|
309
|
+
pending.append((i, src, key))
|
|
310
|
+
|
|
311
|
+
def timed_out_row(src: Path) -> "Dict[str, Any]":
|
|
312
|
+
timed_out["hit"] = True
|
|
313
|
+
return {"file": str(src), "output": str(final_path(src, recipe, outdir)),
|
|
314
|
+
"ok": False, "seconds": 0.0, "skipped": "timeout"}
|
|
315
|
+
|
|
316
|
+
def failed_row(src: Path, exc: BaseException) -> "Dict[str, Any]":
|
|
317
|
+
"""A worker that raised is a failed item, not a dead run. A die() inside a thread
|
|
318
|
+
raises SystemExit through fut.result() and used to take the whole process down
|
|
319
|
+
before the summary and the per-item table were printed -- so the one thing the user
|
|
320
|
+
needed, which item failed and which succeeded, was the thing they did not get."""
|
|
321
|
+
reason = str(exc) or exc.__class__.__name__
|
|
322
|
+
return {"file": str(src), "output": str(final_path(src, recipe, outdir)),
|
|
323
|
+
"ok": False, "seconds": 0.0, "error": reason[:300]}
|
|
324
|
+
|
|
325
|
+
if jobs == 1 or len(pending) < 2:
|
|
326
|
+
for i, src, key in pending:
|
|
327
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
328
|
+
slots[i] = timed_out_row(src)
|
|
329
|
+
continue
|
|
330
|
+
info(f"=== {src.name}")
|
|
331
|
+
try:
|
|
332
|
+
r = process(src, recipe, outdir, item_work(i, src), deadline)
|
|
333
|
+
except KeyboardInterrupt:
|
|
334
|
+
interrupted["hit"] = True
|
|
335
|
+
break
|
|
336
|
+
except BaseException as exc: # noqa: BLE001 - reported, never swallowed
|
|
337
|
+
if isinstance(exc, SystemExit) and not exc.code:
|
|
338
|
+
raise
|
|
339
|
+
slots[i] = failed_row(src, exc)
|
|
340
|
+
continue
|
|
341
|
+
slots[i] = r
|
|
342
|
+
store(key, r)
|
|
343
|
+
return [r for r in slots if r is not None]
|
|
344
|
+
|
|
345
|
+
import concurrent.futures
|
|
346
|
+
|
|
347
|
+
def work_one(i: int, src: Path, key: str) -> Dict[str, Any]:
|
|
348
|
+
_LOG.buffer = [f"=== {src.name}"]
|
|
349
|
+
try:
|
|
350
|
+
r = process(src, recipe, outdir, item_work(i, src), deadline)
|
|
351
|
+
store(key, r)
|
|
352
|
+
return r
|
|
353
|
+
finally:
|
|
354
|
+
r_lines, _LOG.buffer = _LOG.buffer, None
|
|
355
|
+
setattr(_LOG, "lines", r_lines)
|
|
356
|
+
|
|
357
|
+
# Submitted in the existing sorted order and written straight into each item's own slot,
|
|
358
|
+
# so the summary and the per-item table are identical to a serial run's whatever order
|
|
359
|
+
# the encodes actually finish in.
|
|
360
|
+
lines: "Dict[int, List[str]]" = {}
|
|
361
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as pool:
|
|
362
|
+
futures: "Dict[Any, tuple]" = {}
|
|
363
|
+
queue = list(pending)
|
|
364
|
+
in_flight: "set" = set()
|
|
365
|
+
try:
|
|
366
|
+
# The pool is topped up to `jobs` in flight and no further: submitting the whole
|
|
367
|
+
# list up front would put every item past the deadline check before the first one
|
|
368
|
+
# had finished, and the shared budget could then never stop anything.
|
|
369
|
+
while queue or in_flight:
|
|
370
|
+
while queue and len(in_flight) < jobs:
|
|
371
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
372
|
+
break
|
|
373
|
+
i, src, key = queue.pop(0)
|
|
374
|
+
fut = pool.submit(_run_buffered, work_one, i, src, key)
|
|
375
|
+
futures[fut] = (i, src)
|
|
376
|
+
in_flight.add(fut)
|
|
377
|
+
if not in_flight:
|
|
378
|
+
break
|
|
379
|
+
done_now, in_flight = concurrent.futures.wait(
|
|
380
|
+
in_flight, return_when=concurrent.futures.FIRST_COMPLETED)
|
|
381
|
+
in_flight = set(in_flight)
|
|
382
|
+
for fut in done_now:
|
|
383
|
+
i, src = futures[fut]
|
|
384
|
+
try:
|
|
385
|
+
slots[i], lines[i] = fut.result()
|
|
386
|
+
except BaseException as exc: # noqa: BLE001 - reported, never swallowed
|
|
387
|
+
slots[i] = failed_row(src, exc)
|
|
388
|
+
lines[i] = [f"=== {src.name}", f" failed: {exc}"]
|
|
389
|
+
for i, src, key in queue:
|
|
390
|
+
slots[i] = timed_out_row(src)
|
|
391
|
+
except KeyboardInterrupt:
|
|
392
|
+
# Spec 4.1: cancel what has not started, let the running children be killed by
|
|
393
|
+
# the shared signal handling, and REPORT what completed -- exit 130 with the
|
|
394
|
+
# partial table, never a traceback.
|
|
395
|
+
interrupted["hit"] = True
|
|
396
|
+
for fut in futures:
|
|
397
|
+
fut.cancel()
|
|
398
|
+
for fut, (i, src) in futures.items():
|
|
399
|
+
if slots[i] is not None or not fut.done():
|
|
400
|
+
continue
|
|
401
|
+
try:
|
|
402
|
+
slots[i], lines[i] = fut.result()
|
|
403
|
+
except BaseException: # noqa: BLE001
|
|
404
|
+
pass
|
|
405
|
+
info("interrupted: reporting what had already finished")
|
|
406
|
+
for i, src in enumerate(files):
|
|
407
|
+
for line in lines.get(i, []):
|
|
408
|
+
info(line)
|
|
409
|
+
return [r for r in slots if r is not None]
|
|
410
|
+
|
|
411
|
+
import concurrent.futures
|
|
412
|
+
|
|
413
|
+
def work_one(i: int, src: Path, key: str) -> Dict[str, Any]:
|
|
414
|
+
_LOG.buffer = [f"=== {src.name}"]
|
|
415
|
+
try:
|
|
416
|
+
r = process(src, recipe, outdir, item_work(i, src), deadline)
|
|
417
|
+
store(key, r)
|
|
418
|
+
return r
|
|
419
|
+
finally:
|
|
420
|
+
r_lines, _LOG.buffer = _LOG.buffer, None
|
|
421
|
+
setattr(_LOG, "lines", r_lines)
|
|
422
|
+
|
|
423
|
+
# Submitted in the existing sorted order and collected into a list indexed by submission
|
|
424
|
+
# order, so the summary and the per-item table are identical to a serial run's whatever
|
|
425
|
+
# order the encodes actually finish in.
|
|
426
|
+
ordered: List[Optional[Dict[str, Any]]] = [None] * len(pending)
|
|
427
|
+
lines: List[List[str]] = [[] for _ in pending]
|
|
428
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as pool:
|
|
429
|
+
futures: "Dict[Any, int]" = {}
|
|
430
|
+
queue = list(enumerate(pending))
|
|
431
|
+
in_flight: "set" = set()
|
|
432
|
+
try:
|
|
433
|
+
# The pool is topped up to `jobs` in flight and no further: submitting the whole
|
|
434
|
+
# list up front would put every item past the deadline check before the first one
|
|
435
|
+
# had finished, and the shared budget could then never stop anything.
|
|
436
|
+
while queue or in_flight:
|
|
437
|
+
while queue and len(in_flight) < jobs:
|
|
438
|
+
slot, (i, src, key) = queue[0]
|
|
439
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
440
|
+
break
|
|
441
|
+
queue.pop(0)
|
|
442
|
+
fut = pool.submit(_run_buffered, work_one, i, src, key)
|
|
443
|
+
futures[fut] = slot
|
|
444
|
+
in_flight.add(fut)
|
|
445
|
+
if not in_flight:
|
|
446
|
+
break
|
|
447
|
+
done_now, in_flight = concurrent.futures.wait(
|
|
448
|
+
in_flight, return_when=concurrent.futures.FIRST_COMPLETED)
|
|
449
|
+
in_flight = set(in_flight)
|
|
450
|
+
for fut in done_now:
|
|
451
|
+
ordered[futures[fut]], lines[futures[fut]] = fut.result()
|
|
452
|
+
for slot, (i, src, key) in queue:
|
|
453
|
+
timed_out["hit"] = True
|
|
454
|
+
ordered[slot] = {"file": str(src),
|
|
455
|
+
"output": str(final_path(src, recipe, outdir)),
|
|
456
|
+
"ok": False, "seconds": 0.0, "skipped": "timeout"}
|
|
457
|
+
except KeyboardInterrupt:
|
|
458
|
+
for fut in futures:
|
|
459
|
+
fut.cancel()
|
|
460
|
+
info("interrupted: finishing what had already started")
|
|
461
|
+
raise
|
|
462
|
+
for slot, (i, src, key) in enumerate(pending):
|
|
463
|
+
for line in lines[slot]:
|
|
464
|
+
info(line)
|
|
465
|
+
if ordered[slot] is None:
|
|
466
|
+
timed_out["hit"] = True
|
|
467
|
+
ordered[slot] = {"file": str(src), "output": str(final_path(src, recipe, outdir)),
|
|
468
|
+
"ok": False, "seconds": 0.0, "skipped": "timeout"}
|
|
469
|
+
results.append(ordered[slot])
|
|
225
470
|
return results
|
|
226
471
|
|
|
227
472
|
results = one_pass()
|
|
@@ -246,11 +491,31 @@ def main() -> int:
|
|
|
246
491
|
if not args.json:
|
|
247
492
|
for r in results:
|
|
248
493
|
print(f"{'OK ' if r['ok'] else 'FAIL'} {r['file']} -> {r['output']}" + (" (cached)" if r.get("cached") else ""))
|
|
494
|
+
if interrupted["hit"]:
|
|
495
|
+
# Ctrl-C on a batch that already produced files: the user needs the partial table, not a
|
|
496
|
+
# traceback and not "nothing was written". Exit 130 with everything that completed.
|
|
497
|
+
die(f"interrupted after {done} of {len(results)} item(s); the finished outputs are kept "
|
|
498
|
+
"and the rest were not started",
|
|
499
|
+
code=130, kind="interrupted", output=None, dry_run=STATE.dry_run, results=results,
|
|
500
|
+
processed=done, total=len(results), jobs=jobs, jobs_requested=requested,
|
|
501
|
+
timed_out=timed_out["hit"])
|
|
502
|
+
if timed_out["hit"]:
|
|
503
|
+
skipped = [r["file"] for r in results if r.get("skipped") == "timeout"]
|
|
504
|
+
die(f"the batch's {STATE.timeout:.0f} s budget ran out with {len(skipped)} item(s) not "
|
|
505
|
+
f"started: {', '.join(os.path.basename(f) for f in skipped[:5])}"
|
|
506
|
+
+ (" ..." if len(skipped) > 5 else "")
|
|
507
|
+
+ ". --timeout is the whole run's limit, not each item's; raise it or split the folder.",
|
|
508
|
+
code=124, kind="timeout", output=None, dry_run=STATE.dry_run, results=results,
|
|
509
|
+
processed=done, total=len(results), jobs=jobs, jobs_requested=requested,
|
|
510
|
+
timed_out=True)
|
|
249
511
|
if done != len(results):
|
|
250
512
|
failed_files = [r["file"] for r in results if not r["ok"]]
|
|
251
513
|
die(f"{len(results) - done} of {len(results)} items failed: {', '.join(failed_files[:5])}" + (" ..." if len(failed_files) > 5 else ""),
|
|
252
514
|
kind="verification", output=None, dry_run=STATE.dry_run, results=results, processed=done, total=len(results))
|
|
253
|
-
emit(None, results=results, processed=done, total=len(results)
|
|
515
|
+
emit(None, results=results, processed=done, total=len(results),
|
|
516
|
+
jobs=jobs, jobs_requested=requested, wall_seconds=round(time.time() - started, 1),
|
|
517
|
+
item_seconds_total=round(sum(float(r.get("seconds") or 0) for r in results), 1),
|
|
518
|
+
timed_out=timed_out["hit"])
|
|
254
519
|
return 0
|
|
255
520
|
|
|
256
521
|
|