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:
+176
-2
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user