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

323 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |
| **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."