Preview: add pdf+pyvips encode path and simplify timings
This commit is contained in:
+51
-32
@@ -21,6 +21,7 @@ import av
|
|||||||
import fitz # PyMuPDF
|
import fitz # PyMuPDF
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pillow_heif
|
import pillow_heif
|
||||||
|
import pyvips
|
||||||
from blake3 import blake3
|
from blake3 import blake3
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from sanic import Blueprint, empty, raw, redirect
|
from sanic import Blueprint, empty, raw, redirect
|
||||||
@@ -107,9 +108,17 @@ class _PreviewWorker:
|
|||||||
if self.proc.stdin is None or self.proc.stdout is None:
|
if self.proc.stdin is None or self.proc.stdout is None:
|
||||||
raise WorkerProtocolError("worker streams not available")
|
raise WorkerProtocolError("worker streams not available")
|
||||||
|
|
||||||
line = msgspec.json.encode(
|
line = (
|
||||||
PreviewRequest(path=str(filepath), quality=quality, maxsize=maxsize, maxzoom=maxzoom)
|
msgspec.json.encode(
|
||||||
) + b"\n"
|
PreviewRequest(
|
||||||
|
path=str(filepath),
|
||||||
|
quality=quality,
|
||||||
|
maxsize=maxsize,
|
||||||
|
maxzoom=maxzoom,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ b"\n"
|
||||||
|
)
|
||||||
self.proc.stdin.write(line)
|
self.proc.stdin.write(line)
|
||||||
await self.proc.stdin.drain()
|
await self.proc.stdin.drain()
|
||||||
|
|
||||||
@@ -211,7 +220,9 @@ class _PreviewWorkerPool:
|
|||||||
msgspec.json.DecodeError,
|
msgspec.json.DecodeError,
|
||||||
) as e:
|
) as e:
|
||||||
replace = True
|
replace = True
|
||||||
logger.warning("Preview worker protocol failure for %s: %s", filepath.name, e)
|
logger.warning(
|
||||||
|
"Preview worker protocol failure for %s: %s", filepath.name, e
|
||||||
|
)
|
||||||
raise PreviewError(filepath.name)
|
raise PreviewError(filepath.name)
|
||||||
finally:
|
finally:
|
||||||
if replace:
|
if replace:
|
||||||
@@ -231,7 +242,9 @@ class _PreviewWorkerPool:
|
|||||||
self._idle.get_nowait()
|
self._idle.get_nowait()
|
||||||
except asyncio.QueueEmpty:
|
except asyncio.QueueEmpty:
|
||||||
break
|
break
|
||||||
await asyncio.gather(*(worker.kill() for worker in workers), return_exceptions=True)
|
await asyncio.gather(
|
||||||
|
*(worker.kill() for worker in workers), return_exceptions=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def start_preview_workers() -> None:
|
async def start_preview_workers() -> None:
|
||||||
@@ -263,7 +276,9 @@ async def shutdown_preview_workers() -> None:
|
|||||||
proc.kill()
|
proc.kill()
|
||||||
except ProcessLookupError:
|
except ProcessLookupError:
|
||||||
pass
|
pass
|
||||||
await asyncio.gather(*(proc.wait() for proc in list(_active_procs)), return_exceptions=True)
|
await asyncio.gather(
|
||||||
|
*(proc.wait() for proc in list(_active_procs)), return_exceptions=True
|
||||||
|
)
|
||||||
_active_procs.clear()
|
_active_procs.clear()
|
||||||
|
|
||||||
|
|
||||||
@@ -348,20 +363,21 @@ async def preview(req, path):
|
|||||||
|
|
||||||
# Generate preview
|
# Generate preview
|
||||||
try:
|
try:
|
||||||
img, preview_resp = await _run_preview_process(filepath, quality, maxsize, maxzoom)
|
img, preview_resp = await _run_preview_process(
|
||||||
|
filepath, quality, maxsize, maxzoom
|
||||||
|
)
|
||||||
except PreviewTimeout:
|
except PreviewTimeout:
|
||||||
return empty(504)
|
return empty(504)
|
||||||
except PreviewError:
|
except PreviewError:
|
||||||
return empty(422)
|
return empty(422)
|
||||||
if preview_resp and preview_resp.backend:
|
if preview_resp and preview_resp.backend:
|
||||||
if preview_resp.load_ms is not None:
|
if preview_resp.timings:
|
||||||
load_ms = int(round(preview_resp.load_ms))
|
timing_detail = "/".join(
|
||||||
process_ms = int(round(preview_resp.process_ms or 0.0))
|
str(int(round(value))) for value in preview_resp.timings
|
||||||
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} ➛"
|
req.ctx._log_extra = f"{preview_resp.backend} {timing_detail} ➛"
|
||||||
|
else:
|
||||||
|
req.ctx._log_extra = preview_resp.backend
|
||||||
if not img:
|
if not img:
|
||||||
# Preview generation failed, redirect to the file itself
|
# Preview generation failed, redirect to the file itself
|
||||||
return redirect(f"/files/{path}", status=303)
|
return redirect(f"/files/{path}", status=303)
|
||||||
@@ -409,16 +425,10 @@ def process_image(path, *, maxsize, quality):
|
|||||||
def process_image_with_timing(path, *, maxsize, quality):
|
def process_image_with_timing(path, *, maxsize, quality):
|
||||||
if FORCE_PIL:
|
if FORCE_PIL:
|
||||||
return process_image_pillow(path, maxsize=maxsize, quality=quality)
|
return process_image_pillow(path, maxsize=maxsize, quality=quality)
|
||||||
try:
|
|
||||||
return process_image_pyvips(path, maxsize=maxsize, quality=quality)
|
return process_image_pyvips(path, maxsize=maxsize, quality=quality)
|
||||||
except Exception as e:
|
|
||||||
logger.debug("Falling back to Pillow preview for %s: %s", path.name, e)
|
|
||||||
return process_image_pillow(path, maxsize=maxsize, quality=quality)
|
|
||||||
|
|
||||||
|
|
||||||
def process_image_pyvips(path, *, maxsize, quality):
|
def process_image_pyvips(path, *, maxsize, quality):
|
||||||
import pyvips
|
|
||||||
|
|
||||||
t_start = perf_counter()
|
t_start = perf_counter()
|
||||||
img = pyvips.Image.new_from_file(str(path), access="sequential")
|
img = pyvips.Image.new_from_file(str(path), access="sequential")
|
||||||
img = img.autorot()
|
img = img.autorot()
|
||||||
@@ -437,7 +447,7 @@ def process_image_pyvips(path, *, maxsize, quality):
|
|||||||
ok=True,
|
ok=True,
|
||||||
mime="image/avif",
|
mime="image/avif",
|
||||||
backend="pyvips",
|
backend="pyvips",
|
||||||
total_ms=round((t_end - t_start) * 1000, 1),
|
timings=[round((t_end - t_start) * 1000, 1)],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -476,10 +486,7 @@ def process_image_pillow(path, *, maxsize, quality):
|
|||||||
ok=True,
|
ok=True,
|
||||||
mime="image/avif",
|
mime="image/avif",
|
||||||
backend="pillow",
|
backend="pillow",
|
||||||
load_ms=round(load_ms, 1),
|
timings=[round(load_ms, 1), round(proc_ms, 1), round(save_ms, 1)],
|
||||||
process_ms=round(proc_ms, 1),
|
|
||||||
save_ms=round(save_ms, 1),
|
|
||||||
total_ms=round((t_end - t_load) * 1000, 1),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -494,18 +501,29 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
|||||||
t_load_end = perf_counter()
|
t_load_end = perf_counter()
|
||||||
|
|
||||||
t_save_start = perf_counter()
|
t_save_start = perf_counter()
|
||||||
|
if FORCE_PIL:
|
||||||
ret = pix.pil_tobytes(
|
ret = pix.pil_tobytes(
|
||||||
format="avif", quality=quality, speed=10, max_threads=1, avif=1
|
format="avif", quality=quality, speed=10, max_threads=1, avif=1
|
||||||
)
|
)
|
||||||
|
backend = "pdf"
|
||||||
|
else:
|
||||||
|
img = pyvips.Image.new_from_memory(
|
||||||
|
pix.samples_mv, pix.width, pix.height, pix.n, "uchar"
|
||||||
|
)
|
||||||
|
ret = img.write_to_buffer(
|
||||||
|
".avif", Q=quality, effort=AVIF_FAST_EFFORT, strip=True
|
||||||
|
)
|
||||||
|
backend = "pdf+pyvips"
|
||||||
t_save_end = perf_counter()
|
t_save_end = perf_counter()
|
||||||
|
|
||||||
return ret, PreviewResponse(
|
return ret, PreviewResponse(
|
||||||
ok=True,
|
ok=True,
|
||||||
mime="image/avif",
|
mime="image/avif",
|
||||||
backend="pdf",
|
backend=backend,
|
||||||
load_ms=round((t_load_end - t_load_start) * 1000, 1),
|
timings=[
|
||||||
save_ms=round((t_save_end - t_save_start) * 1000, 1),
|
round((t_load_end - t_load_start) * 1000, 1),
|
||||||
total_ms=round((t_save_end - t_load_start) * 1000, 1),
|
round((t_save_end - t_save_start) * 1000, 1),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -623,9 +641,10 @@ def process_video(path, *, maxsize, quality):
|
|||||||
ok=True,
|
ok=True,
|
||||||
mime="image/avif",
|
mime="image/avif",
|
||||||
backend="video",
|
backend="video",
|
||||||
load_ms=round((t_load_end - t_load_start) * 1000, 1),
|
timings=[
|
||||||
save_ms=round((t_save_end - t_save_start) * 1000, 1),
|
round((t_load_end - t_load_start) * 1000, 1),
|
||||||
total_ms=round((t_save_end - t_load_start) * 1000, 1),
|
round((t_save_end - t_save_start) * 1000, 1),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
del imgdata, istream, ostream, icc, occ, frame
|
del imgdata, istream, ostream, icc, occ, frame
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Framed response format:
|
|||||||
(blake3(packet))(uint32 json size)(uint32 payload size)(json)(binary payload)
|
(blake3(packet))(uint32 json size)(uint32 payload size)(json)(binary payload)
|
||||||
where packet = (uint32 json size)(uint32 payload size)(json)(binary payload).
|
where packet = (uint32 json size)(uint32 payload size)(json)(binary payload).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
@@ -28,10 +29,7 @@ class PreviewResponse(msgspec.Struct, omit_defaults=True):
|
|||||||
ok: bool
|
ok: bool
|
||||||
mime: str | None = None
|
mime: str | None = None
|
||||||
backend: str | None = None
|
backend: str | None = None
|
||||||
load_ms: float | None = None
|
timings: list[float] | None = None
|
||||||
process_ms: float | None = None
|
|
||||||
save_ms: float | None = None
|
|
||||||
total_ms: float | None = None
|
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -50,9 +48,7 @@ def _write_response(resp: PreviewResponse, payload: bytes) -> None:
|
|||||||
|
|
||||||
def _run_once() -> None:
|
def _run_once() -> None:
|
||||||
if len(sys.argv) != 5:
|
if len(sys.argv) != 5:
|
||||||
sys.stderr.write(
|
sys.stderr.write(f"Usage: {sys.argv[0]} <path> <quality> <maxsize> <maxzoom>\n")
|
||||||
f"Usage: {sys.argv[0]} <path> <quality> <maxsize> <maxzoom>\n"
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
from cista.preview import dispatch
|
from cista.preview import dispatch
|
||||||
@@ -76,7 +72,9 @@ def _run_loop() -> None:
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
req = _dec_req.decode(line)
|
req = _dec_req.decode(line)
|
||||||
result, resp = dispatch(Path(req.path), req.quality, req.maxsize, req.maxzoom)
|
result, resp = dispatch(
|
||||||
|
Path(req.path), req.quality, req.maxsize, req.maxzoom
|
||||||
|
)
|
||||||
_write_response(resp, result or b"")
|
_write_response(resp, result or b"")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_write_response(PreviewResponse(ok=False, error=str(e)), b"")
|
_write_response(PreviewResponse(ok=False, error=str(e)), b"")
|
||||||
|
|||||||
+18
-18
@@ -36,16 +36,16 @@ def main() -> int:
|
|||||||
if not folder.is_dir():
|
if not folder.is_dir():
|
||||||
raise SystemExit(f"Not a directory: {folder}")
|
raise SystemExit(f"Not a directory: {folder}")
|
||||||
|
|
||||||
files = sorted(path for path in folder.rglob("*") if path.is_file() and is_image_file(path))
|
files = sorted(
|
||||||
|
path for path in folder.rglob("*") if path.is_file() and is_image_file(path)
|
||||||
|
)
|
||||||
if not files:
|
if not files:
|
||||||
print(f"No image files found under {folder}")
|
print(f"No image files found under {folder}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
total_files = 0
|
total_files = 0
|
||||||
total_bytes = 0
|
total_bytes = 0
|
||||||
total_load_ms: float = 0.0
|
total_timing_slots: list[float] = []
|
||||||
total_process_ms: float = 0.0
|
|
||||||
total_save_ms: float = 0.0
|
|
||||||
total_preview_ms: float = 0.0
|
total_preview_ms: float = 0.0
|
||||||
failures = 0
|
failures = 0
|
||||||
|
|
||||||
@@ -67,18 +67,19 @@ def main() -> int:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
total_bytes += len(preview)
|
total_bytes += len(preview)
|
||||||
total_load_ms += timing.load_ms or 0.0
|
timings = timing.timings or []
|
||||||
total_process_ms += timing.process_ms or 0.0
|
if len(total_timing_slots) < len(timings):
|
||||||
total_save_ms += timing.save_ms or 0.0
|
total_timing_slots.extend([0.0] * (len(timings) - len(total_timing_slots)))
|
||||||
total_preview_ms += timing.total_ms or 0.0
|
for i, value in enumerate(timings):
|
||||||
|
total_timing_slots[i] += value
|
||||||
|
total_ms = sum(timings)
|
||||||
|
total_preview_ms += total_ms
|
||||||
|
|
||||||
if timing.load_ms is not None:
|
detail = " / ".join(f"{value:.1f}ms" for value in timings)
|
||||||
detail = (
|
if detail:
|
||||||
f"load={timing.load_ms:.1f}ms process={timing.process_ms:.1f}ms "
|
detail = f"timings={detail} total={total_ms:.1f}ms"
|
||||||
f"save={timing.save_ms:.1f}ms total={timing.total_ms:.1f}ms"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
detail = f"total={timing.total_ms:.1f}ms"
|
detail = f"total={total_ms:.1f}ms"
|
||||||
print(f"OK {rel} backend={timing.backend} bytes={len(preview)} {detail}")
|
print(f"OK {rel} backend={timing.backend} bytes={len(preview)} {detail}")
|
||||||
|
|
||||||
completed = total_files - failures
|
completed = total_files - failures
|
||||||
@@ -89,10 +90,9 @@ def main() -> int:
|
|||||||
print(f" failed={failures}")
|
print(f" failed={failures}")
|
||||||
print(f" preview_bytes={total_bytes}")
|
print(f" preview_bytes={total_bytes}")
|
||||||
if completed:
|
if completed:
|
||||||
if total_load_ms or total_process_ms or total_save_ms:
|
if total_timing_slots:
|
||||||
print(f" load_total_ms={total_load_ms:.1f}")
|
for i, value in enumerate(total_timing_slots, start=1):
|
||||||
print(f" process_total_ms={total_process_ms:.1f}")
|
print(f" timing{i}_total_ms={value:.1f}")
|
||||||
print(f" save_total_ms={total_save_ms:.1f}")
|
|
||||||
print(f" preview_total_ms={total_preview_ms:.1f}")
|
print(f" preview_total_ms={total_preview_ms:.1f}")
|
||||||
print(f" preview_avg_ms={total_preview_ms / completed:.1f}")
|
print(f" preview_avg_ms={total_preview_ms / completed:.1f}")
|
||||||
return 0 if failures == 0 else 1
|
return 0 if failures == 0 else 1
|
||||||
|
|||||||
Reference in New Issue
Block a user