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:
+30
-8
@@ -28,10 +28,19 @@ pub struct DevelopSession {
|
||||
|
||||
impl DevelopSession {
|
||||
/// Demosaic an image and prepare its edit graph.
|
||||
pub fn open(ctx: &GpuContext, raw: &RawImage) -> Result<Self, String> {
|
||||
///
|
||||
/// `orientation` is the file's EXIF orientation, not an edit: a sensor is
|
||||
/// scanned the same way whichever way the body was held, so this is what
|
||||
/// makes a portrait frame open upright. It is fixed for the life of the
|
||||
/// session and survives a reset.
|
||||
pub fn open(
|
||||
ctx: &GpuContext,
|
||||
raw: &RawImage,
|
||||
orientation: dr_types::Orientation,
|
||||
) -> Result<Self, String> {
|
||||
let demosaicer = Demosaicer::new(ctx).map_err(|e| e.to_string())?;
|
||||
let demosaiced = demosaicer.run(raw).map_err(|e| e.to_string())?;
|
||||
Ok(Self::with_source(ctx, demosaiced))
|
||||
Ok(Self::with_source(ctx, demosaiced, orientation))
|
||||
}
|
||||
|
||||
/// Prepare an edit graph over an already-processed RGB image.
|
||||
@@ -49,15 +58,22 @@ impl DevelopSession {
|
||||
rgba: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
orientation: dr_types::Orientation,
|
||||
) -> Result<Self, String> {
|
||||
let source =
|
||||
DemosaicedImage::from_rgba8(ctx, rgba, width, height).map_err(|e| e.to_string())?;
|
||||
Ok(Self::with_source(ctx, source))
|
||||
Ok(Self::with_source(ctx, source, orientation))
|
||||
}
|
||||
|
||||
fn with_source(ctx: &GpuContext, demosaiced: DemosaicedImage) -> Self {
|
||||
fn with_source(
|
||||
ctx: &GpuContext,
|
||||
demosaiced: DemosaicedImage,
|
||||
orientation: dr_types::Orientation,
|
||||
) -> Self {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.set_orientation(orientation);
|
||||
Self {
|
||||
graph: EditGraph::default_chain(),
|
||||
graph,
|
||||
demosaiced,
|
||||
adjust: AdjustPass::new(ctx),
|
||||
}
|
||||
@@ -751,7 +767,9 @@ mod tests {
|
||||
rgba.extend_from_slice(&[(x * 4) as u8, (y * 4) as u8, 128, 255]);
|
||||
}
|
||||
}
|
||||
let mut session = DevelopSession::open_rgb(&ctx, &rgba, w, h).expect("session");
|
||||
let mut session =
|
||||
DevelopSession::open_rgb(&ctx, &rgba, w, h, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
|
||||
let fitted = session.render(64, 64).expect("fitted render");
|
||||
session.zoom_about(4.0, 0.5, 0.5);
|
||||
@@ -789,7 +807,9 @@ mod tests {
|
||||
// Bigger than the viewport it is shown in: `fit` scales it down, so
|
||||
// every screen pixel still has several source pixels behind it.
|
||||
let big = vec![128u8; (800 * 800 * 4) as usize];
|
||||
let mut session = DevelopSession::open_rgb(&ctx, &big, 800, 800).expect("session");
|
||||
let mut session =
|
||||
DevelopSession::open_rgb(&ctx, &big, 800, 800, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
assert!(
|
||||
!session.magnifies_source(200, 200),
|
||||
"a downscaled image is not magnified"
|
||||
@@ -808,7 +828,9 @@ mod tests {
|
||||
// Smaller than the viewport: `fit` refuses to upscale, so the render is
|
||||
// 1:1 and unzoomed is exactly the boundary — not past it.
|
||||
let small = vec![128u8; (100 * 100 * 4) as usize];
|
||||
let mut session = DevelopSession::open_rgb(&ctx, &small, 100, 100).expect("session");
|
||||
let mut session =
|
||||
DevelopSession::open_rgb(&ctx, &small, 100, 100, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
assert!(
|
||||
!session.magnifies_source(800, 800),
|
||||
"1:1 is the boundary, not past it — filtering must not flip on a \
|
||||
|
||||
+10
-2
@@ -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,
|
||||
|
||||
@@ -1332,6 +1332,13 @@ async fn fetch_one(
|
||||
collect_metadata(&header, req, found_metadata);
|
||||
}
|
||||
|
||||
// Read unconditionally, unlike the rest of the EXIF above: `needs_metadata`
|
||||
// is false once an image has been catalogued, but a thumbnail can still be
|
||||
// regenerated long after that — a cleared cache, a new size — and a
|
||||
// thumbnail that came out upright the first time must come out upright
|
||||
// every time. This is a header walk, not a decode; see `dr_decode::orientation`.
|
||||
let orientation = dr_decode::orientation(&header).unwrap_or_default();
|
||||
|
||||
// A plain JPEG is its own preview; anything else needs locating.
|
||||
let bytes = if header.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||
match backend.get(&id, None).await {
|
||||
@@ -1368,6 +1375,14 @@ async fn fetch_one(
|
||||
Err(e) => return fail(e.to_string()),
|
||||
};
|
||||
preview.downscale_to(req.thumb_size.edge());
|
||||
// Turn it the right way up before it is measured, cached or shown. An
|
||||
// embedded preview is written in the sensor's orientation, so without this
|
||||
// every frame shot in portrait lies on its side in the grid — and, because
|
||||
// the cache is keyed by file and size alone, stays that way.
|
||||
//
|
||||
// After the downscale, so the permutation moves thumbnail-sized bytes
|
||||
// rather than the full preview's.
|
||||
preview.apply_orientation(orientation);
|
||||
|
||||
// Persist for next time, and for every other client that syncs the shard.
|
||||
// A store failure is logged and dropped: the pixels are already in hand,
|
||||
|
||||
Reference in New Issue
Block a user