Compare commits

...
3 Commits
3 changed files with 74 additions and 90 deletions
+44 -84
View File
@@ -2,7 +2,6 @@ import asyncio
import gc import gc
import io import io
import mimetypes import mimetypes
import os
import struct import struct
import sys import sys
import threading import threading
@@ -20,10 +19,8 @@ import msgspec
import av import av
import fitz # PyMuPDF import fitz # PyMuPDF
import numpy as np import numpy as np
import pillow_heif
import pyvips import pyvips
from blake3 import blake3 from blake3 import blake3
from PIL import Image
from sanic import Blueprint, empty, raw, redirect from sanic import Blueprint, empty, raw, redirect
from sanic.exceptions import NotFound from sanic.exceptions import NotFound
from sanic.log import logger from sanic.log import logger
@@ -32,8 +29,6 @@ from cista import auth, config
from cista.preview_worker import PreviewRequest, PreviewResponse from cista.preview_worker import PreviewRequest, PreviewResponse
from cista.util.filename import sanitize from cista.util.filename import sanitize
pillow_heif.register_heif_opener()
bp = Blueprint("preview", url_prefix="/preview") bp = Blueprint("preview", url_prefix="/preview")
@@ -85,7 +80,6 @@ _active_procs: set[asyncio.subprocess.Process] = set()
_preview_pool = None _preview_pool = None
_preview_pool_lock = asyncio.Lock() _preview_pool_lock = asyncio.Lock()
AVIF_FAST_EFFORT = 0 AVIF_FAST_EFFORT = 0
FORCE_PIL = os.environ.get("CISTA_PIL") == "1"
WORKER_CHECKSUM_BYTES = 32 WORKER_CHECKSUM_BYTES = 32
WORKER_MAX_JSON_BYTES = 1_000_000 WORKER_MAX_JSON_BYTES = 1_000_000
@@ -135,7 +129,11 @@ class _PreviewWorker:
resp = msgspec.json.decode(meta_raw, type=PreviewResponse) resp = msgspec.json.decode(meta_raw, type=PreviewResponse)
if not resp.ok: if not resp.ok:
raise PreviewError(resp.error or "preview worker error") raise PreviewError(
resp.error or "preview worker error",
stderr=resp.stderr,
backend=resp.backend,
)
return payload or None, resp return payload or None, resp
async def kill(self) -> None: async def kill(self) -> None:
@@ -207,7 +205,7 @@ class _PreviewWorkerPool:
except WorkerChecksumError: except WorkerChecksumError:
replace = True replace = True
logger.error("Preview checksum mismatch for %s", filepath.name) logger.error("Preview checksum mismatch for %s", filepath.name)
raise PreviewError(filepath.name) raise PreviewError(f"worker checksum mismatch for {filepath.name}")
except PreviewError: except PreviewError:
raise raise
except ( except (
@@ -223,7 +221,9 @@ class _PreviewWorkerPool:
logger.warning( logger.warning(
"Preview worker protocol failure for %s: %s", filepath.name, e "Preview worker protocol failure for %s: %s", filepath.name, e
) )
raise PreviewError(filepath.name) raise PreviewError(
f"worker protocol failure for {filepath.name}: {e}"
)
finally: finally:
if replace: if replace:
await self._replace_worker(worker) await self._replace_worker(worker)
@@ -295,6 +295,17 @@ class PreviewTimeout(Exception):
class PreviewError(Exception): class PreviewError(Exception):
"""Raised when the preview subprocess exits with a non-zero status.""" """Raised when the preview subprocess exits with a non-zero status."""
def __init__(
self,
message: str,
*,
stderr: str | None = None,
backend: str | None = None,
):
super().__init__(message)
self.stderr = stderr
self.backend = backend
async def _run_preview_process( async def _run_preview_process(
filepath, quality: int, maxsize: int, maxzoom: float filepath, quality: int, maxsize: int, maxzoom: float
@@ -302,22 +313,10 @@ async def _run_preview_process(
"""Run preview request in a persistent worker process.""" """Run preview request in a persistent worker process."""
await start_preview_workers() await start_preview_workers()
if _preview_pool is None: if _preview_pool is None:
raise PreviewError(filepath.name) raise PreviewError(f"preview worker pool unavailable for {filepath.name}")
return await _preview_pool.run(filepath, quality, maxsize, maxzoom) return await _preview_pool.run(filepath, quality, maxsize, maxzoom)
# Map EXIF Orientation value to a corresponding PIL transpose
EXIF_ORI = {
2: Image.Transpose.FLIP_LEFT_RIGHT,
3: Image.Transpose.ROTATE_180,
4: Image.Transpose.FLIP_TOP_BOTTOM,
5: Image.Transpose.TRANSPOSE,
6: Image.Transpose.ROTATE_270,
7: Image.Transpose.TRANSVERSE,
8: Image.Transpose.ROTATE_90,
}
DOC_PREVIEW_SUFFIXES = {".pdf", ".xps", ".epub", ".mobi"} DOC_PREVIEW_SUFFIXES = {".pdf", ".xps", ".epub", ".mobi"}
@@ -368,7 +367,15 @@ async def preview(req, path):
) )
except PreviewTimeout: except PreviewTimeout:
return empty(504) return empty(504)
except PreviewError: except PreviewError as e:
if e.backend:
req.ctx._log_extra = e.backend
detail = str(e)
if detail == "preview worker error" and e.stderr:
captured = e.stderr.strip()
if captured:
detail = captured.splitlines()[0]
logger.error("%s preview: %s", filepath, detail)
return empty(422) return empty(422)
if preview_resp and preview_resp.backend: if preview_resp and preview_resp.backend:
if preview_resp.timings: if preview_resp.timings:
@@ -403,28 +410,26 @@ async def preview(req, path):
def dispatch(path, quality, maxsize, maxzoom): def dispatch(path, quality, maxsize, maxzoom):
backend = "unknown"
try: try:
if path.suffix.lower() in DOC_PREVIEW_SUFFIXES: if path.suffix.lower() in DOC_PREVIEW_SUFFIXES:
backend = "pdf"
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom) return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
mime_type, _ = mimetypes.guess_type(path.name) mime_type, _ = mimetypes.guess_type(path.name)
if mime_type and mime_type.startswith("video/"): if mime_type and mime_type.startswith("video/"):
backend = "video"
return process_video(path, quality=quality, maxsize=maxsize) return process_video(path, quality=quality, maxsize=maxsize)
if mime_type and mime_type.startswith("image/"): if mime_type and mime_type.startswith("image/"):
backend = "pyvips"
return process_image(path, quality=quality, maxsize=maxsize) return process_image(path, quality=quality, maxsize=maxsize)
except ValueError as e: except ValueError as e:
logger.warning(f"Cannot generate preview for {path}: {e}") return None, PreviewResponse(ok=False, backend=backend, error=str(e))
except Exception as e: except Exception as e:
logger.exception(f"Error generating preview for {path}: {e}") return None, PreviewResponse(ok=False, backend=backend, error=str(e))
return None, PreviewResponse(ok=False) return None, PreviewResponse(ok=False, backend=backend, error="preview unsupported")
def process_image(path, *, maxsize, quality): def process_image(path, *, maxsize, quality):
return process_image_with_timing(path, maxsize=maxsize, quality=quality)
def process_image_with_timing(path, *, maxsize, quality):
if FORCE_PIL:
return process_image_pillow(path, maxsize=maxsize, quality=quality)
return process_image_pyvips(path, maxsize=maxsize, quality=quality) return process_image_pyvips(path, maxsize=maxsize, quality=quality)
@@ -451,45 +456,6 @@ def process_image_pyvips(path, *, maxsize, quality):
) )
def process_image_pillow(path, *, maxsize, quality):
t_load = perf_counter()
with Image.open(path) as img:
# Force decode to include I/O in load timing
img.load()
t_proc = perf_counter()
# Resize
w, h = img.size
img.thumbnail((min(w, maxsize), min(h, maxsize)))
# Transpose pixels according to EXIF Orientation
orientation = img.getexif().get(274, 1)
if orientation in EXIF_ORI:
img = img.transpose(EXIF_ORI[orientation])
# Save as AVIF
imgdata = io.BytesIO()
t_save = perf_counter()
img.save(
imgdata,
format="avif",
quality=quality,
speed=10,
max_threads=1,
avif=1,
)
t_end = perf_counter()
ret = imgdata.getvalue()
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="pillow",
timings=[round(load_ms, 1), round(proc_ms, 1), round(save_ms, 1)],
)
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0): def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
t_load_start = perf_counter() t_load_start = perf_counter()
pdf = fitz.open(path) pdf = fitz.open(path)
@@ -501,19 +467,13 @@ 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: img = pyvips.Image.new_from_memory(
ret = pix.pil_tobytes( pix.samples_mv, pix.width, pix.height, pix.n, "uchar"
format="avif", quality=quality, speed=10, max_threads=1, avif=1 )
) ret = img.write_to_buffer(
backend = "pdf" ".avif", Q=quality, effort=AVIF_FAST_EFFORT, strip=True
else: )
img = pyvips.Image.new_from_memory( backend = "pdf+pyvips"
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(
+28 -5
View File
@@ -10,6 +10,8 @@ where packet = (uint32 json size)(uint32 payload size)(json)(binary payload).
""" """
import logging import logging
import contextlib
import io
import struct import struct
import sys import sys
from pathlib import Path from pathlib import Path
@@ -31,6 +33,7 @@ class PreviewResponse(msgspec.Struct, omit_defaults=True):
backend: str | None = None backend: str | None = None
timings: list[float] | None = None timings: list[float] | None = None
error: str | None = None error: str | None = None
stderr: str | None = None
_enc = msgspec.json.Encoder() _enc = msgspec.json.Encoder()
@@ -70,14 +73,34 @@ def _run_loop() -> None:
line = sys.stdin.buffer.readline() line = sys.stdin.buffer.readline()
if not line: if not line:
return return
stderr_capture = io.StringIO()
handler = logging.StreamHandler(stderr_capture)
root_logger = logging.getLogger()
root_logger.addHandler(handler)
try: try:
req = _dec_req.decode(line) with contextlib.redirect_stderr(stderr_capture):
result, resp = dispatch( req = _dec_req.decode(line)
Path(req.path), req.quality, req.maxsize, req.maxzoom result, resp = dispatch(
) Path(req.path), req.quality, req.maxsize, req.maxzoom
)
if not resp.ok:
captured = stderr_capture.getvalue().strip()
if captured:
resp = PreviewResponse(
ok=False,
backend=resp.backend,
error=resp.error,
stderr=captured,
)
_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"") captured = stderr_capture.getvalue().strip()
_write_response(
PreviewResponse(ok=False, error=str(e), stderr=captured or None), b""
)
finally:
root_logger.removeHandler(handler)
handler.close()
def main() -> None: def main() -> None:
+2 -1
View File
@@ -236,7 +236,8 @@ class _EmojiFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str: def format(self, record: logging.LogRecord) -> str:
emoji = _LEVEL_EMOJI.get(record.levelno, "▪️") emoji = _LEVEL_EMOJI.get(record.levelno, "▪️")
return f"{emoji} {record.getMessage()}" sep = " " if record.levelno in (logging.INFO, logging.WARNING) else " "
return f"{emoji}{sep}{record.getMessage()}"
def configure_main_logging() -> None: def configure_main_logging() -> None: