Skip to main content

mimick/library/
texture_decode.rs

1//! Image decoding pipeline -- codec dispatch, RAW/HEIF/JXL/JPEG2K/SVG/PSD/WebP decoders,
2//! embedded-preview extraction, EXIF orientation, and decode-cache management.
3//!
4//! This module was extracted from `library/mod.rs` to keep the main module
5//! focused on window construction and navigation.
6
7use gtk::prelude::*;
8
9mod codecs;
10
11/// Runtime flag controlling the on-disk RAW decode cache.
12/// Initialised from config at startup; toggled live from the settings UI.
13static RAW_CACHE_ENABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
14
15/// Update the runtime RAW cache flag (called from settings and startup).
16pub fn set_raw_cache_enabled(enabled: bool) {
17    RAW_CACHE_ENABLED.store(enabled, std::sync::atomic::Ordering::Relaxed);
18}
19
20/// Runtime flag: true = full demosaic, false = extract embedded JPEG preview.
21static RAW_FULL_DECODE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
22
23/// Update the runtime RAW full-decode flag (called from settings and startup).
24pub fn set_raw_full_decode(enabled: bool) {
25    RAW_FULL_DECODE.store(enabled, std::sync::atomic::Ordering::Relaxed);
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29enum TextureDecoder {
30    Raw,
31    Heif,
32    JpegXl,
33    Svg,
34    Jpeg,
35    Webp,
36    Jpeg2k,
37    Psd,
38    Pixbuf,
39    ImageFallback,
40}
41
42pub(super) fn load_texture_blocking(path: &std::path::Path) -> Option<gdk4::Texture> {
43    let started = std::time::Instant::now();
44    let decoder = texture_decoder_for_path(path);
45    let (result, winning_route) = decode_with_fallbacks(path, decoder);
46    log_decode_result(
47        path,
48        decoder,
49        winning_route,
50        started.elapsed().as_millis(),
51        &result,
52    );
53    result
54}
55
56fn decode_with_fallbacks(
57    path: &std::path::Path,
58    decoder: TextureDecoder,
59) -> (Option<gdk4::Texture>, TextureDecoder) {
60    if let Some(texture) = decode_with_route(path, decoder) {
61        return (Some(texture), decoder);
62    }
63    if decoder != TextureDecoder::Pixbuf
64        && let Some(texture) = decode_pixbuf_texture(path)
65    {
66        return (Some(texture), TextureDecoder::Pixbuf);
67    }
68    if let Some(texture) = decode_image_texture(path) {
69        return (Some(texture), TextureDecoder::ImageFallback);
70    }
71    (None, decoder)
72}
73
74fn decode_with_route(path: &std::path::Path, decoder: TextureDecoder) -> Option<gdk4::Texture> {
75    match decoder {
76        TextureDecoder::Raw => decode_raw_texture(path),
77        TextureDecoder::Heif => decode_heif_texture(path),
78        TextureDecoder::JpegXl => codecs::decode_jpegxl_texture(path),
79        TextureDecoder::Svg => codecs::decode_svg_texture(path),
80        TextureDecoder::Jpeg => decode_jpeg_texture(path),
81        TextureDecoder::Webp => decode_webp_texture(path),
82        TextureDecoder::Jpeg2k => codecs::decode_jpeg2k_texture(path),
83        TextureDecoder::Psd => decode_psd_texture(path),
84        TextureDecoder::Pixbuf => decode_pixbuf_texture(path),
85        TextureDecoder::ImageFallback => None,
86    }
87}
88
89fn log_decode_result(
90    path: &std::path::Path,
91    decoder: TextureDecoder,
92    winning_route: TextureDecoder,
93    elapsed_ms: u128,
94    result: &Option<gdk4::Texture>,
95) {
96    match &result {
97        Some(texture) => {
98            if winning_route == decoder {
99                log::debug!(
100                    "Decoded {} via {:?} in {}ms ({}x{})",
101                    path.display(),
102                    decoder,
103                    elapsed_ms,
104                    texture.width(),
105                    texture.height(),
106                );
107            } else {
108                log::debug!(
109                    "Decoded {} via {:?} fallback (primary {:?} failed) in {}ms ({}x{})",
110                    path.display(),
111                    winning_route,
112                    decoder,
113                    elapsed_ms,
114                    texture.width(),
115                    texture.height(),
116                );
117            }
118        }
119        None => log::warn!(
120            "All decoders rejected {} (route {:?}) after {}ms",
121            path.display(),
122            decoder,
123            elapsed_ms,
124        ),
125    }
126}
127
128fn texture_decoder_for_path(path: &std::path::Path) -> TextureDecoder {
129    let ext = path
130        .extension()
131        .map(|ext| ext.to_string_lossy().to_ascii_lowercase());
132    match ext.as_deref() {
133        Some(ext) if crate::media_kinds::is_raw_ext(ext) => TextureDecoder::Raw,
134        Some("heic" | "heif" | "hif" | "avif") => TextureDecoder::Heif,
135        Some("jxl") => TextureDecoder::JpegXl,
136        Some("svg" | "svgz") => TextureDecoder::Svg,
137        Some("jpe" | "jpeg" | "jpg" | "insp") => TextureDecoder::Jpeg,
138        Some("webp") => TextureDecoder::Webp,
139        Some("jp2") => TextureDecoder::Jpeg2k,
140        Some("psd") => TextureDecoder::Psd,
141        Some("bmp" | "gif" | "png" | "tif" | "tiff") => TextureDecoder::Pixbuf,
142        _ => TextureDecoder::ImageFallback,
143    }
144}
145
146fn memory_texture(
147    width: u32,
148    height: u32,
149    format: gdk4::MemoryFormat,
150    pixels: Vec<u8>,
151    stride: usize,
152) -> Option<gdk4::Texture> {
153    let width = i32::try_from(width).ok()?;
154    let height = i32::try_from(height).ok()?;
155    let bytes = glib::Bytes::from_owned(pixels);
156    let texture = gdk4::MemoryTexture::new(width, height, format, &bytes, stride);
157    Some(texture.upcast::<gdk4::Texture>())
158}
159
160/// Thumbnail-targeted RAW decode: embedded JPEG first, full demosaic only as
161/// last resort. Ignores `RAW_FULL_DECODE` (which is for lightbox quality, not
162/// 256-px grid tiles -- full sensor data would just be scaled away).
163pub(super) fn decode_raw_thumbnail_texture(path: &std::path::Path) -> Option<gdk4::Texture> {
164    if let Some(tex) = extract_libraw_thumb(path) {
165        return Some(tex);
166    }
167    log::debug!(
168        "No embedded preview in {}; thumbnail falling back to full decode",
169        path.display()
170    );
171    decode_libraw_texture(path)
172}
173
174fn decode_raw_texture(path: &std::path::Path) -> Option<gdk4::Texture> {
175    let full_decode = RAW_FULL_DECODE.load(std::sync::atomic::Ordering::Relaxed);
176    if full_decode {
177        decode_full_raw_texture(path)
178    } else {
179        decode_raw_preview_or_fallback(path)
180    }
181}
182
183fn decode_full_raw_texture(path: &std::path::Path) -> Option<gdk4::Texture> {
184    decode_libraw_texture(path)
185}
186
187fn decode_raw_preview_or_fallback(path: &std::path::Path) -> Option<gdk4::Texture> {
188    if let Some(texture) = extract_libraw_thumb(path) {
189        return Some(texture);
190    }
191    log::debug!(
192        "No embedded preview in {}; falling back to full decode",
193        path.display()
194    );
195    decode_libraw_texture(path)
196}
197
198/// Return the directory used for the on-disk RAW decode cache.
199fn raw_decode_cache_dir() -> std::path::PathBuf {
200    crate::profile::cache_dir()
201        .unwrap_or_else(|| std::path::PathBuf::from("/tmp").join(crate::profile::dir_segment()))
202        .join("raw_decode")
203}
204
205/// Build a stable cache filename for a RAW file based on its canonical path,
206/// last-modified timestamp (seconds), and byte length.  Any change to the file
207/// on disk produces a different key so stale pixels are never served.
208fn raw_decode_cache_key(path: &std::path::Path) -> Option<String> {
209    let meta = std::fs::metadata(path).ok()?;
210    let mtime = meta
211        .modified()
212        .ok()?
213        .duration_since(std::time::UNIX_EPOCH)
214        .ok()?
215        .as_secs();
216    let size = meta.len();
217    let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
218    use std::hash::{Hash, Hasher};
219    let mut hasher = std::collections::hash_map::DefaultHasher::new();
220    canonical.hash(&mut hasher);
221    mtime.hash(&mut hasher);
222    size.hash(&mut hasher);
223    Some(format!("{:016x}.png", hasher.finish()))
224}
225
226/// Attempt to load a previously cached demosaiced texture from disk.
227/// Returns `None` if the cache entry is absent, stale, or unreadable.
228fn read_raw_decode_cache(path: &std::path::Path) -> Option<gdk4::Texture> {
229    let key = raw_decode_cache_key(path)?;
230    let cache_file = raw_decode_cache_dir().join(&key);
231    if !cache_file.exists() {
232        return None;
233    }
234    match gdk4::Texture::from_filename(&cache_file) {
235        Ok(texture) => {
236            log::debug!("RAW decode cache hit for {}", path.display());
237            Some(texture)
238        }
239        Err(err) => {
240            log::debug!(
241                "RAW decode cache read failed for {}: {}",
242                path.display(),
243                err
244            );
245            // Remove the corrupt entry so the next decode replaces it cleanly.
246            let _ = std::fs::remove_file(&cache_file);
247            None
248        }
249    }
250}
251
252/// Persist a successfully demosaiced RAW texture to the disk cache so future
253/// opens can skip the expensive libraw pipeline entirely.
254fn write_raw_decode_cache(path: &std::path::Path, texture: &gdk4::Texture) {
255    let Some(key) = raw_decode_cache_key(path) else {
256        return;
257    };
258    let cache_dir = raw_decode_cache_dir();
259    if let Err(err) = std::fs::create_dir_all(&cache_dir) {
260        log::debug!("RAW decode cache dir create failed: {}", err);
261        return;
262    }
263    let cache_file = cache_dir.join(&key);
264    // Save as PNG via GDK so we roundtrip losslessly through the same loader.
265    if let Err(err) = texture.save_to_png(&cache_file) {
266        log::debug!(
267            "RAW decode cache write failed for {}: {}",
268            path.display(),
269            err
270        );
271    } else {
272        log::debug!("RAW decode cache written for {}", path.display());
273    }
274}
275/// Minimum size for an embedded preview to be considered -- tiny thumbnails
276/// (EXIF 160x120 stubs) are almost always useless and should be skipped in
277/// favour of the full-resolution preview or the libraw fallback.
278const MIN_EMBEDDED_JPEG_SIZE: usize = 4096;
279
280/// True if the JPEG at `bytes[start..end]` uses SOF3 (lossless JPEG).
281/// RAW files wrap compressed Bayer data in SOF3 containers -- these are
282/// never renderable and should be skipped by the preview scanner.
283fn is_lossless_jpeg(bytes: &[u8], start: usize, end: usize) -> bool {
284    let mut pos = start + 2;
285    while pos + 3 < end {
286        if bytes[pos] != 0xFF {
287            return false;
288        }
289        let marker = bytes[pos + 1];
290        match classify_jpeg_marker(marker) {
291            MarkerKind::Fill => {
292                pos += 1;
293            }
294            MarkerKind::Parameterless => {
295                pos += 2;
296            }
297            MarkerKind::Sof => return marker == 0xC3,
298            MarkerKind::Sos => return false,
299            MarkerKind::Segment => {
300                if let Some(next) = skip_segment(bytes, pos, end) {
301                    pos = next;
302                } else {
303                    return false;
304                }
305            }
306        }
307    }
308    false
309}
310
311enum MarkerKind {
312    Fill,
313    Parameterless,
314    Sof,
315    Sos,
316    Segment,
317}
318
319fn classify_jpeg_marker(marker: u8) -> MarkerKind {
320    match marker {
321        0xFF => MarkerKind::Fill,
322        0x01 | 0xD0..=0xD7 => MarkerKind::Parameterless,
323        0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC => MarkerKind::Sof,
324        0xDA => MarkerKind::Sos,
325        _ => MarkerKind::Segment,
326    }
327}
328
329/// Read and skip a variable-length JPEG segment. Returns the position after
330/// the segment, or None if the segment is malformed.
331fn skip_segment(bytes: &[u8], pos: usize, end: usize) -> Option<usize> {
332    if pos + 3 >= end {
333        return None;
334    }
335    let seg_len = u16::from_be_bytes([bytes[pos + 2], bytes[pos + 3]]) as usize;
336    if seg_len < 2 || pos + 2 + seg_len > end {
337        return None;
338    }
339    Some(pos + 2 + seg_len)
340}
341
342/// Return the largest renderable JPEG embedded in the file, or None.
343/// Uses structure-aware marker walking; skips lossless SOF3 payloads
344/// (compressed Bayer data) and payloads below `MIN_EMBEDDED_JPEG_SIZE`.
345fn extract_largest_embedded_jpeg(path: &std::path::Path) -> Option<Vec<u8>> {
346    let bytes = std::fs::read(path).ok()?;
347    let mut best: Option<(usize, usize)> = None;
348    let len = bytes.len();
349    let mut i = 0;
350
351    while i + 3 < len {
352        if !is_embedded_jpeg_start(&bytes, i) {
353            i += 1;
354            continue;
355        }
356        if let Some(end) = find_jpeg_end(&bytes, i) {
357            best = choose_best_embedded_jpeg(&bytes, best, i, end);
358            i = end;
359        } else {
360            i += 2;
361        }
362    }
363
364    best.map(|(start, l)| bytes[start..start + l].to_vec())
365}
366
367fn is_embedded_jpeg_start(bytes: &[u8], i: usize) -> bool {
368    bytes[i] == 0xFF && bytes[i + 1] == 0xD8 && bytes[i + 2] == 0xFF && bytes[i + 3] != 0x00
369}
370
371fn choose_best_embedded_jpeg(
372    bytes: &[u8],
373    best: Option<(usize, usize)>,
374    start: usize,
375    end: usize,
376) -> Option<(usize, usize)> {
377    let payload_len = end - start;
378    let candidate_ok =
379        payload_len >= MIN_EMBEDDED_JPEG_SIZE && !is_lossless_jpeg(bytes, start, end);
380    if candidate_ok && best.is_none_or(|(_, len)| payload_len > len) {
381        Some((start, payload_len))
382    } else {
383        best
384    }
385}
386
387/// Walk the JPEG marker structure starting at the SOI at `bytes[start]` and
388/// return the byte offset just past the EOI (or the end of the buffer if the
389/// file omits the trailing EOI).
390///
391/// The scanner reads marker segments by their declared lengths, then
392/// byte-scans only through the entropy-coded section after SOS where
393/// FF 00 (byte-stuffing) and FF D0-D7 (restart markers) are skipped.
394fn find_jpeg_end(bytes: &[u8], start: usize) -> Option<usize> {
395    let len = bytes.len();
396    let mut pos = start + 2;
397
398    loop {
399        pos = seek_marker(bytes, pos);
400        if pos + 1 >= len {
401            return Some(len);
402        }
403
404        let marker = bytes[pos + 1];
405        match marker {
406            0x00 => pos += 2,
407            0xD9 => return Some(pos + 2),
408            0xD8 => return Some(pos),
409            0xD0..=0xD7 => pos += 2,
410            0xDA => match scan_sos_entropy(bytes, pos) {
411                Some(next) => pos = next,
412                None => return Some(len),
413            },
414            0x01 => pos += 2,
415            _ => match skip_or_accept(bytes, pos) {
416                Some(next) => pos = next,
417                None => return Some(len),
418            },
419        }
420    }
421}
422
423/// Advance past non-marker bytes and padding 0xFF fills, returning the
424/// position of the next real marker prefix.
425fn seek_marker(bytes: &[u8], mut pos: usize) -> usize {
426    let len = bytes.len();
427    while pos < len && bytes[pos] != 0xFF {
428        pos += 1;
429    }
430    while pos + 1 < len && bytes[pos + 1] == 0xFF {
431        pos += 1;
432    }
433    pos
434}
435
436/// Read a SOS header, then scan through entropy-coded data until the next
437/// real marker. Returns the position of that marker, or None on truncation.
438fn scan_sos_entropy(bytes: &[u8], pos: usize) -> Option<usize> {
439    let len = bytes.len();
440    if pos + 3 >= len {
441        return None;
442    }
443    let seg_len = u16::from_be_bytes([bytes[pos + 2], bytes[pos + 3]]) as usize;
444    if seg_len < 2 {
445        return None;
446    }
447    let mut p = pos + 2 + seg_len;
448    while p + 1 < len {
449        if bytes[p] == 0xFF {
450            let next = bytes[p + 1];
451            if next == 0x00 || (0xD0..=0xD7).contains(&next) {
452                p += 2;
453                continue;
454            }
455            return Some(p);
456        }
457        p += 1;
458    }
459    None
460}
461
462/// Skip a variable-length JPEG segment, or return None when the segment is
463/// malformed (signals the caller to accept the whole remaining buffer).
464fn skip_or_accept(bytes: &[u8], pos: usize) -> Option<usize> {
465    let len = bytes.len();
466    if pos + 3 >= len {
467        return None;
468    }
469    let seg_len = u16::from_be_bytes([bytes[pos + 2], bytes[pos + 3]]) as usize;
470    if seg_len < 2 || pos + 2 + seg_len > len {
471        return None;
472    }
473    Some(pos + 2 + seg_len)
474}
475
476/// Read EXIF orientation (tag 0x0112) from JPEG bytes. Returns 1..=8 or None.
477/// Hand-rolled because `Pixbuf::apply_embedded_orientation` silently drops
478/// EXIF on some RAW-embedded JPEGs (truncated/oversize APP1 segments).
479fn read_jpeg_exif_orientation(bytes: &[u8]) -> Option<u8> {
480    if !has_jpeg_soi(bytes) {
481        return None;
482    }
483    let mut i = 2;
484    while i + 4 <= bytes.len() {
485        if bytes[i] != 0xFF {
486            return None;
487        }
488        let marker = bytes[i + 1];
489        if is_standalone_jpeg_marker(marker) {
490            i += 2;
491            continue;
492        }
493        let (seg, next) = jpeg_segment(bytes, i)?;
494        if marker == 0xE1 && is_exif_segment(seg) {
495            return parse_tiff_orientation(&seg[6..]);
496        }
497        i = next;
498    }
499    None
500}
501
502fn has_jpeg_soi(bytes: &[u8]) -> bool {
503    bytes.len() >= 4 && bytes[0] == 0xFF && bytes[1] == 0xD8
504}
505
506fn is_standalone_jpeg_marker(marker: u8) -> bool {
507    marker == 0xD8 || marker == 0xD9 || marker == 0x01 || (0xD0..=0xD7).contains(&marker)
508}
509
510fn jpeg_segment(bytes: &[u8], i: usize) -> Option<(&[u8], usize)> {
511    let seg_len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
512    if seg_len < 2 || i + 2 + seg_len > bytes.len() {
513        return None;
514    }
515    Some((&bytes[i + 4..i + 2 + seg_len], i + 2 + seg_len))
516}
517
518fn is_exif_segment(seg: &[u8]) -> bool {
519    seg.len() >= 6 && &seg[..6] == b"Exif\0\0"
520}
521
522/// Parse TIFF IFD0 starting at the TIFF header, return orientation if present.
523fn parse_tiff_orientation(tiff: &[u8]) -> Option<u8> {
524    if tiff.len() < 8 {
525        return None;
526    }
527    let endian = TiffEndian::from_header(tiff)?;
528    if endian.u16(&tiff[2..4]) != 0x002A {
529        return None;
530    }
531    let ifd0 = endian.u32(&tiff[4..8]) as usize;
532    if ifd0 + 2 > tiff.len() {
533        return None;
534    }
535    let count = endian.u16(&tiff[ifd0..ifd0 + 2]) as usize;
536    for n in 0..count {
537        let off = ifd0 + 2 + n * 12;
538        let entry = tiff.get(off..off + 12)?;
539        if endian.u16(&entry[..2]) == 0x0112 {
540            let v = endian.u16(&entry[8..10]) as u8;
541            return (1..=8).contains(&v).then_some(v);
542        }
543    }
544    None
545}
546
547#[derive(Clone, Copy)]
548enum TiffEndian {
549    Little,
550    Big,
551}
552
553impl TiffEndian {
554    fn from_header(tiff: &[u8]) -> Option<Self> {
555        match &tiff[..2] {
556            b"II" => Some(Self::Little),
557            b"MM" => Some(Self::Big),
558            _ => None,
559        }
560    }
561
562    fn u16(self, p: &[u8]) -> u16 {
563        match self {
564            Self::Little => u16::from_le_bytes([p[0], p[1]]),
565            Self::Big => u16::from_be_bytes([p[0], p[1]]),
566        }
567    }
568
569    fn u32(self, p: &[u8]) -> u32 {
570        match self {
571            Self::Little => u32::from_le_bytes([p[0], p[1], p[2], p[3]]),
572            Self::Big => u32::from_be_bytes([p[0], p[1], p[2], p[3]]),
573        }
574    }
575}
576
577/// Apply EXIF orientation (1..=8) to a Pixbuf via rotate + optional flip.
578fn apply_exif_orientation(pixbuf: &gtk::gdk_pixbuf::Pixbuf, orient: u8) -> gtk::gdk_pixbuf::Pixbuf {
579    use gtk::gdk_pixbuf::PixbufRotation;
580    let rotated = match orient {
581        3 | 4 => pixbuf.rotate_simple(PixbufRotation::Upsidedown),
582        5 | 8 => pixbuf.rotate_simple(PixbufRotation::Counterclockwise),
583        6 | 7 => pixbuf.rotate_simple(PixbufRotation::Clockwise),
584        _ => Some(pixbuf.clone()),
585    }
586    .unwrap_or_else(|| pixbuf.clone());
587    match orient {
588        2 | 4 | 5 | 7 => rotated.flip(true).unwrap_or(rotated),
589        _ => rotated,
590    }
591}
592
593/// Check whether a thumbnail has already been rotated to display orientation
594/// by the camera firmware. Compares the thumbnail's aspect ratio to the raw
595/// sensor's: if a 90-degree flip is needed (flip=5 or flip=6) but the
596/// thumbnail is already in the rotated aspect (portrait when sensor is
597/// landscape, or vice versa), the thumbnail is pre-rotated.
598fn is_thumbnail_prerotated(
599    thumb_w: i32,
600    thumb_h: i32,
601    sensor_w: u32,
602    sensor_h: u32,
603    flip: std::ffi::c_int,
604) -> bool {
605    // Only relevant for 90-degree rotations.
606    if flip != 5 && flip != 6 {
607        return false;
608    }
609    let sensor_landscape = sensor_w >= sensor_h;
610    let thumb_landscape = thumb_w >= thumb_h;
611    // After a 90-degree rotation the aspect flips. If the thumbnail already
612    // has the flipped aspect (portrait when sensor is landscape), the camera
613    // stored it pre-rotated.
614    sensor_landscape != thumb_landscape
615}
616
617/// Decode JPEG bytes to a texture, applying EXIF orientation or libraw flip.
618///
619/// The `flip` and `sensor_dims` parameters come from the RAW container (libraw).
620/// When the decoded JPEG's aspect ratio already matches the rotated sensor
621/// orientation, the container flip is skipped (the camera pre-rotated the
622/// preview). When the JPEG carries its own EXIF orientation > 1, that value
623/// is trusted unconditionally.
624fn jpeg_bytes_to_oriented_texture(
625    bytes: &[u8],
626    flip: i32,
627    sensor_dims: (u32, u32),
628) -> Option<gdk4::Texture> {
629    let stream = gtk::gio::MemoryInputStream::from_bytes(&glib::Bytes::from(bytes));
630    let raw_pixbuf =
631        gtk::gdk_pixbuf::Pixbuf::from_stream(&stream, gtk::gio::Cancellable::NONE).ok()?;
632    let exif_orient = read_jpeg_exif_orientation(bytes);
633    let oriented = match exif_orient {
634        // Embedded JPEG carries a meaningful rotation -- trust it.
635        Some(o) if o > 1 => apply_exif_orientation(&raw_pixbuf, o),
636        // EXIF says identity (1) or no EXIF tag at all:
637        // apply the container flip only if the thumbnail is NOT pre-rotated.
638        _ => {
639            if is_thumbnail_prerotated(
640                raw_pixbuf.width(),
641                raw_pixbuf.height(),
642                sensor_dims.0,
643                sensor_dims.1,
644                flip,
645            ) {
646                raw_pixbuf
647            } else {
648                apply_libraw_flip(&raw_pixbuf, flip)
649            }
650        }
651    };
652    pixbuf_to_texture(&oriented)
653}
654
655fn extract_primary_embedded_jpeg(
656    path: &std::path::Path,
657    flip: i32,
658    sensor_dims: (u32, u32),
659) -> Option<gdk4::Texture> {
660    if let Some(scan) = extract_largest_embedded_jpeg(path) {
661        let scan_len = scan.len();
662        if let Some(texture) = jpeg_bytes_to_oriented_texture(&scan, flip, sensor_dims) {
663            log::debug!(
664                "Extracted embedded JPEG preview ({} bytes via SOI-scan, flip={}) from {}",
665                scan_len,
666                flip,
667                path.display()
668            );
669            return Some(texture);
670        }
671        log::debug!(
672            "SOI-scanned JPEG ({} bytes) failed to decode for {}; falling back to libraw",
673            scan_len,
674            path.display()
675        );
676    }
677    None
678}
679
680/// Embedded RAW preview: SOI-scan for the largest JPEG (primary), fall back
681/// to libraw's bitmap/JPEG thumbnail for files that store the preview as
682/// TIFF strips (Samsung DNG, OnePlus DNG, etc.). Full demosaic path untouched.
683fn extract_libraw_thumb(path: &std::path::Path) -> Option<gdk4::Texture> {
684    use std::os::unix::ffi::OsStrExt;
685    let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
686    // SAFETY: Interfacing with the external LibRaw C API via FFI bindings.
687    // We check that the context is initialized successfully (non-null),
688    // and wrap the raw pointers in custom RAII guards (LibrawHandle, MemImage)
689    // to guarantee that resources are cleaned up correctly when they go out of scope.
690    unsafe {
691        let lr = libraw_sys::libraw_init(0);
692        if lr.is_null() {
693            return None;
694        }
695        let _guard = LibrawHandle(lr);
696        if libraw_sys::libraw_open_file(lr, c_path.as_ptr()) != 0 {
697            return None;
698        }
699
700        let flip = (*lr).sizes.flip;
701        let sensor_dims = ((*lr).sizes.width as u32, (*lr).sizes.height as u32);
702
703        if let Some(texture) = extract_primary_embedded_jpeg(path, flip, sensor_dims) {
704            return Some(texture);
705        }
706
707        unpack_libraw_fallback_thumb(lr, flip, sensor_dims, path)
708    }
709}
710
711/// Unpack and decode the libraw fallback thumbnail (JPEG or bitmap).
712unsafe fn unpack_libraw_fallback_thumb(
713    lr: *mut libraw_sys::libraw_data_t,
714    flip: i32,
715    sensor_dims: (u32, u32),
716    path: &std::path::Path,
717) -> Option<gdk4::Texture> {
718    unsafe {
719        if libraw_sys::libraw_unpack_thumb(lr) != 0 {
720            log::debug!("No embedded thumbnail in {}", path.display());
721            return None;
722        }
723        let mut errcode = 0i32;
724        let img = libraw_sys::libraw_dcraw_make_mem_thumb(lr, &mut errcode);
725        if img.is_null() || errcode != 0 {
726            log::debug!(
727                "libraw_dcraw_make_mem_thumb failed ({}) for {}",
728                errcode,
729                path.display()
730            );
731            return None;
732        }
733        let _img_guard = MemImage(img);
734        let data_size = (*img).data_size as usize;
735        // SAFETY: The image buffer is owned by the `img` memory structure, and its layout is
736        // guaranteed to have at least `data_size` bytes. The slice does not outlive its owner
737        // (as it is dropped prior to `_img_guard` and its contents are immediately copied).
738        let bytes = std::slice::from_raw_parts((*img).data.as_ptr(), data_size);
739
740        if (*img).type_ == libraw_sys::LibRaw_image_formats_LIBRAW_IMAGE_JPEG {
741            decode_libraw_jpeg_thumb(bytes, flip, sensor_dims, data_size, path)
742        } else {
743            decode_libraw_bitmap_thumb(bytes, &*img, flip, sensor_dims, path)
744        }
745    }
746}
747
748/// Decode a JPEG thumbnail extracted by libraw.
749fn decode_libraw_jpeg_thumb(
750    bytes: &[u8],
751    flip: i32,
752    sensor_dims: (u32, u32),
753    data_size: usize,
754    path: &std::path::Path,
755) -> Option<gdk4::Texture> {
756    let texture = jpeg_bytes_to_oriented_texture(bytes, flip, sensor_dims);
757    if texture.is_some() {
758        log::debug!(
759            "Extracted embedded JPEG preview ({} bytes via libraw, flip={}) from {}",
760            data_size,
761            flip,
762            path.display()
763        );
764    } else {
765        log::debug!(
766            "libraw JPEG thumb failed to decode via pixbuf for {}",
767            path.display()
768        );
769    }
770    texture
771}
772
773/// Decode a bitmap (uncompressed RGB) thumbnail extracted by libraw.
774fn decode_libraw_bitmap_thumb(
775    bytes: &[u8],
776    img: &libraw_sys::libraw_processed_image_t,
777    flip: i32,
778    sensor_dims: (u32, u32),
779    path: &std::path::Path,
780) -> Option<gdk4::Texture> {
781    let width = img.width as i32;
782    let height = img.height as i32;
783    let colors = img.colors as i32;
784    if colors != 3 {
785        log::debug!(
786            "Embedded thumbnail has {} channels for {} -- skipping",
787            colors,
788            path.display()
789        );
790        return None;
791    }
792    let row_stride = width.checked_mul(colors)?;
793    let pixbuf = gtk::gdk_pixbuf::Pixbuf::from_mut_slice(
794        bytes.to_vec(),
795        gtk::gdk_pixbuf::Colorspace::Rgb,
796        false,
797        8,
798        width,
799        height,
800        row_stride,
801    );
802    let prerotated = is_thumbnail_prerotated(width, height, sensor_dims.0, sensor_dims.1, flip);
803    let oriented = if prerotated {
804        pixbuf
805    } else {
806        apply_libraw_flip(&pixbuf, flip)
807    };
808    let texture = pixbuf_to_texture(&oriented);
809    if texture.is_some() {
810        log::debug!(
811            "Extracted embedded bitmap preview (flip={}, prerotated={}) from {}",
812            flip,
813            prerotated,
814            path.display()
815        );
816    }
817    texture
818}
819
820/// Convert a `Pixbuf` to a `gdk4::Texture` (same logic as `decode_pixbuf_texture`).
821fn pixbuf_to_texture(pixbuf: &gtk::gdk_pixbuf::Pixbuf) -> Option<gdk4::Texture> {
822    let format = if pixbuf.has_alpha() {
823        gdk4::MemoryFormat::R8g8b8a8
824    } else {
825        gdk4::MemoryFormat::R8g8b8
826    };
827    let bytes = pixbuf.read_pixel_bytes();
828    let texture = gdk4::MemoryTexture::new(
829        pixbuf.width(),
830        pixbuf.height(),
831        format,
832        &bytes,
833        pixbuf.rowstride() as usize,
834    );
835    Some(texture.upcast::<gdk4::Texture>())
836}
837
838/// Apply libraw `flip` orientation to a `Pixbuf`.
839///
840/// libraw flip values:
841///   0 — no rotation (identity)
842///   3 — 180 degrees
843///   5 — 90 CCW (270 CW)
844///   6 — 90 CW
845///
846/// Other values (rare) are left unrotated.
847fn apply_libraw_flip(
848    pixbuf: &gtk::gdk_pixbuf::Pixbuf,
849    flip: std::ffi::c_int,
850) -> gtk::gdk_pixbuf::Pixbuf {
851    use gtk::gdk_pixbuf::PixbufRotation;
852    match flip {
853        3 => pixbuf
854            .rotate_simple(PixbufRotation::Upsidedown)
855            .unwrap_or_else(|| pixbuf.clone()),
856        5 => pixbuf
857            .rotate_simple(PixbufRotation::Counterclockwise)
858            .unwrap_or_else(|| pixbuf.clone()),
859        6 => pixbuf
860            .rotate_simple(PixbufRotation::Clockwise)
861            .unwrap_or_else(|| pixbuf.clone()),
862        _ => pixbuf.clone(),
863    }
864}
865
866/// Decode a RAW via vendored libraw with camera white balance + AHD demosaic
867/// at full resolution. Output goes through a disk cache keyed by (path, mtime,
868/// size) so a re-open hits the cache instead of re-demosaicing.
869fn decode_libraw_texture(path: &std::path::Path) -> Option<gdk4::Texture> {
870    let cache_enabled = RAW_CACHE_ENABLED.load(std::sync::atomic::Ordering::Relaxed);
871    if cache_enabled && let Some(texture) = read_raw_decode_cache(path) {
872        return Some(texture);
873    }
874    let texture = decode_libraw_uncached(path);
875    if let Some(ref tex) = texture
876        && cache_enabled
877    {
878        write_raw_decode_cache_async(path, tex);
879    }
880    texture
881}
882
883fn decode_libraw_uncached(path: &std::path::Path) -> Option<gdk4::Texture> {
884    use std::os::unix::ffi::OsStrExt;
885    let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
886    unsafe {
887        let lr = init_libraw(path)?;
888        let _guard = LibrawHandle(lr);
889        configure_libraw_full_decode(lr);
890        if !run_libraw_full_decode(lr, c_path.as_ptr(), path) {
891            return None;
892        }
893        let img = make_libraw_image(lr, path)?;
894        let _img_guard = MemImage(img);
895        libraw_image_to_texture(img, path)
896    }
897}
898
899struct LibrawHandle(*mut libraw_sys::libraw_data_t);
900
901impl Drop for LibrawHandle {
902    fn drop(&mut self) {
903        unsafe { libraw_sys::libraw_close(self.0) };
904    }
905}
906
907struct MemImage(*mut libraw_sys::libraw_processed_image_t);
908
909impl Drop for MemImage {
910    fn drop(&mut self) {
911        unsafe { libraw_sys::libraw_dcraw_clear_mem(self.0) };
912    }
913}
914
915unsafe fn init_libraw(path: &std::path::Path) -> Option<*mut libraw_sys::libraw_data_t> {
916    let lr = unsafe { libraw_sys::libraw_init(0) };
917    if lr.is_null() {
918        log::debug!("libraw_init returned null for {}", path.display());
919        None
920    } else {
921        Some(lr)
922    }
923}
924
925unsafe fn configure_libraw_full_decode(lr: *mut libraw_sys::libraw_data_t) {
926    unsafe {
927        (*lr).params.use_camera_wb = 1;
928        (*lr).params.output_bps = 8;
929        (*lr).params.output_color = 1;
930        (*lr).params.user_qual = 3;
931    }
932}
933
934unsafe fn run_libraw_full_decode(
935    lr: *mut libraw_sys::libraw_data_t,
936    c_path: *const std::ffi::c_char,
937    path: &std::path::Path,
938) -> bool {
939    unsafe {
940        libraw_step(lr, path, "libraw_open_file", || {
941            libraw_sys::libraw_open_file(lr, c_path)
942        }) && libraw_step(lr, path, "libraw_unpack", || libraw_sys::libraw_unpack(lr))
943            && libraw_step(lr, path, "libraw_dcraw_process", || {
944                libraw_sys::libraw_dcraw_process(lr)
945            })
946    }
947}
948
949unsafe fn libraw_step(
950    _lr: *mut libraw_sys::libraw_data_t,
951    path: &std::path::Path,
952    label: &str,
953    f: impl FnOnce() -> i32,
954) -> bool {
955    let rc = f();
956    if rc != 0 {
957        log::debug!("{} failed ({}) for {}", label, rc, path.display());
958        false
959    } else {
960        true
961    }
962}
963
964unsafe fn make_libraw_image(
965    lr: *mut libraw_sys::libraw_data_t,
966    path: &std::path::Path,
967) -> Option<*mut libraw_sys::libraw_processed_image_t> {
968    let mut errcode = 0i32;
969    let img = unsafe { libraw_sys::libraw_dcraw_make_mem_image(lr, &mut errcode) };
970    if img.is_null() || errcode != 0 {
971        log::debug!(
972            "libraw_dcraw_make_mem_image failed ({}) for {}",
973            errcode,
974            path.display()
975        );
976        None
977    } else {
978        Some(img)
979    }
980}
981
982unsafe fn libraw_image_to_texture(
983    img: *mut libraw_sys::libraw_processed_image_t,
984    path: &std::path::Path,
985) -> Option<gdk4::Texture> {
986    let width = unsafe { (*img).width as u32 };
987    let colors = unsafe { (*img).colors };
988    if colors != 3 {
989        log::debug!(
990            "libraw produced {}-channel output for {} -- skipping",
991            colors,
992            path.display()
993        );
994        return None;
995    }
996    let height = unsafe { (*img).height as u32 };
997    let data_size = unsafe { (*img).data_size as usize };
998    let pixels = unsafe { std::slice::from_raw_parts((*img).data.as_ptr(), data_size) }.to_vec();
999    memory_texture(
1000        width,
1001        height,
1002        gdk4::MemoryFormat::R8g8b8,
1003        pixels,
1004        usize::try_from(width).ok()?.checked_mul(3)?,
1005    )
1006}
1007
1008fn write_raw_decode_cache_async(path: &std::path::Path, texture: &gdk4::Texture) {
1009    let path_clone = path.to_path_buf();
1010    let tex_clone = texture.clone();
1011    if tokio::runtime::Handle::try_current().is_ok() {
1012        tokio::task::spawn_blocking(move || {
1013            write_raw_decode_cache(&path_clone, &tex_clone);
1014        });
1015    } else {
1016        std::thread::spawn(move || {
1017            write_raw_decode_cache(&path_clone, &tex_clone);
1018        });
1019    }
1020}
1021
1022fn decode_heif_texture(path: &std::path::Path) -> Option<gdk4::Texture> {
1023    use libheif_rs::{ColorSpace, HeifContext, LibHeif, RgbChroma};
1024
1025    let libheif = LibHeif::new();
1026    let encoded = std::fs::read(path).ok()?;
1027    let context = HeifContext::read_from_bytes(&encoded).ok()?;
1028    let handle = context.primary_image_handle().ok()?;
1029    let image = libheif
1030        .decode(&handle, ColorSpace::Rgb(RgbChroma::Rgba), None)
1031        .ok()?;
1032    let plane = image.planes().interleaved?;
1033    memory_texture(
1034        plane.width,
1035        plane.height,
1036        gdk4::MemoryFormat::R8g8b8a8,
1037        plane.data.to_vec(),
1038        plane.stride,
1039    )
1040}
1041
1042fn decode_image_texture(path: &std::path::Path) -> Option<gdk4::Texture> {
1043    let reader = match image::ImageReader::open(path) {
1044        Ok(reader) => reader,
1045        Err(err) => {
1046            log::debug!("image-rs open failed for {}: {}", path.display(), err);
1047            return None;
1048        }
1049    };
1050    let reader = match reader.with_guessed_format() {
1051        Ok(reader) => reader,
1052        Err(err) => {
1053            log::debug!(
1054                "image-rs format probe failed for {}: {}",
1055                path.display(),
1056                err
1057            );
1058            return None;
1059        }
1060    };
1061    match reader.decode() {
1062        Ok(image) => dynamic_image_texture(image),
1063        Err(err) => {
1064            log::debug!("image-rs decode failed for {}: {}", path.display(), err);
1065            None
1066        }
1067    }
1068}
1069
1070fn dynamic_image_texture(image: image::DynamicImage) -> Option<gdk4::Texture> {
1071    let rgba = image.into_rgba8();
1072    let (width, height) = rgba.dimensions();
1073    memory_texture(
1074        width,
1075        height,
1076        gdk4::MemoryFormat::R8g8b8a8,
1077        rgba.into_raw(),
1078        usize::try_from(width).ok()?.checked_mul(4)?,
1079    )
1080}
1081
1082fn decode_pixbuf_texture(path: &std::path::Path) -> Option<gdk4::Texture> {
1083    let raw = match gtk::gdk_pixbuf::Pixbuf::from_file(path) {
1084        Ok(pixbuf) => pixbuf,
1085        Err(err) => {
1086            log::debug!("pixbuf decode failed for {}: {}", path.display(), err);
1087            return None;
1088        }
1089    };
1090    let pixbuf = raw.apply_embedded_orientation().unwrap_or(raw);
1091    let format = if pixbuf.has_alpha() {
1092        gdk4::MemoryFormat::R8g8b8a8
1093    } else {
1094        gdk4::MemoryFormat::R8g8b8
1095    };
1096    let bytes = pixbuf.read_pixel_bytes();
1097    let texture = gdk4::MemoryTexture::new(
1098        pixbuf.width(),
1099        pixbuf.height(),
1100        format,
1101        &bytes,
1102        pixbuf.rowstride() as usize,
1103    );
1104    Some(texture.upcast::<gdk4::Texture>())
1105}
1106
1107fn decode_jpeg_texture(path: &std::path::Path) -> Option<gdk4::Texture> {
1108    let bytes = match std::fs::read(path) {
1109        Ok(bytes) => bytes,
1110        Err(err) => {
1111            log::warn!("JPEG read failed for {}: {}", path.display(), err);
1112            return None;
1113        }
1114    };
1115    let orient = read_jpeg_exif_orientation(&bytes);
1116    match turbojpeg::decompress(&bytes, turbojpeg::PixelFormat::RGB) {
1117        Ok(image) => {
1118            let width: i32 = image.width.try_into().ok()?;
1119            let height: i32 = image.height.try_into().ok()?;
1120            let raw_pixbuf = gtk::gdk_pixbuf::Pixbuf::from_mut_slice(
1121                image.pixels,
1122                gtk::gdk_pixbuf::Colorspace::Rgb,
1123                false,
1124                8,
1125                width,
1126                height,
1127                image.pitch as i32,
1128            );
1129            let oriented = match orient {
1130                Some(o) if o != 1 => apply_exif_orientation(&raw_pixbuf, o),
1131                _ => raw_pixbuf,
1132            };
1133            pixbuf_to_texture(&oriented)
1134        }
1135        Err(err) => {
1136            log::debug!("turbojpeg decode failed for {}: {}", path.display(), err);
1137            None
1138        }
1139    }
1140}
1141
1142fn decode_webp_texture(path: &std::path::Path) -> Option<gdk4::Texture> {
1143    let bytes = match std::fs::read(path) {
1144        Ok(bytes) => bytes,
1145        Err(err) => {
1146            log::warn!("WebP read failed for {}: {}", path.display(), err);
1147            return None;
1148        }
1149    };
1150    let decoder = webp::Decoder::new(&bytes);
1151    let image = match decoder.decode() {
1152        Some(image) => image,
1153        None => {
1154            log::debug!("libwebp decode failed for {}", path.display());
1155            return None;
1156        }
1157    };
1158    let format = if image.is_alpha() {
1159        gdk4::MemoryFormat::R8g8b8a8
1160    } else {
1161        gdk4::MemoryFormat::R8g8b8
1162    };
1163    let bpp = if image.is_alpha() { 4 } else { 3 };
1164    let width = image.width();
1165    let height = image.height();
1166    memory_texture(
1167        width,
1168        height,
1169        format,
1170        image.to_vec(),
1171        usize::try_from(width).ok()?.checked_mul(bpp)?,
1172    )
1173}
1174
1175fn decode_psd_texture(path: &std::path::Path) -> Option<gdk4::Texture> {
1176    let bytes = std::fs::read(path)
1177        .map_err(|err| log::warn!("PSD read failed for {}: {}", path.display(), err))
1178        .ok()?;
1179    let psd = psd::Psd::from_bytes(&bytes)
1180        .map_err(|err| log::warn!("PSD parse failed for {}: {:?}", path.display(), err))
1181        .ok()?;
1182    let width = psd.width();
1183    let height = psd.height();
1184    memory_texture(
1185        width,
1186        height,
1187        gdk4::MemoryFormat::R8g8b8a8,
1188        psd.rgba(),
1189        usize::try_from(width).ok()?.checked_mul(4)?,
1190    )
1191}
1192
1193#[cfg(test)]
1194mod texture_decoder_tests {
1195    use super::*;
1196
1197    fn fixture(name: &str) -> std::path::PathBuf {
1198        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1199            .join("tests/fixtures")
1200            .join(name)
1201    }
1202
1203    fn assert_fixture_texture(name: &str, dimensions: (i32, i32)) {
1204        let texture = load_texture_blocking(&fixture(name))
1205            .unwrap_or_else(|| panic!("fixture `{name}` should decode into a texture"));
1206        assert_eq!(
1207            (texture.width(), texture.height()),
1208            dimensions,
1209            "fixture `{name}` decoded with unexpected dimensions"
1210        );
1211    }
1212
1213    #[test]
1214    fn routes_special_lightbox_formats_before_loader_fallbacks() {
1215        for ext in crate::media_kinds::RAW_EXTENSIONS.iter() {
1216            assert_eq!(
1217                texture_decoder_for_path(std::path::Path::new(&format!("camera.{ext}"))),
1218                TextureDecoder::Raw,
1219                ".{ext} should run through the RAW pipeline"
1220            );
1221        }
1222
1223        for ext in ["avif", "heic", "heif", "hif"] {
1224            assert_eq!(
1225                texture_decoder_for_path(std::path::Path::new(&format!("phone.{ext}"))),
1226                TextureDecoder::Heif
1227            );
1228        }
1229        assert_eq!(
1230            texture_decoder_for_path(std::path::Path::new("wide.JXL")),
1231            TextureDecoder::JpegXl
1232        );
1233    }
1234
1235    #[test]
1236    fn every_supported_image_extension_has_a_decoder_route() {
1237        for ext in crate::media_kinds::SUPPORTED.iter() {
1238            let Some(mime) = crate::media_kinds::mime_for(ext) else {
1239                continue;
1240            };
1241            if crate::media_kinds::asset_kind(mime) == crate::media_kinds::AssetKind::Image {
1242                let route =
1243                    texture_decoder_for_path(std::path::Path::new(&format!("fixture.{ext}")));
1244                assert!(
1245                    matches!(
1246                        route,
1247                        TextureDecoder::Raw
1248                            | TextureDecoder::Heif
1249                            | TextureDecoder::JpegXl
1250                            | TextureDecoder::Svg
1251                            | TextureDecoder::Jpeg
1252                            | TextureDecoder::Webp
1253                            | TextureDecoder::Jpeg2k
1254                            | TextureDecoder::Psd
1255                            | TextureDecoder::Pixbuf
1256                            | TextureDecoder::ImageFallback
1257                    ),
1258                    "image extension `.{ext}` has no lightbox decoder route"
1259                );
1260            }
1261        }
1262    }
1263
1264    #[test]
1265    fn fixture_standard_formats_decode_to_textures() {
1266        for name in ["sample.jpg", "sample.webp"] {
1267            assert_fixture_texture(name, (16, 12));
1268        }
1269    }
1270
1271    #[test]
1272    fn fixture_svg_decodes_to_texture() {
1273        assert_fixture_texture("sample.svg", (16, 12));
1274    }
1275
1276    #[test]
1277    fn fixture_heif_family_decodes_to_textures() {
1278        assert_fixture_texture("sample.avif", (16, 12));
1279        assert_fixture_texture("sample.heic", (64, 64));
1280    }
1281
1282    #[test]
1283    fn fixture_jpegxl_decodes_to_texture() {
1284        assert_fixture_texture("sample.jxl", (16, 12));
1285    }
1286
1287    #[test]
1288    fn fixture_dng_decode_pipeline_does_not_panic() {
1289        // Synthetic DNG has no embedded preview; libraw may reject it.
1290        // Verifies the pipeline completes without panicking.
1291        RAW_FULL_DECODE.store(true, std::sync::atomic::Ordering::Relaxed);
1292        let _result = load_texture_blocking(&fixture("sample.dng"));
1293        RAW_FULL_DECODE.store(false, std::sync::atomic::Ordering::Relaxed);
1294    }
1295
1296    #[test]
1297    fn largest_embedded_jpeg_picks_biggest_soi_payload() {
1298        // Build a synthetic buffer with two JPEG SOI/EOI blocks: a small one
1299        // (below the 4KB minimum — should be skipped) and a large one.
1300        // Both use valid JPEG marker structure with declared segment lengths.
1301
1302        /// Build a minimal valid JPEG with an APP marker segment of the given
1303        /// filler size, terminated by EOI.
1304        fn build_jpeg(app_marker: u8, filler_size: usize) -> Vec<u8> {
1305            // APP segment: FF <marker> <len_hi> <len_lo> <filler...>
1306            let seg_len = (filler_size + 2) as u16; // +2 for the length field itself
1307            let mut v = vec![0xFFu8, 0xD8, 0xFF, app_marker];
1308            v.extend_from_slice(&seg_len.to_be_bytes());
1309            v.extend_from_slice(&vec![0x42u8; filler_size]);
1310            v.extend_from_slice(&[0xFF, 0xD9]); // EOI
1311            v
1312        }
1313
1314        let small = build_jpeg(0xE0, 30); // ~36 bytes — well below MIN_EMBEDDED_JPEG_SIZE
1315        let large = build_jpeg(0xE1, 8192); // ~8200 bytes — above threshold
1316
1317        let mut buf = Vec::new();
1318        buf.extend_from_slice(&[0x00; 16]);
1319        buf.extend_from_slice(&small);
1320        buf.extend_from_slice(&[0x00; 64]);
1321        buf.extend_from_slice(&large);
1322        buf.extend_from_slice(&[0x00; 16]);
1323
1324        use std::io::Write;
1325        let mut tmp = tempfile::NamedTempFile::new().unwrap();
1326        tmp.write_all(&buf).unwrap();
1327        let got = extract_largest_embedded_jpeg(tmp.path()).expect("scanner should find a JPEG");
1328
1329        assert_eq!(got.len(), large.len(), "should return the larger payload");
1330        assert_eq!(&got[..4], &[0xFF, 0xD8, 0xFF, 0xE1]);
1331        assert_eq!(&got[got.len() - 2..], &[0xFF, 0xD9]);
1332    }
1333
1334    #[test]
1335    fn largest_embedded_jpeg_skips_byte_stuffed_ff00() {
1336        // FF 00 inside entropy data must NOT be mistaken for an SOI (which is
1337        // FF D8 FF xx with xx != 00). A file with only an FF 00 sequence and
1338        // no real SOI should return None.
1339        let buf: Vec<u8> = vec![0x00, 0xFF, 0x00, 0xFF, 0xD8, 0xFF, 0x00, 0x42];
1340        use std::io::Write;
1341        let mut tmp = tempfile::NamedTempFile::new().unwrap();
1342        tmp.write_all(&buf).unwrap();
1343        let got = extract_largest_embedded_jpeg(tmp.path());
1344        assert!(got.is_none(), "byte-stuffed FF 00 must not match as SOI");
1345    }
1346
1347    #[test]
1348    fn largest_embedded_jpeg_accepts_implicit_eoi_at_eof() {
1349        // Some DNG/CR3/RW2 files omit the trailing FF D9 — the scanner should
1350        // accept the payload up to EOF when no explicit EOI is found.
1351        let seg_len = (8192 + 2) as u16;
1352        let mut jpeg = vec![0xFFu8, 0xD8, 0xFF, 0xE0];
1353        jpeg.extend_from_slice(&seg_len.to_be_bytes());
1354        jpeg.extend_from_slice(&vec![0x42u8; 8192]);
1355        // NO trailing FF D9 — implicit end at EOF.
1356
1357        let mut buf = Vec::new();
1358        buf.extend_from_slice(&[0x00; 16]);
1359        buf.extend_from_slice(&jpeg);
1360
1361        use std::io::Write;
1362        let mut tmp = tempfile::NamedTempFile::new().unwrap();
1363        tmp.write_all(&buf).unwrap();
1364        let got =
1365            extract_largest_embedded_jpeg(tmp.path()).expect("scanner should accept implicit EOI");
1366
1367        // The payload should span from the SOI to the end of the file.
1368        assert_eq!(got.len(), buf.len() - 16, "should capture SOI to EOF");
1369        assert_eq!(&got[..4], &[0xFF, 0xD8, 0xFF, 0xE0]);
1370    }
1371
1372    #[test]
1373    fn jpeg_exif_orientation_parses_tag_0112() {
1374        // Hand-built minimal JPEG: SOI + APP1(Exif/TIFF little-endian, IFD0
1375        // with a single Orientation=6 entry) + EOI. Verifies our parser walks
1376        // segments, finds tag 0x0112, and returns the 16-bit SHORT value.
1377        let mut tiff: Vec<u8> = Vec::new();
1378        tiff.extend_from_slice(b"II"); // little-endian
1379        tiff.extend_from_slice(&0x002A_u16.to_le_bytes());
1380        tiff.extend_from_slice(&8_u32.to_le_bytes()); // IFD0 offset
1381        tiff.extend_from_slice(&1_u16.to_le_bytes()); // one entry
1382        tiff.extend_from_slice(&0x0112_u16.to_le_bytes()); // tag = Orientation
1383        tiff.extend_from_slice(&3_u16.to_le_bytes()); // type = SHORT
1384        tiff.extend_from_slice(&1_u32.to_le_bytes()); // count
1385        tiff.extend_from_slice(&6_u16.to_le_bytes()); // value (low half)
1386        tiff.extend_from_slice(&0_u16.to_le_bytes()); // value (high half)
1387
1388        let mut app1: Vec<u8> = Vec::new();
1389        app1.extend_from_slice(b"Exif\0\0");
1390        app1.extend_from_slice(&tiff);
1391        let app1_len = (app1.len() + 2) as u16;
1392
1393        let mut jpeg: Vec<u8> = vec![0xFF, 0xD8]; // SOI
1394        jpeg.push(0xFF);
1395        jpeg.push(0xE1); // APP1
1396        jpeg.extend_from_slice(&app1_len.to_be_bytes());
1397        jpeg.extend_from_slice(&app1);
1398        jpeg.extend_from_slice(&[0xFF, 0xD9]); // EOI
1399
1400        assert_eq!(read_jpeg_exif_orientation(&jpeg), Some(6));
1401    }
1402
1403    #[test]
1404    fn jpeg_exif_orientation_none_when_missing() {
1405        let jpeg = vec![0xFF, 0xD8, 0xFF, 0xD9];
1406        assert_eq!(read_jpeg_exif_orientation(&jpeg), None);
1407    }
1408
1409    #[test]
1410    fn is_lossless_jpeg_detects_sof3() {
1411        // SOI + SOF3 marker (FF C3) with a 10-byte segment + EOI.
1412        let mut lossless = vec![0xFFu8, 0xD8, 0xFF, 0xC3];
1413        lossless.extend_from_slice(&12_u16.to_be_bytes()); // segment length
1414        lossless.extend_from_slice(&[0x00; 10]); // segment body
1415        lossless.extend_from_slice(&[0xFF, 0xD9]); // EOI
1416        assert!(
1417            is_lossless_jpeg(&lossless, 0, lossless.len()),
1418            "SOF3 should be detected as lossless"
1419        );
1420    }
1421
1422    #[test]
1423    fn is_lossless_jpeg_allows_baseline() {
1424        // SOI + SOF0 (baseline, FF C0) -- not lossless.
1425        let mut baseline = vec![0xFFu8, 0xD8, 0xFF, 0xC0];
1426        baseline.extend_from_slice(&12_u16.to_be_bytes());
1427        baseline.extend_from_slice(&[0x00; 10]);
1428        baseline.extend_from_slice(&[0xFF, 0xD9]);
1429        assert!(
1430            !is_lossless_jpeg(&baseline, 0, baseline.len()),
1431            "SOF0 should NOT be detected as lossless"
1432        );
1433    }
1434
1435    #[test]
1436    fn is_lossless_jpeg_with_app_segments_before_sof() {
1437        // SOI + APP1 (FF E1) + SOF3 (FF C3) -- lossless despite APP prefix.
1438        let mut data = vec![0xFFu8, 0xD8];
1439        // APP1 segment with 20 bytes of filler
1440        data.extend_from_slice(&[0xFF, 0xE1]);
1441        data.extend_from_slice(&22_u16.to_be_bytes());
1442        data.extend_from_slice(&[0x42; 20]);
1443        // SOF3
1444        data.extend_from_slice(&[0xFF, 0xC3]);
1445        data.extend_from_slice(&12_u16.to_be_bytes());
1446        data.extend_from_slice(&[0x00; 10]);
1447        data.extend_from_slice(&[0xFF, 0xD9]);
1448        assert!(is_lossless_jpeg(&data, 0, data.len()));
1449    }
1450
1451    #[test]
1452    fn soi_scanner_skips_lossless_jpeg() {
1453        // Build a file with TWO JPEGs: a large SOF3 (lossless, should be skipped)
1454        // and a smaller SOF0 (baseline, should be selected).
1455
1456        fn build_jpeg_with_sof(sof_marker: u8, filler: usize) -> Vec<u8> {
1457            let mut v = vec![0xFFu8, 0xD8]; // SOI
1458            // SOF segment
1459            v.extend_from_slice(&[0xFF, sof_marker]);
1460            let seg_len = (filler + 2) as u16;
1461            v.extend_from_slice(&seg_len.to_be_bytes());
1462            v.extend_from_slice(&vec![0x42u8; filler]);
1463            v.extend_from_slice(&[0xFF, 0xD9]); // EOI
1464            v
1465        }
1466
1467        let lossless = build_jpeg_with_sof(0xC3, 16000); // 16KB lossless -- larger
1468        let baseline = build_jpeg_with_sof(0xC0, 8000); // 8KB baseline -- smaller
1469
1470        let mut buf = Vec::new();
1471        buf.extend_from_slice(&[0x00; 16]);
1472        buf.extend_from_slice(&lossless);
1473        buf.extend_from_slice(&[0x00; 32]);
1474        buf.extend_from_slice(&baseline);
1475        buf.extend_from_slice(&[0x00; 16]);
1476
1477        use std::io::Write;
1478        let mut tmp = tempfile::NamedTempFile::new().unwrap();
1479        tmp.write_all(&buf).unwrap();
1480        let got = extract_largest_embedded_jpeg(tmp.path())
1481            .expect("scanner should find the baseline JPEG");
1482
1483        // Should get the baseline (SOF0), not the larger lossless (SOF3).
1484        assert_eq!(got.len(), baseline.len());
1485        assert_eq!(&got[..2], &[0xFF, 0xD8]);
1486        assert_eq!(got[3], 0xC0, "should select the SOF0 JPEG, not SOF3");
1487    }
1488
1489    #[test]
1490    fn thumbnail_prerotated_detects_portrait_on_landscape_sensor() {
1491        // Landscape sensor (6000x4000), flip=5 (90-degree), portrait thumb (480x640).
1492        assert!(is_thumbnail_prerotated(480, 640, 6000, 4000, 5));
1493    }
1494
1495    #[test]
1496    fn thumbnail_prerotated_false_for_matching_aspect() {
1497        // Landscape sensor, flip=5, landscape thumb -- not pre-rotated.
1498        assert!(!is_thumbnail_prerotated(640, 480, 6000, 4000, 5));
1499    }
1500
1501    #[test]
1502    fn thumbnail_prerotated_false_for_no_rotation() {
1503        // flip=0 (no rotation) -- never considered pre-rotated.
1504        assert!(!is_thumbnail_prerotated(480, 640, 6000, 4000, 0));
1505    }
1506
1507    #[test]
1508    fn thumbnail_prerotated_false_for_180_rotation() {
1509        // flip=3 (180-degree) -- aspect doesn't change, never pre-rotated.
1510        assert!(!is_thumbnail_prerotated(480, 640, 6000, 4000, 3));
1511    }
1512}