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
|
||||
|
||||
Reference in New Issue
Block a user