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:
@@ -23,7 +23,7 @@ pub use preview::{
|
||||
PREVIEW_PROBE_BYTES,
|
||||
};
|
||||
|
||||
use dr_types::Format;
|
||||
use dr_types::{Format, Orientation};
|
||||
|
||||
/// Capture metadata read from a file header.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
@@ -39,6 +39,15 @@ pub struct Metadata {
|
||||
/// Full sensor dimensions, before crop.
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
/// How the stored pixels sit relative to how the photograph should be
|
||||
/// seen (EXIF `0x0112`).
|
||||
///
|
||||
/// `None` where the file carries no tag, which is not the same claim as
|
||||
/// [`Orientation::NORMAL`]: the first says nothing is known, the second
|
||||
/// says the camera was held level. Callers treat them alike — an unknown
|
||||
/// orientation is displayed as-is — but keeping them apart means a future
|
||||
/// "rotate on import" pass can tell a deliberate `1` from a silent gap.
|
||||
pub orientation: Option<Orientation>,
|
||||
/// When the shutter fired, as Unix seconds.
|
||||
///
|
||||
/// EXIF records wall-clock time with no zone, so this is that reading
|
||||
@@ -259,6 +268,7 @@ pub fn metadata(bytes: &[u8]) -> Result<Metadata, DecodeError> {
|
||||
focal_length: exif.focal_length.map(|r| r.n as f32 / r.d.max(1) as f32),
|
||||
width: None,
|
||||
height: None,
|
||||
orientation: exif.orientation.map(Orientation::from_exif),
|
||||
captured_at: exif
|
||||
.date_time_original
|
||||
.as_deref()
|
||||
@@ -275,18 +285,58 @@ pub fn metadata(bytes: &[u8]) -> Result<Metadata, DecodeError> {
|
||||
// comes back empty. These formats *are* TIFF, so the same reader the JPEG
|
||||
// path uses can find it. Only the missing fields are filled, so rawler
|
||||
// stays authoritative wherever it did answer.
|
||||
if out.captured_at.is_none() {
|
||||
//
|
||||
// Orientation joins the trigger for the same reason it joins the fills: a
|
||||
// sideways frame that rawler declined to report is displayed on its side,
|
||||
// which is a louder failure than a missing date and just as recoverable
|
||||
// from the IFD the tag sits in. The extra walk is over bytes already in
|
||||
// memory, and only for files that came back short.
|
||||
if out.captured_at.is_none() || out.orientation.is_none() {
|
||||
if let Ok(fallback) = locate::tiff_metadata(bytes) {
|
||||
out.captured_at = fallback.captured_at;
|
||||
out.captured_at = out.captured_at.or(fallback.captured_at);
|
||||
out.captured_offset = out.captured_offset.or(fallback.captured_offset);
|
||||
out.iso = out.iso.or(fallback.iso);
|
||||
out.lens = out.lens.take().or(fallback.lens);
|
||||
out.orientation = out.orientation.or(fallback.orientation);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5 | FR-DEV-3h
|
||||
/// Read just the stored orientation, from a file header.
|
||||
///
|
||||
/// Separate from [`metadata`] for the reason the four entry points are
|
||||
/// separate at all (ARCH §3.2): the grid needs this for every cell it draws a
|
||||
/// thumbnail into, and it already holds the header bytes. Going through
|
||||
/// `metadata` would put a full rawler decoder construction behind one tag —
|
||||
/// the same mistake as decoding sensor data to cull.
|
||||
///
|
||||
/// This is an IFD walk over bytes already in memory, so it costs effectively
|
||||
/// nothing on the TIFF-derived formats and on JPEG. The two containers that
|
||||
/// are neither — Canon's CR3, which is ISO-BMFF, and Fujifilm's RAF — fall
|
||||
/// back to the full read, because the alternative is showing those bodies'
|
||||
/// portrait frames on their side.
|
||||
///
|
||||
/// `None` means the header carried no orientation, which callers should treat
|
||||
/// as [`Orientation::NORMAL`] rather than as a failure: most files have no tag.
|
||||
pub fn orientation(header: &[u8]) -> Option<Orientation> {
|
||||
let direct = if header.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||
locate::jpeg_metadata(header).ok()
|
||||
} else {
|
||||
locate::tiff_metadata(header).ok()
|
||||
};
|
||||
if let Some(o) = direct.and_then(|m| m.orientation) {
|
||||
return Some(o);
|
||||
}
|
||||
|
||||
match probe(header) {
|
||||
Some(Format::Cr3) | Some(Format::Raf) => metadata(header).ok().and_then(|m| m.orientation),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an EXIF `DateTimeOriginal` into Unix seconds.
|
||||
///
|
||||
/// The format is `"YYYY:MM:DD HH:MM:SS"` — colons in the date, which is what
|
||||
|
||||
@@ -470,6 +470,11 @@ const EXIF_IFD_POINTER: u16 = 0x8769;
|
||||
mod exif_tag {
|
||||
pub const MAKE: u16 = 0x010F;
|
||||
pub const MODEL: u16 = 0x0110;
|
||||
/// How the stored pixels sit relative to how the image should be seen.
|
||||
///
|
||||
/// Lives in the main IFD rather than the Exif sub-IFD, which is why it is
|
||||
/// found at all: the sub-IFD is where the *capture* tags are.
|
||||
pub const ORIENTATION: u16 = 0x0112;
|
||||
/// When the shutter fired. Absent on scanner output.
|
||||
pub const DATE_TIME_ORIGINAL: u16 = 0x9003;
|
||||
/// When the file was written. A camera sets both; a **scanner sets only
|
||||
@@ -511,6 +516,17 @@ fn read_exif_entries(
|
||||
exif_tag::ISO => md.iso = r.scalar(e),
|
||||
exif_tag::PIXEL_X => md.width = r.scalar(e),
|
||||
exif_tag::PIXEL_Y => md.height = r.scalar(e),
|
||||
// First IFD wins, unlike the fields above, which take the last
|
||||
// reading. This loop visits every IFD in the file, and a TIFF's
|
||||
// second one describes the *embedded thumbnail* — which some
|
||||
// bodies write already upright, tagged `1`. Letting that overwrite
|
||||
// the main image's tag would lay every portrait frame on its side.
|
||||
exif_tag::ORIENTATION => {
|
||||
if let Some(v) = r.scalar(e) {
|
||||
md.orientation
|
||||
.get_or_insert_with(|| dr_types::Orientation::from_exif(v as u16));
|
||||
}
|
||||
}
|
||||
exif_tag::DATE_TIME_ORIGINAL => {
|
||||
if let Some(t) = r.ascii(e).as_deref().and_then(crate::parse_exif_datetime) {
|
||||
md.captured_at = Some(t);
|
||||
@@ -575,6 +591,77 @@ mod tests {
|
||||
v
|
||||
}
|
||||
|
||||
/// Build a little-endian TIFF with two chained IFDs.
|
||||
///
|
||||
/// The second one stands in for a TIFF's thumbnail IFD, which is where the
|
||||
/// orientation test's whole point lives.
|
||||
fn tiff_two_ifds(first: &[(u16, u16, u32, u32)], second: &[(u16, u16, u32, u32)]) -> Vec<u8> {
|
||||
// IFD0 occupies 2 + 12n + 4 bytes from offset 8.
|
||||
let second_at = 8 + 2 + 12 * first.len() as u32 + 4;
|
||||
let mut v = tiff(first, second_at);
|
||||
v.truncate(second_at as usize);
|
||||
|
||||
v.extend_from_slice(&(second.len() as u16).to_le_bytes());
|
||||
for (tag, kind, count, value) in second {
|
||||
v.extend_from_slice(&tag.to_le_bytes());
|
||||
v.extend_from_slice(&kind.to_le_bytes());
|
||||
v.extend_from_slice(&count.to_le_bytes());
|
||||
v.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
v.extend_from_slice(&0u32.to_le_bytes());
|
||||
v.resize(v.len().max(1024), 0);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grid_reads_a_jpegs_orientation_without_decoding_it() {
|
||||
// The exact call the thumbnail worker makes, on the exact bytes it
|
||||
// has: a header, no pixels. Going through `metadata` instead would
|
||||
// build a rawler decoder per grid cell.
|
||||
let jpeg = jpeg_with_exif(&[(exif_tag::ORIENTATION, 3, 1, 6)], &[]);
|
||||
assert_eq!(
|
||||
crate::orientation(&jpeg),
|
||||
Some(dr_types::Orientation::from_exif(6))
|
||||
);
|
||||
|
||||
// And a file that says nothing declines rather than guessing.
|
||||
let plain = jpeg_with_exif(&[(exif_tag::ISO, 3, 1, 400)], &[]);
|
||||
assert_eq!(crate::orientation(&plain), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_is_read_from_the_main_ifd() {
|
||||
// 6 is "rotate 90° clockwise to display" — a phone or a body held on
|
||||
// its side, which is the case this whole path exists for.
|
||||
let h = tiff(&[(exif_tag::ORIENTATION, 3, 1, 6)], 0);
|
||||
let md = tiff_metadata(&h).expect("metadata");
|
||||
assert_eq!(md.orientation, Some(dr_types::Orientation::from_exif(6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_no_orientation_tag_reports_none_rather_than_upright() {
|
||||
// "Nothing was said" and "the camera was level" are different claims.
|
||||
// They are displayed alike, but only one of them can later be
|
||||
// distinguished from a deliberate `1`.
|
||||
let h = tiff(&[(tag::IMAGE_WIDTH, 4, 1, 1620)], 0);
|
||||
let md = tiff_metadata(&h).expect("metadata");
|
||||
assert_eq!(md.orientation, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_thumbnail_ifd_does_not_overwrite_the_main_images_orientation() {
|
||||
// The regression this guards: some bodies write their embedded
|
||||
// thumbnail already upright and tag that IFD `1`. Reading every IFD
|
||||
// last-wins — which is right for make, model and the dates — would
|
||||
// take the thumbnail's `1` and lay every portrait frame on its side.
|
||||
let h = tiff_two_ifds(
|
||||
&[(exif_tag::ORIENTATION, 3, 1, 8)],
|
||||
&[(exif_tag::ORIENTATION, 3, 1, 1)],
|
||||
);
|
||||
let md = tiff_metadata(&h).expect("metadata");
|
||||
assert_eq!(md.orientation, Some(dr_types::Orientation::from_exif(8)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_a_jpeg_interchange_preview() {
|
||||
let h = tiff(
|
||||
|
||||
@@ -26,6 +26,44 @@ pub struct Preview {
|
||||
}
|
||||
|
||||
impl Preview {
|
||||
/// TRACES: FR-DEV-3h
|
||||
/// Turn the pixels the right way up, in place.
|
||||
///
|
||||
/// Every path that shows a preview without the GPU needs this: the grid's
|
||||
/// thumbnails, and the read-only fallback develop shows when no decoder
|
||||
/// could open the file. An embedded preview is written in the sensor's
|
||||
/// orientation, not the photograph's, so a phone or a camera held sideways
|
||||
/// fills the grid with frames on their side until this runs.
|
||||
///
|
||||
/// Done before [`Self::downscale_to`] would be wasteful and after it is
|
||||
/// not: a quarter turn is a permutation, so it costs the same either way,
|
||||
/// and doing it on the smaller buffer moves a fraction of the bytes.
|
||||
///
|
||||
/// Allocates a second buffer rather than rotating in place. An in-place
|
||||
/// quarter turn on a non-square image is a cycle-following permutation
|
||||
/// that is both slower per pixel and far harder to get right, for a saving
|
||||
/// that a thumbnail-sized buffer does not need.
|
||||
pub fn apply_orientation(&mut self, orientation: dr_types::Orientation) {
|
||||
if orientation.is_normal() || self.width == 0 || self.height == 0 {
|
||||
return;
|
||||
}
|
||||
let (dw, dh) = orientation.oriented_size(self.width, self.height);
|
||||
|
||||
let mut out = vec![0u8; (dw as usize) * (dh as usize) * 4];
|
||||
for y in 0..dh {
|
||||
for x in 0..dw {
|
||||
let (sx, sy) = orientation.source_pixel(x, y, dw, dh);
|
||||
let s = ((sy * self.width + sx) * 4) as usize;
|
||||
let d = ((y * dw + x) * 4) as usize;
|
||||
out[d..d + 4].copy_from_slice(&self.rgba[s..s + 4]);
|
||||
}
|
||||
}
|
||||
|
||||
self.rgba = out;
|
||||
self.width = dw;
|
||||
self.height = dh;
|
||||
}
|
||||
|
||||
/// Downscale in place to fit within `max_dim` on the long edge.
|
||||
///
|
||||
/// A 5472x3648 preview is 79.8 MB of RGBA — far more than a grid cell or
|
||||
@@ -237,6 +275,76 @@ fn rgb_to_rgba(rgb: &[u8], w: u32, h: u32) -> Vec<u8> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A preview whose every pixel encodes its own coordinates, so a
|
||||
/// misplaced one is identifiable rather than merely wrong.
|
||||
fn coded(width: u32, height: u32) -> Preview {
|
||||
let mut rgba = Vec::with_capacity((width * height * 4) as usize);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
rgba.extend_from_slice(&[x as u8, y as u8, 0, 255]);
|
||||
}
|
||||
}
|
||||
Preview {
|
||||
width,
|
||||
height,
|
||||
rgba,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quarter_turn_moves_every_pixel_where_the_orientation_says() {
|
||||
// Tag 6: the stored image's first row becomes the displayed right
|
||||
// edge, its first column the displayed top. A 4x2 landscape preview
|
||||
// therefore comes out 2x4 portrait, with stored (0,0) at the top right.
|
||||
let mut p = coded(4, 2);
|
||||
p.apply_orientation(dr_types::Orientation::from_exif(6));
|
||||
|
||||
assert_eq!((p.width, p.height), (2, 4));
|
||||
let at = |x: u32, y: u32| {
|
||||
let i = ((y * p.width + x) * 4) as usize;
|
||||
(p.rgba[i], p.rgba[i + 1])
|
||||
};
|
||||
// Displayed top-right reads stored (0, 0).
|
||||
assert_eq!(at(1, 0), (0, 0));
|
||||
// Displayed top-left reads stored (0, 1) — the last row of column 0.
|
||||
assert_eq!(at(0, 0), (0, 1));
|
||||
// Displayed bottom-right reads stored (3, 0).
|
||||
assert_eq!(at(1, 3), (3, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_upright_file_is_left_untouched() {
|
||||
// The common case, and the one where an unnecessary reallocation
|
||||
// would be paid on every thumbnail in the library.
|
||||
let original = coded(4, 2);
|
||||
let mut p = original.clone();
|
||||
p.apply_orientation(dr_types::Orientation::NORMAL);
|
||||
assert_eq!(p, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_orientation_preserves_the_pixels_it_was_given() {
|
||||
// A turn or a mirror is a permutation: the same bytes, rearranged.
|
||||
// Anything else means a pixel was dropped, duplicated or read out of
|
||||
// bounds — and the bounds case would have panicked first.
|
||||
for tag in 1..=8u16 {
|
||||
let orientation = dr_types::Orientation::from_exif(tag);
|
||||
let mut p = coded(5, 3);
|
||||
p.apply_orientation(orientation);
|
||||
|
||||
assert_eq!(
|
||||
(p.width, p.height),
|
||||
orientation.oriented_size(5, 3),
|
||||
"tag {tag}"
|
||||
);
|
||||
let mut got: Vec<_> = p.rgba.chunks(4).map(|c| (c[0], c[1])).collect();
|
||||
got.sort_unstable();
|
||||
let mut want: Vec<_> = coded(5, 3).rgba.chunks(4).map(|c| (c[0], c[1])).collect();
|
||||
want.sort_unstable();
|
||||
assert_eq!(got, want, "tag {tag}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_preference_falls_through_in_order() {
|
||||
// A container missing the requested size must yield the next
|
||||
|
||||
Reference in New Issue
Block a user