fix(export): apply EXIF orientation in the keepsake too
Round 1 fixed EXIF orientation in the compression worker, which corrected the live app — feed preview and diashow display. The export worker was missed, and it does not reuse those derivatives: it re-decodes the originals itself with `image::open`, which ignores the orientation tag, then re-encodes to JPEG, which drops the tag — so the viewer has no way to recover it. The damage was oddly shaped, which is exactly why it reads as a viewer bug: Gallery.zip originals correct (byte-copied, EXIF intact) Memories viewer grid thumbnails SIDEWAYS (always) Memories viewer full image >5 MB SIDEWAYS (re-encoded at 2000px) Memories viewer full image ≤5 MB correct (streamed byte-for-byte) So in the keepsake people actually keep, every portrait photo in the grid was on its side, and clicking through silently "fixed" small photos but not large ones. Rather than paste the decoder dance a third time, extract `services::imaging:: decode_oriented` and route both workers through it, so there is exactly one way to turn a file on disk into a DynamicImage. It carries a second invariant that had also drifted: `image::open` applies NO decode limits, so the export path was decoding arbitrary user-supplied images unbounded — the decompression-bomb cap existed only in the compression worker. Both now come as a pair, which is the point of having one function. Not done: switching export to consume the existing `display` derivative. It would fix orientation and drop a redundant full-resolution decode per photo, but it would also replace the pristine ≤5 MB originals in the keepsake with 2048px re-encodes — a real quality regression in the one artefact people keep forever. Test uploads the round-1 fixture (40x20 landscape tagged Orientation=6), runs a real export, pulls the thumbnail out of Memories.zip and asserts it came back portrait — with a sanity check that the source really is stored landscape, so the test can't pass against a pipeline that does nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
52
backend/src/services/imaging.rs
Normal file
52
backend/src/services/imaging.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
//! Shared image decoding.
|
||||
//!
|
||||
//! Exists so there is exactly ONE way to turn a file on disk into a `DynamicImage` in this
|
||||
//! codebase. Two properties have to hold everywhere an image is decoded, and both were
|
||||
//! previously re-derived per call site — which is how they drifted apart:
|
||||
//!
|
||||
//! - **EXIF orientation must be applied.** Phones do not rotate sensor data; they record how
|
||||
//! the camera was held in a tag and store the pixels as shot. `image::open` and
|
||||
//! `ImageReader::decode` both hand back the raw pixels and ignore that tag, and re-encoding
|
||||
//! to JPEG writes no EXIF, so the derivative is permanently sideways while the untouched
|
||||
//! original still renders upright. The compression worker was fixed; the export worker was
|
||||
//! not, so every portrait photo came out sideways in the keepsake's HTML viewer.
|
||||
//! - **Decode limits must be set.** The upload body cap bounds the file on disk, but a small
|
||||
//! file can decode to enormous dimensions (a ~1 MB image expanding to 50k×50k px), OOM-ing
|
||||
//! the box. `image::open` applies NO limits at all, so the export path was also decoding
|
||||
//! arbitrary user-supplied images unbounded.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use image::{DynamicImage, ImageDecoder};
|
||||
use std::path::Path;
|
||||
|
||||
/// Bounds for any decode of user-supplied image data. 12000×12000 covers any real phone
|
||||
/// photo; `max_alloc` hard-caps the decode allocation.
|
||||
fn decode_limits() -> image::Limits {
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(12_000);
|
||||
limits.max_image_height = Some(12_000);
|
||||
limits.max_alloc = Some(256 * 1024 * 1024);
|
||||
limits
|
||||
}
|
||||
|
||||
/// Decode an image from disk with decompression-bomb limits applied and its EXIF
|
||||
/// orientation baked into the pixels.
|
||||
///
|
||||
/// Blocking — call inside `spawn_blocking`.
|
||||
pub fn decode_oriented(path: &Path) -> Result<DynamicImage> {
|
||||
let mut reader = image::ImageReader::open(path)
|
||||
.context("failed to open image")?
|
||||
.with_guessed_format()
|
||||
.context("failed to read image header")?;
|
||||
reader.limits(decode_limits());
|
||||
|
||||
// `into_decoder` carries the limits above through, so reading the tag costs nothing in
|
||||
// safety. A missing or malformed tag is not an error — most images simply have none.
|
||||
let mut decoder = reader.into_decoder().context("failed to decode image")?;
|
||||
let orientation = decoder
|
||||
.orientation()
|
||||
.unwrap_or(image::metadata::Orientation::NoTransforms);
|
||||
let mut img = DynamicImage::from_decoder(decoder).context("failed to decode image")?;
|
||||
img.apply_orientation(orientation);
|
||||
Ok(img)
|
||||
}
|
||||
Reference in New Issue
Block a user