Files
DarkRoom/core/dr-gpu/examples/develop.rs
T
dtourolle 09e3043f4c Add secure credential storage, sessions, and a launch screen
Login now persists properly rather than through the JSON file the test
harness was using.

  dr-plat            SecretStore trait plus a Secret Service backend.
                     Verified against the live GNOME Keyring: store,
                     retrieve, delete, confirm-gone all round-trip.
  Session/SessionStore   splits credentials from settings — the app
                     password goes to the keyring (FR-NC-2), while
                     server, login, chosen root and format selection are
                     ordinary config. A test asserts the credential never
                     appears in the config file.
  LaunchModel        the launch-screen state machine, testable without a
                     display server: sign in, approve in browser, choose
                     folder, tick formats, sign out.
  launch.slint       the screen itself, in its own file.

Absence of a secrets daemon is an explicit degraded mode, not a silent
fallback to plaintext — the screen says sign-in will not persist rather
than letting the user find out next launch. Android's Keystore backend
fails loudly for the same reason: a no-op store would look like it
worked and then lose the credential.

Two bugs caught by tests rather than by running it:

  - fail() after busy() signed the user out, because busy() had already
    discarded the session. A failed *scan* would have logged you out.
    Busy now carries the session.
  - normalise_server upgrades http:// to https:// rather than accepting
    it. NFR-SEC-3 requires TLS, and silently sending a credential in the
    clear is not a decision to make on the user's behalf.

launch.slint is not yet wired into app.slint. Calling slint_build::compile
twice replaces the generated module rather than adding to it, which broke
the other in-flight work on dr-ui; I reverted that immediately. Wiring it
needs an import inside app.slint, which is that work's file to change.

419 tests passing across ten crates.
2026-08-09 15:20:39 +02:00

158 lines
6.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Render a RAW file through the full pipeline and write a PPM.
//!
//! The end-to-end check: decode → demosaic → adjust → display encode, on a
//! real file rather than a synthetic fixture. Unit tests prove each stage in
//! isolation; this proves they compose into an image a person would accept.
//!
//! ```sh
//! cargo run -p dr-gpu --example develop --features readback -- IMG.CR2 out.ppm
//! ```
//!
//! PPM because it needs no encoder dependency and every image viewer reads
//! it. This is a diagnostic, not the export path (FR-EXP-*).
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
use dr_pipeline::ops::{colour, colour_mixer, contrast, exposure, tone, white_balance};
use dr_pipeline::{EditGraph, ParamId};
fn main() {
env_logger::init();
let mut args = std::env::args().skip(1);
let Some(input) = args.next() else {
eprintln!("usage: develop <file.cr2> [out.ppm] [preset]");
eprintln!(" preset: neutral (default) | punchy | recover");
std::process::exit(2);
};
let output = args.next().unwrap_or_else(|| "develop.ppm".into());
let preset = args.next().unwrap_or_else(|| "neutral".into());
let bytes = std::fs::read(&input).expect("read file");
let t0 = std::time::Instant::now();
let raw = dr_decode::decode(&bytes).expect("decode");
let decode_ms = t0.elapsed().as_secs_f32() * 1000.0;
println!(
"decoded {} × {} ({:?}), {decode_ms:.0} ms",
raw.crop.width, raw.crop.height, raw.cfa_pattern
);
let ctx = pollster::block_on(GpuContext::new_headless()).expect("gpu");
println!("adapter {} ({:?})", ctx.adapter_name(), ctx.backend());
let t1 = std::time::Instant::now();
let demosaicer = Demosaicer::new(&ctx).expect("demosaicer");
let image = demosaicer.run(&raw).expect("demosaic");
ctx.device.poll(wgpu::Maintain::Wait);
println!("demosaiced {:.0} ms", t1.elapsed().as_secs_f32() * 1000.0);
// Build an edit. The presets exist so the output can be eyeballed for
// each operation actually doing something, not merely compiling.
let mut graph = EditGraph::default_chain();
match preset.as_str() {
"punchy" => {
graph.set_param(exposure::ID, exposure::EXPOSURE, 0.3);
graph.set_param(tone::HIGHLIGHTS_SHADOWS_ID, tone::HIGHLIGHTS, -40.0);
graph.set_param(tone::HIGHLIGHTS_SHADOWS_ID, tone::SHADOWS, 30.0);
graph.set_param(tone::BLACKS_WHITES_ID, tone::BLACKS, -20.0);
graph.set_param(tone::BLACKS_WHITES_ID, tone::WHITES, 25.0);
graph.set_param(colour::VIBRANCE_ID, colour::VIBRANCE, 35.0);
}
"recover" => {
graph.set_param(exposure::ID, exposure::EXPOSURE, -0.5);
graph.set_param(tone::HIGHLIGHTS_SHADOWS_ID, tone::HIGHLIGHTS, -80.0);
graph.set_param(tone::HIGHLIGHTS_SHADOWS_ID, tone::SHADOWS, 60.0);
graph.set_param(colour::BRILLIANCE_ID, colour::BRILLIANCE, 40.0);
graph.set_param(white_balance::ID, white_balance::TEMPERATURE, 15.0);
}
// Contrast alone, so its effect can be judged without anything else
// moving.
"contrast" => {
graph.set_param(contrast::ID, contrast::CONTRAST, 60.0);
}
"flat" => {
graph.set_param(contrast::ID, contrast::CONTRAST, -60.0);
}
// The mixer, pushed hard on the two things this scene actually has:
// green vegetation and grey-blue rock.
"mixer" => {
graph.set_param(colour_mixer::ID, ParamId("green_sat"), 80.0);
graph.set_param(colour_mixer::ID, ParamId("green_hue"), -40.0);
graph.set_param(colour_mixer::ID, ParamId("chartreuse_sat"), 60.0);
graph.set_param(colour_mixer::ID, ParamId("azure_lum"), -50.0);
}
// One band only, to check the weighting really is selective rather
// than affecting the whole image.
"mixer_one" => {
graph.set_param(colour_mixer::ID, ParamId("green_sat"), 100.0);
}
_ => {}
}
let shader = graph.compose();
println!(
"shader {} active op(s), {} uniform floats, structure {:016x}",
shader.source.matches("---- ").count(),
shader.uniforms.len(),
shader.structure_hash
);
let mut adjust = AdjustPass::new(&ctx);
let (w, h) = image.size();
let t2 = std::time::Instant::now();
adjust.render(&image, &shader, w, h).expect("adjust");
ctx.device.poll(wgpu::Maintain::Wait);
println!("adjusted {:.2} ms", t2.elapsed().as_secs_f32() * 1000.0);
// Time a second render with only a value changed: this is the slider
// path, and it must not recompile.
graph.set_param(exposure::ID, exposure::EXPOSURE, 0.31);
let again = graph.compose();
let t3 = std::time::Instant::now();
adjust.render(&image, &again, w, h).expect("adjust");
ctx.device.poll(wgpu::Maintain::Wait);
println!(
"re-render {:.2} ms ({} pipeline(s) compiled)",
t3.elapsed().as_secs_f32() * 1000.0,
adjust.cached_pipelines()
);
let (pixels, pw, ph) = adjust.read_output().expect("readback");
// Sanity: an all-black or all-white result means something upstream
// failed silently, and it is far easier to see here than in a viewer.
let mut sum = 0u64;
let mut min = 255u8;
let mut max = 0u8;
for px in pixels.chunks_exact(4) {
let l = px[0].max(px[1]).max(px[2]);
sum += u64::from(l);
min = min.min(l);
max = max.max(l);
}
let mean = sum as f64 / (pixels.len() / 4) as f64;
println!("levels min {min}, mean {mean:.1}, max {max}");
if max == 0 {
eprintln!("WARNING: the image is entirely black");
}
write_ppm(&output, &pixels, pw, ph);
println!("wrote {output} ({pw} × {ph})");
}
/// Write binary PPM (P6): a three-line header then RGB triples.
fn write_ppm(path: &str, rgba: &[u8], w: u32, h: u32) {
use std::io::Write;
let mut out = Vec::with_capacity((w * h * 3) as usize + 32);
out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes());
for px in rgba.chunks_exact(4) {
out.extend_from_slice(&px[..3]);
}
std::fs::File::create(path)
.expect("create output")
.write_all(&out)
.expect("write output");
}