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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user