davanstrien/prelinger-sample / scripts /thumbs_prelinger.py
davanstrien's picture
download
raw
4.89 kB
# /// script
# requires-python = ">=3.11"
# dependencies = ["imageio-ffmpeg"]
# ///
"""Extract one thumbnail per Prelinger chunk (frame at the chunk midpoint).
Work list comes from /data/chunkmeta/*.json — the recorded stream-copy
boundaries — so the midpoint is (end - start) / 2 of the ACTUAL segment, no
ffprobe round-trip. Chunk mp4s have reset timestamps, so that offset is also
the seek position inside the file.
In: /pf/chunks/{id}/c%04d.mp4
/pf/chunkmeta/{id}.json
Out: /pf/thumbs/{id}/c%04d.jpg
/pf/thumbmeta/{id}.json (written LAST = per-film completion marker)
"""
import argparse
import json
import shutil
import subprocess
import tempfile
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import imageio_ffmpeg
FFMPEG = imageio_ffmpeg.get_ffmpeg_exe() # default uv image ships no ffmpeg
ROOT = Path("/pf") # /data is reserved for Jobs artifacts on local-script runs
CHUNKS, CMETA = ROOT / "chunks", ROOT / "chunkmeta"
THUMBS, TMETA = ROOT / "thumbs", ROOT / "thumbmeta"
WIDTH = 480
QSCALE = "4" # mjpeg -q:v 4 ~= quality 80
def exists(p: Path) -> bool:
# FUSE stat races with concurrent writers; a raised OSError is not "absent"
# in a way we can act on, but treating it as absent only costs a redo.
try:
return p.exists()
except OSError:
return False
def grab(src: Path, dst_tmp: Path, at: float) -> bool:
"""One frame at `at` seconds, scaled to WIDTH. -ss before -i = fast seek."""
try:
subprocess.run(
[FFMPEG, "-hide_banner", "-loglevel", "error",
"-ss", f"{at:.3f}", "-i", str(src),
"-frames:v", "1", "-vf", f"scale={WIDTH}:-2",
"-q:v", QSCALE, "-y", str(dst_tmp)],
check=True, stdin=subprocess.DEVNULL, timeout=180)
except (subprocess.SubprocessError, OSError):
return False
return dst_tmp.exists() and dst_tmp.stat().st_size > 0
def thumb_one(metafile: Path) -> str:
try:
meta = json.loads(metafile.read_text())
except (OSError, json.JSONDecodeError):
print(f"[err] unreadable chunkmeta {metafile.name}", flush=True)
return "err"
ident = meta["identifier"]
marker = TMETA / f"{ident}.json"
if exists(marker):
return "skip"
thumbs, failed = [], []
with tempfile.TemporaryDirectory() as td:
tdp = Path(td)
dest = THUMBS / ident
try:
dest.mkdir(parents=True, exist_ok=True)
except OSError as e:
print(f"[err] {ident}: mkdir {type(e).__name__}", flush=True)
return "err"
for c in meta["chunks"]:
name = c["chunk"]
stem = Path(name).stem
mid = max(0.0, (float(c["end"]) - float(c["start"])) / 2.0)
src = CHUNKS / ident / name
local = tdp / f"{stem}.jpg"
if not grab(src, local, mid):
# retry once at the first frame — very short or truncated tails
if not grab(src, local, 0.0):
failed.append(name)
continue
mid = 0.0
try:
# FUSE has no rename: write local, then copy onto the mount
shutil.copyfile(local, dest / f"{stem}.jpg")
except OSError:
failed.append(name)
continue
thumbs.append({"chunk": name, "thumb": f"{stem}.jpg",
"t": round(mid, 3)})
try:
marker.write_text(json.dumps({"identifier": ident, "thumbs": thumbs,
"failed": failed}))
except OSError as e:
print(f"[err] {ident}: marker {type(e).__name__}", flush=True)
return "err"
if failed:
print(f"[partial] {ident}: {len(failed)} chunk(s) failed", flush=True)
return "partial"
return "ok"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--workers", type=int, default=8)
args = ap.parse_args()
THUMBS.mkdir(parents=True, exist_ok=True)
TMETA.mkdir(parents=True, exist_ok=True)
films = sorted(CMETA.glob("*.json"))
print(f"[plan] {len(films)} films with chunkmeta, workers={args.workers}",
flush=True)
counts = {"ok": 0, "skip": 0, "partial": 0, "err": 0}
t0 = time.time()
with ThreadPoolExecutor(args.workers) as ex:
for n, res in enumerate(ex.map(thumb_one, films), 1):
counts[res] += 1
if n % 50 == 0 or n == len(films):
print(f"[progress] {n}/{len(films)} {counts} "
f"{time.time() - t0:.0f}s", flush=True)
try:
total = sum(1 for _ in THUMBS.rglob("*.jpg"))
except OSError:
total = -1
print(f"[THUMBS DONE] {counts} total_thumbs={total} "
f"{time.time() - t0:.0f}s", flush=True)
if __name__ == "__main__":
main()

Xet Storage Details

Size:
4.89 kB
·
Xet hash:
a212be7e39f3dfbbcbd0edefdedd38333c59b2d886cbbdc548d853dc2a117ca6

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.