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/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
- def run_step(argv: List[str]) -> bool:
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
- info(" → " + " ".join(os.path.basename(c) if i < 1 else c for i, c in enumerate(cmd)))
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
- info(" " + "\n ".join(proc.stderr.strip().splitlines()[-4:]))
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
- info(" " + line)
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) -> Dict[str, Any]:
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
- for src in files:
205
- key = f"{file_key(src)}:{rkey}"
206
- hit = cache.get(key)
207
- if hit and Path(hit.get("output", "")).exists() and not args.force:
208
- info(f"skip (cached) {src.name}")
209
- results.append({**hit, "cached": True})
210
- continue
211
- info(f"=== {src.name}")
212
- r = process(src, recipe, outdir, work)
213
- results.append(r)
214
- if r["ok"] and not STATE.dry_run:
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