Files
DarkRoom/docs/milestone-v0.1.md
T
dtourolle 9717e59909 Add RAW decode and a working image viewer
dr-decode exposes four entry points rather than one decode, because
callers differ sharply in what they need (ARCH §3.2): culling wants a
preview, the grid wants metadata, only develop and export touch sensor
data. Fusing them forces a full decode where a header read suffices,
which is why Lightroom stalls ~2s per image while culling.

Smoke-tested against 1,852 real Canon CR2 files (EOS 6D, ~27MB each):

  metadata      0.2ms   from a 256KB header read, no full decode
  preview     ~250ms   5472x3648, downscaled to 2048 for display
  jpeg          3.0ms

Two findings worth recording:

rawler 0.7.2's CR2 decoder implements only full_image; thumbnail_image
and preview_image are unimplemented trait defaults returning None. So
every rung of the preview ladder resolves to a full-resolution decode at
~250ms — 5x over NFR-P13's 50ms budget. CR2 does carry smaller IFDs, so
the fix is our own IFD walk or an upstream contribution. The ladder is
written now so that fixing it is a decoder change, not a change to every
caller. Recorded in milestone-v0.1 risks.

Preview.downscale_to bounds memory: a 5472x3648 RGBA preview is 79.8MB,
which exhausts a phone's budget after a handful of images. Box-filtered
so downscaled thumbnails do not alias.

Also fixed a RefCell double-borrow that panicked on first navigation —
`*x.borrow_mut() = *x.borrow() + 1` holds both borrows at once. Verified
with 10,000 programmatic navigations.

58 tests passing. Traceability 20.3% (29/143).
2026-08-09 08:28:26 +02:00

14 KiB
Raw Blame History

DarkRoom v0.1 — Remote library viewer

Status: Draft · 2026-08-08 Companion to: requirements.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.

# 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; 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:

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
rawler exposes only full-resolution previews Confirmed Measured 2026-08-09: rawler 0.7.2's CR2 decoder implements full_image only; thumbnail_image/preview_image are unimplemented defaults. Every rung resolves to a 5472×3648 decode at ~250 ms, 5× over NFR-P13. CR2 does carry smaller IFDs, so the fix is our own IFD walk or an upstream contribution — not a change to callers
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."