From 0ebff0ec175fb1a5e115ac910785c314f8e448d9 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 25 Apr 2026 00:06:35 +0000 Subject: [PATCH] Preview: simplify pyvips timing to single processing ms + req total. --- cista/preview.py | 26 ++++------ scripts/preview_images.py | 102 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 16 deletions(-) create mode 100644 scripts/preview_images.py diff --git a/cista/preview.py b/cista/preview.py index ba10fc5..592fa11 100644 --- a/cista/preview.py +++ b/cista/preview.py @@ -354,10 +354,14 @@ async def preview(req, path): except PreviewError: return empty(422) if preview_resp and preview_resp.backend: - load_ms = int(round(preview_resp.load_ms or 0.0)) - process_ms = int(round(preview_resp.process_ms or 0.0)) - save_ms = int(round(preview_resp.save_ms or 0.0)) - req.ctx._log_extra = f"{preview_resp.backend} {load_ms}/{process_ms}/{save_ms} =" + if preview_resp.load_ms is not None: + load_ms = int(round(preview_resp.load_ms)) + process_ms = int(round(preview_resp.process_ms or 0.0)) + save_ms = int(round(preview_resp.save_ms or 0.0)) + timing_detail = f"{load_ms}/{process_ms}/{save_ms}" + else: + timing_detail = str(int(round(preview_resp.total_ms or 0.0))) + req.ctx._log_extra = f"{preview_resp.backend} {timing_detail} ➛" if not img: # Preview generation failed, redirect to the file itself return redirect(f"/files/{path}", status=303) @@ -415,16 +419,12 @@ def process_image_with_timing(path, *, maxsize, quality): def process_image_pyvips(path, *, maxsize, quality): import pyvips - t_load = perf_counter() + t_start = perf_counter() img = pyvips.Image.new_from_file(str(path), access="sequential") - t_proc = perf_counter() - img = img.autorot() scale = min(maxsize / img.width, maxsize / img.height, 1.0) if scale < 1.0: img = img.resize(scale) - - t_save = perf_counter() ret = img.write_to_buffer( ".avif", Q=quality, @@ -433,17 +433,11 @@ def process_image_pyvips(path, *, maxsize, quality): ) t_end = perf_counter() - load_ms = (t_proc - t_load) * 1000 - proc_ms = (t_save - t_proc) * 1000 - save_ms = (t_end - t_save) * 1000 return ret, PreviewResponse( ok=True, mime="image/avif", backend="pyvips", - load_ms=round(load_ms, 1), - process_ms=round(proc_ms, 1), - save_ms=round(save_ms, 1), - total_ms=round((t_end - t_load) * 1000, 1), + total_ms=round((t_end - t_start) * 1000, 1), ) diff --git a/scripts/preview_images.py b/scripts/preview_images.py new file mode 100644 index 0000000..2cec854 --- /dev/null +++ b/scripts/preview_images.py @@ -0,0 +1,102 @@ +import argparse +import mimetypes +from pathlib import Path + +from cista.preview import process_image_with_timing + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Generate image previews for all files in a folder, one at a time.", + ) + parser.add_argument("folder", type=Path, help="Folder to scan recursively") + parser.add_argument( + "--px", + type=int, + default=1024, + help="Maximum preview dimension in pixels (default: 1024)", + ) + parser.add_argument( + "--quality", + type=int, + default=60, + help="AVIF quality passed to preview generation (default: 60)", + ) + return parser.parse_args() + + +def is_image_file(path: Path) -> bool: + mime_type, _ = mimetypes.guess_type(path.name) + return bool(mime_type and mime_type.startswith("image/")) + + +def main() -> int: + args = parse_args() + folder = args.folder.resolve() + if not folder.is_dir(): + raise SystemExit(f"Not a directory: {folder}") + + files = sorted(path for path in folder.rglob("*") if path.is_file() and is_image_file(path)) + if not files: + print(f"No image files found under {folder}") + return 0 + + total_files = 0 + total_bytes = 0 + total_load_ms: float = 0.0 + total_process_ms: float = 0.0 + total_save_ms: float = 0.0 + total_preview_ms: float = 0.0 + failures = 0 + + print(f"Scanning {folder}") + print(f"Generating previews for {len(files)} image files") + + for path in files: + total_files += 1 + rel = path.relative_to(folder) + try: + preview, timing = process_image_with_timing( + path, + maxsize=args.px, + quality=args.quality, + ) + except Exception as exc: + failures += 1 + print(f"FAIL {rel} error={exc}") + continue + + total_bytes += len(preview) + total_load_ms += timing.load_ms or 0.0 + total_process_ms += timing.process_ms or 0.0 + total_save_ms += timing.save_ms or 0.0 + total_preview_ms += timing.total_ms or 0.0 + + if timing.load_ms is not None: + detail = ( + f"load={timing.load_ms:.1f}ms process={timing.process_ms:.1f}ms " + f"save={timing.save_ms:.1f}ms total={timing.total_ms:.1f}ms" + ) + else: + detail = f"total={timing.total_ms:.1f}ms" + print(f"OK {rel} backend={timing.backend} bytes={len(preview)} {detail}") + + completed = total_files - failures + print() + print("Summary") + print(f" files={total_files}") + print(f" completed={completed}") + print(f" failed={failures}") + print(f" preview_bytes={total_bytes}") + if completed: + if total_load_ms or total_process_ms or total_save_ms: + print(f" load_total_ms={total_load_ms:.1f}") + print(f" process_total_ms={total_process_ms:.1f}") + print(f" save_total_ms={total_save_ms:.1f}") + print(f" preview_total_ms={total_preview_ms:.1f}") + print(f" preview_avg_ms={total_preview_ms / completed:.1f}") + return 0 if failures == 0 else 1 + + +if __name__ == "__main__": + raise SystemExit(main())