make-sprites.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. #!/usr/bin/env python3
  2. """Scrub-preview sprites + WebVTT thumbnail track for web players.
  3. Renders tiled sprite sheets at a fixed interval and writes the thumbs.vtt that
  4. maps each time range to its sprite region (#xywh media fragments) — the format
  5. Video.js / JW Player / Plyr / hls.js preview plugins consume. The geometry math
  6. (page, row, column per thumb) is exactly the part worth never re-deriving.
  7. Usage: make-sprites.py [--interval S] [--width PX] [--cols N] [--rows N]
  8. [--out-dir DIR] [--json] <media>
  9. Input: one video file as positional
  10. Output: stdout = written file list (or --json envelope,
  11. schema claude-mods.ffmpeg-ops.sprites/v1)
  12. Stderr: progress, errors
  13. Exit: 0 ok, 2 usage, 3 file not found, 4 probe/render failure, 5 ffmpeg missing
  14. Examples:
  15. make-sprites.py --interval 5 video.mp4
  16. make-sprites.py --interval 10 --width 240 --out-dir previews/ lecture.mp4
  17. make-sprites.py --json video.mp4 | jq -r '.data.vtt'
  18. """
  19. import argparse
  20. import json
  21. import math
  22. import shutil
  23. import subprocess
  24. import sys
  25. from pathlib import Path
  26. from typing import NoReturn
  27. SCHEMA = "claude-mods.ffmpeg-ops.sprites/v1"
  28. EXIT_OK, EXIT_USAGE, EXIT_NOT_FOUND, EXIT_VALIDATION, EXIT_MISSING_DEP = 0, 2, 3, 4, 5
  29. def err(json_mode: bool, code: str, message: str, exit_code: int) -> NoReturn:
  30. if json_mode:
  31. print(json.dumps({"error": {"code": code, "message": message, "details": {}}}))
  32. print(f"ERROR: {message}", file=sys.stderr)
  33. sys.exit(exit_code)
  34. def probe(ffprobe: str, path: Path) -> dict:
  35. # Full -show_streams, not selective -show_entries: the rotation side data
  36. # (side_data_list) is silently omitted by entry-filtered queries on some
  37. # ffprobe versions, which made rotated sources produce squashed thumbs.
  38. proc = subprocess.run(
  39. [ffprobe, "-v", "error", "-select_streams", "v:0", "-print_format", "json",
  40. "-show_streams", "-show_format", str(path)],
  41. capture_output=True, text=True)
  42. if proc.returncode != 0:
  43. return {}
  44. raw = json.loads(proc.stdout)
  45. streams = raw.get("streams", [])
  46. if not streams:
  47. return {}
  48. s = streams[0]
  49. rotation = 0
  50. for sd in s.get("side_data_list", []) or []:
  51. try:
  52. rotation = int(sd.get("rotation", 0)) % 360
  53. except (TypeError, ValueError):
  54. pass
  55. w, h = s.get("width", 0), s.get("height", 0)
  56. if rotation in (90, 270): # ffmpeg autorotates on decode; sprites show display dims
  57. w, h = h, w
  58. return {"width": w, "height": h,
  59. "duration": float(raw.get("format", {}).get("duration", 0) or 0)}
  60. def ts(seconds: float) -> str:
  61. h, rem = divmod(int(seconds), 3600)
  62. m, s = divmod(rem, 60)
  63. return f"{h:02d}:{m:02d}:{s:02d}.{int(round((seconds % 1) * 1000)):03d}"
  64. def main() -> int:
  65. ap = argparse.ArgumentParser(
  66. description="Sprite sheets + WebVTT thumbnail track for player scrub previews.",
  67. epilog="Examples:\n"
  68. " make-sprites.py --interval 5 video.mp4\n"
  69. " make-sprites.py --interval 10 --width 240 --out-dir previews/ in.mp4\n",
  70. formatter_class=argparse.RawDescriptionHelpFormatter)
  71. ap.add_argument("file", help="video file")
  72. ap.add_argument("--interval", type=float, default=5.0,
  73. help="seconds per thumbnail (default 5)")
  74. ap.add_argument("--width", type=int, default=160,
  75. help="thumbnail width in px (default 160)")
  76. ap.add_argument("--cols", type=int, default=10, help="grid columns (default 10)")
  77. ap.add_argument("--rows", type=int, default=10, help="grid rows (default 10)")
  78. ap.add_argument("--out-dir", default="sprites", help="output dir (default ./sprites)")
  79. ap.add_argument("--json", action="store_true", help="emit JSON envelope on stdout")
  80. args = ap.parse_args()
  81. if args.interval <= 0 or args.width < 16 or args.cols < 1 or args.rows < 1:
  82. err(args.json, "USAGE", "interval/width/cols/rows out of range", EXIT_USAGE)
  83. ffmpeg, ffprobe = shutil.which("ffmpeg"), shutil.which("ffprobe")
  84. if not ffmpeg or not ffprobe:
  85. err(args.json, "MISSING_DEPENDENCY", "ffmpeg/ffprobe not found on PATH",
  86. EXIT_MISSING_DEP)
  87. path = Path(args.file)
  88. if not path.is_file():
  89. err(args.json, "NOT_FOUND", f"file not found: {path}", EXIT_NOT_FOUND)
  90. info = probe(ffprobe, path)
  91. if not info or not info["width"] or info["duration"] <= 0:
  92. err(args.json, "VALIDATION", "no probeable video stream/duration",
  93. EXIT_VALIDATION)
  94. # Explicit even thumb height so our geometry and ffmpeg's agree exactly.
  95. tw = args.width // 2 * 2
  96. th = max(2, round(tw * info["height"] / info["width"] / 2) * 2)
  97. per_page = args.cols * args.rows
  98. n_thumbs = max(1, math.ceil(info["duration"] / args.interval))
  99. n_pages = math.ceil(n_thumbs / per_page)
  100. out_dir = Path(args.out_dir)
  101. out_dir.mkdir(parents=True, exist_ok=True)
  102. print(f"{n_thumbs} thumbs ({tw}x{th}) on {n_pages} sheet(s)...", file=sys.stderr)
  103. proc = subprocess.run(
  104. [ffmpeg, "-y", "-v", "error", "-i", str(path.resolve()),
  105. "-vf", f"fps=1/{args.interval},scale={tw}:{th},tile={args.cols}x{args.rows}",
  106. "-q:v", "3", "sprite_%02d.jpg"],
  107. capture_output=True, text=True, cwd=str(out_dir))
  108. if proc.returncode != 0:
  109. err(args.json, "VALIDATION",
  110. f"sprite render failed: {(proc.stderr.strip().splitlines() or ['?'])[-1]}",
  111. EXIT_VALIDATION)
  112. sheets = sorted(out_dir.glob("sprite_*.jpg"))
  113. lines = ["WEBVTT", ""]
  114. for i in range(n_thumbs):
  115. t0 = i * args.interval
  116. t1 = min((i + 1) * args.interval, info["duration"])
  117. page = i // per_page + 1
  118. idx = i % per_page
  119. x, y = (idx % args.cols) * tw, (idx // args.cols) * th
  120. lines += [f"{ts(t0)} --> {ts(t1)}",
  121. f"sprite_{page:02d}.jpg#xywh={x},{y},{tw},{th}", ""]
  122. vtt = out_dir / "thumbs.vtt"
  123. vtt.write_text("\n".join(lines), encoding="utf-8")
  124. data = {"media": str(path), "thumbs": n_thumbs, "thumb_size": [tw, th],
  125. "grid": [args.cols, args.rows], "interval_s": args.interval,
  126. "sheets": [str(p) for p in sheets], "vtt": str(vtt)}
  127. if args.json:
  128. print(json.dumps({"data": data, "meta": {"schema": SCHEMA}}, indent=2))
  129. else:
  130. for p in [*sheets, vtt]:
  131. print(p)
  132. print(f"done: point the player's thumbnail track at {vtt.name} "
  133. f"(URLs resolve relative to the VTT)", file=sys.stderr)
  134. return EXIT_OK
  135. if __name__ == "__main__":
  136. sys.exit(main())