8 Commits
Author SHA1 Message Date
LeoVasanko 4103b8928c onlyoffice: fixed container IP instead of published port; fetch-aware timeout
Docker silently discards published ports on internal networks, so the
isolated oonet setup left the container unreachable at localhost:8988.
Reach it at its fixed IP (172.30.0.2) on the bridge instead; the host
is the gateway, so this needs no port publishing at all. The oosetup
CLI drops its now-meaningless <port> argument.

Also: record whether OnlyOffice fetched the input file from the
temporary callback server and report it in the timeout error message
('input file never fetched' = network/callback failure, vs a stalled
conversion). Convert POST timeout is 7s (conversion runs inside the
request), result PNG download stays at 2s.
2026-08-13 06:51:31 +00:00
LeoVasanko 3c1894f337 onlyoffice: isolated docker network, pinned server sources, strict deadlines
- Dockerfile: pin ONLYOFFICE server sources to a known-good commit
  (build-arg ONLYOFFICE_SERVER_REF) instead of tracking master; master
  gained a 'memory runtime' branch that forks no converter workers on
  community edition, silently breaking all conversions. Also patch that
  branch out as defense in depth.
- setup_docker: run the container on an internal-only network (oonet,
  172.30.0.0/24) with no outbound internet; only host callback traffic
  is possible.
- _get_callback_host: resolve the oonet gateway via docker network
  inspect instead of assuming docker0; fall back to docker0.
- OOConversionManager: cancel the background conversion task when its
  last waiter is cancelled (strict preview deadline), releasing the
  semaphore slot and aborting the hung HTTP request; shield the shared
  future so one waiter's cancellation does not affect others.
- Split timeouts: convert POST 7s (conversion happens inside the
  request with async:false), result PNG download 2s.
2026-08-13 06:40:01 +00:00
LeoVasanko 7ac373179b Shorter short error messages. 2026-08-13 05:19:50 +00:00
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
34 changed files with 1034 additions and 175 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",
+28 -19
View File
@@ -2,16 +2,16 @@
Usage:
mediapreview <path> [-o OUTPUT] [-q QUALITY] [--maxsize N] [--maxzoom Z]
mediapreview oosetup [<name>] [<port>]
mediapreview oosetup [<name>]
mediapreview (-h | --help)
Generate an AVIF preview for a media file (one-shot, in-process), or set up
the bundled OnlyOffice container.
the bundled OnlyOffice container (isolated network, reachable from the host
at its fixed container IP).
Arguments:
<path> media file to preview
<name> container name [default: onlyoffice-mediapreview]
<port> container host port [default: 8988]
Options:
-o OUTPUT output .avif file (default: write AVIF bytes to stdout)
@@ -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 _oosetup(name: str, port: int) -> None:
logging.basicConfig(level=logging.INFO, format="%(message)s")
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) -> None:
try:
# Lazy import: keeps the base CLI free of office-extra concerns.
from mediapreview.office import setup_docker # noqa: PLC0415
@@ -43,7 +52,7 @@ def _oosetup(name: str, port: int) -> None:
sys.exit(1)
try:
# Logs go to stderr; stdout carries only the secret line below.
secret = setup_docker(name=name, port=port)
secret = setup_docker(name=name)
except Exception as e:
sys.stderr.write(f"error: OnlyOffice setup failed: {e}\n")
sys.exit(1)
@@ -58,12 +67,16 @@ def _preview(args: dict) -> None:
sys.stderr.write(f"error: no such file: {path}\n")
sys.exit(2)
result, resp = dispatch(
path,
quality=int(args["-q"]),
maxsize=int(args["--maxsize"]),
maxzoom=float(args["--maxzoom"]),
)
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,17 +97,13 @@ 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.
if sys.argv[1:2] == ["oosetup"]:
args = docopt(
"Usage:\n mediapreview oosetup [<name>] [<port>]", argv=sys.argv[1:]
)
_oosetup(
args["<name>"] or "onlyoffice-mediapreview",
int(args["<port>"] or 8988),
)
args = docopt("Usage:\n mediapreview oosetup [<name>]", argv=sys.argv[1:])
_oosetup(args["<name>"] or "onlyoffice-mediapreview")
return
_preview(docopt(__doc__))
+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,
+23 -15
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()
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)
t_load_end = 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"
)
ret = img.write_to_buffer(".avif", Q=quality, effort=AVIF_FAST_EFFORT, keep="none")
backend = "pdf+pyvips"
t_save_start = perf_counter()
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")
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
+17 -4
View File
@@ -36,10 +36,17 @@ RUN apt-get update -qq && \
ca-certificates && \
rm -rf /var/lib/apt/lists/*
# Clone the open-source server components (shallow, ~15 MB).
# The master branch is used because the Linux/web tags are not published
# in the server repo; the license.js file has been stable for years.
RUN git clone --depth 1 https://github.com/ONLYOFFICE/server.git /opt/oo-server
# Pin the open-source server components to a known-good commit (~15 MB).
# The master branch is a moving target (the Linux/web tags are not published
# in the server repo): a 2026 convertermaster change there detects community
# edition as a "memory runtime" and forks NO converter workers, silently
# breaking all conversions. Pinned to the commit proven in production.
ARG ONLYOFFICE_SERVER_REF=4e56b8d056640557ffcd8c860a65535ab6cbd95b
RUN git init /opt/oo-server && \
cd /opt/oo-server && \
git remote add origin https://github.com/ONLYOFFICE/server.git && \
git fetch --depth 1 origin "$ONLYOFFICE_SERVER_REF" && \
git checkout FETCH_HEAD
# Patch license.js so the converter worker count is read from an env var
# instead of being hardcoded to 1.
@@ -47,6 +54,12 @@ RUN sed -i \
's/count: 1,/count: parseInt(process.env.WORKERS, 10) || 8,/' \
/opt/oo-server/Common/sources/license.js
# Defense in depth: never take the "memory runtime" branch that forks no
# converter workers, even if the pinned ref is bumped carelessly.
RUN sed -i \
's/runtimeProfile\.isMemoryRuntime()/false \/* patched: always fork converter workers *\//g' \
/opt/oo-server/FileConverter/sources/convertermaster.js
# Install npm dependencies for the modules the FileConverter touches.
# DocService deps are also needed because converter.js pulls in baseConnector.
RUN cd /opt/oo-server/Common && npm ci --no-audit --no-fund
+194
View File
@@ -0,0 +1,194 @@
"""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,
fetched: bool | None = None,
):
super().__init__(message, short, backend=backend)
self.timeout_seconds = timeout_seconds
self.fetched = fetched
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]
# Many backend messages look like "source: summary: detail ...".
# Drop the source prefix and any trailing detail so the short label
# is usable in UIs with limited space.
if ": " in short:
short = short.split(": ", 1)[1]
if ": " in short:
short = short.split(": ", 1)[0]
short = short[:60]
return PreviewBackendError(
f"[{backend}] preview failed: {message}",
short,
backend=backend,
)
def preview_timeout_error(
backend: str, timeout_seconds: float, fetched: bool | None = None
) -> PreviewTimeoutError:
log = f"{backend.capitalize()} preview timed out after {timeout_seconds}s"
if fetched is not None:
# OnlyOffice: whether it ever downloaded the input file from our
# callback server distinguishes network/callback failures from a
# stalled conversion.
log += " (input file fetched)" if fetched else " (input file never fetched)"
return PreviewTimeoutError(
log,
"timeout",
backend=backend,
timeout_seconds=timeout_seconds,
fetched=fetched,
)
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"
+184 -56
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
@@ -39,31 +47,75 @@ except ImportError: # pragma: no cover - optional office extra
logger = logging.getLogger(__name__)
# Isolated docker network for the OnlyOffice container: internal-only (no
# outbound internet), the container can only reach the host on this bridge.
# Docker discards published ports on internal networks, so the container is
# reached at its fixed IP instead of a published localhost port.
OO_NETWORK = "oonet"
OO_SUBNET = "172.30.0.0/24"
OO_CONTAINER_IP = "172.30.0.2"
# ---------------------------------------------------------------------------
# Configuration helpers
# ---------------------------------------------------------------------------
_httpx_client: httpx.AsyncClient | None = None
_httpx_client_loop: asyncio.AbstractEventLoop | None = None
def _get_onlyoffice_url() -> str:
return os.environ.get(
"ONLYOFFICE_URL",
os.environ.get("ONLYOFFICE_CISTA_URL", "http://localhost:8988"),
)
if url := os.environ.get(
"ONLYOFFICE_URL", os.environ.get("ONLYOFFICE_CISTA_URL")
):
return url
# When the isolated network exists, the container is at its fixed IP and
# no localhost port is published (Docker discards ports on internal
# networks). Otherwise assume a legacy setup with a published port.
if _docker_network_gateway(OO_NETWORK):
return f"http://{OO_CONTAINER_IP}"
return "http://localhost:8988"
def _get_jwt_secret() -> str:
return os.environ.get("ONLYOFFICE_JWT_SECRET", "")
def _docker_network_gateway(network: str) -> str | None:
"""Return the host-side gateway IP of a docker network, or None."""
try:
result = subprocess.run(
[
"docker",
"network",
"inspect",
network,
"--format",
"{{range .IPAM.Config}}{{.Gateway}}{{end}}",
],
capture_output=True,
text=True,
timeout=2,
check=False,
)
gateway = result.stdout.strip()
if result.returncode == 0 and gateway:
return gateway
except Exception:
logger.debug("Failed to inspect docker network %s", network)
return None
@lru_cache(maxsize=1)
def _get_callback_host() -> str:
"""Return the host IP that OnlyOffice (usually in Docker) can use to reach us."""
"""Return the host IP that OnlyOffice (in Docker) can use to reach us."""
if host := os.environ.get("ONLYOFFICE_CALLBACK_HOST"):
return host
# Try to auto-detect docker bridge IP
# Prefer the gateway of the isolated network setup_docker() creates —
# this is the network the container is actually attached to.
if gateway := _docker_network_gateway(OO_NETWORK):
return gateway
# Fall back to the default docker bridge IP
try:
result = subprocess.run(
["/sbin/ip", "-4", "addr", "show", "docker0"],
@@ -82,35 +134,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
@@ -155,12 +199,14 @@ def log_reachable_info() -> None:
logger.warning("OnlyOffice probe failed%s", suffix)
def setup_docker(name: str = "onlyoffice-mediapreview", port: int = 8988) -> str:
def setup_docker(name: str = "onlyoffice-mediapreview") -> str:
"""Build and run the patched OnlyOffice Docker image.
Uses ONLYOFFICE_JWT_SECRET if set, otherwise generates a random secret.
Returns the secret used, so the caller is responsible for persisting it
(the CLI prints it as `ONLYOFFICE_JWT_SECRET=<token>`).
The container runs on an isolated internal network (OO_NETWORK) with no
outbound internet and no published ports; the host reaches it at
OO_CONTAINER_IP. Uses ONLYOFFICE_JWT_SECRET if set, otherwise generates a
random secret. Returns the secret used, so the caller is responsible for
persisting it (the CLI prints it as `ONLYOFFICE_JWT_SECRET=<token>`).
The Docker build context ships inside the package at `mediapreview/docker`.
"""
if secret := _get_jwt_secret():
@@ -181,13 +227,33 @@ def setup_docker(name: str = "onlyoffice-mediapreview", port: int = 8988) -> str
if result.returncode != 0:
raise RuntimeError("Failed to build OnlyOffice image")
# Isolated network: internal-only, so the container has no outbound
# internet access and can only reach the host on this bridge (needed
# for the preview file callback). Already-exists is fine.
net_cmd = [
"docker",
"network",
"create",
"--internal",
"--subnet",
OO_SUBNET,
OO_NETWORK,
]
result = subprocess.run(net_cmd, capture_output=True, check=False) # noqa: S603
if result.returncode != 0 and b"already exists" not in result.stderr:
raise RuntimeError(
f"Failed to create docker network {OO_NETWORK}: {result.stderr.decode(errors='replace').strip()}"
)
logger.info("Starting OnlyOffice container")
run_cmd = [
"docker",
"run",
"-d",
"-p",
f"{port}:80",
"--network",
OO_NETWORK,
"--ip",
OO_CONTAINER_IP,
"-e",
f"JWT_SECRET={secret}",
"-e",
@@ -202,7 +268,12 @@ def setup_docker(name: str = "onlyoffice-mediapreview", port: int = 8988) -> str
result = subprocess.run(run_cmd, check=False, shell=False) # noqa: S603
if result.returncode != 0:
raise RuntimeError("Failed to start OnlyOffice container")
logger.info("OnlyOffice is running on http://localhost:%d", port)
# Docker discards published ports on internal networks, so the container
# is reached at its fixed IP; no localhost port is exposed.
logger.info("OnlyOffice is running on http://%s", OO_CONTAINER_IP)
logger.info(
"Callback host for file downloads: %s", _docker_network_gateway(OO_NETWORK)
)
return secret
@@ -259,7 +330,9 @@ async def is_available_cached() -> bool:
class _QuietHandler(SimpleHTTPRequestHandler):
def log_message(self, fmt, *args) -> None:
pass
# Any request logged here means a client (OnlyOffice) connected to
# fetch the file; record it for timeout diagnostics.
self.server.oo_fetched = True
def _get_free_port() -> int:
@@ -276,6 +349,7 @@ def _serve_file_temporarily(file_path: Path):
handler = partial(_QuietHandler, directory=directory)
httpd = socketserver.TCPServer(("0.0.0.0", port), handler) # noqa: S104
httpd.oo_fetched = False
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
@@ -296,9 +370,14 @@ def _build_jwt_token(payload: dict) -> str | None:
return jwt.encode(payload, secret, algorithm="HS256")
async def convert_to_png_async(file_path: Path, request_timeout: float = 5.0) -> bytes:
async def convert_to_png_async(
file_path: Path, request_timeout: float = 7.0, download_timeout: float = 2.0
) -> bytes:
"""Convert *file_path* to PNG using OnlyOffice Document Server (async).
With ``async: false`` the conversion itself runs inside the POST request,
so *request_timeout* must cover full conversion time. *download_timeout*
covers fetching the resulting one-page PNG, which is pure transfer.
Returns the PNG bytes. Raises RuntimeError on failure.
"""
if httpx is None or jwt is None:
@@ -330,26 +409,36 @@ async def convert_to_png_async(file_path: Path, request_timeout: float = 5.0) ->
headers["Authorization"] = token
t_start = perf_counter()
response = await client.post(
convert_url,
content=json.dumps(payload).encode(),
headers=headers,
timeout=request_timeout,
)
response.raise_for_status()
try:
response = await client.post(
convert_url,
content=json.dumps(payload).encode(),
headers=headers,
timeout=request_timeout,
)
response.raise_for_status()
except httpx.TimeoutException as e:
raise preview_timeout_error(
"onlyoffice", request_timeout, fetched=httpd.oo_fetched
) 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 +446,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
png_response = await client.get(file_url, timeout=request_timeout)
png_response.raise_for_status()
try:
png_response = await client.get(file_url, timeout=download_timeout)
png_response.raise_for_status()
except httpx.TimeoutException as e:
raise preview_timeout_error("onlyoffice", download_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)
@@ -373,48 +471,78 @@ async def convert_to_png_async(file_path: Path, request_timeout: float = 5.0) ->
OO_MAX_CONCURRENT = max(2, min(8, cpu_count()))
class _InFlight:
"""A deduplicated conversion: shared future, its task, and waiter count."""
__slots__ = ("future", "task", "waiters")
def __init__(self, future: asyncio.Future[bytes], task: asyncio.Task[None]):
self.future = future
self.task = task
self.waiters = 0
class OOConversionManager:
"""Manages async OnlyOffice conversions with deduplication and concurrency limits."""
def __init__(self, max_concurrent: int = OO_MAX_CONCURRENT):
self._semaphore = asyncio.Semaphore(max_concurrent)
self._in_flight: dict[str, asyncio.Future[bytes]] = {}
self._in_flight: dict[str, _InFlight] = {}
self._tasks: set[asyncio.Task[None]] = set()
self._lock = asyncio.Lock()
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}"
async with self._lock:
if key in self._in_flight:
future = self._in_flight[key]
else:
entry = self._in_flight.get(key)
if entry is None:
future = asyncio.get_running_loop().create_future()
self._in_flight[key] = future
task = asyncio.create_task(self._do_convert(filepath, key, future))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
entry = _InFlight(future, task)
self._in_flight[key] = entry
entry.waiters += 1
return await future
try:
# shield: one waiter's cancellation must not cancel the future
# shared with other waiters.
return await asyncio.shield(entry.future)
except asyncio.CancelledError:
# The caller hit the (strict) preview deadline or disconnected.
# When no other waiter remains, cancel the background task so it
# releases its semaphore slot and aborts the HTTP request instead
# of running orphaned and piling load onto OnlyOffice.
async with self._lock:
entry.waiters -= 1
orphan = entry.waiters == 0
if orphan:
entry.task.cancel()
raise
async def _do_convert(
self, filepath: Path, key: str, future: asyncio.Future[bytes]
) -> None:
try:
async with self._semaphore:
png_bytes = await convert_to_png_async(filepath, request_timeout=5.0)
png_bytes = await convert_to_png_async(filepath)
except asyncio.CancelledError:
# All waiters gave up; cancel the future so nothing hangs on it.
if not future.done():
future.cancel()
raise
except Exception as e:
if not future.done():
future.set_exception(e)
async with self._lock:
self._in_flight.pop(key, None)
else:
if not future.done():
future.set_result(png_bytes)
finally:
async with self._lock:
self._in_flight.pop(key, None)
+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
+27 -11
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:
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()
_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.
+114
View File
@@ -0,0 +1,114 @@
"""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_backend_error_short_message_strips_source_and_detail():
"""Backend messages like "source: summary: detail" become just the summary."""
err = backend_error(
"vips",
"pyvips: cannot decode image: unable to load from file b'/mnt/c/Users...",
)
assert err.short == "cannot decode image"
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