Initial workspace: GPU context, compute pass, adaptive Slint shell
Establishes the v0.1 foundations on both platforms: - dr-types: SourceRef (never a filesystem path — Android SAF has none), Format, Availability, Validator with ETag quote normalisation - dr-gpu: wgpu device, compute pass writing a storage texture, resize - dr-ui: Slint shell with FR-UI-1 adaptive layout, computed in Rust to avoid a binding loop - docker/android: pinned toolchain, verified producing API 28 ARM binaries Measured the cost of the temporary CPU readback path (dr-gpu bench): compute is 0.06-0.28ms across sizes while readback is 0.63-7.43ms, so readback is 90-96% of frame time and scales with area. Recorded in ARCH §6.1 — this is why spike S1 is the priority. Mitigations pending S1: reuse the staging buffer, apply at most one resize per frame, and cap render resolution at 2048 on the long edge. 10 tests passing; core crates cross-compile for aarch64-linux-android.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(curl -s \"https://api.github.com/search/code?q=WrapTexture+org:Noesis\" -H \"Accept: application/vnd.github+json\")",
|
||||
"Bash(curl -s \"https://api.github.com/orgs/Noesis/repos?per_page=100\")",
|
||||
"WebFetch(domain:wiki.wxwidgets.org)",
|
||||
"Bash(curl -sS \"https://gitlab.com/sciter-engine/sciter-js-sdk/-/raw/main/samples.gpu/hello-es-triangle.htm\")",
|
||||
"Bash(curl -sS \"https://gitlab.com/api/v4/projects/sciter-engine%2Fsciter-js-sdk/repository/tree?path=include&recursive=true&per_page=100&ref=main\")",
|
||||
"Bash(python3 -c ' *)",
|
||||
"Bash(curl -sL --max-time 40 \"https://api.github.com/repos/wxWidgets/wxWidgets/contents/src?ref=master\")",
|
||||
"Bash(curl -sL --max-time 40 \"https://api.github.com/repos/wxWidgets/wxWidgets/contents/include/wx/android?ref=master\")",
|
||||
"Bash(curl -sL --max-time 40 -H \"Accept: application/vnd.github.text-match+json\" \"https://api.github.com/search/code?q=vulkan+repo:wxWidgets/wxWidgets\")",
|
||||
"Bash(curl -sS \"https://gitlab.com/sciter-engine/sciter-js-sdk/-/raw/main/include/sciter-x-video-api.h\")",
|
||||
"WebFetch(domain:docs.wxwidgets.org)",
|
||||
"Bash(curl -sS \"https://gitlab.com/sciter-engine/sciter-js-sdk/-/raw/main/CHANGELOG.md\")",
|
||||
"Bash(curl -sL --max-time 40 \"https://raw.githubusercontent.com/wxWidgets/wxWidgets/master/docs/readme.txt\")",
|
||||
"Bash(curl -sL --max-time 40 \"https://raw.githubusercontent.com/wxWidgets/wxWidgets/master/docs/licence.txt\")",
|
||||
"Bash(curl -sL --max-time 40 \"https://raw.githubusercontent.com/wxWidgets/wxWidgets/master/docs/licendu.txt\")",
|
||||
"Bash(curl -sS \"https://gitlab.com/api/v4/projects/sciter-engine%2Fsciter-js-sdk/repository/tree?path=build&recursive=true&per_page=100&ref=main\")",
|
||||
"Bash(curl -sS \"https://gitlab.com/sciter-engine/sciter-js-sdk/-/raw/main/premake5.lua\")",
|
||||
"WebFetch(domain:slack-chats.kotlinlang.org)",
|
||||
"Bash(curl -sS -L \"https://sciter.com/\")",
|
||||
"Bash(curl -sL --max-time 45 \"https://api.github.com/orgs/ultralight-ux/repos?per_page=100&sort=pushed\")",
|
||||
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/GNOME/gtk/-/raw/main/gdk/gdkdmabuftexturebuilder.h\")",
|
||||
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/GNOME/gtk/-/raw/main/gdk/gdkgltexturebuilder.h\")",
|
||||
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/api/v4/projects/GNOME%2Fgtk/repository/tree?path=gdk&ref=main&per_page=100\")",
|
||||
"WebFetch(domain:docs.slint.dev)",
|
||||
"WebFetch(domain:releases.slint.dev)",
|
||||
"WebFetch(domain:flutter.dev)",
|
||||
"Bash(curl -sS -L \"https://sciter.com/forums/topic/status-of-quark-sciter-lite-sciterjs-android-ios/\")",
|
||||
"Bash(curl -sL --max-time 45 \"https://api.github.com/repos/ultralight-ux/AppCore/git/trees/master?recursive=1\")",
|
||||
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/GNOME/gtk/-/raw/main/gdk/meson.build\")",
|
||||
"WebFetch(domain:www.jetbrains.com)",
|
||||
"Bash(curl -sS \"https://gitlab.com/api/v4/projects/sciter-engine%2Fsciter-js-sdk/repository/tree?path=demos.lite&recursive=true&per_page=100&ref=main\")",
|
||||
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/api/v4/projects/GNOME%2Fgtk/repository/commits?path=gdk/android/gdkandroidglcontext.c&ref_name=main&per_page=20\")",
|
||||
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/GNOME/gtk/-/raw/main/gdk/android/meson.build\")",
|
||||
"WebFetch(domain:docs.sciter.com)",
|
||||
"Bash(curl -sS -L \"https://sciter.com/support-of-displayflex-and-displaygrid-in-sciter/\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/target
|
||||
/target-android
|
||||
Cargo.lock.bak
|
||||
*.log
|
||||
Generated
+6562
File diff suppressed because it is too large
Load Diff
+46
@@ -0,0 +1,46 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"core/dr-types",
|
||||
"core/dr-gpu",
|
||||
"ui/dr-ui",
|
||||
"apps/darkroom-desktop",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.92"
|
||||
license = "GPL-3.0-or-later"
|
||||
repository = "https://github.com/dtourolle/DarkRoom"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Internal
|
||||
dr-types = { path = "core/dr-types" }
|
||||
dr-gpu = { path = "core/dr-gpu" }
|
||||
dr-ui = { path = "ui/dr-ui" }
|
||||
|
||||
# GPU + UI
|
||||
wgpu = "23"
|
||||
slint = { version = "1.9", default-features = false }
|
||||
slint-build = "1.9"
|
||||
|
||||
# Foundations
|
||||
anyhow = "1"
|
||||
thiserror = "2"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
pollster = "0.4"
|
||||
bytemuck = { version = "1", features = ["derive"] }
|
||||
|
||||
[profile.dev]
|
||||
# Dependencies optimised even in dev builds — wgpu and image decoding are
|
||||
# unusably slow otherwise, and they rarely need debugging.
|
||||
opt-level = 0
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
@@ -0,0 +1,48 @@
|
||||
# DarkRoom
|
||||
|
||||
A cross-platform, non-destructive RAW photo editor for Linux and Android.
|
||||
|
||||
**Status:** early. v0.1 is a remote library viewer — see
|
||||
[docs/milestone-v0.1.md](docs/milestone-v0.1.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Contents |
|
||||
|---|---|
|
||||
| [requirements.md](docs/requirements.md) | What the software must do — 122 numbered requirements |
|
||||
| [architecture.md](docs/architecture.md) | How it is built — crates, GPU pipeline, data model, sync |
|
||||
| [milestone-v0.1.md](docs/milestone-v0.1.md) | The first buildable milestone |
|
||||
|
||||
## Building
|
||||
|
||||
Desktop:
|
||||
|
||||
```bash
|
||||
cargo run -p darkroom-desktop
|
||||
```
|
||||
|
||||
Android (containerised toolchain, see [docker/android](docker/android/README.md)):
|
||||
|
||||
```bash
|
||||
./docker/android/build.sh cargo ndk -t arm64-v8a build --release
|
||||
```
|
||||
|
||||
## Current state
|
||||
|
||||
Working: workspace, GPU context and compute pass, adaptive Slint shell, Android
|
||||
cross-compilation of the core crates.
|
||||
|
||||
**Not yet working:** the zero-copy display path. The build currently uploads
|
||||
frames through the CPU, which is exactly what
|
||||
[ARCH §6.1](docs/architecture.md) forbids — measured at 96% of frame time at
|
||||
4K. Replacing it is spike S1, the project's highest priority.
|
||||
|
||||
```
|
||||
cargo run -p dr-gpu --example bench --features readback
|
||||
```
|
||||
|
||||
reproduces that measurement.
|
||||
|
||||
## Licence
|
||||
|
||||
GPL-3.0-or-later.
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "darkroom-desktop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dr-ui.workspace = true
|
||||
anyhow.workspace = true
|
||||
env_logger.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["readback"]
|
||||
# Temporary CPU upload path until spike S1 lands zero-copy adoption.
|
||||
readback = ["dr-ui/readback"]
|
||||
@@ -0,0 +1,11 @@
|
||||
//! DarkRoom desktop entry point.
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
env_logger::Builder::from_env(
|
||||
env_logger::Env::default().default_filter_or("info,wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn"),
|
||||
)
|
||||
.init();
|
||||
|
||||
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
|
||||
dr_ui::run()
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "dr-gpu"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dr-types.workspace = true
|
||||
wgpu.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
bytemuck.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
pollster.workspace = true
|
||||
env_logger.workspace = true
|
||||
|
||||
[[example]]
|
||||
name = "bench"
|
||||
required-features = ["readback"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Exposes read_pixels outside tests. Production must not enable this.
|
||||
readback = []
|
||||
@@ -0,0 +1,33 @@
|
||||
// Measures the per-frame cost at several canvas sizes, isolating the readback
|
||||
// path from windowing.
|
||||
use dr_gpu::{GpuContext, RenderTarget};
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
let ctx = pollster::block_on(GpuContext::new_headless()).unwrap();
|
||||
println!("adapter: {}\n", ctx.adapter_name());
|
||||
println!("{:>12} {:>10} {:>10} {:>8}", "size", "compute", "+readback", "fps");
|
||||
|
||||
for &(w, h) in &[(840u32, 692u32), (1280, 720), (1920, 1080), (2048, 1152), (3840, 2160)] {
|
||||
let rt = RenderTarget::new(&ctx, w, h).unwrap();
|
||||
// warm
|
||||
for i in 0..10 { rt.render(i as f32 * 0.01); let _ = pollster::block_on(rt.read_pixels()); }
|
||||
|
||||
let n = 40;
|
||||
let t0 = Instant::now();
|
||||
for i in 0..n { rt.render(i as f32 * 0.01); }
|
||||
ctx.device.poll(wgpu::Maintain::Wait);
|
||||
let compute = t0.elapsed().as_secs_f64() / n as f64;
|
||||
|
||||
let t1 = Instant::now();
|
||||
for i in 0..n {
|
||||
rt.render(i as f32 * 0.01);
|
||||
let _ = pollster::block_on(rt.read_pixels()).unwrap();
|
||||
}
|
||||
let full = t1.elapsed().as_secs_f64() / n as f64;
|
||||
|
||||
println!("{:>5}x{:<6} {:>8.2}ms {:>8.2}ms {:>8.0}",
|
||||
w, h, compute * 1000.0, full * 1000.0, 1.0 / full);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/// Failures from the GPU layer.
|
||||
///
|
||||
/// `DeviceLost` is deliberately a distinct variant rather than folded into a
|
||||
/// generic error: it is an expected event on Android (ARCH §6.10), not an
|
||||
/// exceptional one, and callers recover from it by rebuilding the device and
|
||||
/// re-driving from the edit graph.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GpuError {
|
||||
#[error("no suitable GPU adapter found")]
|
||||
NoAdapter,
|
||||
|
||||
#[error("failed to request device: {0}")]
|
||||
DeviceRequest(String),
|
||||
|
||||
#[error("GPU device lost — recreate and re-render from the edit graph")]
|
||||
DeviceLost,
|
||||
|
||||
#[error("shader compilation failed: {0}")]
|
||||
ShaderCompilation(String),
|
||||
|
||||
#[error("readback failed: {0}")]
|
||||
Readback(String),
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
//! GPU device and compute for DarkRoom.
|
||||
//!
|
||||
//! In v0.1 this exists to prove one thing: a compute shader can write a
|
||||
//! texture that reaches the screen without a CPU round-trip (ARCH §6.1). It
|
||||
//! holds no pipeline, no tiling, and no masks — those arrive in v0.2.
|
||||
//!
|
||||
//! Deliberately free of UI dependencies (ARCH §6.5a). The texture is handed
|
||||
//! out as a `wgpu::Texture`; who composites it is not this crate's concern.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
mod error;
|
||||
pub use error::GpuError;
|
||||
|
||||
/// Owns the wgpu device and queue.
|
||||
///
|
||||
/// One device is shared by the compute pipeline and the UI, which is what
|
||||
/// allows compositing with no interop layer. Cloning is cheap and shares the
|
||||
/// same underlying device.
|
||||
#[derive(Clone)]
|
||||
pub struct GpuContext {
|
||||
pub device: Arc<wgpu::Device>,
|
||||
pub queue: Arc<wgpu::Queue>,
|
||||
adapter_info: wgpu::AdapterInfo,
|
||||
}
|
||||
|
||||
impl GpuContext {
|
||||
/// Create a headless context — no surface, no window.
|
||||
///
|
||||
/// Used by tests and by the Slint path, which supplies its own surface.
|
||||
pub async fn new_headless() -> Result<Self, GpuError> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||
// Vulkan on both targets (D1). GL is allowed as a fallback so a
|
||||
// machine without a Vulkan loader still runs the tests.
|
||||
backends: wgpu::Backends::VULKAN | wgpu::Backends::GL,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let adapter = instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
compatible_surface: None,
|
||||
force_fallback_adapter: false,
|
||||
})
|
||||
.await
|
||||
.ok_or(GpuError::NoAdapter)?;
|
||||
|
||||
let adapter_info = adapter.get_info();
|
||||
log::info!(
|
||||
"gpu: {} ({:?}, {:?})",
|
||||
adapter_info.name,
|
||||
adapter_info.device_type,
|
||||
adapter_info.backend
|
||||
);
|
||||
|
||||
let (device, queue) = adapter
|
||||
.request_device(
|
||||
&wgpu::DeviceDescriptor {
|
||||
label: Some("darkroom-device"),
|
||||
required_features: wgpu::Features::empty(),
|
||||
// Defaults, not `downlevel_defaults`: storage textures
|
||||
// in compute shaders are required, and the downlevel tier
|
||||
// does not guarantee them. This is effectively our GPU
|
||||
// floor (NFR-COMPAT-1).
|
||||
required_limits: wgpu::Limits::default(),
|
||||
memory_hints: wgpu::MemoryHints::Performance,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| GpuError::DeviceRequest(e.to_string()))?;
|
||||
|
||||
Ok(Self {
|
||||
device: Arc::new(device),
|
||||
queue: Arc::new(queue),
|
||||
adapter_info,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a context from a device and queue owned by someone else — the
|
||||
/// path used when Slint has already created them.
|
||||
pub fn from_parts(
|
||||
device: Arc<wgpu::Device>,
|
||||
queue: Arc<wgpu::Queue>,
|
||||
adapter_info: wgpu::AdapterInfo,
|
||||
) -> Self {
|
||||
Self {
|
||||
device,
|
||||
queue,
|
||||
adapter_info,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn adapter_name(&self) -> &str {
|
||||
&self.adapter_info.name
|
||||
}
|
||||
|
||||
pub fn backend(&self) -> wgpu::Backend {
|
||||
self.adapter_info.backend
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Params {
|
||||
width: u32,
|
||||
height: u32,
|
||||
phase: f32,
|
||||
_pad: f32,
|
||||
}
|
||||
|
||||
/// A compute pass writing into a storage texture.
|
||||
///
|
||||
/// Stands in for the develop pipeline in v0.1. What matters is the shape:
|
||||
/// compute writes a texture, the texture is handed to the compositor, and
|
||||
/// pixels never travel back through the CPU.
|
||||
pub struct RenderTarget {
|
||||
ctx: GpuContext,
|
||||
texture: wgpu::Texture,
|
||||
view: wgpu::TextureView,
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
bind_group_layout: wgpu::BindGroupLayout,
|
||||
bind_group: wgpu::BindGroup,
|
||||
params_buf: wgpu::Buffer,
|
||||
width: u32,
|
||||
height: u32,
|
||||
/// Reused staging buffer for the temporary readback path. Allocating one
|
||||
/// per frame is a significant cost at large window sizes.
|
||||
#[cfg(any(test, feature = "readback"))]
|
||||
readback_buf: std::cell::RefCell<Option<(wgpu::Buffer, u32)>>,
|
||||
}
|
||||
|
||||
impl RenderTarget {
|
||||
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
|
||||
pub fn new(ctx: &GpuContext, width: u32, height: u32) -> Result<Self, GpuError> {
|
||||
let (width, height) = (width.max(1), height.max(1));
|
||||
|
||||
let shader = ctx
|
||||
.device
|
||||
.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("gradient"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
include_str!("shaders/gradient.wgsl").into(),
|
||||
),
|
||||
});
|
||||
|
||||
let bind_group_layout =
|
||||
ctx.device
|
||||
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("render-target-bgl"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::StorageTexture {
|
||||
access: wgpu::StorageTextureAccess::WriteOnly,
|
||||
format: Self::FORMAT,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let layout = ctx
|
||||
.device
|
||||
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("render-target-layout"),
|
||||
bind_group_layouts: &[&bind_group_layout],
|
||||
push_constant_ranges: &[],
|
||||
});
|
||||
|
||||
let pipeline = ctx
|
||||
.device
|
||||
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("gradient-pipeline"),
|
||||
layout: Some(&layout),
|
||||
module: &shader,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
|
||||
let params_buf = ctx
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("params"),
|
||||
contents: bytemuck::bytes_of(&Params {
|
||||
width,
|
||||
height,
|
||||
phase: 0.0,
|
||||
_pad: 0.0,
|
||||
}),
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
|
||||
let (texture, view) = Self::create_texture(ctx, width, height);
|
||||
let bind_group = Self::create_bind_group(ctx, &bind_group_layout, &view, ¶ms_buf);
|
||||
|
||||
Ok(Self {
|
||||
ctx: ctx.clone(),
|
||||
texture,
|
||||
view,
|
||||
pipeline,
|
||||
bind_group_layout,
|
||||
bind_group,
|
||||
params_buf,
|
||||
width,
|
||||
height,
|
||||
#[cfg(any(test, feature = "readback"))]
|
||||
readback_buf: std::cell::RefCell::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
fn create_texture(
|
||||
ctx: &GpuContext,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> (wgpu::Texture, wgpu::TextureView) {
|
||||
let texture = ctx.device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("render-target"),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: Self::FORMAT,
|
||||
// STORAGE_BINDING to write from compute; TEXTURE_BINDING so the
|
||||
// compositor can sample it. COPY_SRC exists only for tests —
|
||||
// production never reads this back (ARCH §6.1).
|
||||
usage: wgpu::TextureUsages::STORAGE_BINDING
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
(texture, view)
|
||||
}
|
||||
|
||||
fn create_bind_group(
|
||||
ctx: &GpuContext,
|
||||
layout: &wgpu::BindGroupLayout,
|
||||
view: &wgpu::TextureView,
|
||||
params: &wgpu::Buffer,
|
||||
) -> wgpu::BindGroup {
|
||||
ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("render-target-bg"),
|
||||
layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: params.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// Resize, reallocating the texture. No-op when unchanged.
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
let (width, height) = (width.max(1), height.max(1));
|
||||
if width == self.width && height == self.height {
|
||||
return;
|
||||
}
|
||||
let (texture, view) = Self::create_texture(&self.ctx, width, height);
|
||||
self.bind_group = Self::create_bind_group(
|
||||
&self.ctx,
|
||||
&self.bind_group_layout,
|
||||
&view,
|
||||
&self.params_buf,
|
||||
);
|
||||
self.texture = texture;
|
||||
self.view = view;
|
||||
self.width = width;
|
||||
self.height = height;
|
||||
#[cfg(any(test, feature = "readback"))]
|
||||
{
|
||||
// Size changed, so the staging buffer no longer fits.
|
||||
*self.readback_buf.borrow_mut() = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the compute pass. Results stay on the GPU.
|
||||
pub fn render(&self, phase: f32) {
|
||||
self.ctx.queue.write_buffer(
|
||||
&self.params_buf,
|
||||
0,
|
||||
bytemuck::bytes_of(&Params {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
phase,
|
||||
_pad: 0.0,
|
||||
}),
|
||||
);
|
||||
|
||||
let mut enc = self
|
||||
.ctx
|
||||
.device
|
||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("render-encoder"),
|
||||
});
|
||||
{
|
||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("gradient-pass"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||
// 8x8 workgroups, rounded up so edge pixels are covered.
|
||||
pass.dispatch_workgroups(self.width.div_ceil(8), self.height.div_ceil(8), 1);
|
||||
}
|
||||
self.ctx.queue.submit(Some(enc.finish()));
|
||||
}
|
||||
|
||||
pub fn texture(&self) -> &wgpu::Texture {
|
||||
&self.texture
|
||||
}
|
||||
|
||||
pub fn view(&self) -> &wgpu::TextureView {
|
||||
&self.view
|
||||
}
|
||||
|
||||
pub fn size(&self) -> (u32, u32) {
|
||||
(self.width, self.height)
|
||||
}
|
||||
|
||||
/// Read pixels back to the CPU.
|
||||
///
|
||||
/// **Tests only.** Production code must never call this — it is exactly
|
||||
/// the round-trip ARCH §6.1 forbids, and AC-8 asserts it does not happen.
|
||||
#[cfg(any(test, feature = "readback"))]
|
||||
pub async fn read_pixels(&self) -> Result<Vec<u8>, GpuError> {
|
||||
// Buffer rows must be aligned to COPY_BYTES_PER_ROW_ALIGNMENT (256).
|
||||
let unpadded = self.width * 4;
|
||||
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||||
let padded = unpadded.div_ceil(align) * align;
|
||||
|
||||
let needed = (padded * self.height) as u64;
|
||||
let mut slot = self.readback_buf.borrow_mut();
|
||||
if slot.as_ref().map(|(_, p)| *p) != Some(padded) {
|
||||
*slot = Some((
|
||||
self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("readback"),
|
||||
size: needed,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
padded,
|
||||
));
|
||||
}
|
||||
let buf = &slot.as_ref().unwrap().0;
|
||||
|
||||
let mut enc = self
|
||||
.ctx
|
||||
.device
|
||||
.create_command_encoder(&Default::default());
|
||||
enc.copy_texture_to_buffer(
|
||||
wgpu::ImageCopyTexture {
|
||||
texture: &self.texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
wgpu::ImageCopyBuffer {
|
||||
buffer: buf,
|
||||
layout: wgpu::ImageDataLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(padded),
|
||||
rows_per_image: Some(self.height),
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
self.ctx.queue.submit(Some(enc.finish()));
|
||||
|
||||
let slice = buf.slice(..);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
slice.map_async(wgpu::MapMode::Read, move |r| {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
self.ctx.device.poll(wgpu::Maintain::Wait);
|
||||
rx.recv()
|
||||
.map_err(|e| GpuError::Readback(e.to_string()))?
|
||||
.map_err(|e| GpuError::Readback(e.to_string()))?;
|
||||
|
||||
// Strip row padding.
|
||||
let data = slice.get_mapped_range();
|
||||
let mut out = Vec::with_capacity((unpadded * self.height) as usize);
|
||||
for row in 0..self.height {
|
||||
let start = (row * padded) as usize;
|
||||
out.extend_from_slice(&data[start..start + unpadded as usize]);
|
||||
}
|
||||
drop(data);
|
||||
buf.unmap();
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
// CI runners and headless machines may have no usable adapter. Skip
|
||||
// rather than fail — the device-dependent assertions still run
|
||||
// wherever a GPU exists.
|
||||
match pollster::block_on(GpuContext::new_headless()) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
eprintln!("skipping: no GPU adapter ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_writes_the_texture() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let rt = RenderTarget::new(&ctx, 64, 64).expect("render target");
|
||||
rt.render(0.0);
|
||||
|
||||
let px = pollster::block_on(rt.read_pixels()).expect("readback");
|
||||
assert_eq!(px.len(), 64 * 64 * 4);
|
||||
|
||||
// The shader writes opaque pixels everywhere; an all-zero buffer would
|
||||
// mean the dispatch silently did nothing.
|
||||
assert!(
|
||||
px.chunks_exact(4).all(|p| p[3] == 255),
|
||||
"every pixel should be opaque"
|
||||
);
|
||||
assert!(
|
||||
px.iter().any(|&b| b != 0),
|
||||
"texture should not be uniformly zero"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_changes_output() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let rt = RenderTarget::new(&ctx, 32, 32).expect("render target");
|
||||
|
||||
rt.render(0.0);
|
||||
let a = pollster::block_on(rt.read_pixels()).expect("readback");
|
||||
rt.render(std::f32::consts::PI);
|
||||
let b = pollster::block_on(rt.read_pixels()).expect("readback");
|
||||
|
||||
assert_ne!(a, b, "moving the highlight should change the image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_reallocates() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let mut rt = RenderTarget::new(&ctx, 16, 16).expect("render target");
|
||||
assert_eq!(rt.size(), (16, 16));
|
||||
|
||||
rt.resize(48, 24);
|
||||
assert_eq!(rt.size(), (48, 24));
|
||||
|
||||
rt.render(0.0);
|
||||
let px = pollster::block_on(rt.read_pixels()).expect("readback");
|
||||
assert_eq!(px.len(), 48 * 24 * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_size_is_clamped() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
// A minimised window reports zero; texture creation would panic.
|
||||
let rt = RenderTarget::new(&ctx, 0, 0).expect("render target");
|
||||
assert_eq!(rt.size(), (1, 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Placeholder compute shader — stands in for the develop pipeline.
|
||||
//
|
||||
// Its only job is to prove the path: a compute shader writes a storage
|
||||
// texture, and that texture reaches the screen without a CPU round-trip
|
||||
// (ARCH §6.1). Replaced by real pipeline stages in v0.2.
|
||||
|
||||
struct Params {
|
||||
width: u32,
|
||||
height: u32,
|
||||
// Animates so it is visually obvious the compute pass runs every frame
|
||||
// rather than a stale texture being redisplayed.
|
||||
phase: f32,
|
||||
_pad: f32,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var output: texture_storage_2d<rgba8unorm, write>;
|
||||
@group(0) @binding(1) var<uniform> params: Params;
|
||||
|
||||
@compute @workgroup_size(8, 8, 1)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (gid.x >= params.width || gid.y >= params.height) {
|
||||
return;
|
||||
}
|
||||
|
||||
let uv = vec2<f32>(
|
||||
f32(gid.x) / f32(params.width),
|
||||
f32(gid.y) / f32(params.height),
|
||||
);
|
||||
|
||||
// Warm dark ground with a moving highlight — deliberately unlike a test
|
||||
// pattern, so a stuck frame is obvious at a glance.
|
||||
let d = distance(uv, vec2<f32>(0.5 + 0.25 * cos(params.phase), 0.5 + 0.25 * sin(params.phase)));
|
||||
let glow = 1.0 - smoothstep(0.0, 0.55, d);
|
||||
|
||||
let base = vec3<f32>(0.08, 0.07, 0.06);
|
||||
let accent = vec3<f32>(0.69, 0.23, 0.15);
|
||||
let rgb = base + accent * glow * (0.35 + 0.65 * uv.y);
|
||||
|
||||
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(rgb, 1.0));
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "dr-types"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Shared vocabulary for DarkRoom.
|
||||
//!
|
||||
//! Deliberately dependency-light: no platform code, no UI, no GPU. Everything
|
||||
//! else in `core/` builds on these types, so anything added here is paid for
|
||||
//! everywhere.
|
||||
|
||||
use std::fmt;
|
||||
use std::ops::Range;
|
||||
|
||||
/// Identifies a granted library location — a directory on Linux, a persisted
|
||||
/// document tree on Android.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct RootId(pub u64);
|
||||
|
||||
/// Identifies an image within a catalog.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct ImageId(pub u64);
|
||||
|
||||
/// Identifies one edit variant of an image (a virtual copy).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct VersionId(pub u64);
|
||||
|
||||
/// An opaque, re-resolvable reference to source image data.
|
||||
///
|
||||
/// **Never a filesystem path.** Android's Storage Access Framework provides no
|
||||
/// usable path (ARCH §6.9), so a `Path`-based API would not be portable. This
|
||||
/// is the type that keeps `core/` platform-agnostic.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum SourceRef {
|
||||
/// Desktop: a path relative to a granted root.
|
||||
Local { root: RootId, relative: String },
|
||||
/// Android: a SAF document URI under a persisted tree grant.
|
||||
Document { tree: RootId, document_id: String },
|
||||
/// Remote: resolved through the sync layer; may support range reads only.
|
||||
Remote { file_id: u64, path: String },
|
||||
}
|
||||
|
||||
impl SourceRef {
|
||||
/// A stable, display-friendly name — the last path component.
|
||||
pub fn display_name(&self) -> &str {
|
||||
let full = match self {
|
||||
SourceRef::Local { relative, .. } => relative.as_str(),
|
||||
SourceRef::Document { document_id, .. } => document_id.as_str(),
|
||||
SourceRef::Remote { path, .. } => path.as_str(),
|
||||
};
|
||||
// SAF document ids use ':' as a separator; paths use '/'. Split on
|
||||
// whichever appears last so both yield a sensible name.
|
||||
full.rsplit(['/', ':']).next().unwrap_or(full)
|
||||
}
|
||||
|
||||
/// Lowercase file extension, if any.
|
||||
pub fn extension(&self) -> Option<String> {
|
||||
let name = self.display_name();
|
||||
let (_, ext) = name.rsplit_once('.')?;
|
||||
if ext.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ext.to_ascii_lowercase())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SourceRef {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.display_name())
|
||||
}
|
||||
}
|
||||
|
||||
/// Image formats recognised at the catalog level.
|
||||
///
|
||||
/// Recognition is by extension only; whether a decoder can actually handle the
|
||||
/// file is a separate question answered by `dr-decode`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Format {
|
||||
Cr2,
|
||||
Cr3,
|
||||
Nef,
|
||||
Arw,
|
||||
Raf,
|
||||
Rw2,
|
||||
Orf,
|
||||
Dng,
|
||||
Jpeg,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
/// Recognise from a lowercase extension.
|
||||
pub fn from_extension(ext: &str) -> Option<Self> {
|
||||
Some(match ext {
|
||||
"cr2" => Format::Cr2,
|
||||
"cr3" => Format::Cr3,
|
||||
"nef" => Format::Nef,
|
||||
"arw" => Format::Arw,
|
||||
"raf" => Format::Raf,
|
||||
"rw2" => Format::Rw2,
|
||||
"orf" => Format::Orf,
|
||||
"dng" => Format::Dng,
|
||||
"jpg" | "jpeg" => Format::Jpeg,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether this is a camera RAW format needing demosaic.
|
||||
pub fn is_raw(self) -> bool {
|
||||
!matches!(self, Format::Jpeg)
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of an image is available locally (FR-NC-6c).
|
||||
///
|
||||
/// Surfaced in the UI so a user always knows what they have — the failure
|
||||
/// Lightroom makes by showing an original's filename beside a 2560px proxy.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Availability {
|
||||
/// Catalogued, nothing cached.
|
||||
MetadataOnly,
|
||||
/// A preview is cached; enough to browse, not to export.
|
||||
Preview,
|
||||
/// Full source available locally.
|
||||
Original,
|
||||
/// Source unreachable — drive unplugged, permission revoked, offline.
|
||||
Offline,
|
||||
}
|
||||
|
||||
/// An opaque change-validator for a remote entry (an ETag, or an mtime where
|
||||
/// no ETag exists).
|
||||
///
|
||||
/// Quoting is inconsistent across servers, so comparison normalises rather
|
||||
/// than testing raw equality — otherwise spurious full rescans result.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Validator(String);
|
||||
|
||||
impl Validator {
|
||||
pub fn new(raw: impl Into<String>) -> Self {
|
||||
let raw: String = raw.into();
|
||||
let trimmed = raw.trim().trim_start_matches("W/").trim_matches('"');
|
||||
Validator(trimmed.to_string())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A byte range request against a source.
|
||||
pub type ByteRange = Range<u64>;
|
||||
|
||||
/// Errors from resolving or reading a source.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SourceError {
|
||||
#[error("source is offline or unreachable")]
|
||||
Offline,
|
||||
#[error("permission denied or revoked")]
|
||||
PermissionDenied,
|
||||
#[error("source not found")]
|
||||
NotFound,
|
||||
#[error("range requests unsupported by this source")]
|
||||
RangeUnsupported,
|
||||
#[error("io error: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn display_name_handles_paths_and_saf_ids() {
|
||||
let local = SourceRef::Local {
|
||||
root: RootId(1),
|
||||
relative: "2026/08/IMG_0042.CR3".into(),
|
||||
};
|
||||
assert_eq!(local.display_name(), "IMG_0042.CR3");
|
||||
|
||||
// SAF document ids are colon-separated, not slash-separated.
|
||||
let saf = SourceRef::Document {
|
||||
tree: RootId(1),
|
||||
document_id: "primary:DCIM/Camera/IMG_0042.CR3".into(),
|
||||
};
|
||||
assert_eq!(saf.display_name(), "IMG_0042.CR3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_is_lowercased() {
|
||||
let s = SourceRef::Local {
|
||||
root: RootId(1),
|
||||
relative: "IMG.CR3".into(),
|
||||
};
|
||||
assert_eq!(s.extension().as_deref(), Some("cr3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_absent_when_no_dot() {
|
||||
let s = SourceRef::Local {
|
||||
root: RootId(1),
|
||||
relative: "NOEXTENSION".into(),
|
||||
};
|
||||
assert_eq!(s.extension(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_round_trip_and_classify() {
|
||||
assert_eq!(Format::from_extension("cr3"), Some(Format::Cr3));
|
||||
assert_eq!(Format::from_extension("jpeg"), Some(Format::Jpeg));
|
||||
assert_eq!(Format::from_extension("txt"), None);
|
||||
assert!(Format::Cr3.is_raw());
|
||||
assert!(!Format::Jpeg.is_raw());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validator_normalises_quoting() {
|
||||
// Nextcloud quotes ETags; some servers add a weak prefix. All three
|
||||
// spellings must compare equal or every sync looks like a change.
|
||||
assert_eq!(Validator::new("\"abc123\""), Validator::new("abc123"));
|
||||
assert_eq!(Validator::new("W/\"abc123\""), Validator::new("abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn availability_orders_by_completeness() {
|
||||
assert!(Availability::Original > Availability::Preview);
|
||||
assert!(Availability::Preview > Availability::MetadataOnly);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
# DarkRoom — reproducible Android build environment
|
||||
#
|
||||
# Pins the entire toolchain: JDK, Android SDK, NDK, Rust, and the four Android
|
||||
# targets. Both CI and local builds use this image, so "works on my machine"
|
||||
# and "works in CI" are the same machine.
|
||||
#
|
||||
# Build: podman build -t darkroom-android:latest docker/android
|
||||
# Use: ./docker/android/build.sh cargo ndk -t arm64-v8a build --release
|
||||
|
||||
FROM docker.io/library/debian:bookworm-slim
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Versions — pinned deliberately. Bumping any of these is a reviewable change,
|
||||
# not something that drifts underneath the build.
|
||||
# ---------------------------------------------------------------------------
|
||||
ARG JDK_VERSION=17
|
||||
# Compile SDK / target API.
|
||||
ARG ANDROID_API=36
|
||||
# Minimum supported API — the level native code links against (NFR-COMPAT-1).
|
||||
# 28 (Android 9) matches the floor where Vulkan support is dependable.
|
||||
# cargo-ndk otherwise defaults to 21, which is far below what this app needs.
|
||||
ARG MIN_API=28
|
||||
ARG BUILD_TOOLS=36.0.0
|
||||
ARG NDK_VERSION=27.2.12479018
|
||||
ARG CMDLINE_TOOLS=13114758
|
||||
# Must satisfy cargo-ndk's MSRV (4.1.x needs >= 1.86) as well as our own crates.
|
||||
ARG RUST_VERSION=1.92.0
|
||||
|
||||
# JDK 17, not the host's 25: the Android Gradle Plugin supports 17 as its
|
||||
# stable target, and newer JDKs regularly break Gradle in ways that cost more
|
||||
# time than they save.
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
ANDROID_HOME=/opt/android-sdk \
|
||||
ANDROID_SDK_ROOT=/opt/android-sdk \
|
||||
JAVA_HOME=/usr/lib/jvm/java-${JDK_VERSION}-openjdk-amd64 \
|
||||
CARGO_HOME=/opt/cargo \
|
||||
RUSTUP_HOME=/opt/rustup \
|
||||
PATH=/opt/cargo/bin:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:$PATH
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System packages
|
||||
# ---------------------------------------------------------------------------
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl unzip git \
|
||||
openjdk-${JDK_VERSION}-jdk-headless \
|
||||
# Slint / winit build-time needs
|
||||
pkg-config libfontconfig1-dev \
|
||||
# native deps that may need building for host-side tooling
|
||||
build-essential cmake python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Android SDK + NDK
|
||||
# ---------------------------------------------------------------------------
|
||||
RUN mkdir -p ${ANDROID_HOME}/cmdline-tools \
|
||||
&& curl -fsSL -o /tmp/tools.zip \
|
||||
"https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip" \
|
||||
&& unzip -q /tmp/tools.zip -d ${ANDROID_HOME}/cmdline-tools \
|
||||
&& mv ${ANDROID_HOME}/cmdline-tools/cmdline-tools ${ANDROID_HOME}/cmdline-tools/latest \
|
||||
&& rm /tmp/tools.zip
|
||||
|
||||
RUN yes | sdkmanager --licenses > /dev/null 2>&1 || true \
|
||||
&& sdkmanager --install \
|
||||
"platform-tools" \
|
||||
"platforms;android-${ANDROID_API}" \
|
||||
"build-tools;${BUILD_TOOLS}" \
|
||||
"ndk;${NDK_VERSION}" \
|
||||
> /dev/null
|
||||
|
||||
ENV ANDROID_NDK_HOME=${ANDROID_HOME}/ndk/${NDK_VERSION} \
|
||||
ANDROID_NDK_ROOT=${ANDROID_HOME}/ndk/${NDK_VERSION}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rust + Android targets
|
||||
#
|
||||
# All four ABIs. arm64-v8a covers essentially every current device; the others
|
||||
# exist so an ABI-specific build break is caught here rather than at release.
|
||||
# ---------------------------------------------------------------------------
|
||||
RUN curl -fsSL https://sh.rustup.rs | sh -s -- \
|
||||
-y --no-modify-path --profile minimal --default-toolchain ${RUST_VERSION} \
|
||||
&& rustup target add \
|
||||
aarch64-linux-android \
|
||||
armv7-linux-androideabi \
|
||||
x86_64-linux-android \
|
||||
i686-linux-android \
|
||||
&& rustup component add rustfmt clippy \
|
||||
&& cargo install cargo-ndk --locked \
|
||||
&& chmod -R a+rwX ${CARGO_HOME} ${RUSTUP_HOME}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Linker configuration
|
||||
#
|
||||
# cargo-ndk normally handles this, but setting it explicitly means plain
|
||||
# `cargo build --target …` works too, which matters for tooling that shells
|
||||
# out to cargo directly (rust-analyzer, cargo-metadata).
|
||||
# ---------------------------------------------------------------------------
|
||||
ENV NDK_BIN=${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin
|
||||
|
||||
# Linkers target MIN_API, not ANDROID_API — the binary must run on the oldest
|
||||
# supported device, while the SDK compiles against the newest.
|
||||
ENV CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=${NDK_BIN}/aarch64-linux-android${MIN_API}-clang \
|
||||
CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER=${NDK_BIN}/armv7a-linux-androideabi${MIN_API}-clang \
|
||||
CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER=${NDK_BIN}/x86_64-linux-android${MIN_API}-clang \
|
||||
CARGO_TARGET_I686_LINUX_ANDROID_LINKER=${NDK_BIN}/i686-linux-android${MIN_API}-clang
|
||||
|
||||
# cargo-ndk reads this; without it it defaults to API 21.
|
||||
ENV CARGO_NDK_PLATFORM=${MIN_API} \
|
||||
ANDROID_PLATFORM=${MIN_API}
|
||||
|
||||
# Shared cargo registry cache — bind-mount over this to persist across runs.
|
||||
VOLUME ["/opt/cargo/registry"]
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
# Rootless podman maps the host user into the container, so the image must not
|
||||
# assume a fixed uid. Keep world-writable toolchain dirs and let the caller
|
||||
# pass --user.
|
||||
RUN chmod -R a+rwX ${ANDROID_HOME}
|
||||
|
||||
CMD ["/bin/bash"]
|
||||
@@ -0,0 +1,73 @@
|
||||
# Android build environment
|
||||
|
||||
Reproducible container for cross-compiling DarkRoom to Android. CI and local builds use the same
|
||||
image, so a break appears in one place rather than two.
|
||||
|
||||
## Use
|
||||
|
||||
```bash
|
||||
# Cross-compile for a device ABI
|
||||
./docker/android/build.sh cargo ndk -t arm64-v8a build --release
|
||||
|
||||
# All four ABIs
|
||||
./docker/android/build.sh cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -t x86 build --release
|
||||
|
||||
# Type-check without linking
|
||||
./docker/android/build.sh cargo check --target aarch64-linux-android
|
||||
|
||||
# Interactive shell
|
||||
./docker/android/build.sh
|
||||
|
||||
# After editing the Dockerfile
|
||||
./docker/android/build.sh --rebuild
|
||||
```
|
||||
|
||||
Prefers `podman`, falls back to `docker`. First run builds the image, which takes several minutes;
|
||||
after that it is cached.
|
||||
|
||||
## Pinned versions
|
||||
|
||||
| Component | Version | Why this one |
|
||||
|---|---|---|
|
||||
| JDK | 17 | AGP's stable target. The host's JDK 25 breaks Gradle. |
|
||||
| Compile SDK | API 36 | Required for Play distribution |
|
||||
| **Min API** | **28** (Android 9) | Floor where Vulkan support is dependable (NFR-COMPAT-1) |
|
||||
| Build tools | 36.0.0 | Matches compile SDK |
|
||||
| NDK | 27.2.12479018 (r27c) | Current LTS |
|
||||
| Rust | 1.92.0 | Slint 1.17 requires it; cargo-ndk 4.1.x needs ≥ 1.86 |
|
||||
| cargo-ndk | 4.1.2 | |
|
||||
|
||||
Bumping any of these is a reviewable change, not silent drift.
|
||||
|
||||
### Compile SDK versus min API
|
||||
|
||||
These are different and both matter. `ANDROID_API` (36) is what the SDK compiles against — the
|
||||
newest APIs available. `MIN_API` (28) is what native code *links* against, and determines the
|
||||
oldest device that can run the result.
|
||||
|
||||
cargo-ndk defaults to **API 21** if not told otherwise, which is far below what this app needs.
|
||||
`CARGO_NDK_PLATFORM` and the per-target linker paths both pin it to `MIN_API`. Verify with:
|
||||
|
||||
```bash
|
||||
file target/aarch64-linux-android/release/*.so
|
||||
# → "... for Android 28, built by NDK r27c"
|
||||
```
|
||||
|
||||
## Caching
|
||||
|
||||
The cargo registry and target directory persist in `~/.cache/darkroom-android`, so rebuilds do not
|
||||
re-download the crate index. Clear with:
|
||||
|
||||
```bash
|
||||
rm -rf ~/.cache/darkroom-android
|
||||
```
|
||||
|
||||
The Android target dir is kept separate from the host's `target/` — sharing one directory between
|
||||
host and container builds causes constant rebuilds as they invalidate each other's fingerprints.
|
||||
|
||||
## What is not here
|
||||
|
||||
No emulator, no `adb` device access. This image cross-compiles; it does not run or deploy. Device
|
||||
testing (spikes S2 and S10, which need two GPU vendors) happens on real hardware — the emulator's
|
||||
GPU behaviour is not representative of Adreno or Mali, which is precisely what those spikes exist
|
||||
to test.
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run a command inside the DarkRoom Android build container.
|
||||
#
|
||||
# ./docker/android/build.sh cargo ndk -t arm64-v8a build --release
|
||||
# ./docker/android/build.sh cargo check --target aarch64-linux-android
|
||||
# ./docker/android/build.sh # interactive shell
|
||||
#
|
||||
# Builds the image on first use. Rebuild after editing the Dockerfile with:
|
||||
# ./docker/android/build.sh --rebuild
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="darkroom-android:latest"
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO="$(cd "${HERE}/../.." && pwd)"
|
||||
|
||||
# Prefer podman (rootless by default); fall back to docker.
|
||||
if command -v podman >/dev/null 2>&1; then
|
||||
ENGINE=podman
|
||||
elif command -v docker >/dev/null 2>&1; then
|
||||
ENGINE=docker
|
||||
else
|
||||
echo "error: neither podman nor docker found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "--rebuild" ]]; then
|
||||
shift
|
||||
"${ENGINE}" build -t "${IMAGE}" "${HERE}"
|
||||
elif ! "${ENGINE}" image exists "${IMAGE}" 2>/dev/null && \
|
||||
! "${ENGINE}" image inspect "${IMAGE}" >/dev/null 2>&1; then
|
||||
echo "==> building ${IMAGE} (first run; several minutes)"
|
||||
"${ENGINE}" build -t "${IMAGE}" "${HERE}"
|
||||
fi
|
||||
|
||||
# Persist the cargo registry and target dir across runs, or every build
|
||||
# re-downloads the crate index.
|
||||
CACHE="${XDG_CACHE_HOME:-${HOME}/.cache}/darkroom-android"
|
||||
mkdir -p "${CACHE}/registry" "${CACHE}/target"
|
||||
|
||||
ARGS=(
|
||||
--rm
|
||||
-v "${REPO}:/work:z"
|
||||
-v "${CACHE}/registry:/opt/cargo/registry:z"
|
||||
-v "${CACHE}/target:/work/target-android:z"
|
||||
-e CARGO_TARGET_DIR=/work/target-android
|
||||
-w /work
|
||||
)
|
||||
|
||||
# Rootless podman already maps the host user; docker needs it stated.
|
||||
if [[ "${ENGINE}" == "docker" ]]; then
|
||||
ARGS+=(--user "$(id -u):$(id -g)")
|
||||
fi
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
ARGS+=(-it)
|
||||
set -- /bin/bash
|
||||
fi
|
||||
|
||||
exec "${ENGINE}" run "${ARGS[@]}" "${IMAGE}" "$@"
|
||||
@@ -0,0 +1,946 @@
|
||||
# DarkRoom — Architecture
|
||||
|
||||
**Status:** Draft v0.1 · 2026-08-08
|
||||
**Companion to:** [requirements.md](requirements.md)
|
||||
|
||||
How DarkRoom is built. The requirements document says *what* the software must do; this says *how*,
|
||||
and records the decisions and constraints behind the design.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
DarkRoom is a Rust application with a Slint interface, rendering through wgpu to Vulkan on both
|
||||
Linux and Android. The design is organised around four ideas, each of which the rest of this
|
||||
document elaborates:
|
||||
|
||||
1. **Pixels stay on the GPU.** From decode to display, image data never round-trips through the
|
||||
CPU. This is the constraint that most shapes the codebase (§6.1).
|
||||
2. **Sidecars are authoritative; the catalog is a rebuildable index.** Durability comes from plain
|
||||
text files next to the images, not from a database (§6.12).
|
||||
3. **Operations describe themselves.** The develop pipeline emits parameter descriptors as data;
|
||||
the UI generates controls from them. The core never depends on the UI toolkit (§4.3, §6.5a).
|
||||
4. **Everything is tiled and cancellable.** Work is decomposed into tiles scheduled by visibility,
|
||||
so interaction always preempts background work (§5.3).
|
||||
|
||||
### 1.1 Stack
|
||||
|
||||
| Layer | Choice | Decision |
|
||||
|---|---|---|
|
||||
| Language | Rust | D1 |
|
||||
| UI | Slint | D1, D8 |
|
||||
| GPU | wgpu → Vulkan (Linux + Android) | D1 |
|
||||
| Shaders | Hand-written WGSL | D6 |
|
||||
| RAW decode | rawler; LibRaw fallback behind a trait | D2 |
|
||||
| Catalog | SQLite (WAL) — a rebuildable index | D5, §6.12 |
|
||||
| Colour | lcms2 + GPU-side matrix/LUT transforms | D5 |
|
||||
| Network | reqwest + quick-xml | D7 |
|
||||
| Licence | GPLv3 | D8 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Crate layout
|
||||
|
||||
Dependency direction encodes the layering rule: nothing in `core/` may depend on anything in `ui/`.
|
||||
|
||||
```
|
||||
darkroom/
|
||||
├── core/
|
||||
│ ├── dr-types SourceRef, ImageId, VersionId, ParamValue — shared vocabulary
|
||||
│ ├── dr-catalog SQLite index, scan, query, metadata
|
||||
│ ├── dr-sidecar the authoritative edit store (§6.12)
|
||||
│ ├── dr-decode RawDecoder trait, rawler impl, embedded-preview extraction
|
||||
│ ├── dr-pipeline Operation trait, descriptors, edit graph, registry
|
||||
│ ├── dr-gpu wgpu device, tile scheduler, WGSL shaders, mask rasteriser
|
||||
│ ├── dr-colour lcms2 bindings, camera profiles, working-space transforms
|
||||
│ ├── dr-export encoders, resampling, output sizing
|
||||
│ ├── dr-sync RemoteBackend trait, sync engine, cache rules, merge
|
||||
│ └── dr-sync-nextcloud the only backend implementation (§8.4)
|
||||
├── ui/
|
||||
│ ├── dr-ui Slint components, adaptive layout, descriptor→control mapping
|
||||
│ └── dr-widgets custom controls per WidgetKind (curve, wheel, crop, brush)
|
||||
├── platform/
|
||||
│ ├── dr-plat trait definitions: storage, secrets, lifecycle, power
|
||||
│ ├── dr-plat-linux XDG dirs, Secret Service, filesystem
|
||||
│ └── dr-plat-android SAF, Keystore, WorkManager, lifecycle
|
||||
└── apps/
|
||||
├── darkroom-desktop
|
||||
└── darkroom-android
|
||||
```
|
||||
|
||||
**CI asserts the dependency rule.** A UI dependency appearing in any `core/` crate's tree fails the
|
||||
build. This is easy to violate accidentally — one `use slint::` undoes headless testability and the
|
||||
one-operation-two-presentations property together.
|
||||
|
||||
`platform/` exists so `core/` contains no `#[cfg(target_os)]`. Platform traits are defined in
|
||||
`dr-plat` and injected at construction by the app crates.
|
||||
|
||||
---
|
||||
|
||||
## 3. Core abstractions
|
||||
|
||||
### 3.1 SourceRef — addressing image data
|
||||
|
||||
Android's Storage Access Framework provides no filesystem path (§6.9), so **no core API takes a
|
||||
`Path`**.
|
||||
|
||||
```rust
|
||||
/// An opaque, re-resolvable reference to source image data.
|
||||
pub struct SourceRef(SourceRefInner);
|
||||
|
||||
enum SourceRefInner {
|
||||
/// Desktop: an absolute path under a granted root.
|
||||
Path { root: RootId, relative: PathBuf },
|
||||
/// Android: a SAF document URI under a persisted tree grant.
|
||||
Document { tree: RootId, document_id: String },
|
||||
/// Remote: resolved through the sync layer, possibly byte-range only.
|
||||
Remote { file_id: u64, path: String },
|
||||
}
|
||||
|
||||
impl SourceRef {
|
||||
/// Open a seekable stream. May fail if the source is offline (FR-CAT-9).
|
||||
pub fn open(&self, plat: &dyn Storage) -> Result<Box<dyn SeekableRead>, SourceError>;
|
||||
/// Read a byte range without opening the whole source — the fast path for
|
||||
/// embedded preview extraction (FR-CULL-2) and remote browsing (FR-NC-3).
|
||||
pub fn read_range(&self, plat: &dyn Storage, r: Range<u64>) -> Result<Vec<u8>, SourceError>;
|
||||
}
|
||||
```
|
||||
|
||||
`read_range` is deliberately first-class rather than a convenience over `open`. Extracting an
|
||||
embedded JPEG preview from a remote 80 MB RAW must transfer ~1–3 MB, and the culling preview ladder
|
||||
depends on the same primitive locally.
|
||||
|
||||
### 3.2 RawDecoder
|
||||
|
||||
```rust
|
||||
pub trait RawDecoder: Send + Sync {
|
||||
fn probe(&self, header: &[u8]) -> Option<Format>;
|
||||
fn embedded_preview(&self, src: &mut dyn SeekableRead) -> Result<Option<Preview>, DecodeError>;
|
||||
fn decode(&self, src: &mut dyn SeekableRead) -> Result<RawImage, DecodeError>;
|
||||
fn metadata(&self, src: &mut dyn SeekableRead) -> Result<Metadata, DecodeError>;
|
||||
}
|
||||
```
|
||||
|
||||
Four separate entry points because the caller's needs differ sharply by phase. Culling wants
|
||||
`embedded_preview` and nothing else; the grid wants `metadata`; only develop and export need
|
||||
`decode`. Fusing them would force full decode where a 200 KB range read suffices.
|
||||
|
||||
`RawImage` carries CFA-pattern sensor data plus black/white levels and camera colour matrices — it
|
||||
is *not* demosaiced. Demosaic is a GPU pipeline stage (§5.2).
|
||||
|
||||
### 3.3 Operation and descriptors
|
||||
|
||||
The develop pipeline is a sequence of operations with a uniform interface. Polymorphism is by
|
||||
trait, never inheritance — there is no shared implementation to inherit, only a shared shape.
|
||||
|
||||
```rust
|
||||
pub trait Operation: Send + Sync {
|
||||
/// Static parameter description. Drives UI generation (FR-DEV-3a).
|
||||
fn descriptor() -> OpDescriptor where Self: Sized;
|
||||
|
||||
/// Encode GPU work for one tile. No UI types cross this boundary.
|
||||
fn encode(&self, enc: &mut ComputeEncoder, ctx: &TileContext);
|
||||
|
||||
/// Identity for cache invalidation. Integer state only — exactly
|
||||
/// deterministic, unlike GPU float output (§6.13).
|
||||
fn params_hash(&self) -> u64;
|
||||
|
||||
/// What this operation's parameters affect, for invalidation scoping.
|
||||
fn affects(&self) -> Affects;
|
||||
}
|
||||
```
|
||||
|
||||
Descriptors are pure data:
|
||||
|
||||
```rust
|
||||
pub struct ParamDescriptor {
|
||||
pub id: ParamId,
|
||||
pub label: LocalizedKey, // a key, not a string — core has no localiser
|
||||
pub kind: ParamKind,
|
||||
pub default: ParamValue,
|
||||
pub affects: Affects,
|
||||
}
|
||||
|
||||
pub enum ParamKind {
|
||||
Scalar { min: f32, max: f32, scale: Scale, unit: Unit, precision: u8 },
|
||||
Bool,
|
||||
Enum { variants: Vec<(EnumId, LocalizedKey)> },
|
||||
Colour { has_alpha: bool },
|
||||
/// Controls that don't reduce to primitives. Core names the *kind*;
|
||||
/// dr-widgets owns the implementation.
|
||||
Custom { widget: WidgetKind, schema: CustomParamSchema },
|
||||
}
|
||||
```
|
||||
|
||||
`LocalizedKey` rather than a resolved string is what lets NFR-A11Y-1 work without `core/` depending
|
||||
on a localisation library that pulls in UI concerns. The UI resolves keys against its catalogue.
|
||||
|
||||
### 3.4 Edit graph
|
||||
|
||||
```rust
|
||||
pub struct EditGraph {
|
||||
version: VersionId,
|
||||
ops: Vec<OpInstance>, // ordered; order is data, not code
|
||||
masks: Vec<MaskDef>,
|
||||
}
|
||||
|
||||
pub struct OpInstance {
|
||||
kind: OpKind,
|
||||
enabled: bool,
|
||||
params: BTreeMap<ParamId, ParamValue>,
|
||||
mask: Option<MaskId>, // None = global
|
||||
}
|
||||
```
|
||||
|
||||
`BTreeMap` rather than `HashMap` so serialisation is deterministic — the sidecar format is diffable
|
||||
and the graph hash is stable across runs.
|
||||
|
||||
The graph is **CPU-side state**, which is what makes GPU device-loss recovery tractable (§6.10): the
|
||||
device can be destroyed and rebuilt, and the render re-driven from the graph with nothing lost.
|
||||
|
||||
---
|
||||
|
||||
## 4. Layering
|
||||
|
||||
### 4.1 The four layers
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ apps/ wiring, platform injection │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ ui/ Slint; descriptor → control mapping │
|
||||
│ owns: presentation, gestures, layout │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ core/ catalog, pipeline, sync, export │
|
||||
│ owns: all behaviour and state │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ platform/ storage, secrets, lifecycle, power │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Calls go downward. `core/` reaches `platform/` through traits; `ui/` reaches `core/` through a
|
||||
session API. Nothing calls upward — the UI observes change through subscriptions, not callbacks
|
||||
registered into the core.
|
||||
|
||||
### 4.2 The session API
|
||||
|
||||
`ui/` sees a small surface, not the internals:
|
||||
|
||||
```rust
|
||||
pub struct App { /* … */ }
|
||||
|
||||
impl App {
|
||||
pub fn catalog(&self) -> &Catalog;
|
||||
pub fn open_develop(&self, v: VersionId) -> DevelopSession;
|
||||
pub fn open_cull(&self, filter: Filter) -> CullSession;
|
||||
pub fn export(&self, sel: &[VersionId], preset: &ExportPreset) -> JobHandle;
|
||||
pub fn sync(&self) -> &SyncEngine;
|
||||
/// Change notifications. The UI subscribes; the core never holds UI callbacks.
|
||||
pub fn subscribe(&self) -> Receiver<CoreEvent>;
|
||||
}
|
||||
|
||||
pub struct DevelopSession { /* … */ }
|
||||
|
||||
impl DevelopSession {
|
||||
pub fn descriptors(&self) -> &[OpDescriptor]; // drives panel generation
|
||||
pub fn set_param(&mut self, op: OpId, p: ParamId, v: ParamValue);
|
||||
pub fn set_viewport(&mut self, vp: Viewport);
|
||||
/// The rendered result as a GPU texture handle. Never pixels.
|
||||
pub fn texture(&self) -> TextureHandle;
|
||||
pub fn histogram(&self) -> &HistogramBuffer; // GPU reduction, not readback
|
||||
pub fn undo(&mut self); pub fn redo(&mut self);
|
||||
}
|
||||
```
|
||||
|
||||
`texture()` returning a handle rather than pixel data is the API-level expression of §6.1.
|
||||
|
||||
### 4.3 Descriptor → control mapping
|
||||
|
||||
`dr-ui` maps `ParamKind` to a control by input modality (FR-DEV-3b). The mapping table lives in the
|
||||
UI; the core is unaware presentation varies.
|
||||
|
||||
| `ParamKind` | Pointer | Touch |
|
||||
|---|---|---|
|
||||
| `Scalar` | Slider + numeric entry, wheel fine-adjust | Drag-strip, double-tap reset |
|
||||
| `Bool` | Checkbox | Switch, ≥44pt |
|
||||
| `Enum` | Dropdown | Segmented control or sheet |
|
||||
| `Colour` | Swatch → popover | Swatch → sheet |
|
||||
| `Custom` | `dr-widgets` control | Same control, touch hit-targets |
|
||||
|
||||
Adding an operation therefore requires no UI change (FR-DEV-3c) unless it needs a new `WidgetKind`.
|
||||
|
||||
---
|
||||
|
||||
## 5. GPU architecture
|
||||
|
||||
### 5.1 Device and surface
|
||||
|
||||
One `wgpu::Device` shared by the pipeline and Slint, so compute output composites without an
|
||||
interop layer. Slint's `create_texture_from_hal` imports the texture directly.
|
||||
|
||||
**Spike S1 validates this**, and it is the project's highest-risk assumption. If it fails, D1's
|
||||
recorded fallbacks apply.
|
||||
|
||||
### 5.2 Pipeline stages
|
||||
|
||||
Ordered; each consumes and produces GPU textures. Order is data (§3.4), so operations can be
|
||||
reordered without code changes.
|
||||
|
||||
```
|
||||
RawImage (sensor data, CPU)
|
||||
│ upload
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ black/white levels │ integer normalise
|
||||
├─────────────────────┤
|
||||
│ demosaic │ Bayer or Markesteijn (X-Trans, FR-RAW-5)
|
||||
├─────────────────────┤
|
||||
│ AI denoise │ optional; raw-domain, joint with demosaic where possible
|
||||
├─────────────────────┤
|
||||
│ camera profile │ matrices + per-body base curve (FR-DEV-3e)
|
||||
├─────────────────────┤
|
||||
│ → working space │ linear, wide-gamut, f16
|
||||
├─────────────────────┤
|
||||
│ white balance │
|
||||
│ exposure/contrast │
|
||||
│ highlights/shadows │ ← masks apply per-op from here down
|
||||
│ tone curve │
|
||||
│ HSL / colour mixer │
|
||||
│ texture / clarity │
|
||||
│ spot removal │
|
||||
│ sharpen / NR │
|
||||
│ lens corrections │
|
||||
│ look (HaldCLUT) │ FR-DEV-3f
|
||||
├─────────────────────┤
|
||||
│ geometry │ crop, straighten, rotate
|
||||
├─────────────────────┤
|
||||
│ output transform │ → display or export profile
|
||||
└─────────────────────┘
|
||||
│
|
||||
├─→ display texture (composited by Slint — never read back)
|
||||
└─→ histogram reduction (compute → small buffer, §5.5)
|
||||
```
|
||||
|
||||
Working precision is f16 in a linear wide-gamut space, quantising once at the output transform.
|
||||
|
||||
### 5.3 Tiling and scheduling
|
||||
|
||||
Work decomposes into tiles (default 256×256) scheduled by priority class:
|
||||
|
||||
| Class | Work | Preempts |
|
||||
|---|---|---|
|
||||
| `Interactive` | Visible tiles at current viewport | everything |
|
||||
| `Prefetch` | Tiles just outside viewport; next cull image | Background |
|
||||
| `Background` | Export, thumbnail generation, proxy building | — |
|
||||
|
||||
`Interactive` **strictly preempts** `Background`. Without this, moving a slider during a batch
|
||||
export misses its frame budget — the common case, not an edge case.
|
||||
|
||||
Tile results cache keyed by `(VersionId, tile, zoom, graph_hash_prefix)`, where the prefix covers
|
||||
operations up to the first `Affects` change. Adjusting exposure reuses cached demosaic and camera
|
||||
profile output for every tile.
|
||||
|
||||
### 5.4 Mask rasterisation
|
||||
|
||||
**All masks rasterise on the GPU, including drawn brush strokes** (§6.11). Strokes arrive as
|
||||
parameters — points, radius, hardness, flow — and a compute shader rasterises them. The mask never
|
||||
exists in CPU memory.
|
||||
|
||||
This is a direct response to darktable, where drawn masks are CPU-rasterised and users consistently
|
||||
describe brush lag as making them "unworkable". The problem is architectural, not performance
|
||||
tuning, and not fixable with a faster CPU.
|
||||
|
||||
### 5.5 Histogram without readback
|
||||
|
||||
The histogram is a compute-shader reduction into a small storage buffer, read once per frame at
|
||||
most, and only the *bins* — never image data. A per-frame CPU readback of pixels would reintroduce
|
||||
exactly the stall §6.1 exists to prevent.
|
||||
|
||||
Raw-domain histograms for culling (FR-CULL-3) reduce over the pre-demosaic texture, which is why
|
||||
they can report headroom the embedded JPEG's histogram cannot.
|
||||
|
||||
### 5.6 Device loss
|
||||
|
||||
Handled as expected, not exceptional (§6.10):
|
||||
|
||||
1. Detect `wgpu::SurfaceError::Lost` or device-lost callback
|
||||
2. Discard all GPU-side state — textures, buffers, pipelines
|
||||
3. Recreate device, recompile from the shader cache
|
||||
4. Re-drive the current render from the edit graph
|
||||
|
||||
No user edit is lost, because the graph is CPU-side. Shader pipelines cache on disk keyed by device,
|
||||
driver version, and shader hash (NFR-P12).
|
||||
|
||||
---
|
||||
|
||||
## 6. Data architecture
|
||||
|
||||
### 6.1 Sidecars are authoritative
|
||||
|
||||
The durability model inverts the usual arrangement (§6.12):
|
||||
|
||||
```
|
||||
image.CR3 ← never written to
|
||||
image.CR3.drsc ← authoritative edit state, plain text
|
||||
(or app-managed store where the location is read-only)
|
||||
catalog.sqlite ← index; deletable and rebuildable at any time
|
||||
```
|
||||
|
||||
Sidecar format is versioned plain text holding **all** Versions for an image:
|
||||
|
||||
```toml
|
||||
schema = 1
|
||||
image_hash = "blake3:..."
|
||||
|
||||
[versions.default]
|
||||
name = "Default"
|
||||
created = 2026-08-08T14:02:00Z
|
||||
revision = 7
|
||||
device = "…"
|
||||
|
||||
[versions.default.ops.exposure]
|
||||
enabled = true
|
||||
exposure = 0.35
|
||||
contrast = 12.0
|
||||
```
|
||||
|
||||
Writing discipline, each point addressing a documented failure elsewhere:
|
||||
|
||||
- **Atomic** — temp plus rename, never partial
|
||||
- **Debounced** — not per slider tick
|
||||
- **Only on real content change** — darktable rewrites sidecars without edits, breaking backup
|
||||
deduplication and mtime-based sync
|
||||
- **Never embedded in the RAW** — would force re-backup of the whole file
|
||||
|
||||
### 6.2 The catalog as index
|
||||
|
||||
SQLite in WAL mode, holding what is needed for interactive query over 50k images and nothing
|
||||
authoritative:
|
||||
|
||||
```sql
|
||||
roots(id, kind, grant_blob, label, last_seen)
|
||||
images(id, root_id, source_ref, content_hash, format, w, h,
|
||||
captured_at, camera, lens, iso, aperture, shutter,
|
||||
availability, sidecar_mtime)
|
||||
versions(id, image_id, name, is_default, graph_hash, rating, label, flag)
|
||||
keywords(version_id, keyword)
|
||||
folders(id, root_id, path, etag, parent_id) -- ETag pruning (§6.6)
|
||||
remote(image_id, file_id, etag, sync_state, remote_path)
|
||||
cache(version_id, kind, resolution, graph_hash, path, bytes, last_used)
|
||||
jobs(id, kind, state, payload, attempts) -- resumable across process death
|
||||
```
|
||||
|
||||
`folders.etag` must exist from schema v1 — sync pruning cannot work without persisted folder ETags,
|
||||
and adding it later means a migration plus a full re-scan of every library (§6.6).
|
||||
|
||||
Rebuild reads sidecars and re-derives everything else. This makes NFR-R6's recovery path the normal
|
||||
mechanism rather than a last resort.
|
||||
|
||||
### 6.3 Identity
|
||||
|
||||
| Entity | Key | Stable across |
|
||||
|---|---|---|
|
||||
| Image | content hash | rename, move, re-import |
|
||||
| Version | UUID | everything |
|
||||
| Remote file | `oc:fileid` | server-side rename/move |
|
||||
| Cache entry | `(version, kind, resolution, graph_hash)` | — |
|
||||
|
||||
Content-hash identity is what makes FR-CAT-9's reconnection work: a moved file is recognised rather
|
||||
than re-imported, and a server-side move is not a re-download of 80 MB.
|
||||
|
||||
---
|
||||
|
||||
## 7. Concurrency
|
||||
|
||||
### 7.1 Executors
|
||||
|
||||
| Executor | Threads | Work |
|
||||
|---|---|---|
|
||||
| UI | 1 | Slint event loop. **Never blocks.** |
|
||||
| GPU submit | 1 | Command encoding and queue submission |
|
||||
| Decode | cores − 2 | RAW decode, preview extraction |
|
||||
| I/O | 4 | Catalog, sidecar, cache, filesystem |
|
||||
| Network | 2 | Nextcloud transfer |
|
||||
|
||||
The UI executor never blocks — this is the mechanism behind R4 and NFR-P9, which the requirements
|
||||
state as outcomes without saying how.
|
||||
|
||||
### 7.2 Cancellation
|
||||
|
||||
Cooperative, with tokens threaded through every long operation. Observed within 100 ms
|
||||
(NFR-ARCH-3), including in-flight GPU submissions. Cancelling a scan, export, or sync leaves no
|
||||
partial state beyond what is separately resumable.
|
||||
|
||||
### 7.3 Errors
|
||||
|
||||
No worker panics the process. Errors are typed and attach to the affected image or job:
|
||||
|
||||
```rust
|
||||
pub enum ImageError { Decode(DecodeError), SourceOffline, Gpu(GpuError), … }
|
||||
```
|
||||
|
||||
A decode failure marks one image and continues the batch (FR-RAW-4). A GPU error triggers §5.6
|
||||
recovery. Panics in decode are caught at the boundary, since RAW parsing handles untrusted input.
|
||||
|
||||
---
|
||||
|
||||
## 8. Sync architecture
|
||||
|
||||
Sync is pluggable. `dr-sync` defines a `RemoteBackend` trait; **only the Nextcloud connector is
|
||||
implemented**, but the boundary is designed so S3, generic WebDAV, or a self-hosted photo server can
|
||||
be added without touching the sync engine.
|
||||
|
||||
### 8.1 Why capability negotiation, not a common denominator
|
||||
|
||||
The obvious abstraction — a trait exposing only what every backend can do — would be a mistake here,
|
||||
and it is worth being explicit about why.
|
||||
|
||||
Nextcloud's fast path depends on a behaviour that is **not** a WebDAV guarantee: it propagates ETag
|
||||
changes *up* the folder tree, so an unchanged root ETag proves nothing anywhere in the library
|
||||
changed. That single property is what turns a 50k-image no-op sync into **one HTTP request**. A
|
||||
plain WebDAV server (Apache `mod_dav`, an SFTP mount, a raw S3 bucket) offers no such guarantee, and
|
||||
a trait built to their common subset would force full enumeration on every sync — the same 50k
|
||||
requests the design exists to avoid.
|
||||
|
||||
So backends **declare capabilities**, and the sync engine picks the best available strategy:
|
||||
|
||||
```rust
|
||||
pub struct Capabilities {
|
||||
/// How the backend reports what changed. Determines sync cost.
|
||||
pub change_detection: ChangeDetection,
|
||||
/// Stable identity across server-side rename/move.
|
||||
pub stable_ids: bool,
|
||||
/// Byte-range reads — required for embedded-preview extraction.
|
||||
pub range_reads: bool,
|
||||
/// Resumable upload for large files.
|
||||
pub chunked_upload: Option<ChunkConstraints>,
|
||||
/// Many-small-files upload in one request (sidecars).
|
||||
pub bulk_upload: bool,
|
||||
/// Conditional write for optimistic concurrency.
|
||||
pub conditional_write: bool,
|
||||
/// Server-rendered thumbnails, if any.
|
||||
pub server_previews: ServerPreviews,
|
||||
}
|
||||
|
||||
pub enum ChangeDetection {
|
||||
/// Backend hands us a cursor; we ask "what changed since?".
|
||||
/// Cheapest possible. (No current backend, but the shape most
|
||||
/// object stores and delta APIs fit.)
|
||||
DeltaCursor,
|
||||
/// Directory ETags propagate upward — unchanged parent proves
|
||||
/// unchanged subtree. Nextcloud. One request for a no-op sync.
|
||||
PropagatingEtags,
|
||||
/// ETags exist but only on the entry itself. Must enumerate the
|
||||
/// tree; ETags then avoid re-downloading unchanged content.
|
||||
LocalEtags,
|
||||
/// Nothing but modification times. Enumerate and compare.
|
||||
Timestamps,
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 Strategy per capability tier
|
||||
|
||||
The engine has one strategy per `ChangeDetection` variant, chosen at connect time:
|
||||
|
||||
| Tier | Detection | No-op sync cost, 50k images | Notes |
|
||||
|---|---|---|---|
|
||||
| 1 | `DeltaCursor` | 1 request | Cursor persisted in `remote_state` |
|
||||
| 2 | `PropagatingEtags` | **1 request** | Nextcloud. Root ETag unchanged → done |
|
||||
| 3 | `LocalEtags` | ~1 per folder | Enumerate tree; ETags prevent re-download |
|
||||
| 4 | `Timestamps` | ~1 per folder + clock-skew risk | Degraded; warn the user |
|
||||
|
||||
Tiers 3 and 4 are honest degradations, not silent ones — the UI reports the sync strategy in use so
|
||||
a slow backend is visibly slow rather than mysteriously slow.
|
||||
|
||||
**Capability absence never breaks correctness, only speed** — with two exceptions the engine must
|
||||
handle explicitly:
|
||||
|
||||
- **No `range_reads`** → embedded-preview extraction is impossible, so remote browsing must fall
|
||||
back to server previews or full download. On a metered connection the engine refuses full
|
||||
downloads for browsing and reports why.
|
||||
- **No `conditional_write`** → sidecar conflict detection loses its guarantee. The engine falls back
|
||||
to revision-counter comparison inside the sidecar body, which narrows but does not close the race.
|
||||
This is reported as a reduced-safety mode.
|
||||
|
||||
### 8.3 The trait
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait RemoteBackend: Send + Sync {
|
||||
fn capabilities(&self) -> &Capabilities;
|
||||
fn connect(&mut self, auth: AuthHandle) -> Result<Identity, RemoteError>;
|
||||
|
||||
// ---- discovery -------------------------------------------------
|
||||
/// One level. `since` carries a per-entry validator (ETag/mtime)
|
||||
/// so unchanged entries can be skipped by the backend where possible.
|
||||
async fn list(&self, dir: &RemotePath, since: Option<&Validator>)
|
||||
-> Result<Vec<RemoteEntry>, RemoteError>;
|
||||
|
||||
/// Tier 1 only — capability-gated, returns Unsupported otherwise.
|
||||
async fn delta(&self, cursor: &Cursor)
|
||||
-> Result<(Vec<RemoteChange>, Cursor), RemoteError>;
|
||||
|
||||
/// Tier 2 only — cheap validator for a directory, without listing it.
|
||||
async fn dir_validator(&self, dir: &RemotePath)
|
||||
-> Result<Validator, RemoteError>;
|
||||
|
||||
// ---- transfer --------------------------------------------------
|
||||
async fn get(&self, id: &RemoteId, range: Option<Range<u64>>)
|
||||
-> Result<Bytes, RemoteError>;
|
||||
async fn put(&self, path: &RemotePath, body: Body, precond: Option<Precondition>)
|
||||
-> Result<Validator, RemoteError>;
|
||||
async fn put_many(&self, items: Vec<(RemotePath, Bytes)>)
|
||||
-> Result<Vec<Result<Validator, RemoteError>>, RemoteError>;
|
||||
async fn delete(&self, id: &RemoteId, precond: Option<Precondition>)
|
||||
-> Result<(), RemoteError>;
|
||||
|
||||
// ---- optional --------------------------------------------------
|
||||
async fn thumbnail(&self, id: &RemoteId, size: u32)
|
||||
-> Result<Option<Bytes>, RemoteError>;
|
||||
}
|
||||
```
|
||||
|
||||
Design notes worth keeping:
|
||||
|
||||
- **`get` takes an optional range** rather than having a separate `get_range`. Callers always
|
||||
express what they need; backends without range support return the whole object and the engine
|
||||
slices, so correctness holds while the capability flag tells the caller whether it was cheap.
|
||||
- **`put` takes a `Precondition`**, not a bare ETag — so `IfMatch`, `IfNoneMatch`, and `None`
|
||||
are all expressible, and a backend without conditional writes can reject at compile-time-visible
|
||||
runtime rather than silently ignoring.
|
||||
- **`put_many` is a required method** with a default implementation looping over `put`. Nextcloud
|
||||
overrides it with bulk upload; other backends get correct behaviour for free.
|
||||
- **Chunked upload is not in the trait.** It is an implementation detail of `put` — the backend
|
||||
decides based on body size and its own constraints. Exposing it would leak Nextcloud's protocol
|
||||
into the interface.
|
||||
|
||||
### 8.4 The Nextcloud connector
|
||||
|
||||
The only implementation. Mapping to the trait:
|
||||
|
||||
| Trait method | Nextcloud |
|
||||
|---|---|
|
||||
| `capabilities` | `PropagatingEtags`, stable ids, ranges, chunked (5MB–5GB), bulk, conditional |
|
||||
| `list` | `PROPFIND Depth:1` with `oc:fileid`, `getetag`, `nc:has-preview` |
|
||||
| `dir_validator` | `PROPFIND Depth:0` requesting `getetag` only |
|
||||
| `delta` | `Unsupported` — no RFC 6578 for files (ARCH §6.6) |
|
||||
| `get` + range | `GET` with `Range:`; detect support by `206` vs `200`, never `HEAD` |
|
||||
| `put` large | Chunked v2: `MKCOL` upload dir → `PUT` chunks → `MOVE .file` |
|
||||
| `put_many` | `POST /remote.php/dav/bulk`, `multipart/related` |
|
||||
| `thumbnail` | `/core/preview?fileId=…&forceIcon=false` — `false` is mandatory, or a server that cannot render RAW returns a generic mimetype icon that we would cache as a thumbnail |
|
||||
| auth | Login Flow v2, system browser, app password |
|
||||
|
||||
Pruning walk, the Tier 2 strategy in full:
|
||||
|
||||
```
|
||||
dir_validator(root)
|
||||
├─ unchanged vs stored → done. One request, whole library.
|
||||
└─ changed → list(dir, since)
|
||||
├─ child dirs whose validator differs → recurse
|
||||
└─ files whose validator differs → queue transfer
|
||||
```
|
||||
|
||||
`folders.etag` must exist from schema v1. Adding it later means a migration plus a full re-scan of
|
||||
every user's library (ARCH §6.6).
|
||||
|
||||
### 8.5 Sidecar conflict resolution
|
||||
|
||||
`put` with `Precondition::IfMatch(validator)`. On precondition failure:
|
||||
|
||||
1. `get` the remote sidecar
|
||||
2. **Merge per Version, per operation** — a crop on one device and an exposure change on another
|
||||
both survive, because they touch disjoint operations
|
||||
3. Genuinely conflicting operations resolve by device timestamp
|
||||
4. Retry with the new validator, bounded retry count
|
||||
|
||||
No `(conflicted copy)` files. Sidecars are a few KB of structured data, so read-merge-rewrite is
|
||||
cheap and preserves intent — a meaningful improvement over file-level conflict copies, which is what
|
||||
the sidecar-authoritative model (ARCH §6.12) buys.
|
||||
|
||||
---
|
||||
|
||||
## 9. Caching and availability policy
|
||||
|
||||
Sync decides *what changed*. Caching policy decides *what is kept locally* — a separate concern, and
|
||||
the one that determines whether the app is usable on a tablet with a 2 TB library behind it.
|
||||
|
||||
### 9.1 The three tiers, restated as policy
|
||||
|
||||
| Tier | Content | Default |
|
||||
|---|---|---|
|
||||
| Metadata | Catalog rows, sidecars, ratings | Always synced; kilobytes; even on metered |
|
||||
| Preview | Embedded JPEGs, display proxies | LRU within a size cap |
|
||||
| Original | Full RAW | Never by default — explicit pin or on-demand |
|
||||
|
||||
Never bulk-sync originals. A 2 TB library against a 128 GB tablet makes mirror-style sync unusable
|
||||
(ARCH §6.8) — which is precisely where the official Nextcloud desktop client's model fails.
|
||||
|
||||
### 9.2 Cache rules
|
||||
|
||||
A **cache rule** pins a *set* of images at a *tier*. Rules are user-authored, evaluated against the
|
||||
catalog, and re-evaluated as it changes — so "the last three months" stays current without the user
|
||||
touching it.
|
||||
|
||||
```rust
|
||||
pub struct CacheRule {
|
||||
pub id: RuleId,
|
||||
pub selector: Selector,
|
||||
pub tier: Tier, // Preview | Original
|
||||
pub priority: u8, // eviction order; higher survives longer
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
pub enum Selector {
|
||||
Collection(CollectionId),
|
||||
Folder { root: RootId, path: PathBuf, recursive: bool },
|
||||
/// Absolute or rolling. `Rolling` re-evaluates daily.
|
||||
DateRange(DateSelector),
|
||||
Rating { min: u8 },
|
||||
Label(ColourLabel),
|
||||
Flag(FlagState),
|
||||
Keyword(String),
|
||||
/// Boolean composition, so "5-star OR flagged, in the last year" works.
|
||||
All(Vec<Selector>),
|
||||
Any(Vec<Selector>),
|
||||
Not(Box<Selector>),
|
||||
}
|
||||
|
||||
pub enum DateSelector {
|
||||
Between { from: Date, to: Date },
|
||||
/// "Last 90 days" — window moves with the clock.
|
||||
Rolling { days: u32 },
|
||||
/// "This trip" — bounded by a collection's own capture range.
|
||||
CollectionSpan(CollectionId),
|
||||
}
|
||||
```
|
||||
|
||||
Worked examples of what this expresses:
|
||||
|
||||
| Intent | Rule |
|
||||
|---|---|
|
||||
| Trip on the tablet | `Collection(trip) → Original` |
|
||||
| Recent work at full quality | `Rolling{90} → Original` |
|
||||
| Portfolio always available | `Rating{min:5} → Original`, high priority |
|
||||
| Everything browsable | `All → Preview` (the implicit default rule) |
|
||||
| Client edit on the road | `All([Collection(job), Flag(Pick)]) → Original` |
|
||||
|
||||
### 9.3 Evaluation and eviction
|
||||
|
||||
Rules produce a **desired state** per image; the sync engine reconciles actual against desired:
|
||||
|
||||
```
|
||||
for each image:
|
||||
desired = max(tier of every matching enabled rule) // most generous wins
|
||||
if desired > actual → enqueue fetch (priority = rule priority)
|
||||
if desired < actual → mark evictable (not evicted immediately)
|
||||
```
|
||||
|
||||
Eviction is lazy and only under pressure — cache cap reached (NFR-RES-4) or platform memory pressure
|
||||
(FR-PLAT-AND-5). Order: unpinned originals by last-used, then proxies, then thumbnails. **Never
|
||||
metadata or sidecars** — those are authoritative (ARCH §6.12) and tiny.
|
||||
|
||||
An image that leaves a rolling window is not deleted at midnight; it becomes the first candidate when
|
||||
space is actually needed. Fetching is likewise opportunistic: queued at the rule's priority behind
|
||||
anything interactive, and on Android gated by the unmetered-and-charging constraints in FR-NC-6.
|
||||
|
||||
### 9.4 Honesty about what is local
|
||||
|
||||
Adobe's sync fails on *legibility* more than transport: Lightroom Classic syncs only 2560px proxies
|
||||
while displaying the original's filename, extension, and size, so users genuinely do not know what
|
||||
they have. DarkRoom shall not repeat this.
|
||||
|
||||
- Every image carries a visible availability state: `Original` · `Preview` · `Metadata only` ·
|
||||
`Offline`
|
||||
- Operations needing data that is absent say so *before* starting, with the transfer size
|
||||
- Export from a preview-only image is refused, not silently degraded
|
||||
- A pinned set reports its true byte cost before the user commits to it
|
||||
|
||||
### 9.5 Storage
|
||||
|
||||
```sql
|
||||
cache_rules(id, selector_json, tier, priority, enabled)
|
||||
image_cache(image_id, tier_actual, tier_desired, bytes, last_used, pinned_by_rule)
|
||||
```
|
||||
|
||||
`tier_desired` is materialised rather than recomputed per query, so the grid can render availability
|
||||
badges without evaluating every rule for every visible cell.
|
||||
|
||||
---
|
||||
|
||||
## 10. Platform abstraction
|
||||
|
||||
```rust
|
||||
pub trait Storage: Send + Sync {
|
||||
fn roots(&self) -> Vec<RootId>;
|
||||
fn enumerate(&self, root: RootId, cb: &mut dyn FnMut(SourceRef)) -> Result<(), StorageError>;
|
||||
fn open(&self, src: &SourceRef) -> Result<Box<dyn SeekableRead>, StorageError>;
|
||||
fn read_range(&self, src: &SourceRef, r: Range<u64>) -> Result<Vec<u8>, StorageError>;
|
||||
fn writable_sidecar_dir(&self, src: &SourceRef) -> Option<PathBuf>;
|
||||
}
|
||||
|
||||
pub trait Secrets: Send + Sync { /* Secret Service | Android Keystore */ }
|
||||
pub trait Lifecycle: Send + Sync { /* process death, memory pressure, background */ }
|
||||
```
|
||||
|
||||
| Concern | Linux | Android |
|
||||
|---|---|---|
|
||||
| Storage | Filesystem under granted roots | SAF tree URIs, `DocumentsContract` |
|
||||
| Secrets | Secret Service (libsecret) | Keystore + DataStore + Tink |
|
||||
| Background | Threads | WorkManager, foreground service |
|
||||
| Lifecycle | Process lives | Death at any moment; `onTrimMemory` |
|
||||
|
||||
`writable_sidecar_dir` returns `None` on read-only mounts and most SAF trees, which is when the
|
||||
app-managed sidecar store is used.
|
||||
|
||||
---
|
||||
|
||||
## 11. Build order
|
||||
|
||||
Independent of D12's resolution, the foundations are shared. What differs is what gets built on top
|
||||
first.
|
||||
|
||||
**Phase 0 — spikes.** S1 (Slint+wgpu zero-copy), S2 (Android, two GPU vendors), S10 (SAF at 10k
|
||||
files), S11 (Play permissions), S9 (cross-platform tolerance). These can invalidate the
|
||||
architecture; everything else assumes they pass.
|
||||
|
||||
**Phase 1 — foundations, needed either way.** `dr-types`, `dr-plat` + both implementations,
|
||||
`dr-catalog`, `dr-sidecar`, `dr-decode` (preview path first), `dr-gpu` device and tile scheduler.
|
||||
|
||||
**Phase 2 — depends on D12:**
|
||||
|
||||
- *Culler-first:* preview ladder, culling mode, raw histogram, focus peaking, ratings, burst
|
||||
grouping. Ships something usable without any develop chain.
|
||||
- *Vertical-slice-first:* minimal develop chain (exposure, curve), export, proving the full
|
||||
architecture end to end but not yet useful.
|
||||
|
||||
**Phase 3+:** the remaining develop operations, masking, sync, ingest, AI denoise.
|
||||
|
||||
---
|
||||
|
||||
## 12. Architectural constraints
|
||||
|
||||
Decisions treated as fixed because reversing them is expensive. Each is evidence-backed.
|
||||
|
||||
### 6.1 GPU results never round-trip through the CPU
|
||||
|
||||
darktable identifies this as their single biggest bottleneck: OpenCL output returns to the GTK
|
||||
thread, which paints it on one CPU core. It constrains the UI framework choice more than any other
|
||||
requirement — whatever renders the UI must composite our GPU texture directly.
|
||||
|
||||
**Measured on this project** (Radeon RX 7900 XTX, `cargo run -p dr-gpu --example bench
|
||||
--features readback`), comparing the compute pass alone against compute plus a CPU readback:
|
||||
|
||||
| Canvas | Compute | With readback | Readback share |
|
||||
|---|---|---|---|
|
||||
| 840×692 | 0.06 ms | 0.63 ms | 90% |
|
||||
| 1920×1080 | 0.11 ms | 2.35 ms | 95% |
|
||||
| 3840×2160 | 0.28 ms | 7.43 ms | **96%** |
|
||||
|
||||
At 4K the shader finishes in 0.28 ms and then 7.15 ms is spent moving pixels through the CPU — a
|
||||
26× overhead that scales with area, which is why an uncapped window resize falls off a cliff. The
|
||||
constraint is not a stylistic preference; it is the dominant cost in the frame.
|
||||
|
||||
### 6.2 Tiling from day one
|
||||
|
||||
Mobile GPUs have far less memory. Retrofitting tiling into a whole-image pipeline is a rewrite.
|
||||
|
||||
### 6.3 Non-destructive edit graph, GPU-resident
|
||||
|
||||
Recompute from the graph rather than caching CPU bitmaps between stages.
|
||||
|
||||
### 6.4 GPU-first, not GPU-accelerated
|
||||
|
||||
RawTherapee shows excellent quality is achievable on CPU — and that retrofitting GPU into a mature
|
||||
CPU pipeline effectively never happens.
|
||||
|
||||
### 6.5 The catalog is the UI's source of truth for queries
|
||||
|
||||
The UI never touches storage or network synchronously.
|
||||
|
||||
### 6.5a The core has no UI dependency
|
||||
|
||||
Operations describe parameters as data. This buys headless golden-image testing, one-operation-two-
|
||||
presentations, and UI replaceability. CI-enforced.
|
||||
|
||||
### 6.6 Sync cannot rely on server-side change tokens
|
||||
|
||||
**Verified:** Nextcloud's `Directory.php` does not implement `ISyncCollection`; sync tokens exist
|
||||
only for CalDAV/CardDAV (`nextcloud/server#22584`, open since 2020). Folder ETags must be persisted
|
||||
from schema v1.
|
||||
|
||||
### 6.7 Server-side RAW previews are not assumable
|
||||
|
||||
**Verified:** no RAW preview provider ships with Nextcloud. Range-based embedded extraction is the
|
||||
primary path; server previews are opportunistic. `preview_max_filesize_image` defaults to 50 MB,
|
||||
excluding many RAWs even where a provider exists.
|
||||
|
||||
### 6.8 Sync is selective, not mirror-style
|
||||
|
||||
### 6.9 Android forbids the filesystem-scan model
|
||||
|
||||
**Verified:** `MANAGE_EXTERNAL_STORAGE` is not grantable — Play policy explicitly disallows "media
|
||||
files access" as a permitted use. `READ_MEDIA_IMAGES` would not help: proprietary RAW is not typed
|
||||
`image/*` by the platform scanner, so it does not appear in `MediaStore.Images`. SAF is the only
|
||||
path, and it provides no filesystem path — hence `SourceRef` (§3.1).
|
||||
|
||||
### 6.10 GPU device loss is expected
|
||||
|
||||
Routine on Android: backgrounding, driver resets, thermal events. Recovery is tractable because the
|
||||
edit graph is CPU-side.
|
||||
|
||||
### 6.11 Drawn masks are GPU-rasterised
|
||||
|
||||
darktable rasterises parametric masks on GPU and drawn masks on CPU; users describe drawn masking as
|
||||
"unworkable" and the lag is not fixable with more cores. Highest-value architectural win available
|
||||
relative to the incumbents, and free if designed in now.
|
||||
|
||||
### 6.12 Sidecars authoritative, catalog disposable
|
||||
|
||||
darktable maintains both a database and sidecars while achieving the reliability of neither —
|
||||
documented lockouts, corruption where all three recovery paths failed, sidecars silently not written
|
||||
in 5.0.0. RawTherapee's `.pp3` is the model: plain text, next to the file, no database in the trust
|
||||
path.
|
||||
|
||||
### 6.13 Bit-identity applies to integer state only
|
||||
|
||||
GPU float results diverge across vendors — transcendental implementations differ, drivers optimise
|
||||
differently, f16 rounding varies. Cache keys and graph hashes are computed over CPU-side integer
|
||||
state, which is exactly deterministic. Cross-platform *rendering* equality is a bounded tolerance
|
||||
(R1), not a checksum.
|
||||
|
||||
---
|
||||
|
||||
## 13. Decisions
|
||||
|
||||
Full rationale in [requirements.md §8](requirements.md). Summary:
|
||||
|
||||
| # | Decision | Status |
|
||||
|---|---|---|
|
||||
| D1 | Rust + Slint + wgpu | Decided |
|
||||
| D2 | rawler, LibRaw fallback behind a trait | Decided |
|
||||
| D3 | First milestone | **Provisional — depends on D12** |
|
||||
| D4 | Nextcloud: ETag pruning, chunked upload, Login Flow v2 | Resolved |
|
||||
| D5 | lcms2 + GPU-side transforms | Decided |
|
||||
| D6 | Hand-written WGSL | Decided |
|
||||
| D7 | reqwest + quick-xml | Decided |
|
||||
| D8 | GPLv3 | Decided |
|
||||
| D9 | Declarative operation descriptors | Decided |
|
||||
| D10 | Single adaptive interface | Decided |
|
||||
| D11 | Product positioning | Decided |
|
||||
| D12 | Scope versus pace | **Open** |
|
||||
|
||||
---
|
||||
|
||||
## 14. Open questions
|
||||
|
||||
**D12 — scope versus pace.** The selected feature set implies years of full-time work against a
|
||||
stated evenings-and-weekends budget. Resolving it sets D3's milestone and §11's Phase 2.
|
||||
|
||||
**NFR-R8 — CPU fallback extent.** §6.4 says GPU-first; NFR-RES-2 assumes a CPU fallback on
|
||||
allocation failure. Decide whether that means a full CPU pipeline (a second implementation) or only
|
||||
tile-spill staging. Recommend the latter.
|
||||
|
||||
**Slint accessibility on Android.** Unverified; may be a toolkit gap (NFR-A11Y-2). Cheaper to
|
||||
discover before the UI is built.
|
||||
|
||||
**Play Store versus GPLv3.** Generally workable but should be confirmed, with F-Droid as fallback.
|
||||
@@ -0,0 +1,321 @@
|
||||
# DarkRoom v0.1 — Remote library viewer
|
||||
|
||||
**Status:** Draft · 2026-08-08
|
||||
**Companion to:** [requirements.md](requirements.md) · [architecture.md](architecture.md)
|
||||
|
||||
The first buildable milestone: connect to a Nextcloud folder, index it locally, and display RAW
|
||||
previews on both Linux and Android.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this is
|
||||
|
||||
A photo *viewer*, not an editor. It connects to a Nextcloud account, lets the user pick a folder,
|
||||
indexes what is there into a local catalog, and displays images by extracting their embedded JPEG
|
||||
previews over byte-range requests.
|
||||
|
||||
**It exists to prove the architecture is sound before anything is built on it.** Four assumptions
|
||||
in the design would each be expensive to discover wrong later, and this milestone tests all four
|
||||
against real servers, real files, and real devices.
|
||||
|
||||
### 1.1 Why this shape
|
||||
|
||||
The alternative first milestone (D3's vertical slice: local scan → grid → open → two edits →
|
||||
export) proves the pipeline but is useful to nobody and tests nothing about sync or SAF. This
|
||||
milestone is comparable in size, exercises the genuinely risky parts, and produces something you
|
||||
can actually point at a server and use.
|
||||
|
||||
### 1.2 What it deliberately excludes
|
||||
|
||||
No editing. No develop pipeline, no sidecars, no export, no culling mode, no ratings. Those depend
|
||||
on foundations this milestone establishes; adding them before the foundations are proven risks
|
||||
building on sand.
|
||||
|
||||
---
|
||||
|
||||
## 2. The four assumptions under test
|
||||
|
||||
Each maps to a spike in [requirements.md §9](requirements.md).
|
||||
|
||||
| # | Assumption | If wrong | Spike |
|
||||
|---|---|---|---|
|
||||
| **A1** | Slint can composite a wgpu compute texture zero-copy, on both platforms | ARCH §6.1 fails; D1's fallbacks apply; **the framework choice is wrong** | S1, S2 |
|
||||
| **A2** | Android SAF can enumerate and range-read at library scale | FR-CAT-1a and the Android performance targets fail | S10 |
|
||||
| **A3** | HTTP Range extraction of embedded previews works against Nextcloud | Remote browsing on mobile data is not viable; ARCH §6.7 fails | S4 |
|
||||
| **A4** | reqwest does TLS on Android without unmanageable pain | D7's escape hatch needed | S3 |
|
||||
|
||||
**A1 is the one that would hurt most.** It determines whether Rust + Slint was the right call at
|
||||
all, so it should be proven in the first week, before catalog or sync work begins.
|
||||
|
||||
---
|
||||
|
||||
## 3. Functional scope
|
||||
|
||||
Requirement IDs reference [requirements.md](requirements.md); a v0.1 suffix marks a reduced subset
|
||||
of the full requirement.
|
||||
|
||||
### 3.1 Account and connection
|
||||
|
||||
**M-1 — Login.** Connect to a Nextcloud instance via Login Flow v2 (FR-NC-1): POST to
|
||||
`/index.php/login/v2`, open the returned URL in the **system browser**, poll until an app password
|
||||
arrives. The app never handles the user's primary password.
|
||||
|
||||
`User-Agent` identifies the device so the resulting app password is revocable per-device.
|
||||
|
||||
**M-2 — Credential storage.** Store the app password in platform secure storage (FR-NC-2) — Secret
|
||||
Service on Linux, Keystore-backed on Android. Never in the catalog, never in logs.
|
||||
|
||||
**M-3 — Folder selection.** Browse the remote tree and choose one folder as the library root.
|
||||
Recursive descent is in scope; multiple roots are not.
|
||||
|
||||
**M-4 — Disconnect.** Revoke via `DELETE /ocs/v2.php/core/apppassword` and clear local credentials.
|
||||
|
||||
### 3.2 Indexing
|
||||
|
||||
**M-5 — Remote listing.** `PROPFIND Depth:1` walking the chosen folder recursively, requesting
|
||||
`oc:fileid`, `getetag`, `getcontentlength`, `getlastmodified`, `resourcetype`, and
|
||||
`nc:has-preview`. Files whose extension matches the supported set (§3.3) are catalogued; others are
|
||||
ignored.
|
||||
|
||||
**M-6 — Catalog.** Persist to SQLite in WAL mode. v0.1 schema is a strict subset of ARCH §6.2:
|
||||
|
||||
```sql
|
||||
schema_version(version)
|
||||
|
||||
accounts(id, server_url, login_name, user_id)
|
||||
|
||||
folders(id, account_id, parent_id, remote_path, etag, last_listed)
|
||||
|
||||
images(id, account_id, file_id, remote_path, etag, size, remote_mtime,
|
||||
format, captured_at, camera, lens, iso, aperture, shutter,
|
||||
width, height, availability)
|
||||
|
||||
previews(image_id, kind, width, height, bytes, path, last_used)
|
||||
```
|
||||
|
||||
`folders.etag` is present **from schema v1** — ARCH §6.6 requires it, and retrofitting means a
|
||||
migration plus a full re-scan of every library.
|
||||
|
||||
`schema_version` exists from the first commit so NFR-R5's migration machinery has somewhere to
|
||||
start.
|
||||
|
||||
**M-7 — Incremental re-listing.** On subsequent syncs use ETag pruning (FR-NC-4): `PROPFIND
|
||||
Depth:0` on the root, and if its ETag is unchanged, **stop** — one request proves the whole library
|
||||
is unchanged. Where changed, `Depth:1` and recurse only into folders whose ETags differ.
|
||||
|
||||
This is the mechanism that has to work for the library to scale, so v0.1 exercises it deliberately
|
||||
rather than always re-listing.
|
||||
|
||||
**M-8 — Offline browsing.** The catalog is queryable with no network. Previously indexed images
|
||||
appear with their metadata and any cached previews. Availability is visible per image (FR-NC-6c):
|
||||
*Preview* or *Metadata only*.
|
||||
|
||||
### 3.3 RAW handling
|
||||
|
||||
**M-9 — Formats.** The FR-RAW-1 launch set: CR2, CR3, NEF, ARW, RAF, RW2, ORF, DNG. Plus JPEG, so
|
||||
a mixed folder displays sensibly.
|
||||
|
||||
**M-10 — Range-based preview extraction.** Display without downloading whole files:
|
||||
|
||||
1. Range-read the first 256 KB of the file
|
||||
2. Parse the container to locate the embedded JPEG preview (offset and length)
|
||||
3. Range-read exactly those bytes
|
||||
4. Decode the JPEG and cache it locally
|
||||
|
||||
Typical cost 1–3 MB against 25–100 MB for the full file. **This is what makes the app usable on
|
||||
mobile data**, and it is assumption A3.
|
||||
|
||||
Range support is detected by issuing a `Range` request and checking for `206` versus `200` —
|
||||
**never** by probing with `HEAD`, since Nextcloud does not advertise `Accept-Ranges` (ARCH §6.7).
|
||||
|
||||
**M-11 — Fallbacks, in order.** Where step 2 finds no usable preview:
|
||||
|
||||
1. Server preview via `/core/preview?fileId=…&forceIcon=false` where `nc:has-preview` is true.
|
||||
**`forceIcon=false` is mandatory** — the default returns a generic mimetype icon for files the
|
||||
server cannot render, which would otherwise be cached as though it were a thumbnail.
|
||||
2. Full download and decode via rawler, on explicit user action only, never automatically.
|
||||
3. Placeholder with an explanatory state.
|
||||
|
||||
**M-12 — Metadata.** Extract from the same header range already fetched in M-10: camera make and
|
||||
model, lens, capture time, ISO, aperture, shutter, dimensions. No second request.
|
||||
|
||||
### 3.4 Display
|
||||
|
||||
**M-13 — Grid.** Virtualised thumbnail grid (FR-CAT-4) rendering only visible cells plus a prefetch
|
||||
margin, with bounded memory independent of folder size.
|
||||
|
||||
**M-14 — Single image view.** Full-window display of one image, with fit and 1:1 zoom, pan, and
|
||||
next/previous navigation.
|
||||
|
||||
**M-15 — GPU display path.** Decoded previews upload to GPU textures and composite through Slint
|
||||
via `create_texture_from_hal`. **Pixels are never read back to the CPU** (ARCH §6.1).
|
||||
|
||||
This is assumption A1, and the reason it is in v0.1 at all: it is far cheaper to discover a
|
||||
compositing problem now than after a develop pipeline is written against it.
|
||||
|
||||
**M-16 — Adaptive layout.** Compact and expanded layout classes (FR-UI-1) driven by window size,
|
||||
not device type. Touch targets meet 44pt when touch is the active modality (FR-UI-3).
|
||||
|
||||
### 3.5 Platform
|
||||
|
||||
**M-17 — Linux.** X11 and Wayland. XDG base directories for catalog, cache, and config. Secret
|
||||
Service for credentials.
|
||||
|
||||
**M-18 — Android.** Storage Access Framework only (FR-PLAT-AND-1) — no `MANAGE_EXTERNAL_STORAGE`,
|
||||
no `READ_MEDIA_IMAGES`. v0.1 reads remote content, so SAF matters for the *cache* location and for
|
||||
proving the `SourceRef` abstraction holds before local library support arrives.
|
||||
|
||||
Keystore-backed credential storage. Process death mid-browse restores the current folder and scroll
|
||||
position (FR-PLAT-AND-3).
|
||||
|
||||
---
|
||||
|
||||
## 4. Explicitly out of scope
|
||||
|
||||
Listed so absence reads as a decision.
|
||||
|
||||
| Excluded | Arrives in |
|
||||
|---|---|
|
||||
| Any editing, develop pipeline, sidecars | v0.2 |
|
||||
| Export | v0.2 |
|
||||
| Culling mode, ratings, flags, labels | v0.3 |
|
||||
| Focus peaking, raw histogram | v0.3 |
|
||||
| Local (non-Nextcloud) library scanning | v0.2 |
|
||||
| Upload, bi-directional sync, conflict merge | v0.4 |
|
||||
| Cache rules and pinning (FR-NC-6a) | v0.4 |
|
||||
| Multiple accounts or roots | later |
|
||||
| Ingest from card | later |
|
||||
| Search and filter beyond folder navigation | v0.3 |
|
||||
|
||||
**Read-only against the server.** v0.1 performs no `PUT`, `MOVE`, or `DELETE` on remote content.
|
||||
This removes conflict handling and chunked upload from scope entirely, and means a bug cannot
|
||||
damage the user's library.
|
||||
|
||||
---
|
||||
|
||||
## 5. Acceptance criteria
|
||||
|
||||
Measured against a reference Nextcloud instance holding **≥5,000 RAW files**, on the reference
|
||||
desktop and **two Android devices with different GPU vendors** (Adreno and Mali).
|
||||
|
||||
| # | Criterion | Target |
|
||||
|---|---|---|
|
||||
| **AC-1** | Login through to first grid render | < 30 s for 5,000 files |
|
||||
| **AC-2** | Re-sync with nothing changed | **1 HTTP request** |
|
||||
| **AC-3** | Grid scroll, cached previews | 60 fps sustained, both platforms |
|
||||
| **AC-4** | Bytes transferred per image, preview path | < 3 MB average |
|
||||
| **AC-5** | Single-image display from cache | < 100 ms |
|
||||
| **AC-6** | Offline launch → browsable grid | < 2 s desktop, < 4 s Android |
|
||||
| **AC-7** | Memory, 5,000-image folder open | < 400 MB desktop, < 200 MB Android |
|
||||
| **AC-8** | GPU readback of image data | **Zero occurrences** — asserted by instrumentation |
|
||||
| **AC-9** | Android process death mid-browse | Folder and scroll position restored |
|
||||
| **AC-10** | Catalog after forced kill during sync | Opens clean, resumes |
|
||||
|
||||
**AC-2 and AC-8 are the load-bearing ones.** AC-2 proves ETag pruning works, which is what lets the
|
||||
library scale. AC-8 proves ARCH §6.1 holds — and it is asserted by instrumentation rather than
|
||||
inspection, because a readback introduced later would otherwise pass unnoticed until it showed up
|
||||
as unexplained slowness.
|
||||
|
||||
---
|
||||
|
||||
## 6. Build order
|
||||
|
||||
### Phase 0 — de-risk (before anything else)
|
||||
|
||||
Throwaway code answering the four assumptions. If A1 fails, stop and revisit D1 rather than
|
||||
building on it.
|
||||
|
||||
1. **A1 / S1** — wgpu compute writes a texture; Slint composites UI over it; Linux. Test on
|
||||
Mesa/AMD, Intel, and NVIDIA proprietary, under X11 and Wayland.
|
||||
2. **A1 / S2** — the same on both Android devices.
|
||||
3. **A4 / S3** — reqwest HTTPS PROPFIND from an Android device, including the
|
||||
`rustls-platform-verifier` Kotlin init.
|
||||
4. **A3 / S4** — range-extract an embedded JPEG from a CR3, NEF, and ARW on a real server; measure
|
||||
bytes.
|
||||
5. **A2 / S10** — SAF enumeration and range reads over a 10k-file tree; measure against the
|
||||
NFR-P1/P3 figures.
|
||||
|
||||
### Phase 1 — foundations
|
||||
|
||||
`dr-types` (`SourceRef`, ids) · `dr-plat` traits and both implementations · `dr-catalog` (v0.1
|
||||
schema, migrations from commit one) · `dr-gpu` (device, texture upload, Slint bridge).
|
||||
|
||||
### Phase 2 — connectivity
|
||||
|
||||
`dr-sync` (`RemoteBackend` trait) · `dr-sync-nextcloud` (Login Flow v2, PROPFIND, ETag pruning,
|
||||
range GET) · credential storage.
|
||||
|
||||
### Phase 3 — imaging
|
||||
|
||||
`dr-decode` (container parsing, embedded-preview location, JPEG decode, metadata) · preview cache.
|
||||
|
||||
### Phase 4 — interface
|
||||
|
||||
`dr-ui` (grid, single-image view, adaptive layout, folder picker, connection flow).
|
||||
|
||||
### Phase 5 — hardening
|
||||
|
||||
Offline behaviour · process death · cancellation · error surfaces · the AC suite in CI.
|
||||
|
||||
**Both platforms build in CI from the first commit.** This is the point of choosing both from day
|
||||
one: an Android break is caught the day it lands, not at a porting milestone.
|
||||
|
||||
---
|
||||
|
||||
## 7. Crates in play
|
||||
|
||||
A subset of ARCH §2. Crates not listed are not created yet.
|
||||
|
||||
```
|
||||
core/
|
||||
dr-types ✓ SourceRef, ImageId, AccountId, Validator
|
||||
dr-catalog ✓ v0.1 schema subset, queries, migrations
|
||||
dr-decode ✓ preview extraction + metadata; no demosaic
|
||||
dr-gpu ✓ device, texture upload, Slint bridge; no pipeline
|
||||
dr-sync ✓ RemoteBackend trait, ETag pruning engine
|
||||
dr-sync-nextcloud ✓ the connector
|
||||
dr-pipeline ✗ v0.2
|
||||
dr-colour ✗ v0.2
|
||||
dr-export ✗ v0.2
|
||||
dr-sidecar ✗ v0.2
|
||||
ui/
|
||||
dr-ui ✓ grid, viewer, connection flow
|
||||
dr-widgets ✗ v0.2 (no custom controls yet)
|
||||
platform/
|
||||
dr-plat ✓ Storage, Secrets, Lifecycle traits
|
||||
dr-plat-linux ✓
|
||||
dr-plat-android ✓
|
||||
apps/
|
||||
darkroom-desktop ✓
|
||||
darkroom-android ✓
|
||||
```
|
||||
|
||||
`dr-gpu` exists in v0.1 **only** to upload decoded JPEGs and hand textures to Slint. No compute
|
||||
pipeline, no tiling, no masks. It is deliberately the thinnest thing that still proves A1.
|
||||
|
||||
---
|
||||
|
||||
## 8. Decisions this milestone informs
|
||||
|
||||
| Decision | What v0.1 tells us |
|
||||
|---|---|
|
||||
| **D12** — scope vs pace | How long a milestone of this size actually takes at the available pace. The single most useful output. |
|
||||
| **D3** — first milestone | Supersedes the vertical slice, if this proves the better shape. |
|
||||
| **D1** — Rust + Slint | AC-8 and A1 either confirm the framework choice or reopen it. |
|
||||
| **NFR-COMPAT-1** — hardware baseline | Two real Android devices give the baseline actual numbers instead of a placeholder. |
|
||||
| **D7** — network stack | Whether the reqwest Android TLS path is a half-day or a fortnight. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Risks
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|---|---|---|
|
||||
| Slint `create_texture_from_hal` doesn't work as documented | Medium | Phase 0 first; D1 records fallbacks |
|
||||
| SAF enumeration too slow at 10k files | Medium | S10 measures before commitment; batch and cache aggressively |
|
||||
| Embedded previews too small or absent on some bodies | High | Known — Sony embeds small previews, some bodies none. M-11's fallback chain handles it; detect per camera model |
|
||||
| reqwest Android TLS worse than expected | Medium | D7 escape hatch: `tls_certs_only` with `webpki-roots` |
|
||||
| GPU vendor divergence on Android | Medium | Two vendors in CI from the start |
|
||||
| Scope creeps toward editing | **High** | §4 is explicit; v0.1 is read-only against the server |
|
||||
|
||||
The last one is the real risk. A viewer that works is a strong temptation to add "just one slider."
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "dr-ui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dr-types.workspace = true
|
||||
dr-gpu.workspace = true
|
||||
slint = { workspace = true, features = ["compat-1-2", "renderer-femtovg", "backend-winit"] }
|
||||
wgpu.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
pollster.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
slint-build.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Temporary CPU readback path; see dr-ui docs and spike S1.
|
||||
readback = ["dr-gpu/readback"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
slint_build::compile("ui/app.slint").expect("compiling app.slint");
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//! Slint interface for DarkRoom.
|
||||
//!
|
||||
//! v0.1 exists to validate assumption A1: compute output reaching the screen
|
||||
//! without a CPU round-trip (ARCH §6.1).
|
||||
//!
|
||||
//! **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.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Result;
|
||||
use dr_gpu::{GpuContext, RenderTarget};
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
impl FrameClock {
|
||||
fn new() -> Self {
|
||||
let now = Instant::now();
|
||||
Self {
|
||||
last: now,
|
||||
accum: 0.0,
|
||||
frames: 0,
|
||||
fps: 0,
|
||||
start: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
now.duration_since(self.start).as_secs_f32()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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());
|
||||
|
||||
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)));
|
||||
|
||||
// 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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
// 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());
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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.
|
||||
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);
|
||||
}
|
||||
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.
|
||||
const EXPANDED_MIN_WIDTH: f32 = 820.0;
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Theme } from "theme.slint";
|
||||
|
||||
// Status strip — surfaces the GPU backend and adapter, which matters during
|
||||
// v0.1 because assumption A1 is exactly "does this compositing path work on
|
||||
// this hardware". Seeing the adapter at a glance makes vendor differences
|
||||
// obvious during the S1/S2 spikes.
|
||||
component StatusBar inherits Rectangle {
|
||||
in property <string> adapter;
|
||||
in property <string> backend;
|
||||
in property <string> layout-class;
|
||||
in property <int> fps;
|
||||
|
||||
height: 28px;
|
||||
background: Theme.surface;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Theme.gap;
|
||||
padding-right: Theme.gap;
|
||||
spacing: Theme.gap;
|
||||
alignment: start;
|
||||
|
||||
Text {
|
||||
text: root.backend;
|
||||
color: Theme.accent;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.adapter;
|
||||
color: Theme.ink-dim;
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
|
||||
Text {
|
||||
text: root.layout-class;
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.fps + " fps";
|
||||
color: root.fps >= 55 ? Theme.ink-dim : Theme.accent;
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
y: parent.height - 1px;
|
||||
height: 1px;
|
||||
background: Theme.rule;
|
||||
}
|
||||
}
|
||||
|
||||
// A placeholder panel standing in for the adjustment controls that FR-DEV-3a
|
||||
// will generate from operation descriptors.
|
||||
component SidePanel inherits Rectangle {
|
||||
background: Theme.surface;
|
||||
|
||||
VerticalLayout {
|
||||
padding: Theme.gap;
|
||||
spacing: Theme.gap;
|
||||
alignment: start;
|
||||
|
||||
Text {
|
||||
text: "DEVELOP";
|
||||
color: Theme.accent;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Controls are generated from operation\ndescriptors in v0.2 (FR-DEV-3a).";
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 1px;
|
||||
background: Theme.rule;
|
||||
}
|
||||
}
|
||||
|
||||
export component AppWindow inherits Window {
|
||||
title: "DarkRoom";
|
||||
background: Theme.ground;
|
||||
preferred-width: 1100px;
|
||||
preferred-height: 720px;
|
||||
min-width: 360px;
|
||||
min-height: 320px;
|
||||
|
||||
// Set from Rust each frame: the compute output, delivered as a texture.
|
||||
in property <image> canvas;
|
||||
in property <string> adapter: "detecting…";
|
||||
in property <string> backend: "—";
|
||||
in property <int> fps: 0;
|
||||
|
||||
// FR-UI-1: layout class follows window width, not device type. A narrow
|
||||
// desktop window gets the compact layout, exactly as a tablet would.
|
||||
//
|
||||
// Set from Rust rather than derived from `root.width` here: a property
|
||||
// read inside the layout and also feeding it creates a binding loop,
|
||||
// which Slint warns about and which can panic at runtime.
|
||||
in property <bool> expanded: true;
|
||||
in property <string> layout-class: "expanded";
|
||||
|
||||
callback canvas-resized(int, int);
|
||||
callback window-resized(length);
|
||||
|
||||
// One-way: report width outward, never read layout back into it.
|
||||
changed width => { root.window-resized(self.width); }
|
||||
|
||||
VerticalLayout {
|
||||
StatusBar {
|
||||
adapter: root.adapter;
|
||||
backend: root.backend;
|
||||
layout-class: root.layout-class;
|
||||
fps: root.fps;
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
// The canvas: compute output composited directly. No CPU
|
||||
// round-trip anywhere in this path (ARCH §6.1).
|
||||
canvas-area := Rectangle {
|
||||
horizontal-stretch: 1;
|
||||
background: Theme.ground;
|
||||
clip: true;
|
||||
|
||||
Image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
source: root.canvas;
|
||||
image-fit: contain;
|
||||
}
|
||||
|
||||
// Report size changes so the render target can be resized to
|
||||
// match. Width and height are tracked separately because
|
||||
// Slint has no single "geometry changed" hook.
|
||||
property <int> px-w: Math.round(self.width / 1px);
|
||||
property <int> px-h: Math.round(self.height / 1px);
|
||||
changed px-w => { root.canvas-resized(self.px-w, self.px-h); }
|
||||
changed px-h => { root.canvas-resized(self.px-w, self.px-h); }
|
||||
}
|
||||
|
||||
// Always instantiated, width collapsed to zero in compact mode.
|
||||
// A conditional `if` here creates a binding loop — the layout
|
||||
// depends on `expanded`, which derives from the window width,
|
||||
// which the layout then influences. Slint flags it, and it can
|
||||
// panic at runtime.
|
||||
SidePanel {
|
||||
width: root.expanded ? 260px : 0px;
|
||||
visible: root.expanded;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Darkroom safelight palette. Warm neutrals, single red accent.
|
||||
// Committed to a dark ground — this is a photo editor, and a light UI
|
||||
// surrounding an image biases how that image is judged.
|
||||
|
||||
export global Theme {
|
||||
out property <color> ground: #14120F;
|
||||
out property <color> surface: #1D1A16;
|
||||
out property <color> surface-raised: #262119;
|
||||
out property <color> rule: #332C24;
|
||||
|
||||
out property <color> ink: #F0EAE0;
|
||||
out property <color> ink-dim: #A79E91;
|
||||
out property <color> ink-faint: #7C7367;
|
||||
|
||||
out property <color> accent: #D9543C;
|
||||
out property <color> accent-dim: #8F2E1E;
|
||||
|
||||
out property <length> gap-sm: 6px;
|
||||
out property <length> gap: 12px;
|
||||
out property <length> gap-lg: 20px;
|
||||
|
||||
out property <length> text-sm: 11px;
|
||||
out property <length> text: 13px;
|
||||
out property <length> text-lg: 17px;
|
||||
out property <length> text-xl: 24px;
|
||||
|
||||
// FR-UI-3: minimum 44pt hit target under touch.
|
||||
out property <length> touch-target: 44px;
|
||||
}
|
||||
Reference in New Issue
Block a user