HDR-capable preview pipeline

- Video rotation (0/90/180/270) now stays fully in planar YUV420,
  preserving 10-bit HDR (yuv420p10le via PyAV uint16 planes) and the
  source colorspace; no more RGB round-trip.
- AVIF images now go through the ffmpeg CLI path like HEIC: pyvips drops
  CICP colour metadata, turning HDR sources into washed-out SDR previews.
- Fix pyvips "out of order read" on JPEGs needing EXIF rotation by
  reopening with random access only when autorot actually rotates.
This commit is contained in:
2026-08-11 20:36:13 +00:00
parent 5ed627d9f4
commit 8613d6c25e
+45 -50
View File
@@ -208,8 +208,10 @@ def _get_image_dimensions(path: Path) -> tuple[int, int] | None:
def _image_via_ffmpeg(path: Path, maxsize: int, quality: int) -> bytes: def _image_via_ffmpeg(path: Path, maxsize: int, quality: int) -> bytes:
"""Convert any image to AVIF using ffmpeg CLI. """Convert any image to AVIF using ffmpeg CLI.
ffmpeg handles HEIC tile assembly, EXIF rotation, HDR metadata and ffmpeg handles HEIC tile assembly, HDR metadata and ICC profile embedding
ICC profile embedding automatically. automatically. Note: -vf cannot be used here — HEIC tile assembly feeds
the stream from a complex filtergraph, which conflicts with simple -vf
filtering; scaling must use the -s output option instead.
""" """
dims = _get_image_dimensions(path) dims = _get_image_dimensions(path)
crf = int(63 * (1 - quality / 100) ** 2) crf = int(63 * (1 - quality / 100) ** 2)
@@ -281,11 +283,12 @@ def process_image_pyvips(path, *, maxsize, quality):
t_start = perf_counter() t_start = perf_counter()
suffix = path.suffix.lower() suffix = path.suffix.lower()
# HEIC/HEIF: ffmpeg handles tile assembly and HDR correctly; # HEIC/HEIF/AVIF: ffmpeg handles tile assembly and HDR correctly;
# skip pyvips entirely. # skip pyvips entirely (pyvips drops CICP colour metadata, turning
if suffix in (".heic", ".heif"): # HDR sources into washed-out SDR previews).
heic_dims = _get_image_dimensions(path) if suffix in (".heic", ".heif", ".avif"):
width, height = heic_dims or (None, None) dims = _get_image_dimensions(path)
width, height = dims or (None, None)
ret = _image_via_ffmpeg(path, maxsize, quality) ret = _image_via_ffmpeg(path, maxsize, quality)
t_end = perf_counter() t_end = perf_counter()
return ret, PreviewResponse( return ret, PreviewResponse(
@@ -300,10 +303,12 @@ def process_image_pyvips(path, *, maxsize, quality):
# Other image formats: pyvips only. ffmpeg is not a useful fallback # Other image formats: pyvips only. ffmpeg is not a useful fallback
# here — when pyvips cannot decode a file, ffmpeg's image decoders # here — when pyvips cannot decode a file, ffmpeg's image decoders
# cannot either, and their failure output is far noisier. # cannot either, and their failure output is far noisier.
load_opts = {"access": "sequential"}
try: try:
img = pyvips.Image.new_from_file(str(path), **load_opts) img = pyvips.Image.new_from_file(str(path), access="sequential")
img = img.autorot() if img.get_typeof("orientation") and img.get("orientation") != 1:
# autorot's rot90 reads pixels out of order, which sequential
# access cannot do — reopen with random access when rotating.
img = pyvips.Image.new_from_file(str(path)).autorot()
orig_w, orig_h = img.width, img.height orig_w, orig_h = img.width, img.height
scale = min(maxsize / img.width, maxsize / img.height, 1.0) scale = min(maxsize / img.width, maxsize / img.height, 1.0)
if scale < 1.0: if scale < 1.0:
@@ -387,6 +392,34 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
) )
def _rotate_frame_yuv(frame, k):
"""Rotate a planar YUV420 frame by k*90° counter-clockwise, keeping its format.
Rotating each plane independently preserves the pixel format (including
10-bit HDR formats like yuv420p10le, which PyAV exposes as uint16 planes)
and the source colorspace.
"""
fmt = frame.format
w, h = frame.width, frame.height
if (
not fmt.is_planar
or fmt.chroma_width(w) * 2 != w
or fmt.chroma_height(h) * 2 != h
):
raise ValueError(f"unsupported format for YUV rotation: {fmt.name}")
planes = frame.to_ndarray()
y, u, v = (
planes[:h],
planes[h : h + h // 4].reshape(h // 2, w // 2),
planes[h + h // 4 :].reshape(h // 2, w // 2),
)
planes = np.hstack(
[p.flat for p in (np.rot90(y, k), np.rot90(u, k), np.rot90(v, k))]
)
new_width = w if k % 2 == 0 else h
return av.VideoFrame.from_ndarray(planes.reshape(-1, new_width), format=fmt.name)
def process_video(path, *, maxsize, quality): def process_video(path, *, maxsize, quality):
frame = None frame = None
imgdata = io.BytesIO() imgdata = io.BytesIO()
@@ -427,49 +460,11 @@ def process_video(path, *, maxsize, quality):
new_height = int(frame.height * scale_factor) new_height = int(frame.height * scale_factor)
frame = frame.reformat(width=new_width, height=new_height) frame = frame.reformat(width=new_width, height=new_height)
# Apply EXIF rotation if present # Apply display-matrix rotation if present
if frame.rotation: if frame.rotation:
# frame.rotation indicates clockwise rotation needed to display correctly # frame.rotation indicates clockwise rotation needed to display correctly
# np.rot90 rotates counter-clockwise, so we negate k
k = (frame.rotation // 90) % 4 # Convert to counter-clockwise rotations k = (frame.rotation // 90) % 4 # Convert to counter-clockwise rotations
if k == 2: frame = _rotate_frame_yuv(frame, k)
# 180° rotation can be done in YUV420p, preserving HDR
try:
fplanes = frame.to_ndarray()
# Split into Y, U, V planes of proper dimensions
planes = [
fplanes[: frame.height],
fplanes[
frame.height : frame.height + frame.height // 4
].reshape(frame.height // 2, frame.width // 2),
fplanes[frame.height + frame.height // 4 :].reshape(
frame.height // 2, frame.width // 2
),
]
# Rotate each plane by 180°
planes = [np.rot90(p, 2) for p in planes]
# Restore PyAV format
planes = np.hstack([p.flat for p in planes]).reshape(
-1, planes[0].shape[1]
)
frame = av.VideoFrame.from_ndarray(planes, format=frame.format.name)
del planes, fplanes
except Exception:
logger.exception("Error rotating video frame by 180°")
elif k in (1, 3):
# 90° or 270° rotation requires RGB conversion (loses HDR)
try:
rgb = frame.to_ndarray(format="rgb24")
rgb = np.rot90(rgb, k)
frame = av.VideoFrame.from_ndarray(rgb, format="rgb24")
frame = frame.reformat(
format="yuv420p"
) # Convert back for encoding
del rgb
except Exception:
logger.exception(
"Error rotating video frame by %s°", frame.rotation
)
# libsvtav1 rejects full-range JPEG-style YUV pixel formats such as # libsvtav1 rejects full-range JPEG-style YUV pixel formats such as
# yuvj420p, so normalize them before opening the encoder. # yuvj420p, so normalize them before opening the encoder.