ffmpeg-skill 2.11.0 → 2.12.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/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, read_text_or_die, MEDIA_EXT as _MEDIA_EXT
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
- pass
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, or None if it couldn't
107
- be parsed. {in}/{out} are already resolved by the caller before argv reaches here, so forcing
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
- die(f"recipe step names a script that isn't one of this skill's own tools: {script!r} "
114
- f"(must be a bare filename like 'silence.py', found in scripts/)")
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(read_text_or_die(str(recipe["project"]), "recipe.project"))
163
- except ValueError as e:
164
- die(f"recipe.project: {recipe['project']} is not valid JSON: {e}")
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
- die("recipe needs steps or project")
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
- except ValueError:
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
- cache[key] = r
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 hit and Path(hit.get("output", "")).exists() and not args.force:
356
- info(f"skip (cached) {src.name}")
357
- slots[i] = {**hit, "cached": True}
358
- continue
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
- reason = str(exc) or exc.__class__.__name__
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": reason[:300]}
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
- store(key, r)
402
- return r
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, let the running children be killed by
443
- # the shared signal handling, and REPORT what completed -- exit 130 with the
444
- # partial table, never a traceback.
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 everything that completed.
551
- die(f"interrupted after {done} of {len(results)} item(s); the finished outputs are kept "
552
- "and the rest were not started",
553
- code=130, kind="interrupted", output=None, dry_run=STATE.dry_run, results=results,
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
- emit(None, results=results, processed=done, total=len(results),
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)