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,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."
|
||||
Reference in New Issue
Block a user