ffmpeg-skill 3.1.7 → 3.2.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/render.py CHANGED
@@ -75,7 +75,7 @@ from typing import Any, Dict, List, Optional, Sequence
75
75
  from export import PRESETS, PLATFORM_OF
76
76
  from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
77
77
  from _common import guard_sidecar, STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, X264_PRESETS
78
- from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args, found_exe
78
+ from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args, child_exit_code, found_exe, kept_clause, kept_deliverables, mark_delivered, remember_written
79
79
  from _common.emit import absorb_stage, audit, record_fallback, record_skip, refuse, warn
80
80
  import subprocess
81
81
  from _contract import CONTRACT_VERSION
@@ -351,6 +351,7 @@ def render_pack(names: List[str], args) -> int:
351
351
  rows: List[Dict[str, Any]] = []
352
352
  outputs: List[str] = []
353
353
  failed: List[str] = []
354
+ stopped: List[Dict[str, Any]] = [] # destinations a timeout stopped: {kind, exit_code}
354
355
  for name in names:
355
356
  # the destination decides the container: an audio-only destination in a pack must not be
356
357
  # handed a .mp4 name the single-template form would never have written (review 12)
@@ -394,6 +395,9 @@ def render_pack(names: List[str], args) -> int:
394
395
  outputs.append(out)
395
396
  if not ok:
396
397
  failed.append(name)
398
+ err = doc.get("error") or {}
399
+ if err.get("kind") in ("timeout", "interrupted"):
400
+ stopped.append({"kind": err["kind"], "exit_code": child_exit_code(doc, proc.returncode)})
397
401
  pack = str(outdir / f"{stem}_pack.md")
398
402
  lines = [f"# Social pack — {stem}", "",
399
403
  "| platform | file | size | duration | check |", "|---|---|---|---|---|"]
@@ -410,8 +414,11 @@ def render_pack(names: List[str], args) -> int:
410
414
  Path(pack).write_text("\n".join(lines) + "\n", encoding="utf-8")
411
415
  info(("[dry-run] would write " if STATE.dry_run else "wrote ") + pack)
412
416
  if failed:
417
+ # a destination that was stopped makes the pack a stopped run: its kind and exit code (124,
418
+ # 130/143), as a stage's are render.py's (docs/contract.md); otherwise a verification failure
413
419
  die(f"pack: {len(failed)} of {len(rows)} destinations failed: {', '.join(failed)}",
414
- kind="verification", output=pack, dry_run=STATE.dry_run, pack=rows, outputs=outputs)
420
+ code=stopped[0]["exit_code"] if stopped else 1, kind=stopped[0]["kind"] if stopped else "verification",
421
+ output=pack, dry_run=STATE.dry_run, pack=rows, outputs=outputs)
415
422
  emit(pack, pack=rows, outputs=outputs, stages=["pack"],
416
423
  verification=[{"step": "check", "ok": r["ok"], "platform": r["platform"]} for r in rows])
417
424
  return 0
@@ -577,6 +584,8 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
577
584
  # child's document standing, which a caller reading _LAST_DOC would misattribute.
578
585
  _LAST_DOC.clear()
579
586
  _LAST_DOC["cached"] = True
587
+ if not STATE.dry_run:
588
+ remember_written(dest) # placed by this run, as a stage's own write would be (#415)
580
589
  return dest
581
590
  CACHE["misses"].append(stage)
582
591
  _refuse_uncached_earlier_stage(stage)
@@ -603,12 +612,27 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
603
612
  extra_fields = {"hint": err["hint"]} if err.get("hint") else {}
604
613
  error_extra = absorb_stage(Path(script).stem, doc) # the stage's reason/details and records ride along
605
614
  extra_fields.update(_stage_failure_hw(doc))
606
- die(f"{script} failed: {err.get('message') or (proc.stderr.strip().splitlines() or ['?'])[-1][:300]}",
615
+ why = err.get('message') or (proc.stderr.strip().splitlines() or ['?'])[-1][:300]
616
+ if kept_deliverables() and err.get("kind") in ("timeout", "interrupted"):
617
+ # a stage after the delivery (chapters, check): the stage's own "nothing was written"
618
+ # is about its own file, while the delivery is on disk (#415)
619
+ why = why.replace("; nothing was written", "") + f" ({kept_clause()})"
620
+ extra_fields.setdefault("output", kept_deliverables()[-1])
621
+ elif err.get("kind") in ("timeout", "interrupted") and doc.get("output") and os.path.isfile(str(doc["output"])):
622
+ # the stage was stopped after writing its own deliverable and kept it (export.py's
623
+ # --normalize pass): its message says so, and the file is this run's output too
624
+ extra_fields.setdefault("output", str(doc["output"]))
625
+ die(f"{script} failed: {why}",
607
626
  code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script, error_extra=error_extra, **extra_fields)
608
627
  _LAST_DOC.clear()
609
628
  _LAST_DOC.update(doc if isinstance(doc, dict) else {})
610
629
  absorb_stage(Path(script).stem, doc) # a stage's GPU facts (ran on VideoToolbox, fell back and why) and its diagnostics reach this result
611
630
  out_path = str(doc.get("output") or "")
631
+ if not STATE.dry_run:
632
+ # A file a stage of this run wrote is this run's, not someone else's: the chapters stage
633
+ # places its tagged copy over the export's delivery, which the output guard would
634
+ # otherwise refuse as an existing file without --overwrite (#415).
635
+ remember_written(out_path, dest)
612
636
  if key:
613
637
  cache_store(stage, key, out_path or (dest or ""), time.time() - started, doc)
614
638
  return out_path
@@ -951,8 +975,10 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
951
975
  if proc.returncode != 0 or doc.get("status") != "completed":
952
976
  err = doc.get("error") or {}
953
977
  error_extra = absorb_stage(tool, doc) # the tool's reason/details and records ride along
978
+ # the tool's exit code is the plan's (124 timeout, 130/143 interrupted), as sh() passes a stage's on
954
979
  die(f"{tool} failed while executing the plan: {err.get('message') or proc.stderr.strip()[-300:]}",
955
- kind=err.get("kind", "ffmpeg"), plan=path, tool=tool, error_extra=error_extra, **_stage_failure_hw(doc))
980
+ code=child_exit_code(doc, proc.returncode), kind=err.get("kind", "ffmpeg"), plan=path, tool=tool,
981
+ error_extra=error_extra, **_stage_failure_hw(doc))
956
982
  absorb_stage(tool, doc) # the tool's GPU facts (fell back and why, ran on VideoToolbox) and diagnostics reach this result
957
983
  output = doc.get("output") or plan.get("output")
958
984
  check_result = None
@@ -1611,6 +1637,8 @@ def main() -> int:
1611
1637
  place_output(current, output)
1612
1638
  info(("[dry-run] would copy" if STATE.dry_run else "copied") + f" final stage to {output}")
1613
1639
  current = output
1640
+ if not STATE.dry_run:
1641
+ mark_delivered(output) # the chapters and check stages run on it: a stop from here keeps it, and says so (#415)
1614
1642
 
1615
1643
  # ---- chapters (metadata.py on the delivered file: streams copied, markers written)
1616
1644
  ch = proj.get("chapters")
@@ -1648,6 +1676,13 @@ def main() -> int:
1648
1676
  exit_code = 1
1649
1677
  elif check_result.get("error") or check_result.get("status") == "failed":
1650
1678
  err = check_result.get("error")
1679
+ if isinstance(err, dict) and err.get("kind") in ("timeout", "interrupted"):
1680
+ # stopped, not failed: the run exits with check.py's own code (124, 130/143), as a
1681
+ # stage that stopped does, and says the delivery it had written is kept (#415)
1682
+ why = str(err.get("message") or "").replace("; nothing was written", "")
1683
+ die(f"check.py failed: {why} ({kept_clause()})",
1684
+ code=int(check_result.get("exit_code") or err.get("exit_code") or 1), kind=err["kind"],
1685
+ stage="check.py", output=output, stages=stages_done)
1651
1686
  info(f"check: could not run check.py — {err.get('message') if isinstance(err, dict) else err}")
1652
1687
  exit_code = 1
1653
1688
  else:
package/scripts/sync.py CHANGED
@@ -32,6 +32,7 @@ import os
32
32
  import sys
33
33
  from typing import Dict, List
34
34
 
35
+ from _common.runner import fail_if_classified
35
36
  from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args, decode_pcm_mono, rms_envelope
36
37
  from _common.emit import record_skip, warn
37
38
  from _common.decision import stream_starts
@@ -377,6 +378,7 @@ def main() -> int:
377
378
  cmd += ["-af", ",".join(af_parts)]
378
379
  cmd += aac_args() + (["-t", f"{ref_dur:.3f}"] if ref_dur else ["-shortest"]) + [output]
379
380
  proc = run(cmd, check=False)
381
+ fail_if_classified(proc) # a cause the re-encode would hit too is reported, not retried (#415)
380
382
  if proc.returncode != 0:
381
383
  cmd = [c for c in cmd if c != "copy"]
382
384
  idx = cmd.index("-c:v"); del cmd[idx]
@@ -389,6 +391,7 @@ def main() -> int:
389
391
  cmd += ["-movflags", "+faststart"]
390
392
  cmd += ["-avoid_negative_ts", "make_zero", output]
391
393
  proc = run(cmd, check=False)
394
+ fail_if_classified(proc)
392
395
  if proc.returncode != 0:
393
396
  cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second] + (video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf) if has_video else []) + audio_codec_for(output) + [output]
394
397
  run(cmd)
@@ -284,6 +284,14 @@ def main() -> int:
284
284
  notes.append(f"the visualisation band and {args.platform}'s bottom safe zone overlap by "
285
285
  f"{overlap}px: the captions or the app's own UI will sit over the waveform "
286
286
  f"(lower --vis-height to {max(0.05, (args.height - safe_px) / args.height):.2f})")
287
+ if STATE.dry_run and (args.title or args.srt or args.text):
288
+ # graphics.py / caption.py plan on the render this dry run did not write, whose frame they
289
+ # cannot measure: their sizes and offsets come out 0 in the plan, and the real run takes
290
+ # them from the WxH render (#415). Said here, so a plan is not read as the real numbers.
291
+ later = " and ".join(n for n, on in (("title", args.title), ("captions", args.srt or args.text)) if on)
292
+ notes.append(f"the {later} stage was planned on the visualisation render this dry run did not write, so the "
293
+ f"font sizes and offsets in its planned command are placeholders (0); the real run sizes them "
294
+ f"from the {args.width}x{args.height} render")
287
295
  if silent_peak is not None:
288
296
  warn(f"{args.input} is silent (peak {silent_peak:g} dBFS); the visualisation is a flat line", "silent_input",
289
297
  path=args.input, peak_db=silent_peak)