Quiet expected preview failures; rename pyvips to vips

Worker logs expected PreviewError failures as warnings; tracebacks are
reserved for unanticipated internal errors. pdf read errors report
backend "pdf" (vips not yet reached); the stage field is dropped as the
backend name now encodes the failing pipeline step. Backend label
"pyvips" renamed to "vips" everywhere.
This commit is contained in:
2026-08-13 04:46:08 +00:00
parent 511cbd61b9
commit a99996bd9c
9 changed files with 36 additions and 40 deletions
+4 -4
View File
@@ -34,7 +34,7 @@ def dispatch(path, quality, maxsize, maxzoom, data=None):
backend = "unknown" backend = "unknown"
try: try:
if data: if data:
backend = "pyvips" backend = "vips"
return process_image_buffer( return process_image_buffer(
data, quality=quality, maxsize=maxsize, maxzoom=maxzoom data, quality=quality, maxsize=maxsize, maxzoom=maxzoom
) )
@@ -47,11 +47,11 @@ def dispatch(path, quality, maxsize, maxzoom, data=None):
backend = "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" backend = "vips"
return process_image(path, quality=quality, maxsize=maxsize) return process_image(path, quality=quality, maxsize=maxsize)
except PreviewError: except PreviewError:
# Already structured (e.g. a stage of a combined pipeline like # Already structured (e.g. a failing stage of a combined pipeline
# pdf+pyvips) — keep the original backend/stage identity. # like pdf+vips) — keep the original backend identity.
raise raise
except ValueError as e: except ValueError as e:
raise backend_error(backend, str(e)) from e raise backend_error(backend, str(e)) from e
+2 -2
View File
@@ -148,7 +148,7 @@ def process_image_pyvips(path, *, maxsize, quality):
) )
except pyvips.error.Error as e: except pyvips.error.Error as e:
raise ValueError(f"cannot decode image: {e}") from e raise ValueError(f"cannot decode image: {e}") from e
backend = "pyvips" backend = "vips"
t_end = perf_counter() t_end = perf_counter()
return ret, PreviewResponse( return ret, PreviewResponse(
@@ -181,7 +181,7 @@ def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom):
return ret, PreviewResponse( return ret, PreviewResponse(
ok=True, ok=True,
mime="image/avif", mime="image/avif",
backend="pyvips", backend="vips",
timings=[round((t_end - t_start) * 1000, 1)], timings=[round((t_end - t_start) * 1000, 1)],
width=orig_w, width=orig_w,
height=orig_h, height=orig_h,
+5 -4
View File
@@ -1,4 +1,4 @@
"""PDF/XPS/EPUB preview conversion via PyMuPDF + pyvips.""" """PDF/XPS/EPUB preview conversion via PyMuPDF + vips."""
from time import perf_counter from time import perf_counter
@@ -13,7 +13,7 @@ try:
except ImportError: # pragma: no cover - optional pdf extra except ImportError: # pragma: no cover - optional pdf extra
pymupdf = None pymupdf = None
BACKEND = "pdf+pyvips" BACKEND = "pdf+vips"
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0): def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
@@ -31,7 +31,8 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
pix = page.get_pixmap(matrix=mat) pix = page.get_pixmap(matrix=mat)
samples, width, height, n = pix.samples_mv, pix.width, pix.height, pix.n samples, width, height, n = pix.samples_mv, pix.width, pix.height, pix.n
except Exception as e: except Exception as e:
raise backend_error(BACKEND, str(e), stage="pdf") from e # vips was never reached — this is a plain pdf error.
raise backend_error("pdf", str(e)) from e
t_load_end = perf_counter() t_load_end = perf_counter()
t_save_start = perf_counter() t_save_start = perf_counter()
@@ -39,7 +40,7 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
img = pyvips.Image.new_from_memory(samples, width, height, n, "uchar") img = pyvips.Image.new_from_memory(samples, width, height, n, "uchar")
ret = img.write_to_buffer(".avif", Q=quality, effort=AVIF_FAST_EFFORT, keep="none") ret = img.write_to_buffer(".avif", Q=quality, effort=AVIF_FAST_EFFORT, keep="none")
except Exception as e: except Exception as e:
raise backend_error(BACKEND, str(e), stage="pyvips") from e raise backend_error(BACKEND, str(e)) from e
t_save_end = perf_counter() t_save_end = perf_counter()
return ret, PreviewResponse( return ret, PreviewResponse(
+5 -16
View File
@@ -17,9 +17,10 @@ The hierarchy is intentionally small:
- ``OnlyOfficeError`` covers all OnlyOffice failures; optional fields - ``OnlyOfficeError`` covers all OnlyOffice failures; optional fields
(``code``, ``status``, ``url``, ``snippet``) describe the specific failure. (``code``, ``status``, ``url``, ``snippet``) describe the specific failure.
- ``PreviewBackendError`` covers backend conversion failures (ffmpeg, pyvips, - ``PreviewBackendError`` covers backend conversion failures (ffmpeg, vips,
pdf, etc.); ``stage`` identifies the failing step of a combined pipeline pdf, etc.). Combined pipelines report the failing step in ``backend``
(e.g. "pdf" vs "pyvips" in the "pdf+pyvips" backend). (e.g. "pdf" if pdf reading failed before vips was reached, "pdf+vips"
for a vips write failure).
- ``PreviewTimeoutError`` covers timeouts for any backend. - ``PreviewTimeoutError`` covers timeouts for any backend.
- ``PreviewCancelledError`` covers cancellations (e.g. pool shutdown). - ``PreviewCancelledError`` covers cancellations (e.g. pool shutdown).
@@ -80,17 +81,6 @@ class OnlyOfficeError(PreviewError):
class PreviewBackendError(PreviewError): class PreviewBackendError(PreviewError):
"""Backend conversion failure (image/video/pdf/etc).""" """Backend conversion failure (image/video/pdf/etc)."""
def __init__(
self,
message: str = "preview failed",
short: str = "error",
*,
stage: str | None = None,
backend: str | None = None,
):
super().__init__(message, short, backend=backend)
self.stage = stage
class PreviewTimeoutError(PreviewError): class PreviewTimeoutError(PreviewError):
"""Preview conversion exceeded its timeout for a given backend.""" """Preview conversion exceeded its timeout for a given backend."""
@@ -163,13 +153,12 @@ def onlyoffice_no_fileurl_error(snippet: str | None = None) -> OnlyOfficeError:
return OnlyOfficeError(log, "no-fileurl error", snippet=snippet) return OnlyOfficeError(log, "no-fileurl error", snippet=snippet)
def backend_error(backend: str, message: str, *, stage: str | None = None) -> PreviewBackendError: def backend_error(backend: str, message: str) -> PreviewBackendError:
short = message.splitlines()[0][:60] short = message.splitlines()[0][:60]
return PreviewBackendError( return PreviewBackendError(
f"[{backend}] preview failed: {message}", f"[{backend}] preview failed: {message}",
short, short,
backend=backend, backend=backend,
stage=stage,
) )
+1 -1
View File
@@ -90,5 +90,5 @@ def expected_backend(path: Path) -> str:
if mime_type and mime_type.startswith("video/"): if mime_type and mime_type.startswith("video/"):
return "video" return "video"
if mime_type and mime_type.startswith("image/"): if mime_type and mime_type.startswith("image/"):
return "pyvips" return "vips"
return "preview" return "preview"
+1 -1
View File
@@ -477,7 +477,7 @@ async def generate_office_preview(
img, resp = await run_preview(filepath, quality, maxsize, maxzoom, data=png_bytes) img, resp = await run_preview(filepath, quality, maxsize, maxzoom, data=png_bytes)
if resp is not None: if resp is not None:
resp.backend = "onlyoffice+" + (resp.backend or "pyvips") resp.backend = "onlyoffice+" + (resp.backend or "vips")
if resp.timings: if resp.timings:
resp.timings = [round((t_oo_end - t_oo_start) * 1000, 1), *resp.timings] resp.timings = [round((t_oo_end - t_oo_start) * 1000, 1), *resp.timings]
return img, resp return img, resp
+8 -1
View File
@@ -38,6 +38,7 @@ except ImportError: # pragma: no cover - optional worker extra
sys.exit(1) sys.exit(1)
from mediapreview.backends import dispatch from mediapreview.backends import dispatch
from mediapreview.exceptions import PreviewError
from mediapreview.protocol import PreviewRequest, PreviewResponse from mediapreview.protocol import PreviewRequest, PreviewResponse
from mediapreview.util.logformat import format_level_prefix from mediapreview.util.logformat import format_level_prefix
@@ -140,7 +141,13 @@ def _run_loop() -> None:
) )
_write_response(resp, result or b"") _write_response(resp, result or b"")
except Exception as e: except Exception as e:
logger.exception("Preview worker error for %s", req.path) # PreviewError is an expected failure (broken input, missing
# extra, backend error) — a warning suffices. Tracebacks are
# reserved for internal errors we did not anticipate.
if isinstance(e, PreviewError):
logger.warning("Preview failed for %s: %s", req.path, e)
else:
logger.exception("Preview worker error for %s", req.path)
captured = stderr_capture.getvalue().strip() captured = stderr_capture.getvalue().strip()
_write_response( _write_response(
PreviewResponse( PreviewResponse(
+4 -5
View File
@@ -64,11 +64,10 @@ def test_onlyoffice_no_fileurl_error():
assert err.short == "no-fileurl error" assert err.short == "no-fileurl error"
def test_backend_error_stage(): def test_backend_error_pipeline_backend():
"""Combined pipelines tag the failing stage.""" """Combined pipelines report the failing step in the backend name."""
err = backend_error("pdf+pyvips", "cannot read document", stage="pdf") err = backend_error("pdf", "cannot read document")
assert err.backend == "pdf+pyvips" assert err.backend == "pdf"
assert err.stage == "pdf"
assert err.short == "cannot read document" assert err.short == "cannot read document"
assert isinstance(err, PreviewBackendError) assert isinstance(err, PreviewBackendError)
+6 -6
View File
@@ -56,7 +56,7 @@ def _assert_ok(data, resp, backend: str | None = None) -> None:
def test_process_image_exif_orientations(path: Path) -> None: def test_process_image_exif_orientations(path: Path) -> None:
"""Every EXIF orientation fixture must produce a valid preview.""" """Every EXIF orientation fixture must produce a valid preview."""
data, resp = process_image(path, maxsize=512, quality=60) data, resp = process_image(path, maxsize=512, quality=60)
_assert_ok(data, resp, backend="pyvips") _assert_ok(data, resp, backend="vips")
assert resp.width in (1200, 1800) assert resp.width in (1200, 1800)
assert resp.height in (1200, 1800) assert resp.height in (1200, 1800)
@@ -74,7 +74,7 @@ def test_process_image_pyvips() -> None:
"""The pyvips-only image backend works on a plain JPEG.""" """The pyvips-only image backend works on a plain JPEG."""
path = FILES / "Landscape_1.jpg" path = FILES / "Landscape_1.jpg"
data, resp = process_image_pyvips(path, maxsize=512, quality=60) data, resp = process_image_pyvips(path, maxsize=512, quality=60)
_assert_ok(data, resp, backend="pyvips") _assert_ok(data, resp, backend="vips")
def test_process_image_buffer() -> None: def test_process_image_buffer() -> None:
@@ -83,7 +83,7 @@ def test_process_image_buffer() -> None:
data, resp = process_image_buffer( data, resp = process_image_buffer(
path.read_bytes(), maxsize=512, quality=60, maxzoom=2.0 path.read_bytes(), maxsize=512, quality=60, maxzoom=2.0
) )
_assert_ok(data, resp, backend="pyvips") _assert_ok(data, resp, backend="vips")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -127,7 +127,7 @@ def test_process_pdf() -> None:
maxzoom=2.0, maxzoom=2.0,
quality=60, quality=60,
) )
_assert_ok(data, resp, backend="pdf+pyvips") _assert_ok(data, resp, backend="pdf+vips")
assert resp.width == 595 assert resp.width == 595
assert resp.height == 842 assert resp.height == 842
@@ -138,9 +138,9 @@ def test_process_pdf() -> None:
DISPATCH_FIXTURES = [ DISPATCH_FIXTURES = [
("Landscape_1.jpg", "pyvips", 1800, 1200), ("Landscape_1.jpg", "vips", 1800, 1200),
("sample-1mb.mp4", "video", 854, 480), ("sample-1mb.mp4", "video", 854, 480),
("sample.pdf", "pdf+pyvips", 595, 842), ("sample.pdf", "pdf+vips", 595, 842),
] ]