5 Commits
Author SHA1 Message Date
LeoVasanko a99996bd9c 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.
2026-08-13 04:46:08 +00:00
LeoVasanko 511cbd61b9 Add structured preview exception hierarchy
PreviewError subclasses carry a concise short label for UI, a full log
message, backend name, and metadata fields (OnlyOffice code/status/url,
backend stage, timeout seconds, cancel reason). Exceptions travel across
the worker pool wire pickled in the binary response payload (ok=False)
and are re-raised with their original type on the caller side. Combined
pipelines such as pdf+pyvips tag the failing stage.
2026-08-13 03:45:42 +00:00
LeoVasanko 4acb59941b Always configure emoji loggers on CLI entry. 2026-08-13 01:22:00 +00:00
LeoVasanko 11df5cf580 test: add low-level preview success tests for all formats
Add fixtures and tests covering image EXIF orientations, HDR AVIF, SDR video, 90/270 rotated video, HDR video with and without rotation, PDF, dispatch, and OnlyOffice (skipped unless configured).

Regenerate rotated/HDR video fixtures and sample.pdf with tests/files/generate_fixtures.py.
2026-08-13 00:20:13 +00:00
LeoVasanko c92ec3c487 fix(video): make rotation and short/HDR clips convert reliably
- Accept a keyframe with pts as well as dts, so all-intra or short HDR clips are not rejected as No frames found.

- Round resized video dimensions to multiples of 2 (width) and 4 (height) so planar 4:2:0 YUV rotation always works.

- Use abs(frame.rotation) when swapping display dimensions, so both +90 and -90 rotation matrices report the correct orientation.
2026-08-13 00:20:07 +00:00
33 changed files with 848 additions and 135 deletions
+2
View File
@@ -17,12 +17,14 @@ from mediapreview.backends import (
process_video,
)
from mediapreview.cache import CachedPreview, PreviewCache
from mediapreview.exceptions import PreviewError
from mediapreview.formats import is_previewable_path
from mediapreview.protocol import PreviewRequest, PreviewResponse
__all__ = [
"CachedPreview",
"PreviewCache",
"PreviewError",
"PreviewRequest",
"PreviewResponse",
"dispatch",
+15 -1
View File
@@ -31,10 +31,19 @@ from pathlib import Path
from docopt import docopt
from mediapreview.backends import dispatch
from mediapreview.exceptions import PreviewError
from mediapreview.util.logformat import EmojiFormatter
def _configure_logging() -> None:
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(EmojiFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler])
# pyvips is chatty at INFO ("threadpool completed ..." per operation).
logging.getLogger("pyvips").setLevel(logging.WARNING)
def _oosetup(name: str, port: int) -> None:
logging.basicConfig(level=logging.INFO, format="%(message)s")
try:
# Lazy import: keeps the base CLI free of office-extra concerns.
from mediapreview.office import setup_docker # noqa: PLC0415
@@ -58,12 +67,16 @@ def _preview(args: dict) -> None:
sys.stderr.write(f"error: no such file: {path}\n")
sys.exit(2)
try:
result, resp = dispatch(
path,
quality=int(args["-q"]),
maxsize=int(args["--maxsize"]),
maxzoom=float(args["--maxzoom"]),
)
except PreviewError as e:
sys.stderr.write(f"error: {e}\n")
sys.exit(1)
if not resp.ok or result is None:
sys.stderr.write(f"error: {resp.error or 'preview failed'}\n")
if resp.stderr:
@@ -84,6 +97,7 @@ def _preview(args: dict) -> None:
def main() -> None:
_configure_logging()
# docopt matches usage patterns in order, so `oosetup` would be swallowed
# by the <path> pattern if it came second. Dispatch it before parsing;
# the main help above still documents both modes.
+11 -7
View File
@@ -15,8 +15,8 @@ from mediapreview.backends.image import (
)
from mediapreview.backends.pdf import process_pdf
from mediapreview.backends.video import process_video
from mediapreview.exceptions import PreviewError, backend_error
from mediapreview.formats import DOC_PREVIEW_SUFFIXES
from mediapreview.protocol import PreviewResponse
__all__ = [
"dispatch",
@@ -34,7 +34,7 @@ def dispatch(path, quality, maxsize, maxzoom, data=None):
backend = "unknown"
try:
if data:
backend = "pyvips"
backend = "vips"
return process_image_buffer(
data, quality=quality, maxsize=maxsize, maxzoom=maxzoom
)
@@ -47,15 +47,19 @@ def dispatch(path, quality, maxsize, maxzoom, data=None):
backend = "video"
return process_video(path, quality=quality, maxsize=maxsize)
if mime_type and mime_type.startswith("image/"):
backend = "pyvips"
backend = "vips"
return process_image(path, quality=quality, maxsize=maxsize)
except PreviewError:
# Already structured (e.g. a failing stage of a combined pipeline
# like pdf+vips) — keep the original backend identity.
raise
except ValueError as e:
return None, PreviewResponse(ok=False, backend=backend, error=str(e))
raise backend_error(backend, str(e)) from e
except ImportError as e:
# Missing optional extra — expected, so a plain message, no traceback.
logger.error("Preview dispatch failed for %s: %s", path, e) # noqa: TRY400
return None, PreviewResponse(ok=False, backend=backend, error=str(e))
raise backend_error(backend, str(e)) from e
except Exception as e:
logger.exception("Preview dispatch failed for %s", path)
return None, PreviewResponse(ok=False, backend=backend, error=str(e))
return None, PreviewResponse(ok=False, backend=backend, error="preview unsupported")
raise backend_error(backend, str(e)) from e
raise backend_error(backend, "preview unsupported")
+2 -2
View File
@@ -148,7 +148,7 @@ def process_image_pyvips(path, *, maxsize, quality):
)
except pyvips.error.Error as e:
raise ValueError(f"cannot decode image: {e}") from e
backend = "pyvips"
backend = "vips"
t_end = perf_counter()
return ret, PreviewResponse(
@@ -181,7 +181,7 @@ def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom):
return ret, PreviewResponse(
ok=True,
mime="image/avif",
backend="pyvips",
backend="vips",
timings=[round((t_end - t_start) * 1000, 1)],
width=orig_w,
height=orig_h,
+14 -6
View File
@@ -1,10 +1,11 @@
"""PDF/XPS/EPUB preview conversion via PyMuPDF + pyvips."""
"""PDF/XPS/EPUB preview conversion via PyMuPDF + vips."""
from time import perf_counter
import pyvips
from mediapreview.backends.image import AVIF_FAST_EFFORT
from mediapreview.exceptions import backend_error
from mediapreview.protocol import PreviewResponse
try:
@@ -12,6 +13,8 @@ try:
except ImportError: # pragma: no cover - optional pdf extra
pymupdf = None
BACKEND = "pdf+vips"
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
if pymupdf is None:
@@ -19,26 +22,31 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
"PDF previews require the 'pdf' extra: pip install mediapreview[pdf]"
)
t_load_start = perf_counter()
try:
with pymupdf.open(path) as pdf:
page = pdf.load_page(page_number)
w, h = page.rect[2:4]
zoom = min(maxsize / w, maxsize / h, maxzoom)
mat = pymupdf.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat)
samples, width, height, n = pix.samples_mv, pix.width, pix.height, pix.n
except Exception as 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_save_start = perf_counter()
img = pyvips.Image.new_from_memory(
pix.samples_mv, pix.width, pix.height, pix.n, "uchar"
)
try:
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")
backend = "pdf+pyvips"
except Exception as e:
raise backend_error(BACKEND, str(e)) from e
t_save_end = perf_counter()
return ret, PreviewResponse(
ok=True,
mime="image/avif",
backend=backend,
backend=BACKEND,
timings=[
round((t_load_end - t_load_start) * 1000, 1),
round((t_save_end - t_save_start) * 1000, 1),
+8 -7
View File
@@ -15,8 +15,8 @@ try:
import av
import numpy as np
except ImportError: # pragma: no cover - optional video extra
av = None
np = None
av = None # type: ignore[assignment]
np = None # type: ignore[assignment]
def _rotate_frame_yuv(frame, k):
@@ -78,21 +78,22 @@ def process_video(path, *, maxsize, quality):
istream.codec_context.skip_frame = "NONKEY"
icontainer.seek((icontainer.duration or 0) // 8)
for frame in icontainer.decode(istream):
if frame.dts is not None:
if frame.dts is not None or frame.pts is not None:
break
else:
raise RuntimeError("No frames found in video")
# Resize frame to thumbnail size
# Resize frame to thumbnail size. Keep dimensions even for planar
# 4:2:0 chroma subsampling, which _rotate_frame_yuv expects.
# Capture display dimensions before resize (accounting for rotation)
disp_w = frame.width
disp_h = frame.height
if frame.rotation in (90, 270):
if abs(frame.rotation) in (90, 270):
disp_w, disp_h = disp_h, disp_w
if frame.width > maxsize or frame.height > maxsize:
scale_factor = min(maxsize / frame.width, maxsize / frame.height)
new_width = int(frame.width * scale_factor)
new_height = int(frame.height * scale_factor)
new_width = int(frame.width * scale_factor) // 2 * 2
new_height = int(frame.height * scale_factor) // 4 * 4
frame = frame.reformat(width=new_width, height=new_height)
# Apply display-matrix rotation if present
+175
View File
@@ -0,0 +1,175 @@
"""Structured preview exceptions.
Preview failures are represented by ``PreviewError`` and a small number of
subclasses, carrying their fields directly: ``short`` (concise, for UI with
limited space — the backend name is usually printed in front of it, so it is
left out), the exception message itself (for logs), ``backend``, and
optional subclass-specific metadata such as ``code`` or ``timeout_seconds``
for callers that wish to do their own processing.
The worker pool ships exceptions between processes with pickle (same
trust domain — the pool unpickles only data from its own workers), so any
exception arrives intact on the caller side, no per-class serialization
machinery needed. Because the initializers are keyword-heavy, pickling is
routed through ``__dict__`` via ``PreviewError.__reduce__``.
The hierarchy is intentionally small:
- ``OnlyOfficeError`` covers all OnlyOffice failures; optional fields
(``code``, ``status``, ``url``, ``snippet``) describe the specific failure.
- ``PreviewBackendError`` covers backend conversion failures (ffmpeg, vips,
pdf, etc.). Combined pipelines report the failing step in ``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.
- ``PreviewCancelledError`` covers cancellations (e.g. pool shutdown).
BaseExceptions such as ``KeyboardInterrupt``, ``SystemExit`` and
``asyncio.CancelledError`` are never wrapped in these types.
"""
from __future__ import annotations
class PreviewError(Exception):
"""Base preview exception.
``short`` is concise text for UIs with limited space (the backend name
is usually printed in front of it); ``str(err)`` is the full
human-readable message; ``backend`` identifies the backend
(e.g. "onlyoffice", "ffmpeg").
"""
def __init__(
self,
message: str = "preview failed",
short: str = "error",
*,
backend: str | None = None,
):
super().__init__(message)
self.short = short
self.backend = backend
def __reduce__(self):
# Keyword-heavy initializers do not unpickle via args; pass the message
# positionally and restore the rest from __dict__.
return (type(self), (str(self),), self.__dict__)
class OnlyOfficeError(PreviewError):
"""OnlyOffice conversion failed. Specifics are in the extra fields."""
def __init__( # noqa: PLR0913 - metadata fields are independent
self,
message: str = "OnlyOffice conversion failed",
short: str = "error",
*,
code: str | None = None,
status: int | None = None,
url: str | None = None,
snippet: str | None = None,
backend: str | None = "onlyoffice",
):
super().__init__(message, short, backend=backend)
self.code = code
self.status = status
self.url = url
self.snippet = snippet
class PreviewBackendError(PreviewError):
"""Backend conversion failure (image/video/pdf/etc)."""
class PreviewTimeoutError(PreviewError):
"""Preview conversion exceeded its timeout for a given backend."""
def __init__(
self,
message: str = "preview timed out",
short: str = "timeout",
*,
timeout_seconds: float = 0.0,
backend: str | None = None,
):
super().__init__(message, short, backend=backend)
self.timeout_seconds = timeout_seconds
class PreviewCancelledError(PreviewError):
"""Preview was cancelled (e.g. pool shut down)."""
def __init__(
self,
message: str = "Preview cancelled (pool closed)",
short: str = "cancelled",
*,
reason: str = "pool closed",
backend: str | None = None,
):
super().__init__(message, short, backend=backend)
self.reason = reason
# ---------------------------------------------------------------------------
# Factory helpers
# ---------------------------------------------------------------------------
_OO_CODE_ERRORS = {
"-8": ("jwt error", "OnlyOffice JWT authentication failed"),
"-4": ("input error", "OnlyOffice input error"),
"-2": ("timeout error", "OnlyOffice conversion timed out"),
"-1": ("unknown error", "OnlyOffice conversion failed with unknown error"),
}
def onlyoffice_error_from_code(code: str | None = None) -> OnlyOfficeError:
"""Build an OnlyOfficeError from a conversion status code (e.g. "-8")."""
if code in _OO_CODE_ERRORS:
short, log = _OO_CODE_ERRORS[code]
elif code:
short, log = f"{code} error", f"OnlyOffice conversion failed: {code}"
else:
short, log = "unknown error", "OnlyOffice conversion failed with unknown error"
return OnlyOfficeError(log, short, code=code)
def onlyoffice_unavailable_error(url: str | None = None) -> OnlyOfficeError:
log = "OnlyOffice document server not reachable"
if url:
log = f"{log} at {url}"
return OnlyOfficeError(log, "unavailable", url=url)
def onlyoffice_http_error(status: int) -> OnlyOfficeError:
return OnlyOfficeError(f"OnlyOffice HTTP error: {status}", "http error", status=status)
def onlyoffice_no_fileurl_error(snippet: str | None = None) -> OnlyOfficeError:
log = "OnlyOffice response did not contain FileUrl"
if snippet:
log = f"{log}: {snippet}"
return OnlyOfficeError(log, "no-fileurl error", snippet=snippet)
def backend_error(backend: str, message: str) -> PreviewBackendError:
short = message.splitlines()[0][:60]
return PreviewBackendError(
f"[{backend}] preview failed: {message}",
short,
backend=backend,
)
def preview_timeout_error(backend: str, timeout_seconds: float) -> PreviewTimeoutError:
return PreviewTimeoutError(
f"{backend.capitalize()} preview timed out after {timeout_seconds}s",
"timeout",
backend=backend,
timeout_seconds=timeout_seconds,
)
def preview_cancelled_error(reason: str = "pool closed") -> PreviewCancelledError:
return PreviewCancelledError(f"Preview cancelled ({reason})", "cancelled", reason=reason)
+1 -1
View File
@@ -90,5 +90,5 @@ def expected_backend(path: Path) -> str:
if mime_type and mime_type.startswith("video/"):
return "video"
if mime_type and mime_type.startswith("image/"):
return "pyvips"
return "vips"
return "preview"
+41 -23
View File
@@ -30,6 +30,14 @@ from pathlib import Path
from time import perf_counter
from urllib.parse import quote
from mediapreview.exceptions import (
onlyoffice_error_from_code,
onlyoffice_http_error,
onlyoffice_no_fileurl_error,
onlyoffice_unavailable_error,
preview_timeout_error,
)
try:
import httpx
import jwt
@@ -45,6 +53,7 @@ logger = logging.getLogger(__name__)
_httpx_client: httpx.AsyncClient | None = None
_httpx_client_loop: asyncio.AbstractEventLoop | None = None
def _get_onlyoffice_url() -> str:
@@ -82,35 +91,27 @@ def _get_callback_host() -> str:
return "127.0.0.1"
def onlyoffice_error_short_text(detail: str) -> str:
"""Short human-readable label for an OnlyOffice failure, for log annotation."""
if detail.startswith("OnlyOffice conversion error:"):
code = detail.rsplit(":", 1)[-1].strip()
return {
"-8": "onlyoffice jwt error",
"-4": "onlyoffice input error",
"-2": "onlyoffice timeout error",
"-1": "onlyoffice unknown error",
}.get(code, f"onlyoffice {code} error")
if "OnlyOffice response did not contain FileUrl" in detail:
return "onlyoffice no-fileurl error"
return "onlyoffice error"
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Async HTTP client
# ---------------------------------------------------------------------------
def get_httpx_client() -> httpx.AsyncClient:
"""Return the shared async HTTP client for OnlyOffice requests."""
"""Return the shared async HTTP client for OnlyOffice requests.
The client is recreated if the running event loop changes, because an
``httpx.AsyncClient`` is bound to the loop that created it.
"""
if httpx is None:
raise ImportError(
"OnlyOffice integration requires the 'office' extra: pip install mediapreview[office]"
)
global _httpx_client
if _httpx_client is None:
global _httpx_client, _httpx_client_loop
current_loop = asyncio.get_running_loop()
if _httpx_client is None or _httpx_client_loop is not current_loop:
_httpx_client = httpx.AsyncClient()
_httpx_client_loop = current_loop
return _httpx_client
@@ -330,6 +331,7 @@ async def convert_to_png_async(file_path: Path, request_timeout: float = 5.0) ->
headers["Authorization"] = token
t_start = perf_counter()
try:
response = await client.post(
convert_url,
content=json.dumps(payload).encode(),
@@ -337,19 +339,26 @@ async def convert_to_png_async(file_path: Path, request_timeout: float = 5.0) ->
timeout=request_timeout,
)
response.raise_for_status()
except httpx.TimeoutException as e:
raise preview_timeout_error("onlyoffice", request_timeout) from e
except httpx.HTTPStatusError as e:
raise onlyoffice_http_error(e.response.status_code) from e
except httpx.RequestError as e:
raise onlyoffice_unavailable_error(_get_onlyoffice_url()) from e
body = response.content
t_end = perf_counter()
# Parse XML response
text = body.decode("utf-8", errors="replace")
if "<Error>" in text:
code = "unknown"
if "<Error>" in text and "</Error>" in text:
code = None
if "</Error>" in text:
code = text.split("<Error>")[1].split("</Error>")[0]
raise RuntimeError(f"OnlyOffice conversion error: {code}")
raise onlyoffice_error_from_code(code)
if "<FileUrl>" not in text:
raise RuntimeError("OnlyOffice response did not contain FileUrl")
snippet = text if len(text) <= 200 else text[:200] + "..."
raise onlyoffice_no_fileurl_error(snippet)
file_url = text.split("<FileUrl>")[1].split("</FileUrl>")[0]
file_url = file_url.replace("&amp;", "&")
@@ -357,8 +366,17 @@ async def convert_to_png_async(file_path: Path, request_timeout: float = 5.0) ->
logger.debug("OnlyOffice converted in %.2fs: %s", t_end - t_start, file_url)
# Download converted PNG
try:
png_response = await client.get(file_url, timeout=request_timeout)
png_response.raise_for_status()
except httpx.TimeoutException as e:
raise preview_timeout_error("onlyoffice", request_timeout) from e
except httpx.HTTPStatusError as e:
raise onlyoffice_http_error(e.response.status_code) from e
except httpx.RequestError as e:
# The converted file lives on the OO server, so a request failure here
# usually means OO itself could not be reached after conversion.
raise onlyoffice_unavailable_error(_get_onlyoffice_url()) from e
return png_response.content
finally:
await asyncio.to_thread(httpd.shutdown)
@@ -385,7 +403,7 @@ class OOConversionManager:
async def convert(self, filepath: Path) -> bytes:
"""Return PNG bytes for *filepath*, deduplicating concurrent requests."""
if not await is_available_cached():
raise RuntimeError("OnlyOffice server not reachable")
raise onlyoffice_unavailable_error(_get_onlyoffice_url())
stat = await asyncio.to_thread(filepath.stat)
key = f"{filepath}:{stat.st_mtime_ns}"
+51 -52
View File
@@ -4,6 +4,7 @@ import asyncio
import contextlib
import logging
import os
import pickle
import signal
import struct
import sys
@@ -20,6 +21,13 @@ except ImportError as e: # pragma: no cover - optional worker extra
"The worker pool requires the 'worker' extra: pip install mediapreview[worker]"
) from e
from mediapreview.exceptions import (
PreviewError,
PreviewTimeoutError,
backend_error,
preview_cancelled_error,
preview_timeout_error,
)
from mediapreview.formats import (
expected_backend as _expected_preview_backend,
)
@@ -35,7 +43,6 @@ from mediapreview.protocol import PreviewRequest, PreviewResponse
__all__ = [
"PREVIEW_TIMEOUT",
"PreviewError",
"PreviewPoolClosedError",
"PreviewTimeoutError",
"generate_office_preview",
"is_previewable_path",
@@ -47,33 +54,6 @@ __all__ = [
logger = logging.getLogger(__name__)
class PreviewTimeoutError(Exception):
"""Raised when the preview subprocess exceeds PREVIEW_TIMEOUT."""
def __init__(self, message: str, *, backend: str | None = None):
super().__init__(message)
self.backend = backend
class PreviewError(Exception):
"""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
class PreviewPoolClosedError(PreviewError):
"""The preview worker pool has been shut down."""
class WorkerChecksumError(Exception):
"""Raised when worker response checksum does not match the packet."""
@@ -82,6 +62,20 @@ class WorkerProtocolError(Exception):
"""Raised when worker response packet is malformed."""
def _reraise_worker_error(resp: PreviewResponse, payload: bytes) -> None:
"""Re-raise the exception the worker sent back in the payload, if any.
The payload is pickled by our own worker processes (same trust domain), so
the original exception type arrives intact on the caller side. Falls back
to a plain PreviewBackendError built from the error message.
"""
if payload:
exc = pickle.loads(payload) # noqa: S301 - trusted: our own workers
if isinstance(exc, Exception):
raise exc
raise backend_error(resp.backend or "unknown", resp.error or "preview worker error")
PREVIEW_TIMEOUT = 10.0 # seconds until preview subprocess is killed
PREVIEW_WORKERS = max(2, min(8, cpu_count()))
WORKER_KILL_GRACE = 5.0 # max seconds to wait for a killed worker to be reaped
@@ -139,11 +133,7 @@ class _PreviewWorker:
resp = msgspec.json.decode(meta_raw, type=PreviewResponse)
if not resp.ok:
raise PreviewError(
resp.error or "preview worker error",
stderr=resp.stderr,
backend=resp.backend,
)
_reraise_worker_error(resp, payload)
return payload or None, resp
async def kill(self) -> None:
@@ -279,9 +269,9 @@ class _PreviewWorkerPool:
)
if not future.done():
future.set_exception(
PreviewTimeoutError(
args[0].name,
backend=_expected_preview_backend(args[0]),
preview_timeout_error(
_expected_preview_backend(args[0]),
PREVIEW_TIMEOUT,
)
)
return
@@ -305,9 +295,9 @@ class _PreviewWorkerPool:
)
if not future.done():
future.set_exception(
PreviewTimeoutError(
filepath.name,
backend=_expected_preview_backend(filepath),
preview_timeout_error(
_expected_preview_backend(filepath),
PREVIEW_TIMEOUT,
)
)
except WorkerChecksumError:
@@ -319,7 +309,10 @@ class _PreviewWorkerPool:
)
if not future.done():
future.set_exception(
PreviewError(f"worker checksum mismatch for {filepath.name}")
backend_error(
_expected_preview_backend(filepath),
f"worker checksum mismatch for {filepath.name}",
)
)
except PreviewError as e:
if not future.done():
@@ -342,14 +335,20 @@ class _PreviewWorkerPool:
)
if not future.done():
future.set_exception(
PreviewError(f"worker protocol failure for {filepath.name}: {e}")
backend_error(
_expected_preview_backend(filepath),
f"worker protocol failure for {filepath.name}: {e}",
)
)
except Exception:
replace = True
logger.exception("Unexpected preview worker error for %s", filepath.name)
if not future.done():
future.set_exception(
PreviewError(f"unexpected worker error for {filepath.name}")
backend_error(
_expected_preview_backend(filepath),
f"unexpected worker error for {filepath.name}",
)
)
finally:
if replace:
@@ -378,7 +377,7 @@ class _PreviewWorkerPool:
data: bytes | None = None,
):
if self._closed:
raise PreviewPoolClosedError("preview worker pool closed")
raise preview_cancelled_error("preview worker pool closed")
loop = asyncio.get_running_loop()
future = loop.create_future()
self._in_flight.add(future)
@@ -410,18 +409,14 @@ class _PreviewWorkerPool:
# out their timeouts during server shutdown.
for future in list(self._in_flight):
if not future.done():
future.set_exception(
PreviewPoolClosedError("preview worker pool closed")
)
future.set_exception(preview_cancelled_error("pool closed"))
while not self._pending.empty():
try:
_priority, _seq, future, _args = self._pending.get_nowait()
except asyncio.QueueEmpty:
break
if not future.done():
future.set_exception(
PreviewPoolClosedError("preview worker pool closed")
)
future.set_exception(preview_cancelled_error("pool closed"))
while not self._idle.empty():
try:
self._idle.get_nowait()
@@ -469,7 +464,11 @@ async def shutdown_preview_workers() -> None:
async def generate_office_preview(
filepath: Path, quality: int, maxsize: int, maxzoom: float
) -> tuple[bytes | None, PreviewResponse | None]:
"""Generate a preview for an office file using OnlyOffice + worker AVIF conversion."""
"""Generate a preview for an office file using OnlyOffice + worker AVIF conversion.
Raises:
OnlyOfficeError: If the OnlyOffice Document Server cannot convert the file.
"""
manager = get_oo_manager()
t_oo_start = perf_counter()
png_bytes = await manager.convert(filepath)
@@ -478,7 +477,7 @@ async def generate_office_preview(
img, resp = await run_preview(filepath, quality, maxsize, maxzoom, data=png_bytes)
if resp is not None:
resp.backend = "onlyoffice+" + (resp.backend or "pyvips")
resp.backend = "onlyoffice+" + (resp.backend or "vips")
if resp.timings:
resp.timings = [round((t_oo_end - t_oo_start) * 1000, 1), *resp.timings]
return img, resp
@@ -490,5 +489,5 @@ async def run_preview(
"""Run preview request in a persistent worker process."""
await start_preview_workers()
if _preview_pool is None:
raise PreviewPoolClosedError("preview worker pool closed")
raise preview_cancelled_error("preview worker pool closed")
return await _preview_pool.run(filepath, quality, maxsize, maxzoom, data)
+1 -1
View File
@@ -11,7 +11,7 @@ class PreviewRequest(msgspec.Struct, omit_defaults=True):
class PreviewResponse(msgspec.Struct, omit_defaults=True):
ok: bool
ok: bool # Indicates whether binary payload is the file or a pickled exception
mime: str | None = None
backend: str | None = None
timings: list[float] | None = None
+26 -10
View File
@@ -19,6 +19,7 @@ import contextlib
import io
import logging
import os
import pickle
import signal
import struct
import sys
@@ -37,12 +38,25 @@ except ImportError: # pragma: no cover - optional worker extra
sys.exit(1)
from mediapreview.backends import dispatch
from mediapreview.exceptions import PreviewError
from mediapreview.protocol import PreviewRequest, PreviewResponse
from mediapreview.util.logformat import format_level_prefix
logger = logging.getLogger(__name__)
def _serialize_exception(e: BaseException) -> bytes:
"""Pickle an exception for re-raising across the pool wire.
Falls back to an empty payload (caller uses the plain error message) in
the unlikely case the exception cannot be pickled.
"""
try:
return pickle.dumps(e)
except Exception:
return b""
class _WorkerLogFormatter(logging.Formatter):
"""Emoji level prefix like the main process, tagged with the worker pid."""
@@ -125,21 +139,23 @@ def _run_loop() -> None:
result, resp = dispatch(
Path(req.path), req.quality, req.maxsize, req.maxzoom, data
)
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"")
except Exception as e:
# 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()
_write_response(
PreviewResponse(ok=False, error=str(e), stderr=captured or None), b""
PreviewResponse(
ok=False,
error=str(e),
stderr=captured or None,
),
_serialize_exception(e),
)
finally:
root_logger.removeHandler(handler)
Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Generate missing test fixtures for the low-level preview tests.
This script only creates files that are not already present in tests/files/.
Run it whenever you need to rebuild the rotated/HDR video fixtures or the
sample PDF.
"""
from __future__ import annotations
import logging
import subprocess
import sys
from pathlib import Path
try:
import pymupdf
except ImportError as e: # pragma: no cover - optional pdf extra
raise ImportError(
"Fixture generation requires pymupdf: pip install mediapreview[pdf]"
) from e
ROOT = Path(__file__).resolve().parent
FILES = ROOT
logger = logging.getLogger(__name__)
def run(cmd: list[str]) -> None:
subprocess.run(cmd, check=True, capture_output=True, text=True)
def _find_box(data: bytearray, box_type: bytes, start: int = 0, end: int | None = None) -> int:
end = end or len(data)
i = start
while i + 8 <= end:
size = int.from_bytes(data[i : i + 4], "big")
btype = data[i + 4 : i + 8]
if btype == box_type:
return i
if size == 0:
break
if size == 1:
size = int.from_bytes(data[i + 8 : i + 16], "big")
if size < 8:
raise ValueError(f"Invalid box size {size} for {btype.decode('ascii', errors='replace')}")
i += size
return -1
def _matrix_90_cw() -> list[int]:
return [0, 1 << 16, 0, -(1 << 16), 0, 0, 0, 0, 1 << 16]
def _matrix_270_cw() -> list[int]:
return [0, -(1 << 16), 0, 1 << 16, 0, 0, 0, 0, 1 << 16]
def _patch_tkhd_rotation(in_path: Path, out_path: Path, degrees: int) -> None:
"""Write a copy of *in_path* with the video track display matrix rotated."""
data = bytearray(in_path.read_bytes())
moov_idx = _find_box(data, b"moov")
if moov_idx < 0:
raise ValueError("moov box not found")
moov_end = moov_idx + int.from_bytes(data[moov_idx : moov_idx + 4], "big")
trak_idx = _find_box(data, b"trak", moov_idx + 8, moov_end)
while trak_idx >= 0:
trak_size = int.from_bytes(data[trak_idx : trak_idx + 4], "big")
trak_end = trak_idx + trak_size
mdia_idx = _find_box(data, b"mdia", trak_idx + 8, trak_end)
if mdia_idx < 0:
trak_idx = _find_box(data, b"trak", trak_end, moov_end)
continue
mdia_end = mdia_idx + int.from_bytes(data[mdia_idx : mdia_idx + 4], "big")
hdlr_idx = _find_box(data, b"hdlr", mdia_idx + 8, mdia_end)
if hdlr_idx < 0:
trak_idx = _find_box(data, b"trak", trak_end, moov_end)
continue
handler_type = data[hdlr_idx + 16 : hdlr_idx + 20]
if handler_type == b"vide":
tkhd_idx = _find_box(data, b"tkhd", trak_idx + 8, trak_end)
if tkhd_idx < 0:
raise ValueError("video track has no tkhd")
version = data[tkhd_idx + 8]
if version != 0:
raise ValueError(f"unsupported tkhd version {version}")
matrix_offset = tkhd_idx + 48
matrix = _matrix_90_cw() if degrees == 90 else _matrix_270_cw()
for i, val in enumerate(matrix):
data[matrix_offset + i * 4 : matrix_offset + (i + 1) * 4] = val.to_bytes(
4, "big", signed=True
)
out_path.write_bytes(data)
return
trak_idx = _find_box(data, b"trak", trak_end, moov_end)
raise ValueError("video track not found")
def generate_pdf() -> None:
pdf_path = FILES / "sample.pdf"
if pdf_path.exists():
return
doc = pymupdf.open()
page = doc.new_page(width=595, height=842)
page.insert_text((100, 100), "Hello, PDF preview test!")
page.draw_rect((100, 150, 400, 250), color=(1, 0, 0), width=2)
doc.save(str(pdf_path))
doc.close()
logger.info("generated %s", pdf_path)
def generate_sdr_rotated() -> None:
source = FILES / "sample-1mb.mp4"
if not source.exists():
raise FileNotFoundError(f"Missing source fixture: {source}")
for degrees in (90, 270):
out = FILES / f"rotated_{degrees}.mp4"
if out.exists():
continue
_patch_tkhd_rotation(source, out, degrees)
logger.info("generated %s", out)
def generate_hdr_video() -> None:
out = FILES / "hdr_video.mp4"
if out.exists():
return
run(
[
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel",
"error",
"-nostdin",
"-f",
"lavfi",
"-i",
"testsrc=duration=1:size=320x240:rate=30",
"-c:v",
"libx265",
"-preset",
"fast",
"-crf",
"30",
"-pix_fmt",
"yuv420p10le",
"-x265-params",
"colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc:repeat-headers=1",
"-an",
str(out),
]
)
logger.info("generated %s", out)
def generate_hdr_rotated() -> None:
source = FILES / "hdr_video.mp4"
if not source.exists():
raise FileNotFoundError(f"Generate hdr_video.mp4 first: {source}")
for degrees in (90, 270):
out = FILES / f"hdr_rotated_{degrees}.mp4"
if out.exists():
continue
_patch_tkhd_rotation(source, out, degrees)
logger.info("generated %s", out)
def main() -> int:
logging.basicConfig(level=logging.INFO, format="%(message)s")
generate_pdf()
generate_sdr_rotated()
generate_hdr_video()
generate_hdr_rotated()
return 0
if __name__ == "__main__":
sys.exit(main())
Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+105
View File
@@ -0,0 +1,105 @@
"""Tests for OnlyOffice error handling and structured responses."""
from __future__ import annotations
import pickle
from pathlib import Path
import pytest
from mediapreview import office
from mediapreview.exceptions import (
OnlyOfficeError,
PreviewBackendError,
backend_error,
onlyoffice_error_from_code,
onlyoffice_http_error,
onlyoffice_no_fileurl_error,
onlyoffice_unavailable_error,
)
from mediapreview.pool import generate_office_preview
FILES = Path(__file__).resolve().parent / "files"
@pytest.mark.parametrize(
("code", "short", "message"),
[
("-8", "jwt error", "OnlyOffice JWT authentication failed"),
("-4", "input error", "OnlyOffice input error"),
("-2", "timeout error", "OnlyOffice conversion timed out"),
("-1", "unknown error", "OnlyOffice conversion failed with unknown error"),
("-99", "-99 error", "OnlyOffice conversion failed: -99"),
(None, "unknown error", "OnlyOffice conversion failed with unknown error"),
],
)
def test_onlyoffice_error_from_code(code, short, message):
"""Conversion status codes map to clean labels and messages."""
err = onlyoffice_error_from_code(code)
assert str(err) == message
assert err.short == short
assert err.code == code
assert err.backend == "onlyoffice"
assert isinstance(err, OnlyOfficeError)
def test_onlyoffice_http_error():
err = onlyoffice_http_error(502)
assert str(err) == "OnlyOffice HTTP error: 502"
assert err.status == 502
assert err.short == "http error"
def test_onlyoffice_unavailable_error():
err = onlyoffice_unavailable_error("http://localhost:8988")
assert "http://localhost:8988" in str(err)
assert err.url == "http://localhost:8988"
assert err.short == "unavailable"
def test_onlyoffice_no_fileurl_error():
err = onlyoffice_no_fileurl_error("<empty />")
assert "<empty />" in str(err)
assert err.snippet == "<empty />"
assert err.short == "no-fileurl error"
def test_backend_error_pipeline_backend():
"""Combined pipelines report the failing step in the backend name."""
err = backend_error("pdf", "cannot read document")
assert err.backend == "pdf"
assert err.short == "cannot read document"
assert isinstance(err, PreviewBackendError)
def test_error_pickle_round_trip():
"""Exceptions survive pickling (the worker pool wire) intact."""
err = onlyoffice_error_from_code("-8")
restored = pickle.loads(pickle.dumps(err))
assert type(restored) is type(err)
assert str(restored) == str(err)
assert restored.code == err.code
assert restored.short == err.short
assert restored.backend == err.backend
@pytest.mark.asyncio
async def test_generate_office_preview_raises_structured_error(monkeypatch):
"""On OnlyOffice failure, generate_office_preview raises OnlyOfficeError."""
async def fake_convert(_filepath: Path, request_timeout: float = 5.0) -> bytes:
raise onlyoffice_error_from_code("-8")
monkeypatch.setattr(office, "convert_to_png_async", fake_convert)
with pytest.raises(OnlyOfficeError) as exc_info:
await generate_office_preview(
FILES / "file-sample_100kB.docx",
quality=60,
maxsize=512,
maxzoom=2.0,
)
err = exc_info.value
assert err.code == "-8"
assert err.short == "jwt error"
assert str(err) == "OnlyOffice JWT authentication failed"
+191
View File
@@ -0,0 +1,191 @@
"""Success tests for low-level preview conversion functions.
These tests exercise the concrete backend converters (image, video, PDF,
office) against the fixture files in tests/files/. The only thing they
assert is that the conversion succeeds and returns non-empty AVIF bytes, plus
basic metadata sanity checks.
Office tests are skipped unless an OnlyOffice Document Server is reachable.
Configure them with environment variables before running pytest:
ONLYOFFICE_URL=http://localhost:8988
ONLYOFFICE_JWT_SECRET=<same-secret-you-gave-the-oo-container>
ONLYOFFICE_CALLBACK_HOST=<host the OO container can reach>
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from mediapreview import dispatch
from mediapreview.backends.image import (
process_image,
process_image_buffer,
process_image_pyvips,
)
from mediapreview.backends.pdf import process_pdf
from mediapreview.backends.video import process_video
from mediapreview.office import is_available_async
from mediapreview.pool import generate_office_preview
FILES = Path(__file__).resolve().parent / "files"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _assert_ok(data, resp, backend: str | None = None) -> None:
assert resp.ok, f"conversion failed: {resp.error}"
assert data
assert resp.mime == "image/avif"
if backend:
assert resp.backend == backend
# ---------------------------------------------------------------------------
# Images
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("path", sorted(FILES.glob("Landscape_*.jpg")))
def test_process_image_exif_orientations(path: Path) -> None:
"""Every EXIF orientation fixture must produce a valid preview."""
data, resp = process_image(path, maxsize=512, quality=60)
_assert_ok(data, resp, backend="vips")
assert resp.width in (1200, 1800)
assert resp.height in (1200, 1800)
def test_process_image_hdr_avif() -> None:
"""HDR AVIF images are routed through ffmpeg to preserve colour metadata."""
path = FILES / "hdr_cosmos01650_cicp9-16-9_yuv444_full_qp10.avif"
data, resp = process_image(path, maxsize=512, quality=60)
_assert_ok(data, resp, backend="ffmpeg")
assert resp.width is not None
assert resp.height is not None
def test_process_image_pyvips() -> None:
"""The pyvips-only image backend works on a plain JPEG."""
path = FILES / "Landscape_1.jpg"
data, resp = process_image_pyvips(path, maxsize=512, quality=60)
_assert_ok(data, resp, backend="vips")
def test_process_image_buffer() -> None:
"""Processing a JPEG from a buffer produces the same valid preview."""
path = FILES / "Landscape_1.jpg"
data, resp = process_image_buffer(
path.read_bytes(), maxsize=512, quality=60, maxzoom=2.0
)
_assert_ok(data, resp, backend="vips")
# ---------------------------------------------------------------------------
# Videos
# ---------------------------------------------------------------------------
VIDEO_FIXTURES = [
("sample-1mb.mp4", 854, 480),
("rotated_90.mp4", 480, 854),
("rotated_270.mp4", 480, 854),
("hdr_video.mp4", 320, 240),
("hdr_rotated_90.mp4", 240, 320),
("hdr_rotated_270.mp4", 240, 320),
]
@pytest.mark.parametrize(
("filename", "expected_width", "expected_height"),
VIDEO_FIXTURES,
ids=[f[0] for f in VIDEO_FIXTURES],
)
def test_process_video(filename: str, expected_width: int, expected_height: int) -> None:
"""SDR and HDR video clips, with and without rotation, convert successfully."""
data, resp = process_video(FILES / filename, maxsize=512, quality=60)
_assert_ok(data, resp, backend="video")
assert resp.width == expected_width
assert resp.height == expected_height
# ---------------------------------------------------------------------------
# PDF
# ---------------------------------------------------------------------------
def test_process_pdf() -> None:
"""A simple single-page PDF converts to a valid preview."""
data, resp = process_pdf(
FILES / "sample.pdf",
maxsize=512,
maxzoom=2.0,
quality=60,
)
_assert_ok(data, resp, backend="pdf+vips")
assert resp.width == 595
assert resp.height == 842
# ---------------------------------------------------------------------------
# Public dispatch entry point
# ---------------------------------------------------------------------------
DISPATCH_FIXTURES = [
("Landscape_1.jpg", "vips", 1800, 1200),
("sample-1mb.mp4", "video", 854, 480),
("sample.pdf", "pdf+vips", 595, 842),
]
@pytest.mark.parametrize(
("filename", "backend", "expected_width", "expected_height"),
DISPATCH_FIXTURES,
ids=[f[0] for f in DISPATCH_FIXTURES],
)
def test_dispatch(
filename: str, backend: str, expected_width: int, expected_height: int
) -> None:
"""The public dispatch() wrapper routes to the correct backend and succeeds."""
data, resp = dispatch(FILES / filename, quality=60, maxsize=512, maxzoom=2.0)
_assert_ok(data, resp, backend=backend)
assert resp.width == expected_width
assert resp.height == expected_height
# ---------------------------------------------------------------------------
# Office previews via OnlyOffice
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_generate_office_preview() -> None:
"""DOCX preview through OnlyOffice.
Skipped unless an OnlyOffice server is reachable and ONLYOFFICE_JWT_SECRET
is set to the same secret the OO container is using. The shared secret must
be at least 32 bytes long so PyJWT doesn't warn about weak HMAC keys.
"""
secret = os.environ.get("ONLYOFFICE_JWT_SECRET", "")
if len(secret.encode()) < 32:
pytest.skip("ONLYOFFICE_JWT_SECRET not set or shorter than 32 bytes")
if not await is_available_async():
pytest.skip("OnlyOffice Document Server not reachable")
data, resp = await generate_office_preview(
FILES / "file-sample_100kB.docx",
quality=60,
maxsize=512,
maxzoom=2.0,
)
assert data is not None
assert resp is not None
_assert_ok(data, resp)
assert resp.width is not None
assert resp.height is not None