dr-decode exposes four entry points rather than one decode, because callers differ sharply in what they need (ARCH §3.2): culling wants a preview, the grid wants metadata, only develop and export touch sensor data. Fusing them forces a full decode where a header read suffices, which is why Lightroom stalls ~2s per image while culling. Smoke-tested against 1,852 real Canon CR2 files (EOS 6D, ~27MB each): metadata 0.2ms from a 256KB header read, no full decode preview ~250ms 5472x3648, downscaled to 2048 for display jpeg 3.0ms Two findings worth recording: rawler 0.7.2's CR2 decoder implements only full_image; thumbnail_image and preview_image are unimplemented trait defaults returning None. So every rung of the preview ladder resolves to a full-resolution decode at ~250ms — 5x over NFR-P13's 50ms budget. CR2 does carry smaller IFDs, so the fix is our own IFD walk or an upstream contribution. The ladder is written now so that fixing it is a decoder change, not a change to every caller. Recorded in milestone-v0.1 risks. Preview.downscale_to bounds memory: a 5472x3648 RGBA preview is 79.8MB, which exhausts a phone's budget after a handful of images. Box-filtered so downscaled thumbnails do not alias. Also fixed a RefCell double-borrow that panicked on first navigation — `*x.borrow_mut() = *x.borrow() + 1` holds both borrows at once. Verified with 10,000 programmatic navigations. 58 tests passing. Traceability 20.3% (29/143).
345 lines
11 KiB
Rust
345 lines
11 KiB
Rust
//! Slint interface for DarkRoom.
|
||
//!
|
||
//! v0.1 is a viewer: open a folder of RAW files, extract embedded previews,
|
||
//! display them.
|
||
//!
|
||
//! **Read before assuming A1 is proven.** Slint's public API for adopting an
|
||
//! externally created wgpu texture is not wired up here; this build uploads
|
||
//! through `SharedPixelBuffer`, which *is* a CPU round-trip — explicitly the
|
||
//! thing ARCH §6.1 forbids in production. Spike S1 replaces it. Until then A1
|
||
//! is unvalidated.
|
||
|
||
use std::cell::RefCell;
|
||
use std::path::{Path, PathBuf};
|
||
use std::rc::Rc;
|
||
|
||
use anyhow::Result;
|
||
use dr_decode::{Metadata, PreviewSize};
|
||
|
||
slint::include_modules!();
|
||
|
||
/// TRACES: FR-DSP-1 | NFR-RES-1
|
||
/// Longest edge the viewer renders at.
|
||
///
|
||
/// FR-DSP-1: work at the resolution the viewport needs, not the source
|
||
/// resolution. A 5472×3648 preview is 79.8 MB of RGBA; at 2048 it is 11 MB,
|
||
/// which is what keeps a folder browsable within NFR-RES-1's budget.
|
||
const MAX_DISPLAY_DIM: u32 = 2048;
|
||
|
||
/// TRACES: FR-UI-1 | FR-UI-2 | M-16
|
||
/// Width at which the expanded layout appears (FR-UI-1).
|
||
///
|
||
/// Logical pixels, not a device check — a narrow desktop window gets the
|
||
/// compact layout exactly as a tablet in portrait would.
|
||
const EXPANDED_MIN_WIDTH: f32 = 820.0;
|
||
|
||
/// Everything loaded for the currently displayed image.
|
||
struct Loaded {
|
||
image: slint::Image,
|
||
meta: Metadata,
|
||
width: u32,
|
||
height: u32,
|
||
}
|
||
|
||
/// Load and decode one image for display.
|
||
///
|
||
/// Reads the whole file because rawler needs the full container to locate a
|
||
/// preview. The remote path (FR-NC-3) fetches only a byte range, which is why
|
||
/// the decode API takes bytes rather than a reader.
|
||
fn load(path: &Path) -> Result<Loaded, String> {
|
||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||
let meta = dr_decode::metadata(&bytes).unwrap_or_default();
|
||
|
||
let mut preview =
|
||
dr_decode::extract_preview(&bytes, PreviewSize::Screen).map_err(|e| e.to_string())?;
|
||
|
||
// Bound memory before handing pixels to the UI.
|
||
preview.downscale_to(MAX_DISPLAY_DIM);
|
||
|
||
let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(
|
||
&preview.rgba,
|
||
preview.width,
|
||
preview.height,
|
||
);
|
||
|
||
Ok(Loaded {
|
||
image: slint::Image::from_rgba8(buffer),
|
||
meta,
|
||
width: preview.width,
|
||
height: preview.height,
|
||
})
|
||
}
|
||
|
||
/// Collect displayable images from file or directory arguments.
|
||
fn collect(paths: &[PathBuf]) -> Vec<PathBuf> {
|
||
let mut out = Vec::new();
|
||
for p in paths {
|
||
if p.is_dir() {
|
||
let Ok(entries) = std::fs::read_dir(p) else {
|
||
continue;
|
||
};
|
||
let mut found: Vec<PathBuf> = entries
|
||
.flatten()
|
||
.map(|e| e.path())
|
||
.filter(|p| p.is_file() && is_supported(p))
|
||
.collect();
|
||
found.sort();
|
||
out.extend(found);
|
||
} else if p.is_file() && is_supported(p) {
|
||
out.push(p.clone());
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn is_supported(p: &Path) -> bool {
|
||
p.extension()
|
||
.map(|e| e.to_string_lossy().to_ascii_lowercase())
|
||
.and_then(|e| dr_types::Format::from_extension(&e))
|
||
.is_some()
|
||
}
|
||
|
||
/// TRACES: M-13 | M-14
|
||
/// Build and run the viewer.
|
||
pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||
let entries = Rc::new(collect(&paths));
|
||
log::info!("{} image(s) to browse", entries.len());
|
||
|
||
let window = AppWindow::new()?;
|
||
|
||
// Report the GPU even though v0.1 displays through the CPU path: the
|
||
// adapter is what spike S1 exercises, and showing it makes vendor
|
||
// differences obvious during that work.
|
||
match pollster::block_on(dr_gpu::GpuContext::new_headless()) {
|
||
Ok(ctx) => {
|
||
log::info!("adapter: {} ({:?})", ctx.adapter_name(), ctx.backend());
|
||
window.set_adapter(ctx.adapter_name().into());
|
||
window.set_backend(format!("{:?}", ctx.backend()).to_uppercase().into());
|
||
}
|
||
Err(e) => {
|
||
log::warn!("no GPU adapter: {e}");
|
||
window.set_backend("NO GPU".into());
|
||
}
|
||
}
|
||
|
||
window.set_total(entries.len() as i32);
|
||
let index = Rc::new(RefCell::new(0usize));
|
||
|
||
let show = {
|
||
let entries = entries.clone();
|
||
let index = index.clone();
|
||
Rc::new(move |window: &AppWindow| {
|
||
let i = *index.borrow();
|
||
let Some(path) = entries.get(i) else { return };
|
||
|
||
let name = path
|
||
.file_name()
|
||
.unwrap_or_default()
|
||
.to_string_lossy()
|
||
.to_string();
|
||
window.set_filename(name.clone().into());
|
||
window.set_index(i as i32);
|
||
|
||
match load(path) {
|
||
Ok(l) => {
|
||
window.set_canvas(l.image);
|
||
window.set_load_error("".into());
|
||
window.set_camera(describe_camera(&l.meta).into());
|
||
window.set_exposure(describe_exposure(&l.meta).into());
|
||
window.set_dimensions(format!("{} × {}", l.width, l.height).into());
|
||
log::info!("{name}: {}×{}", l.width, l.height);
|
||
}
|
||
Err(e) => {
|
||
// A failure on one image must not stop browsing (FR-RAW-4).
|
||
log::warn!("{name}: {e}");
|
||
window.set_load_error(e.into());
|
||
window.set_camera("".into());
|
||
window.set_exposure("".into());
|
||
window.set_dimensions("".into());
|
||
}
|
||
}
|
||
})
|
||
};
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let index = index.clone();
|
||
let entries = entries.clone();
|
||
let show = show.clone();
|
||
window.on_next_image(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if entries.is_empty() {
|
||
return;
|
||
}
|
||
// Read, then write — `*x.borrow_mut() = *x.borrow() + 1` holds
|
||
// both borrows at once and panics.
|
||
let next = {
|
||
let cur = *index.borrow();
|
||
(cur + 1) % entries.len()
|
||
};
|
||
*index.borrow_mut() = next;
|
||
show(&w);
|
||
});
|
||
}
|
||
{
|
||
let weak = window.as_weak();
|
||
let index = index.clone();
|
||
let entries = entries.clone();
|
||
let show = show.clone();
|
||
window.on_prev_image(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if entries.is_empty() {
|
||
return;
|
||
}
|
||
let prev = {
|
||
let cur = *index.borrow();
|
||
if cur == 0 {
|
||
entries.len() - 1
|
||
} else {
|
||
cur - 1
|
||
}
|
||
};
|
||
*index.borrow_mut() = prev;
|
||
show(&w);
|
||
});
|
||
}
|
||
|
||
// FR-UI-1: layout class from window width. Computed here rather than in
|
||
// Slint because a property that both derives from and feeds the layout is
|
||
// a binding loop.
|
||
{
|
||
let weak = window.as_weak();
|
||
window.on_window_resized(move |width| {
|
||
let Some(window) = weak.upgrade() else { return };
|
||
apply_layout_class(&window, width);
|
||
});
|
||
}
|
||
{
|
||
let size = window.window().size();
|
||
let scale = window.window().scale_factor().max(0.01);
|
||
apply_layout_class(&window, size.width as f32 / scale);
|
||
}
|
||
|
||
if !entries.is_empty() {
|
||
show(&window);
|
||
}
|
||
|
||
window.run()?;
|
||
Ok(())
|
||
}
|
||
|
||
fn describe_camera(m: &Metadata) -> String {
|
||
match (&m.make, &m.model) {
|
||
(Some(make), Some(model)) => {
|
||
// Model often repeats the make; "Canon Canon EOS 6D" reads badly.
|
||
if model.starts_with(make.as_str()) {
|
||
model.trim().to_string()
|
||
} else {
|
||
format!("{} {}", make.trim(), model.trim())
|
||
}
|
||
}
|
||
(_, Some(model)) => model.trim().to_string(),
|
||
(Some(make), _) => make.trim().to_string(),
|
||
_ => String::new(),
|
||
}
|
||
}
|
||
|
||
fn describe_exposure(m: &Metadata) -> String {
|
||
let mut parts = Vec::new();
|
||
if let Some(s) = m.shutter {
|
||
// Photographers read fractions, not decimals.
|
||
parts.push(if s >= 1.0 {
|
||
format!("{s:.1}s")
|
||
} else {
|
||
format!("1/{}", (1.0 / s).round() as u32)
|
||
});
|
||
}
|
||
if let Some(a) = m.aperture {
|
||
parts.push(format!("f/{a:.1}"));
|
||
}
|
||
if let Some(iso) = m.iso {
|
||
parts.push(format!("ISO {iso}"));
|
||
}
|
||
if let Some(f) = m.focal_length {
|
||
parts.push(format!("{f:.0}mm"));
|
||
}
|
||
parts.join(" ")
|
||
}
|
||
|
||
fn apply_layout_class(window: &AppWindow, width: f32) {
|
||
let expanded = width >= EXPANDED_MIN_WIDTH;
|
||
window.set_expanded(expanded);
|
||
window.set_layout_class(if expanded { "expanded" } else { "compact" }.into());
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn meta() -> Metadata {
|
||
Metadata {
|
||
make: Some("Canon".into()),
|
||
model: Some("Canon EOS 6D".into()),
|
||
shutter: Some(1.0 / 250.0),
|
||
aperture: Some(2.8),
|
||
iso: Some(400),
|
||
focal_length: Some(50.0),
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn camera_does_not_repeat_the_make() {
|
||
// rawler reports make "Canon" and model "Canon EOS 6D"; naive
|
||
// concatenation gives "Canon Canon EOS 6D".
|
||
assert_eq!(describe_camera(&meta()), "Canon EOS 6D");
|
||
}
|
||
|
||
#[test]
|
||
fn camera_joins_when_model_omits_the_make() {
|
||
let m = Metadata {
|
||
make: Some("NIKON".into()),
|
||
model: Some("D850".into()),
|
||
..Default::default()
|
||
};
|
||
assert_eq!(describe_camera(&m), "NIKON D850");
|
||
}
|
||
|
||
#[test]
|
||
fn missing_camera_metadata_is_empty_not_a_placeholder() {
|
||
assert_eq!(describe_camera(&Metadata::default()), "");
|
||
}
|
||
|
||
#[test]
|
||
fn shutter_reads_as_a_fraction_below_one_second() {
|
||
assert!(describe_exposure(&meta()).starts_with("1/250"));
|
||
}
|
||
|
||
#[test]
|
||
fn long_exposures_read_as_seconds() {
|
||
let m = Metadata {
|
||
shutter: Some(2.5),
|
||
..Default::default()
|
||
};
|
||
assert_eq!(describe_exposure(&m), "2.5s");
|
||
}
|
||
|
||
#[test]
|
||
fn exposure_omits_absent_fields() {
|
||
let m = Metadata {
|
||
iso: Some(100),
|
||
..Default::default()
|
||
};
|
||
assert_eq!(describe_exposure(&m), "ISO 100");
|
||
assert_eq!(describe_exposure(&Metadata::default()), "");
|
||
}
|
||
|
||
#[test]
|
||
fn only_supported_extensions_are_collected() {
|
||
assert!(is_supported(Path::new("a.CR2")));
|
||
assert!(is_supported(Path::new("a.jpg")));
|
||
assert!(!is_supported(Path::new("a.txt")));
|
||
assert!(!is_supported(Path::new("noextension")));
|
||
}
|
||
}
|