Develop a JPEG through the same pipeline as a RAW

DemosaicedImage gains a second producer, from_rgba8, alongside the CFA path.
Nothing about the type is CFA-specific — it is "an image on the GPU, ready to
adjust" — which is what lets develop mode work on a JPEG without the edit
graph or any operation knowing the source was not a RAW file.

The one real difference is the transfer function: sensor data is linear, a
JPEG is gamma-encoded. Every operation assumes linear scene-referred colour
(exposure is a multiply, and doubling a gamma-encoded value is not a stop), so
the shader prologue linearises once, at the only point where the two source
kinds still differ. The flag rides in as_shot_wb.w, which was padding. For a
JPEG the white balance uniform is neutral and the colour matrix is identity,
so both stay unconditional multiplies rather than becoming branches.

max_dimension is exposed because it is a hardware limit the caller must plan
around, not a failure to report afterwards: a 13728x8928 film scan exceeds the
common 8192 texture limit, and fitting it first is the only way to develop it
at all.

Assisted-by: LLM
This commit is contained in:
2026-08-09 21:07:20 +02:00
parent 050dcff5bb
commit 5786977a51
4 changed files with 566 additions and 6 deletions
+176 -2
View File
@@ -13,9 +13,11 @@
//! is exactly why Lightroom stalls ~2 s per image during culling.
mod error;
mod locate;
mod preview;
pub use error::DecodeError;
pub use locate::{is_complete_jpeg, locate_preview, PreviewLocation, HEADER_BYTES};
pub use preview::{
decode_jpeg, extract_embedded_preview, extract_preview, Preview, PreviewSize,
PREVIEW_PROBE_BYTES,
@@ -37,6 +39,19 @@ pub struct Metadata {
/// Full sensor dimensions, before crop.
pub width: Option<u32>,
pub height: Option<u32>,
/// When the shutter fired, as Unix seconds.
///
/// EXIF records wall-clock time with no zone, so this is that reading
/// interpreted as UTC. Paired with [`captured_offset`](Self::captured_offset)
/// it reconstructs the actual instant; alone it is still correct for
/// ordering within one timezone, which is what a timeline needs.
pub captured_at: Option<i64>,
/// Minutes east of UTC, where the camera recorded a zone.
///
/// Absent on most bodies before ~2018. A photograph's timestamp is local
/// to where it was taken, so without this a shoot in Tokyo displays on the
/// wrong day in Paris.
pub captured_offset: Option<i32>,
}
/// Decoded sensor data, before demosaic.
@@ -218,6 +233,14 @@ pub fn probe(header: &[u8]) -> Option<Format> {
pub fn metadata(bytes: &[u8]) -> Result<Metadata, DecodeError> {
use rawler::rawsource::RawSource;
// rawler has no decoder for a plain JPEG, so without this every JPEG in a
// library reports no capture time — and a mixed library's timeline is
// silently missing thousands of images. Scanned film and camera JPEGs both
// land here.
if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
return locate::jpeg_metadata(bytes);
}
let source = RawSource::new_from_slice(bytes);
let decoder =
rawler::get_decoder(&source).map_err(|e| DecodeError::Unsupported(e.to_string()))?;
@@ -226,7 +249,7 @@ pub fn metadata(bytes: &[u8]) -> Result<Metadata, DecodeError> {
.map_err(|e| DecodeError::Metadata(e.to_string()))?;
let exif = &md.exif;
Ok(Metadata {
let mut out = Metadata {
make: Some(md.make.clone()).filter(|s| !s.is_empty()),
model: Some(md.model.clone()).filter(|s| !s.is_empty()),
lens: exif.lens_model.clone(),
@@ -236,7 +259,101 @@ 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,
})
captured_at: exif
.date_time_original
.as_deref()
.and_then(parse_exif_datetime),
captured_offset: exif
.offset_time_original
.as_deref()
.or(exif.offset_time.as_deref())
.and_then(parse_exif_offset),
};
// rawler reports no capture time for some TIFF-derived files whose tag is
// plainly present — one reference DNG carries it at byte 826 and still
// 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() {
if let Ok(fallback) = locate::tiff_metadata(bytes) {
out.captured_at = 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);
}
}
Ok(out)
}
/// Parse an EXIF `DateTimeOriginal` into Unix seconds.
///
/// The format is `"YYYY:MM:DD HH:MM:SS"` — colons in the date, which is what
/// trips generic date parsers. No timezone is present, so the reading is taken
/// as UTC and the zone, if any, comes from `OffsetTimeOriginal` separately.
///
/// Returns `None` rather than guessing on anything malformed: a wrong
/// timestamp puts an image at the wrong point on the timeline, which is worse
/// than leaving it unplaced.
pub(crate) fn parse_exif_datetime(s: &str) -> Option<i64> {
let s = s.trim();
let (date, time) = s.split_once(' ')?;
// EXIF specifies colons in the date, but real files disagree: the
// CanoScan 9000F writes `2013/06/28`, and enough devices use dashes that
// rejecting either would leave whole classes of file undated.
let mut d = date.split([':', '/', '-']);
let (y, mo, da): (i64, i64, i64) = (
d.next()?.parse().ok()?,
d.next()?.parse().ok()?,
d.next()?.parse().ok()?,
);
let mut t = time.split(':');
let (h, mi, se): (i64, i64, i64) = (
t.next()?.parse().ok()?,
t.next()?.parse().ok()?,
// Some bodies append fractional seconds; take the whole part.
t.next()?.split('.').next()?.parse().ok()?,
);
// A camera with a dead clock battery reports 1970 or similar. Reject
// obvious nonsense rather than clustering those images at the epoch.
if !(1900..=2200).contains(&y)
|| !(1..=12).contains(&mo)
|| !(1..=31).contains(&da)
|| !(0..=23).contains(&h)
|| !(0..=59).contains(&mi)
|| !(0..=60).contains(&se)
{
return None;
}
// Days from the civil date, via the usual era-based algorithm.
let y_adj = if mo <= 2 { y - 1 } else { y };
let era = if y_adj >= 0 { y_adj } else { y_adj - 399 } / 400;
let yoe = y_adj - era * 400;
let mp = (mo + 9) % 12;
let doy = (153 * mp + 2) / 5 + da - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
Some(days * 86_400 + h * 3_600 + mi * 60 + se)
}
/// Parse an EXIF offset like `"+02:00"` into minutes east of UTC.
pub(crate) fn parse_exif_offset(s: &str) -> Option<i32> {
let s = s.trim();
let (sign, rest) = match s.as_bytes().first()? {
b'+' => (1, &s[1..]),
b'-' => (-1, &s[1..]),
_ => return None,
};
let (h, m) = rest.split_once(':')?;
let (h, m): (i32, i32) = (h.parse().ok()?, m.parse().ok()?);
if !(0..=14).contains(&h) || !(0..=59).contains(&m) {
return None;
}
Some(sign * (h * 60 + m))
}
/// TRACES: FR-RAW-3 | FR-EXP-9
@@ -505,6 +622,63 @@ fn cfa_from_rawler(cfa: &rawler::CFA, model: &str) -> CfaPattern {
#[cfg(test)]
mod tests {
use super::{parse_exif_datetime, parse_exif_offset};
#[test]
fn exif_datetime_uses_colon_separated_dates() {
// The format that defeats generic parsers: colons in the date.
// Checked against a reference implementation, not computed by hand.
assert_eq!(
parse_exif_datetime("2026:08:09 14:30:00"),
Some(1_786_285_800)
);
assert_eq!(parse_exif_datetime("1970:01:01 00:00:00"), Some(0));
}
#[test]
fn fractional_seconds_are_tolerated() {
assert_eq!(
parse_exif_datetime("2026:08:09 14:30:00.75"),
parse_exif_datetime("2026:08:09 14:30:00")
);
}
#[test]
fn a_malformed_datetime_is_none_rather_than_a_guess() {
// A wrong timestamp puts an image at the wrong place on the timeline,
// which is worse than leaving it unplaced.
assert_eq!(parse_exif_datetime(""), None);
assert_eq!(parse_exif_datetime("not a date"), None);
// Dashes and slashes are accepted: real devices write both, and
// rejecting them left every CanoScan-scanned frame undated.
assert_eq!(
parse_exif_datetime("2026-08-09 14:30:00"),
parse_exif_datetime("2026:08:09 14:30:00")
);
assert_eq!(
parse_exif_datetime("2013/06/28 23:32:54"),
parse_exif_datetime("2013:06:28 23:32:54")
);
assert_eq!(parse_exif_datetime("2026:13:09 14:30:00"), None, "month 13");
assert_eq!(parse_exif_datetime("2026:08:09 25:00:00"), None, "hour 25");
assert_eq!(parse_exif_datetime("0000:00:00 00:00:00"), None);
}
#[test]
fn exif_offsets_parse_both_signs() {
assert_eq!(parse_exif_offset("+02:00"), Some(120));
assert_eq!(parse_exif_offset("-05:30"), Some(-330));
assert_eq!(parse_exif_offset("+00:00"), Some(0));
}
#[test]
fn an_absent_or_malformed_offset_is_none() {
// Most bodies before ~2018 record no zone at all.
assert_eq!(parse_exif_offset(""), None);
assert_eq!(parse_exif_offset("02:00"), None, "no sign");
assert_eq!(parse_exif_offset("+99:00"), None);
}
use super::*;
#[test]