Add RAW decode and a working image viewer
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).
This commit is contained in:
+277
-183
@@ -1,91 +1,206 @@
|
||||
//! Slint interface for DarkRoom.
|
||||
//!
|
||||
//! v0.1 exists to validate assumption A1: compute output reaching the screen
|
||||
//! without a CPU round-trip (ARCH §6.1).
|
||||
//! v0.1 is a viewer: open a folder of RAW files, extract embedded previews,
|
||||
//! display them.
|
||||
//!
|
||||
//! **Current state — read this before assuming A1 is proven.** Slint's public
|
||||
//! API for adopting an externally-created wgpu texture is not yet wired up
|
||||
//! here; this build uploads through `SharedPixelBuffer`, which *is* a CPU
|
||||
//! round-trip. It is correct and cross-platform, but it is explicitly the
|
||||
//! thing the architecture forbids in production.
|
||||
//!
|
||||
//! Spike S1 replaces this with the zero-copy path. Until it does, `A1` is
|
||||
//! unvalidated and the `readback` feature makes the temporary path visible
|
||||
//! rather than silent.
|
||||
//! **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 std::time::Instant;
|
||||
|
||||
use anyhow::Result;
|
||||
use dr_gpu::{GpuContext, RenderTarget};
|
||||
use dr_decode::{Metadata, PreviewSize};
|
||||
|
||||
slint::include_modules!();
|
||||
|
||||
/// Frame timing, averaged over a short window so the readout is stable enough
|
||||
/// to read.
|
||||
struct FrameClock {
|
||||
last: Instant,
|
||||
accum: f32,
|
||||
frames: u32,
|
||||
fps: i32,
|
||||
start: Instant,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl FrameClock {
|
||||
fn new() -> Self {
|
||||
let now = Instant::now();
|
||||
Self {
|
||||
last: now,
|
||||
accum: 0.0,
|
||||
frames: 0,
|
||||
fps: 0,
|
||||
start: now,
|
||||
}
|
||||
}
|
||||
/// 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();
|
||||
|
||||
/// Advance one frame; returns elapsed seconds since start.
|
||||
fn tick(&mut self) -> f32 {
|
||||
let now = Instant::now();
|
||||
let dt = now.duration_since(self.last).as_secs_f32();
|
||||
self.last = now;
|
||||
let mut preview =
|
||||
dr_decode::extract_preview(&bytes, PreviewSize::Screen).map_err(|e| e.to_string())?;
|
||||
|
||||
self.accum += dt;
|
||||
self.frames += 1;
|
||||
if self.accum >= 0.5 {
|
||||
self.fps = (self.frames as f32 / self.accum).round() as i32;
|
||||
self.accum = 0.0;
|
||||
self.frames = 0;
|
||||
}
|
||||
// Bound memory before handing pixels to the UI.
|
||||
preview.downscale_to(MAX_DISPLAY_DIM);
|
||||
|
||||
now.duration_since(self.start).as_secs_f32()
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build and run the application window.
|
||||
pub fn run() -> Result<()> {
|
||||
let ctx = pollster::block_on(GpuContext::new_headless())?;
|
||||
log::info!("adapter: {} ({:?})", ctx.adapter_name(), ctx.backend());
|
||||
/// 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()?;
|
||||
window.set_adapter(ctx.adapter_name().into());
|
||||
window.set_backend(format!("{:?}", ctx.backend()).to_uppercase().into());
|
||||
|
||||
let target = Rc::new(RefCell::new(RenderTarget::new(&ctx, 1280, 720)?));
|
||||
let clock = Rc::new(RefCell::new(FrameClock::new()));
|
||||
// Desired canvas size, applied once per frame rather than per resize
|
||||
// event. A window drag emits dozens of events a second, and each one
|
||||
// would otherwise reallocate the texture.
|
||||
let pending_size = Rc::new(std::cell::Cell::new((1280u32, 720u32)));
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
|
||||
// Resize the render target when the canvas area changes. Slint delivers
|
||||
// this per-dimension, so both callbacks land on the same handler.
|
||||
{
|
||||
let pending = pending_size.clone();
|
||||
window.on_canvas_resized(move |w, h| {
|
||||
if w > 0 && h > 0 {
|
||||
pending.set((w as u32, h as u32));
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,94 +214,57 @@ pub fn run() -> Result<()> {
|
||||
apply_layout_class(&window, width);
|
||||
});
|
||||
}
|
||||
// Seed from the initial window size; `window-resized` maintains it after.
|
||||
{
|
||||
let size = window.window().size();
|
||||
let scale = window.window().scale_factor().max(0.01);
|
||||
apply_layout_class(&window, size.width as f32 / scale);
|
||||
}
|
||||
|
||||
// Drive rendering from a timer rather than a redraw hook: v0.1 animates
|
||||
// continuously to make a stalled frame obvious. Real rendering is
|
||||
// event-driven (NFR-RES-3 forbids continuous redraw when idle).
|
||||
let timer = slint::Timer::default();
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let target = target.clone();
|
||||
let clock = clock.clone();
|
||||
let pending_size = pending_size.clone();
|
||||
|
||||
timer.start(
|
||||
slint::TimerMode::Repeated,
|
||||
std::time::Duration::from_millis(16),
|
||||
move || {
|
||||
let Some(window) = weak.upgrade() else { return };
|
||||
|
||||
let elapsed = clock.borrow_mut().tick();
|
||||
|
||||
// Apply at most one resize per frame, and cap the render
|
||||
// resolution. FR-DSP-1 renders at what the viewport needs,
|
||||
// not at whatever size the window happens to be — on a large
|
||||
// display an uncapped canvas costs far more than it shows.
|
||||
{
|
||||
let (w, h) = pending_size.get();
|
||||
let (w, h) = clamp_render_size(w, h);
|
||||
let mut t = target.borrow_mut();
|
||||
if t.size() != (w, h) {
|
||||
t.resize(w, h);
|
||||
}
|
||||
}
|
||||
|
||||
let target = target.borrow();
|
||||
target.render(elapsed);
|
||||
|
||||
match to_slint_image(&target) {
|
||||
Ok(img) => window.set_canvas(img),
|
||||
Err(e) => log::error!("frame failed: {e}"),
|
||||
}
|
||||
let fps = clock.borrow().fps;
|
||||
window.set_fps(fps);
|
||||
if std::env::var_os("DR_LOG_FPS").is_some() && fps > 0 {
|
||||
log::info!("frame: {fps} fps, canvas {:?}", target.size());
|
||||
}
|
||||
},
|
||||
);
|
||||
if !entries.is_empty() {
|
||||
show(&window);
|
||||
}
|
||||
|
||||
window.run()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upper bound on render resolution.
|
||||
///
|
||||
/// FR-DSP-1: the display pipeline works at the resolution the viewport needs,
|
||||
/// not the source resolution. The same reasoning applies to the window — a
|
||||
/// maximised 4K canvas costs 4× a 1080p one for detail nobody is looking at
|
||||
/// while dragging. Real zoom-to-1:1 will render the visible crop at full
|
||||
/// resolution instead of scaling the whole canvas up.
|
||||
/// TRACES: FR-DSP-1
|
||||
const MAX_RENDER_DIM: u32 = 2048;
|
||||
|
||||
fn clamp_render_size(w: u32, h: u32) -> (u32, u32) {
|
||||
let w = w.max(1);
|
||||
let h = h.max(1);
|
||||
let longest = w.max(h);
|
||||
if longest <= MAX_RENDER_DIM {
|
||||
return (w, h);
|
||||
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(),
|
||||
}
|
||||
let scale = MAX_RENDER_DIM as f32 / longest as f32;
|
||||
(
|
||||
((w as f32 * scale).round() as u32).max(1),
|
||||
((h as f32 * scale).round() as u32).max(1),
|
||||
)
|
||||
}
|
||||
|
||||
/// Width at which the expanded layout appears (FR-UI-1).
|
||||
///
|
||||
/// A threshold in logical pixels, not a device check — a narrow desktop window
|
||||
/// gets the compact layout exactly as a tablet in portrait would.
|
||||
/// TRACES: FR-UI-1 | FR-UI-2
|
||||
const EXPANDED_MIN_WIDTH: f32 = 820.0;
|
||||
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;
|
||||
@@ -194,57 +272,73 @@ fn apply_layout_class(window: &AppWindow, width: f32) {
|
||||
window.set_layout_class(if expanded { "expanded" } else { "compact" }.into());
|
||||
}
|
||||
|
||||
/// Convert the render target into something Slint can display.
|
||||
///
|
||||
/// **This is the temporary path.** It reads pixels back to the CPU, which
|
||||
/// ARCH §6.1 forbids in production. Spike S1 replaces it with texture
|
||||
/// adoption; until then this keeps the app runnable on both platforms so the
|
||||
/// rest of the shell can be built.
|
||||
#[cfg(feature = "readback")]
|
||||
fn to_slint_image(target: &RenderTarget) -> Result<slint::Image> {
|
||||
use slint::{Rgba8Pixel, SharedPixelBuffer};
|
||||
|
||||
let (w, h) = target.size();
|
||||
let pixels = pollster::block_on(target.read_pixels())?;
|
||||
let buffer = SharedPixelBuffer::<Rgba8Pixel>::clone_from_slice(&pixels, w, h);
|
||||
Ok(slint::Image::from_rgba8(buffer))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "readback"))]
|
||||
fn to_slint_image(_target: &RenderTarget) -> Result<slint::Image> {
|
||||
anyhow::bail!(
|
||||
"zero-copy texture adoption is not implemented yet (spike S1). \
|
||||
Build with --features readback for the temporary CPU path."
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn render_size_is_capped_preserving_aspect() {
|
||||
// Under the cap, untouched.
|
||||
assert_eq!(clamp_render_size(1600, 900), (1600, 900));
|
||||
|
||||
// Over the cap, scaled down with aspect preserved.
|
||||
let (w, h) = clamp_render_size(3840, 2160);
|
||||
assert_eq!(w, MAX_RENDER_DIM);
|
||||
assert!((h as f32 - 1152.0).abs() < 2.0, "got {h}");
|
||||
|
||||
// Degenerate sizes never produce a zero dimension.
|
||||
assert_eq!(clamp_render_size(0, 0), (1, 1));
|
||||
let (w, h) = clamp_render_size(4000, 1);
|
||||
assert_eq!(w, MAX_RENDER_DIM);
|
||||
assert!(h >= 1);
|
||||
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 frame_clock_reports_after_window() {
|
||||
let mut c = FrameClock::new();
|
||||
// Before half a second elapses there is no average to report.
|
||||
assert_eq!(c.fps, 0);
|
||||
let t = c.tick();
|
||||
assert!(t >= 0.0);
|
||||
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")));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user