f87bf6ebc08ee73507e05db2cfd2a314a55124e1
25
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e8b072c815 |
Say the app is starting before there is anything to say it with
Nothing from our own code reached logcat on the tablet: 284 lines from the app's PID during a launch, every one of them from Hwaps, BlockMonitor, InputEventReceiver or nativeloader, and not even the version banner android_main emits three statements in. I could not find the fault in our wiring, and this commit does not claim to fix it. What it does is make the next launch say which side is at fault, and close a hole that is real whatever the answer turns out to be. What reading rules out, so nobody repeats it: install does call log::set_max_level. The Config carries an explicit tag and an explicit max level, and its env_filter is None, so android_logger's enabled() and filter_matches() both pass an Info record. Tee::enabled delegates to the console and gates nothing else. set_boxed_logger cannot have failed — its Err path drops the Tee, taking the LogFile with it, and the file on the device has content. And Tee::log calls console.log() unconditionally *before* the file write, which is itself gated on console.enabled(), so every line that reached the file proves the AndroidLogger was handed the same record. Nothing else in the graph installs a logger; android-activity and Slint's backend do not. The diff that introduced this changed the level, the tag and the Config not at all — init_once and set_boxed_logger leave the same logger installed at the same level. That leaves below __android_log_write, which no amount of reading this file can reach. So: the console logger is now built first, and one line goes through it directly, before the state directory and before the log file. Two things follow. Everything between android_main's first statement and install returning — external_data_path, create_dir_all and an open on a FUSE volume the system may still be mounting — currently has no surface at all to fail on; that window is what logcat is for, and it now has a line in it. And when the log is silent, that line separates the two cases: present with the log::info! lines below it missing is the facade, absent along with them is liblog not delivering this process's records. It goes through Log::log rather than log::info!, which is not a style choice: the facade's maximum level is Off until install sets it, so a log::info! there compiles and emits nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
328fda6f7c |
Merge: mask a whole category, not just one instance
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> # Conflicts: # apps/darkroom-desktop/Cargo.toml # docs/traceability.md |
||
|
|
763dfd353a |
Weigh the categories in the same precompute, and mask with them
The scene model shipped with a decoder and no caller. This runs it. ## Beside the instance pass, not instead of it `compute` now does both on the same upright frame and lays both back down the same way, so instance masks and category masks index into one grid — the sensor's. A failure in the scene half is logged and dropped rather than propagated: no scene model is an ordinary state, and a photograph that can still be masked by subject should not become unopenable because the categories are missing. Categories under half a percent of the frame never reach the cache. A control that does nothing when moved is worse than an absent one, and each one it skips is a proxy-sized buffer not allocated. ## The shader needed nothing A category reaches `dr-gpu` as a soft coverage buffer at proxy resolution, turned into a distance field — which is exactly what a subject is. So they share `MODE_SUBJECT`. That is not a shortcut taken for speed: the shader has no way to tell them apart and no reason to want one. What differs is only which model produced the coverage, and that has already happened by then. Feather, falloff, dilation and erosion therefore work on a category on the day it arrives, because they were never subject-specific. ## Where the weights come from `scene-model` compiles the graph in and the desktop app takes it; Android leaves it off and reads the copy `install_bundled_models` unpacks, because 24 MB of constant is worth avoiding in a mobile install and not worth the plumbing to avoid on a desktop one. Embedded is tried first — a build that has the weights compiled in should not be silently overridden by a stale file in a data directory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
509c3a3e96 |
Merge: a log that survives the process, so the tablet can be debugged
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> # Conflicts: # apps/darkroom-android/Cargo.toml # apps/darkroom-desktop/Cargo.toml # platform/dr-plat/src/lib.rs |
||
|
|
d70dcf78d1 |
Merge: recover a damaged catalog, and capture a crash locally
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7312aceded |
Get the scene model onto the devices that need it
The decoder can load from a path; nothing yet put a file at one. Four packaging routes, and one lookup that finds the result. ## Not `include_bytes!`, unlike the instance model The instance model is 11 MB and compiled in, which was the right call for it: Android hands the app no filesystem path (ARCH §6.9) and 11 MB is tolerable. The scene model is 24 MB, and 35 MB of constants in the binary is paid by every install whether or not the tab is ever opened. So it follows `models/face/` instead — carried as an APK asset, unpacked once at first launch into the shared directory a desktop install already uses, after which every lookup finds it where it finds a desktop user's. Assets are stored rather than deflated in the APK, so unpacking is a copy rather than an inflate. `embedded-scene-model` exists for the desktop build with nowhere else to read from, and for tests wanting the real graph. Off by default, which is the asymmetry with `embedded-model` and the reason for a separate feature. ## Three files, all or none `scene_model` insists on the graph, its vocabulary and the category descriptor together, for the reason `face_models` insists on its pair: a graph alone decodes to 150 anonymous channels. Reporting the set missing beats starting and failing at the first inference. ## The two model sets are not the same kind of thing `install_bundled_models` now carries both, and the distinction is worth keeping in view. Face weights are absent from the repository *by design* — the InsightFace grant is research-only (docs/faces.md §2) — so a build carrying none is ordinary. The scene model is committed, so a build carrying none means a checkout without `git lfs pull`. Neither is fatal. A photo editor that refuses to start over a missing grading feature is worse than one that starts without it, so both report themselves unavailable exactly as face indexing already did. The LFS-pointer guards apply to the `.onnx` only. The vocabulary and the descriptor are legitimately a few kilobytes, and a size check that fails on them would be a guard against the wrong thing. `scene_model` is exported ahead of the tab that will consume it so the packaging added here has something to be verified against — assets written where no lookup looks would be a silent mistake for as long as the tab took to arrive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
465f5a7ffa |
Keep the log after the session that produced it
Everything this application knew about a failure went to stderr on the desktop and to logcat on Android, and both are gone the moment the terminal closes or the ring buffer wraps. That is fine when the person debugging is sitting at the machine. It is useless for the case NFR-OPS-1 actually describes, and the one Android makes normal: somebody reproduces a bug on a tablet, and then sends us a file. A large amount of Android behaviour has never run on a device — image intents read over JNI, an ExportProvider, a class loaded through the activity's class loader, memory-pressure eviction, lost-root recovery — and the single most likely failure of the lot, the activity's loader not resolving our classes from android_main's thread, produces one line that scrolls past. That line is now in a file, with the thread that emitted it named beside it. dr_plat::state answers "where does this platform keep state for this app": $XDG_STATE_HOME/darkroom on Linux, and on Android whatever the entry point declares. Separate from configuration and from the catalog for the reason XDG separates them — state is the thing nobody backs up and the user may delete without consequence. dr_plat::diagnostics is the sink. Two files of 4 MiB, so the worst case is a number rather than a discovery on a full phone; one line per write with no BufWriter anywhere, because on Android processes are killed rather than ended and a buffered log loses exactly the line it was kept for; and redaction applied at the sink rather than at the call sites, since a rule every author has to remember is not a rule. It tees the platform's own logger rather than replacing it, so logcat is unchanged — losing that while debugging would have made this a downgrade. Android logs to external_data_path, not internal. Both are app-private and both survive backgrounding; what separates them is that /data/data/<pkg>/files needs run-as against a debuggable build to read and /sdcard/Android/data/<pkg>/files is a plain adb pull from any build. A log nobody can retrieve is not a diagnostic. The consequence is that anyone holding the tablet can read it, which is why the redaction is where it is, and why configuration stays on internal_data_path. What is redacted is what NFR-SEC-2 and NFR-OPS-1 name: credentials and tokens, found by the keyword that nearly always sits next to them, plus the two forms that carry one with no keyword at all — an Authorization scheme and a URL's userinfo. What is deliberately not redacted is filesystem paths and the names of the user's photographs. They are in neither requirement's list, and "failed to decode <redacted>" is not a diagnostic; the preview-and-consent step NFR-OPS-1 asks for governs those better than scrubbing would, because it lets the user look. The over-redaction failure is tested as carefully as the under-redaction one. A scrubber that eats "using basic sRGB as the fallback" makes a log useless without ever being caught. |
||
|
|
35954dfa1d |
Write a panic down where it can still be read, with nothing in it that names the user
NFR-OPS-2 is two sentences — local crash capture always, upload only on explicit opt-in — and what existed was one `log::error!` in the Android entry point and nothing at all on desktop. So a panic on desktop went to stderr and died with the terminal, and a panic on Android went to a logcat ring buffer that is gone long before anyone reports anything. What the user saw either way was a job that stopped or a control that went dead, with nothing to send. That matters more here than it would in most applications, because NFR-ARCH-4 says no worker error may panic the process and the code is written that way: errors are typed and attached to the image or job they belong to. A panic is therefore by construction a bug — an invariant this codebase believed and got wrong — and it was the one class of failure with no trace. `dr_plat::crash` writes a record to the XDG state directory: version, time, os, arch, thread, panic location, message, backtrace. In dr-plat rather than in either entry point because "where does this platform let an application keep state" is a platform question, and Android's answer is neither XDG nor `temp_dir` — `set_state_dir` takes it from `internal_data_path`, the same place `dr_sync::account::set_data_dir` gets its answer. The hook resolves the directory when it fires rather than when it is installed, which is what lets it go in before everything else and cover the startup it would otherwise miss. **The content rule is the substance of this, not the plumbing.** NFR-SEC-2 forbids credentials in logs and plain files; the same reasoning applies with more force to what this application is actually about, because a user's library is private and so is its shape. `/home/anna/Photos/2019 Divorce/` says something about a person, and a crash record is exactly the file someone attaches to a bug report while trying to be helpful. So `redact` runs over the message *and* the backtrace, and is deliberately blunt: anything containing a slash goes, `content://` and `primary:DCIM/...` included, since SAF names a library just as precisely as a path does; anything beside a word like `password` goes; a long opaque run with letters and digits in it goes, which is the shape of an app password nobody labelled. The one exception is a `.rs` path, which keeps its basename — a backtrace with no filenames is close to useless and `library.rs:1270` says nothing about anybody. Over-redaction costs legibility. Under-redaction costs a user something they cannot take back. Those are not comparable, so the boundary is not the place to be clever. NFR-SEC-5 — face data never in a crash report, under any configuration — is met structurally rather than by filtering: this module reads no catalog, opens no image, touches no account. A record is assembled from the panic hook's own arguments and `std::env::consts`, and there is no code path from here to an embedding. The message length cap is the backstop for a payload some other module formatted something large into. stderr is the one surface that still sees the message unredacted, deliberately: the previous hook is chained rather than replaced, so a developer watching a terminal does not lose the panic because the application started writing files. It is ephemeral, local, and never attached to a report. **No upload path, and not half of one** — no endpoint, no queue, no "send this later" flag. Opt-in upload needs a server to receive it and a consent flow stating what leaves the device (NFR-SEC-4, and the preview-and-consent step NFR-OPS-1 requires of the diagnostics bundle). Neither exists, and a transport built ahead of its consent is the shape of thing that later gets switched on by default. Leaves NFR-OPS-1 cheaper by three things it will want unchanged: `state_dir` (the log belongs at `state_dir()/log` beside `crash/`, so the diagnostics bundle has one directory to collect), `redact` (NFR-OPS-1's "automatic redaction of credentials and tokens" is this function), and `prune` (a size-capped rotation is this, counting bytes instead of files). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
09dddde594 |
Take the photograph another app hands over, and hand an export back
FR-PLAT-AND-6 asks for two things this app did neither of: be a receiver for image view and share intents, and share exported results out through a FileProvider. The manifest declared one activity with one MAIN/LAUNCHER filter, so nothing on the device ever offered DarkRoom for a photograph, and there was no route out at all — Android has refused file:// URIs between apps since API 24, and a content:// URI needs a provider to be behind it. Inbound. Three filters now: VIEW for a gallery or a file manager, SEND and SEND_MULTIPLE for the share sheet, all on image/*. `android_main` reads the launch Intent before it gives `app` away to Slint, and what comes back is passed to `dr_ui::run` exactly as argv is on the desktop — `startup_action` already treats a non-empty list as "the user asked for these specifically", which is what a share is. The URIs are copied into the cache before the viewer opens, and that cost is real: a shared raw file is written once, in full, on the startup path. A content:// URI is a handle into another app's provider, not a path, and the decoders take paths; the alternative is teaching the whole read path about URIs, which is FR-PLAT-AND-1's SAF connector and is not built. Outbound. ExportProvider serves one directory — getFilesDir(), which is the same path `internal_data_path` gives the Rust side — and refuses everything else by canonicalising the request and checking it is inside that root, so `../` and a planted symlink fail the same test. Not AndroidX's FileProvider, because AndroidX is a Maven artefact and this build has no resolver; what it does is a hundred lines and they are here. The share half has no caller. The provider, the URI grant and the chooser are all in place, but the control that would invoke them belongs in `ui/dr-ui`, and wiring it needs an `AndroidApp` the interface can reach. It is documented as unwired and deliberately not tagged as covering the requirement. `launchMode="singleTask"` comes with the filters and is not decoration: another app can now launch this activity while it is running, and the default mode answers that by creating a second NativeActivity in the same process — a second android_main, a second Slint backend, a second wgpu device. The cost of the fix is stated in the manifest: a share arriving while DarkRoom is already open brings it forward without opening the image, because onNewIntent has no route through android-activity's event stream. The Java is Java because Android constructs it: a ContentProvider is instantiated by the system from its manifest entry, and getIntent() exists only on an activity object. Both directions live there rather than in JNI so that what crosses the boundary is two method signatures instead of forty, each of which is a string checked at run time and nowhere else. What a test can hold: the declarations. Nothing about an Intent or a ContentProvider is reachable from `cargo test`, but an intent filter that is deleted takes the app out of every "open with" menu silently, and an authority that stops matching its class raises a SecurityException inside somebody else's app. The tests in lib.rs read the manifest and ExportProvider.java through `include_str!` and hold both to that, on the host, which is the only place in the workspace that looks at either file from Rust. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2b812ebe21 |
Give memory back in the order the user will miss it least
FR-PLAT-AND-5. Android asks for memory back through onTrimMemory and kills the process if it is not given; until now nothing listened, so the answer was always "no". A tiered registry answers instead: GPU caches first, then proxies, then thumbnails, driven from android_main on MainEvent::LowMemory and MainEvent::Stop. The order is the argument. A backgrounded app has no window to draw and therefore no use for a render pipeline, while its thumbnails are exactly what the user will be looking at half a second after they come back -- so going into the background frees only the GPU tier, and only being measured against death frees everything. Sinks register beside the cache they free and hold weak handles, so the registry cannot keep a controller -- and every decoded portrait in it -- alive past the interface it belonged to. `try_borrow_mut` and skip: a warning can land mid-render, freeing textures under the code drawing with them is worse than missing one, and a warning not acted on is always followed by another. The GPU test is the one that matters: an eviction must change no pixel. A freed intermediate pool whose `colour_key` promise still stands renders an empty texture, and nothing else would have caught it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f12aece07e |
Make storage pluggable, and prove it with a folder backend
`RemoteBackend` existed from the first release and bought nothing it was
designed for. Seven files in `dr-ui` constructed a `NextcloudBackend`
directly, an account *was* a server URL beside a DAV user id, the local
cache directory was named after a hostname, and the launch screen knew
that signing in meant a browser handshake. The trait was real; the seam
was documentation.
A trait over operations is only a quarter of it. Pluggable storage needs
four things, and this adds the other three:
- **Capabilities** — already there, and the reason the engine can drive
two backends at the speed each actually runs at.
- **Configuration** — `dr_sync::Account`: where a library lives, in
whatever form its connector addresses, with no server in it. Loads
every existing config unchanged (`backend` defaults to `nextcloud`,
`endpoint` is stored under its historical `server` key), and
`Account::namespace()` reproduces the old catalog directory byte for
byte, because changing it would abandon a catalog, its thumbnail
shards, and the sidecars holding unsynced offline work.
- **Registration** — `BackendProvider` and `BackendRegistry`.
`ui/dr-ui/src/remote.rs` is now the only file above `dr-sync` that
names a connector.
`Connection` (an account plus an optional `Secret`) replaces the
credentials-and-user-id pair that was threaded through fifteen
signatures in an order that could be swapped. `Secret`'s inner string is
reachable only through `expose()` and its `Debug` prints `Secret(***)`,
so the indirect leak — a `{:?}` on anything holding one — no longer
compiles into a leak.
Nextcloud is unchanged and keeps every peculiarity: propagating ETags,
chunked upload v2, `oc:fileid`, the `oc:permissions` probe on a refused
PUT, the 423 retry classification, Login Flow v2. Those are what the
capability model exists to serve, not something to hide.
`dr-sync-folder` is the second connector: a local disk, a network mount,
an external drive, or a folder a Nextcloud client already syncs. No
account, no credential — the route that works where no secrets daemon
does. It declares `LocalEtags` rather than claiming propagation a POSIX
directory cannot provide, which costs nothing because 50k `stat` calls
are not 50k PROPFINDs. Identity is a path hash, not an inode: an inode
survives a rename but differs between devices and is reused after a
delete, so two machines would disagree about which photograph a
thumbnail belonged to. Re-deriving a thumbnail is a cost; showing the
wrong one is a bug.
docs/storage.md is the contract — the traits, the four steps to add a
backend, and what each connector declares. ARCH §8.0 and §8.4a, and
FR-NC-13, say why.
|
||
|
|
2d95807542 |
Package the models on every platform, not just the phone
The Android bundling landed the weights under that platform's asset directory, which was the wrong home the moment a second packager wanted them. `makepkg -si` produced a desktop install with no model at all — the same "no face model is installed" the phone used to show, for the same reason: nothing put the files anywhere the app looks. So `models/face/` at the root is the one copy, and both packagers read it: assemble-apk.sh bundles it as APK assets, and the PKGBUILD installs it to /usr/share/darkroom/models. Both refuse an LFS pointer rather than shipping a 130-byte file that fails inside the graph loader on a user's machine. `face_models` now searches three places, most specific first: the account's own directory, the shared user directory, then $XDG_DATA_DIRS. So a packaged pair is found automatically and a pair the user placed by hand still outranks it — which is what keeps a deliberate choice of weights from being overridden by an upgrade. $XDG_DATA_DIRS rather than a hard-coded /usr/share: that is the variable a distribution, a prefix install or a Nix-style store already sets to say where its data went, and its documented default is exactly the two paths that would otherwise have been hard-coded. Empty on Android, which has no such directories — there the APK's copy is unpacked into the shared user directory instead, because an asset inside a package is not a path anything can read from. Verified: the APK still carries both models at assets/models/, the PKGBUILD parses and installs from the new path, 467 tests pass. Includes the pkgver 0.6.0 → 0.7.0 bump that was already sitting uncommitted in the working tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
eaafacc3fb |
Give the phone the model it had no way to obtain
Face indexing was compiled into the APK all along — dr-ui takes dr-face with `inference` on every target, so SCRFD, alignment, MBF, calibration and clustering were all in there. What was missing was the weights, and on Android there was no way to supply them. Route C (docs/faces.md §2.2) says the user obtains the model and the app loads it. On a desktop that is a real gesture: drop two files in ~/.local/share/darkroom/models/ and indexing starts working. On Android it is not a gesture at all. `internal_data_path` is app-private, `run-as` needs a debuggable build, and the in-app fetch route C specifies was never built — so the settings page reported "no face model is installed" on every launch with nothing behind the message. Not "off until you supply weights"; off. So the shape-fixed pair goes into LFS under the APK's assets, assemble-apk.sh copies it into the package, and `android_main` unpacks it to the shared models directory before anything asks whether a model is present. Three things that are not incidental: The models directory is now shared across accounts rather than per-account. Weights are identified by `faces.model_id`, not by who is signed in, so two accounts had no reason to hold two copies — and the unpack runs before any session exists to key a per-account path off. `face_models` still prefers a per-account directory when one is populated, so anyone mid-migration keeps the ability to pin one library to its own pair. The unpack writes under a temporary name and renames. `face_models` decides availability on `is_file()` alone, so a copy truncated by the process being killed would leave a file that passes that test and fails inside tract — reported to the user as a broken model rather than a missing one. assemble-apk.sh refuses an LFS pointer. At ~130 bytes it looks exactly like a model to `cp`, and unchecked it reaches the device and fails in the graph loader instead of telling someone to run `git lfs pull` — the same guard dr-segment's build script applies to yolo26n-seg.onnx. The licensing half is unchanged and recorded in §2.2a: the InsightFace grant is research-only, this is a private repository and a self-installed build, and these files come back out before anything is published. The weights are still not a cargo build input — dr-face has no `models/` directory and no `embedded-model` feature, and nothing in the build reads them. The APK assembly step copies two files and is the only thing in the tree that knows they exist. Verified on device: both models unpack on first launch (2524817 and 13616095 bytes) and the APK carries them at assets/models/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d78c9a27fd |
Exit desktop process immediately after window close
window.run() returning unwinds through main, which races a still-live zbus/keyring background thread's TLS teardown and aborts with "thread local ... during or after destruction". Skip that unwind with a hard exit once the run succeeds; dr_ui::run itself is left alone since the Android entry point also calls it and must not be exited. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
3cfa78cde2 |
Give the app a face and a name on the launcher
There was no icon anywhere, and on Android that was not a missing line in the manifest. `aapt2 link` was being handed a manifest and nothing else, so the APK carried no res/ and no resources.arsc — there was no table for an `@mipmap/...` reference to resolve against even if one had been written. Packaging now compiles the resource tree first and links the result in, which is the two steps aapt2 insists on: link reads compiled input only, never a directory. That absent table is also why the launcher caption was blank, which had looked like a second, separate bug. `android:label="DarkRoom"` was there and correct the whole time, and Settings' App info read it fine; the launcher could not, because resolving a label goes through the package's Resources and there were none to open. Nothing about the label changed here. It came back with the table under it. `android:icon` then names one drawable for both icon generations, because the `anydpi-v26` qualifier is what separates them. API 26 and up take the adaptive icon and its three layers; the third of those, monochrome, is what lets Android 13 recolour it rather than drop the app out of the themed set. Below 26 the same name lands on a density-matched PNG. `roundIcon` is deliberately absent — a launcher old enough to read it is one that would ignore the adaptive XML, and minSdk is 28. The desktop icon is one `@image-url` on the window, and the only raster asset in a UI that is otherwise entirely Path. The reasoning at the top of icons.slint does not reach it: that is about glyphs a font might not carry, and this image is never drawn by us at all. It goes to the window manager, which wants pixels and composites them unmasked, so it is pre-shaped with rounded corners rather than square the way the Android layers are. Which exposed Slint's resource default. An `@image-url` compiles down to the absolute path it had on the build machine, to be opened at runtime — already wrong for Android, where the build happens under /work inside a container and no such directory exists on the device, and wrong silently, as an image that loads empty. `EmbedFiles` puts the bytes in the binary instead. It reaches nothing else, since every glyph is a Path. Verified on a device: the APK installs and the home screen draws both the icon and "DarkRoom" under it, where before it had neither. In the link step the adaptive icon resolves at all six densities and resources.arsc lands uncompressed, which API 30 requires and the existing zipalign preserves. On the desktop by reading _NET_WM_ICON off the running window — 256x256, as handed over. Where that actually shows is narrower than it sounds, and the comment says so: Wayland ignores the property in favour of matching app_id against an installed .desktop file, which this repo does not install. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cf8f5b632f |
Show the develop frame itself, instead of a photocopy of it
The oldest open item in the project (ARCH §6.1, spike S1, AC-8). Every frame
in develop was read off the GPU into a `SharedPixelBuffer` and handed back to
Slint to upload again: ~7 ms at 4K against a 0.28 ms compute pass, 96% of the
frame spent carrying pixels to the CPU and back so they could be drawn where
they already were.
Slint 1.17 will adopt a `wgpu::Texture` directly, and the whole of what that
needs is arrangement rather than code.
**One device, made before the window.** A texture belongs to the device that
allocated it, so the compute passes and the compositor cannot each open their
own. `GpuContext::new_shared` opens one and hands back the instance and
adapter alongside it; `dr_ui::shared_gpu` gives all four to
`BackendSelector::require_wgpu_29(WGPUConfiguration::Manual { .. })`. That
call has to come before the first window, because creating one selects a
backend for you — which is why the GPU is now opened at the top of `run`
rather than two hundred lines down beside the other controllers.
dr-gpu still names no UI type. It hands out raw wgpu and does not ask who is
compositing (ARCH §6.5a).
**Vulkan only on the shared path**, where headless keeps its GL fallback.
wgpu's GL backend reaches its display through EGL at instance creation, and
before a window exists there is no display handle to give it — so a GL
instance cannot later produce the window surface Slint needs from it. A
machine with no Vulkan gets no shared device and browses without develop,
which is the same degradation as no adapter at all.
**`renderer-femtovg` becomes `renderer-femtovg-wgpu`.** The old one is FemtoVG
over OpenGL and cannot be handed a wgpu texture at all. It is not kept
alongside as a fallback: FemtoVG-over-GL has no branch for an imported
texture, falls through to "render this image into a buffer", gets nothing, and
draws nothing — a blank canvas with no error, which is worse than the failure
it would be papering over. The consequence is stated plainly in the manifest:
the desktop app now needs a working wgpu adapter to open a window.
**Two output textures, not one, and this is the part that is not obvious.**
Slint repaints when the image property *changes*, and it decides that with
`PartialEq` — which for two images over the same `wgpu::Texture` says
"unchanged". A pass that reused a single target would have rendered every
slider move correctly on the GPU and shown none of them: right, and invisible.
`AdjustPass` alternates between two targets, so consecutive frames are
genuinely different values. It also settles the read-while-write question that
one queue was already answering.
`RENDER_ATTACHMENT` is added to both render targets. Neither pass uses it;
Slint rejects an imported texture without it, on the reasoning that a
compositor handed a texture may need to draw into it.
**`AdjustPass::read_output` is deleted rather than gated.** It and
`export_pixels` were the same transfer under two names, and the comments
explaining why they were separate are the point of the whole criterion:
reading pixels back to *display* them is the defect, reading them back to
*encode a file* is the only way a file is made. The display twin is now gone
outright, which is stronger than a feature flag — it cannot be turned back on.
`export_pixels` is untouched and still ungated. The `readback` feature comes
off dr-ui, darkroom-desktop and darkroom-android; it stays in dr-gpu, where it
still gates `RenderTarget::read_pixels` and the segmentation field readback.
`examples/develop` moves to `export_pixels`, which is honest — it writes a
PPM — and so no longer needs the feature.
Four tests, each named for what it protects and each of which fails without a
screen if the property it guards breaks:
- the adjust target satisfies every condition Slint's import checks, asserted
in the crate that owns the descriptor, because a descriptor that drifts
fails at runtime on a real display and nothing else would notice;
- consecutive renders are different textures, and the third is the first
again, so the alternation is a rotation and not an allocation per frame;
- the develop canvas has no CPU pixel buffer and does have a wgpu texture —
AC-8 itself, in the terms Slint uses;
- consecutive frames compare unequal as `slint::Image`, which is the property
the repaint actually depends on.
The zoom test's readback moves into the test module. It has to: there is no
library function that copies a displayed frame to the CPU any more, and that
is the point — the round-trip now exists in the test binary and nowhere a
shipping build can reach.
**What is not proven.** No GUI was run. What is verified is that the texture
satisfies the import contract, that the import succeeds, that the canvas is a
texture rather than a buffer, and that consecutive frames are distinguishable.
What is unverified is everything that needs a display: that Slint's FemtoVG
wgpu renderer adopts the Manual configuration on a real surface, that the
picture appears the right way up and the right colour, and the frame timing
that motivated the whole exercise. Android is untouched by testing — the
android backend routes a WGPU29 request to Skia, whose wgpu surface does
handle imported textures, but that is read from the source, not observed.
56 dr-gpu tests and 255 dr-ui tests pass, clippy clean under `-D warnings`,
fmt clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
4d78041d1d | Many imorovments | ||
|
|
fa12afed18 |
Keep originals on this device, by pin and by use
Fills in `image_cache`, which the previous commit's "On this device" filter read but nothing wrote. Also carries in-flight work that shared these files: the Android TLS root store, the settings page, and a regenerated traceability report. # Two populations, deliberately separate An original is kept here for one of two reasons, and conflating them produces the exact failure the feature exists to prevent. **Pinned** originals were asked for. Pinning a collection before a trip is a promise, so pinned rows are never evicted and never counted against the budget — a cap that could silently delete a pinned trip would make pinning worthless, because it could not be relied on without checking. **Passively cached** originals are a side effect of working: develop already downloads the whole file, so keeping it costs no bandwidth and saves the entire transfer next time. This population is what the budget bounds, evicted least-recently-used, because it otherwise grows until a day of culling fills a disk. Sharing one budget would let a large pin starve the passive cache, or let browsing evict a pin. They are separate. # What was built `dr_catalog::cache` owns the bookkeeping — held tier, size, last use, pinned — and writes the bytes; deciding to download stays with the caller, which is what keeps a crate with no network out of the network's business. Files are written to a temporary and renamed, so a dropped connection cannot leave a truncated file recorded as a complete original. They are named by image id, not filename: `Photos/IMG_0001.CR2` and `Trips/IMG_0001.CR2` are different photographs, and a flat cache keyed on the name would serve one for the other. `spawn_full_fetch` became read-through. A hit is a disk read; a miss stores what it downloads and enforces the budget. A cache that cannot be opened is a miss, not a failure to open the photograph. Pinning writes intent — `tier_desired` — without downloading, so the button responds immediately, and `spawn_pin_fetch` fills it in sequentially afterwards. Sequential because these are tens of megabytes each: the lanes that make the thumbnail sweep fast buy little against one connection's bandwidth and cost a great deal of memory. A pin interrupted by a lost connection resumes from where it stopped. Schema v5 adds `pinned` and `path`. `pinned` is a column rather than something inferred from `pinned_by_rule`, which is ON DELETE SET NULL and so cannot answer for an image whose rule was deleted. A v4 catalog migrates in place; existing rows default to unpinned, the safe direction. The budget and "keep opened originals" come from the settings page rather than a constant, and are applied at startup rather than only on change — a cache capped at 2 GB last session would otherwise spend this one filling to the default. Turning off keeping leaves what is already cached readable: those bytes are paid for, and refusing them would re-download images sitting right there, including pinned ones. Also removes a doubled `#[test]` introduced in the previous commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d49b4b41de |
Add thumbnail size classes and grid zoom; fix the scrub ordinal
The grid now zooms, which needs thumbnails at two resolutions rather than one, and exposed a scrub that landed in the wrong place. **Two thumbnail size classes.** `ThumbSize::Grid` (256px, ~10 KB) and `Large` (1024px, ~45 KB), with the class part of the store key so both coexist. Storing everything large would take the reference library from ~200 MB to ~860 MB, and shards sync, so that is transfer cost on every device rather than only disk. A store written before the class existed migrates in place: its entries are all grid-sized, which is what the column defaults to, so nothing already fetched is discarded. `forget` now drops every size for an image. Reading a single row left the other class's bytes on the shard's tally for good, sealing it early on space nothing occupied. **Grid zoom.** Ctrl+wheel and pinch resize cells between 90px and 420px in geometric steps, so the gesture feels the same at either end where a fixed pixel step would be imperceptible at 400px and violent at 90px. Crossing 256px switches to the large class, so a zoomed cell is sharp rather than upscaled. Columns and window capacity already derived from cell size, so the grid reflows for free. **The scrub landed about half a library too high.** It counted only dated images while the grid shows all of them — 10,733 dated against 19,841 rows — and ignored `shadowed_by`. Verified against the live catalog: the old formula gave 10,887, the new one 10,732, the true grid position 10,732. The scrub's count and the grid's window must use identical predicates and ordering; a test now fails if they diverge. **Timeline gestures are continuous.** Scrub and pan were quantised to whole buckets, so a slow drag did nothing until it crossed a boundary and then jumped a month. Both work in fractions of the visible span now, and pinch-to-zoom arrives for tablet, where there is no wheel to reach the axis with. The pinch accumulator was wrong on first writing: it took at most one step per update, so an 8x spread — three doublings — yielded one zoom level. `log2().trunc()` now extracts every whole doubling and carries the remainder. The original test asserted the wrong number and defended it in a comment, which is worth remembering: a test can entrench a bug as readily as catch one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9a24623e35 |
Fix the workspace build off-device
Two breaks that only appeared on a full `cargo test --workspace`. `slint::android` exists only when compiling for Android, so darkroom-android failed to compile on the host even though it is a workspace member. The entry point is now gated on the target rather than on a feature. The timeline forwarded `scrub` where the Timeline component declares `scrub-to`, which the Slint compiler rejects. Assisted-by: LLM |
||
|
|
2a7a319d6c |
Depend on slint directly in the Android app
android_main takes an AndroidApp and calls slint::android::init, both of which come from slint itself rather than from dr-ui. The backend feature still arrives through dr-ui's target-specific dependency. anyhow was unused. Assisted-by: LLM |
||
|
|
5dc1279429 |
Add the Android app shell; cap cross-build parallelism
Cap both halves of the container build: CARGO_BUILD_JOBS limits how many rustc processes cargo starts, while --cpus limits what the container gets regardless of what nested build scripts spawn — cc, cmake, and ring's asm build all parallelise on their own account and do not consult cargo. Without both, a full cross-compile takes every thread on the host and makes the machine unusable for the length of a background build. Assisted-by: LLM |
||
|
|
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). |
||
|
|
0f202fd3f9 |
Add requirements traceability gate and Gitea pipelines
Ports JellyTau's traceability tooling to Rust, carrying across the bug it was repaired for. That gate divided a traced count by frozen literal denominators; the requirements file outgrew them and it reported 158% coverage, so it could never fail its own threshold. Two rules, both enforced by the extractor's own tests: - denominators parsed from docs/requirements.md at run time - coverage is |traced ∩ defined| / |defined|, never a raw traced count The gate additionally fails hard on a misconfigured run — zero requirements parsed or zero files scanned — rather than reporting a plausible 0%, and on any orphan tag naming a requirement that does not exist. Adapted for DarkRoom: IDs are FR-CAT-1 / NFR-P13 / FR-DEV-3a shapes rather than JellyTau's fixed three digits, and decisions (D), spikes (S), milestone items (M) and test ids remain taggable while being excluded from the denominator — counting them inflated it by 25. Also adds dr-sync: the RemoteBackend trait and capability model, so the Nextcloud connector is one implementation rather than the only shape the engine understands. No mature Nextcloud crate exists (reqwest_dav is too thin), so the connector will be hand-rolled over reqwest per D7. Gitea workflows follow the same style: containerised, commented with the reasoning, desktop and Android on every push, plus a CI check that no core/ crate depends on the UI toolkit (ARCH §6.5a). Coverage today: 13.3% (19/143). 50 tests passing. |
||
|
|
82a5e21ec6 |
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. |