Show the photograph the way it was taken

Nothing read EXIF orientation, so every frame from a body held sideways
lay on its side — in the grid, in develop, and in the read-only preview.

The tag is honoured as part of *reading the file*, at the same standing
as a RAW's masked-photosite crop, never as an edit. It lives as a
baseline on Framing rather than as a starting value for quarter_turns,
which is what keeps four things true: a sideways file opens unmodified,
reset returns it to upright rather than to the sensor's scan order, its
sidecar stays empty, and the rotate button still moves the image 90°
whatever the file underneath it says.

Framing::effective composes the baseline with the user's own turns
through the group law rather than by adding turns and OR-ing flags. The
naive version gets one case wrong — an odd baseline turn plus a user
mirror — and gets it wrong quietly, because the result is still a
plausible orientation. The composition collapses to a single
permutation, so obeying the tag costs nothing per pixel.

dr_decode::orientation is a header-only IFD walk, separate from
metadata() for the reason the entry points are separate at all: the grid
asks once per cell and must not build a rawler decoder to get one tag.
CR3 and RAF fall back to the full read, being neither TIFF nor JPEG.

Written down as FR-DEV-3h.

Known gap: thumbnails cached before this stay sideways. The store is
keyed by file and size, and its shards sync — invalidating them would
have every client re-download 25 MB a shard, which is not this commit's
call to make.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 15:37:05 +02:00
co-authored by Claude Opus 5
parent b044a8c067
commit 489465faf0
12 changed files with 875 additions and 48 deletions
+10 -2
View File
@@ -120,6 +120,10 @@ fn load_bytes(ctx: Option<&dr_gpu::GpuContext>, bytes: &[u8]) -> Result<Loaded,
// mode work on the JPEGs the library already indexes.
let is_jpeg = dr_decode::probe(bytes) == Some(dr_types::Format::Jpeg);
// How the file stored its pixels. A file that says nothing is taken as
// upright — see `Orientation::from_exif`.
let orientation = meta.orientation.unwrap_or_default();
if let Some(ctx) = ctx {
let opened = if is_jpeg {
dr_decode::decode_jpeg(bytes)
@@ -139,14 +143,14 @@ fn load_bytes(ctx: Option<&dr_gpu::GpuContext>, bytes: &[u8]) -> Result<Loaded,
);
p.downscale_to(limit);
}
DevelopSession::open_rgb(ctx, &p.rgba, p.width, p.height)
DevelopSession::open_rgb(ctx, &p.rgba, p.width, p.height, orientation)
})
} else {
// A failure here is expected for bodies rawler does not know, and
// must not stop the image displaying (FR-RAW-4).
dr_decode::decode(bytes)
.map_err(|e| e.to_string())
.and_then(|raw| DevelopSession::open(ctx, &raw))
.and_then(|raw| DevelopSession::open(ctx, &raw, orientation))
};
match opened {
@@ -169,6 +173,10 @@ fn load_bytes(ctx: Option<&dr_gpu::GpuContext>, bytes: &[u8]) -> Result<Loaded,
let mut preview =
dr_decode::extract_preview(bytes, PreviewSize::Screen).map_err(|e| e.to_string())?;
preview.downscale_to(MAX_DISPLAY_DIM);
// No graph here to carry the baseline, so the pixels are turned instead.
// Cheaper than it sounds after the downscale, and this path is the one a
// phone without a working GPU lands on — where sideways is most likely.
preview.apply_orientation(orientation);
let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(
&preview.rgba,