33e2e277a28e9792dfd477a99dc63d2c0aeb7861
402
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
33e2e277a2 |
Set the Wayland app id late enough for it to take
The launcher and the task bar have shown a generic tile for a working window since the call was written. set_xdg_app_id sat at the top of run(), on the reasoning that the app id is read when the surface is created — true, and beside the point: the call goes through Slint's global context, and there is no global context until something installs a platform. That is BackendSelector inside shared_gpu, or AppWindow::new falling back to the default, and both happen further down. Called before either, it returned NoPlatform and did nothing at all. It moves to just after the window is constructed, which is not the same as shown — run() is far below — so there is a platform to talk to and the surface does not exist yet. The failure was logged at debug, which is why a year of grey squares went unremarked: the whole symptom is invisible from inside the application. It is a warning now, naming the consequence. |
||
|
|
5e67f026ec |
Merge: a catalog the server cannot damage for long, and backups on both sides
fix/corrupt-remote-catalog-deadlock. The catalog on the server had been malformed since 7 September and every device declined to overwrite it, so collections and people stopped syncing everywhere at once; the damage came from two devices assembling chunks in one upload directory. Each chunked upload now has its own directory, the server keeps three generations of the catalog behind the current one, every push is verified before and after, a damaged copy that arrived whole is merged from the generation before it rather than pinned in place, and the catalog is backed up daily as NFR-R2 always asked. |
||
|
|
eeee3d920a |
Back the catalog up daily, not only before migrations
NFR-R2 asks for the catalog to be backed up on a schedule and before schema migrations. Only the second half existed: every backup on disk was a pre-migration copy, and a library that never migrated was never backed up at all. A backup is now also taken at the end of a library sweep when the newest one is more than a day old — the moment the catalog is quiet and a day's collection and people edits have just been folded in — on its own thread and its own connection, so the copy of a 130 MB file is not spent on the UI. Whether one is due is read from the backup directory, not the catalog, so the ordinary case costs nothing. An empty catalog is skipped: there is nothing in it a rescan would not rebuild. Pruning to KEEP_BACKUPS applies as before. |
||
|
|
f100db89ca |
Verify the catalog snapshot before it is sent, and after it lands
Two checks around the upload, both cheap next to what they prevent. Before: the snapshot is quick_checked before it leaves. It is the copy every other device merges from, and a damaged one costs each of them a download, a failed merge and a refusal to push. After: the staged upload's size on the server is compared to the bytes sent before it is rotated into place. A chunked upload is assembled server-side, and an assembly that goes wrong is a file of plausible size no device can open — caught here, on the device that caused it, for one listing; otherwise on every other device, after the fact. A mismatch, or a size the server will not confirm, discards the upload and leaves the current copy and its generations untouched. |
||
|
|
2ce0fcc74a |
Keep three generations of the catalog on the server
The server held one copy of the catalog, overwritten in place on every push. When that copy was damaged there were two answers, both bad: refuse to touch it for ever, which is what every device did for a week, or overwrite it with ours, which loses whatever another device had added since — the escape hatch of the previous commit. A push now uploads to catalog.upload.sqlite, rotates catalog.sqlite to .1 (and .1 to .2, .2 to .3, dropping the oldest), and moves the upload into place. Rotation is server-side renames, oldest first so that every destination is empty when it is written to — move_to refuses to overwrite, by design — and a failure at any step leaves a gap in the generations and never a missing current copy. The only transfer is the upload itself. A damaged current copy that arrived whole now merges from the newest readable generation before ours goes over it, which loses nothing, and is kept as .1 by the ordinary rotation rather than by a separate 40 MB upload. NFR-R2 asks for the catalog to be backed up; this is the half of it that lives with the copy other devices read. |
||
|
|
c1e0f09be7 |
Say where the face models were looked for when they are not found
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m27s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Successful in 1h35m10s
Build and test / Layer separation (push) Successful in 39s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
🐳 Windows image / Build and push (push) Successful in 7m11s
Build and test / windows-image (push) Successful in 7m12s
Traceability / Requirement traces (push) Successful in 58s
Build and test / Android (aarch64) (push) Successful in 24m57s
Build and test / Windows (x86_64, cross) (push) Failing after 57m30s
"The chosen detector is not installed" was the whole of what a user saw, on a machine where the files were three directories away from where the lookup went. The search order was in a doc comment and nowhere a user could read it. Now a missing pair logs the detector file it wanted and every directory it tried, which is what the first Windows install needed and what the next misplaced download will. |
||
|
|
38a87c0bca |
Put settings.json where the Android entry point said, not under /
SettingsStore::open read XDG_CONFIG_HOME and HOME itself. Neither exists on Android, so it resolved to .config/darkroom relative to a working directory of /, and every settings edit on the tablet failed with "read-only file system" — the page reported the error and nothing said why. The doc comment claimed "the same resolution SessionStore does"; now it is, by calling the same function, which honours the directory android_main declares and takes the platform's config directory everywhere else. settings.json sits beside sessions.json on every platform, as the comment always said it did. |
||
|
|
693195fa96 |
Replace a damaged catalog on the server instead of pinning it there
The catalog sync refuses to upload when it cannot read the server's copy, because the upload is a read-modify-write and writing blind would discard another device's collections. That is the right rule for a timeout, a dropped connection or a newer schema — the remote is fine, only our view of it failed. A file SQLite calls malformed is not that. No device will ever read it again, so refusing to write over it preserves nothing — and every client declines in turn, pinning the damaged file in place for good. Collections and people then stop crossing between devices on all of them at once, each logging "catalog not pushed" on every pass. This library did exactly that from 2026-09-07, on the desktop and on a freshly installed phone alike, while 32 collections sat undelivered. Now a copy that arrived whole and still will not open is set aside under a dated name and replaced by ours. Whole is checked against the size the server advertises: a truncated download will not open either, and on a phone that is the far likelier story, so anything short — or any size the listing cannot confirm — is treated as the transport failure it is and the server's copy is left alone. A placeholder's size is not trusted for the comparison, since it means nothing. The report says when this happened, and the log line calls it "pushed over a damaged copy" rather than folding it into an ordinary push: it is the one push that discarded something. |
||
|
|
b396096787 |
Open the sign-in URL on Windows
Login Flow v2 cannot complete without a browser, and the launcher had a branch for xdg-open, one for Android's Intent, and an honest Unsupported error for everything else — which on Windows stranded the flow on "approve the sign-in in your browser". rundll32 url.dll,FileProtocolHandler is ShellExecute on the URL and needs no crate; chosen over cmd /C start, whose quoting of & in a query string is a known trap. Not verified: Wine has no browser to open. |
||
|
|
ef1154af94 |
Resolve every base directory in one place, and on Windows
Five sites each read XDG_*_HOME and fell back to $HOME/.local/… on their own, which is fine on Linux and wrong everywhere else: Windows sets neither variable, so every one of them degraded to a path relative to the working directory — for a Start Menu launch, C:\Windows\System32. The models lookup walked XDG_DATA_DIRS the same way. dr_plat::dirs now holds the rule per platform: XDG on Unix, the known folders on Windows — %APPDATA% for config, which roams, and %LOCALAPPDATA% for data and state, which do not — and the executable's own directory as the system data dir, which is where the installer puts the models. The Android overrides stay where they were; only the fallback behind them moved. Both rule sets are unit-tested on either host, and the Windows one was confirmed by running the application under Wine: its log landed in AppData\Local\darkroom\state and nothing was written anywhere else. |
||
|
|
896188a489 |
Read the sidecars other editors write, and write them back on request
Benchmarks / CPU and I/O (per commit) (push) Successful in 10m59s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Successful in 1h33m36s
Build and test / Layer separation (push) Successful in 1m2s
Traceability / Requirement traces (push) Successful in 1m25s
🐳 Android image / Build and push (push) Successful in 9s
Build and test / android-image (push) Successful in 9s
Build and test / Android (aarch64) (push) Successful in 56m59s
FR-CAT-13 asked for standard XMP and `core/dr-xmp` answered the file: it
has read and written `dc:subject`, `xmp:Rating`, `xmp:Label` and the IPTC
core since
|
||
|
|
d3b6127db6 |
Let a photographer name the state they liked, and go back to it or look at it
FR-DEV-5 asked for named snapshots of an edit state and FR-DEV-7 for a comparison against a chosen one, and neither existed. The history stack is per sitting and forgotten with it, on purpose — the gap that mattered was an automatically saved mis-drag with no way back, and that was closed first. What was left was the other half: a state the photographer wants to keep *because* it is worth keeping, which is a different thing from a step and is not served by making the steps last longer. A snapshot is an edit state, and an edit state is exactly what a sidecar version stores, so it is stored as one: a `[version]` block carrying `snapshot-of = <uuid>`. The parameters, the masks and their parts, the repairs and the film all arrive through the blocks that already carry them, a merge keys on the uuid as it does for any version, and a build that predates the key reads the block as a named version and keeps it — the right failure. Only the pointer is new. The one reader that has to know is `default_version`, which must never answer with a snapshot: a file whose edit is missing is not a file whose edit is one of its saved moments. The snapshots of an edit are listed by that pointer, oldest first, the same on every device. Writing them back removes what this sitting deleted and puts in what it holds, and leaves standing whatever it never saw — a snapshot the other device took since the photograph was opened here is not this device's to remove by not knowing about it. That is the rule the version merge already keeps, applied one level down, and it is why the save carries the deleted ids rather than replacing the list wholesale as the masks are. Each is re-pointed at the uuid the save settled on, because the default may have been fused onto its canonical identity since the snapshot was taken. Restoring is one history step, so undo takes it back whole, as a paste is. Taking and deleting are not steps: they change nothing about the photograph, and an undo that removed a snapshot would be undoing a decision to remember. Holding the eye beside one renders the snapshot and hands the edit straight back — the same suspension "Before" uses, against a point the photographer chose rather than the file. Two sessions on the same photograph get ids that cannot collide, stamped with the second and a random word, because the merge folds equal ids into one. |
||
|
|
369eb8fbf0 |
Put the log and the crash records in one file, and show it before writing it
NFR-OPS-1 asks for a diagnostics bundle — the log, the schema version, the GPU and driver, the app version — "with an explicit preview-and-consent step before anything leaves the device". The log and the crash records have existed since August; what did not exist was any way to hand them over that was not `adb pull` and a knowledge of where the state directory is, which on the tablet the requirement was written for is nobody. Nothing here sends anything, and that is the design rather than a gap: crash.rs already says why a transport built ahead of the consent is the shape of thing that gets switched on by default. The bundle writes one text file to a place the user can find, so that they can attach it. That is the moment it leaves, and it is theirs. So the consent guards the write, not a send. Preparing gathers everything into memory and shows what would be written — each section, its size, what was taken out, and where the file would go — and only the second press puts bytes on disk. A user who reads the preview and presses the other button has changed nothing anywhere. The gathered bundle is held between the presses so what is saved is exactly what was shown, not a second gathering that differs by whatever was logged while they were reading. One text file rather than an archive, because a `.txt` opens wherever the user is sitting and pastes into an issue, and because the preview can then be the file rather than a summary of it. Every line goes through the blunter of the two redactions on the way in, whatever the sink already did to it: the log's own rule keeps paths, since a path read over `adb` is context, but a file meant to be attached to a public report by someone who may not read it first is held to the crash record's rule instead. The About page's graphics line gains the driver, which the requirement names and the adapter has always reported. And docs/outstanding.md is corrected on both OPS requirements: it said crash reporting was a log::error! hook and NFR-OPS-1 had nothing behind it, and neither had been true since 2026-08-30. |
||
|
|
4574c35236 |
Let a part be left out of a mask without being taken out of it
A layer built from parts was missing the one control a correction most often wants: seeing what it did. The question a subtracted gradient raises is whether it took only the sky, and the question a stroke raises is whether it filled the shoulder — and the only way to ask either was to remove the part and look, which answered the question and lost the part. The layer's own ring answers a different question, about the adjustment, and hiding eight layers to check one correction is not an A/B anybody performs. So a part carries `hidden`. It is an edit and a history step, as the layer's switch is, and it is folded into the render fingerprint because hiding a part changes the mask as surely as removing it does. Where the mask is built the shown parts are walked rather than the parts, which is what makes a hidden base hand the fold to the first part that is shown — and a revealed layer whose every part is hidden clears its slice rather than leaving whatever the last rasterisation put there to be read back. `covers` asks the same shown parts, so a layer whose only adding part is hidden costs no slice at all. In the sidecar the key is `hidden`, in the part's block or, for the base, in the mask block — under a word that cannot be confused with the layer's `enabled`, which has always meant the layer. Absent means shown, so no file written before the switch existed reads any differently. The row wears the same ring the layer does, one row down, because it is the same question about a smaller thing. |
||
|
|
9cc52fd72b |
Bind the two develop gestures that were described and not bound
FR-DEV-16's book said resetting a control and hiding a mask layer were reachable by pointer and by finger, and stopped there. The reason was honest: the generated rows have no focus, so "reset the focused control" named a thing the panel could not point at. But a photographer at the keyboard means something narrower than focus. They mean the slider they just dragged too far, and that is a thing the panel can remember. So the Adjustments global keeps the last control moved — two indices, written where the panel forwards the change and cleared when the next photograph opens, so a reset cannot reach back into the previous edit through an index that happens to be shared. R puts it back, through the same callback the track's double-click takes, and is silent until something has moved. The mask layer needs no such notion, because the panel already has a selection: the rows the edge controls point at. H hides or shows those, through the path the ring at the head of the row takes, so it is an edit and a history step exactly as the ring is. A mixed selection goes to shown, since the layer nobody can see is the one being asked about. Both tags now carry the key, and the book says so. |
||
|
|
7c44740d9f |
Skip the read-only-directory test where modes are not enforced
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m41s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Successful in 1h44m9s
Build and test / Layer separation (push) Successful in 48s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Traceability / Requirement traces (push) Successful in 36s
Build and test / Android (aarch64) (push) Successful in 1h1m51s
`a_failed_overwrite_puts_the_original_back` makes the presets directory read-only and expects the overwrite to fail. CI's Desktop job runs in a container as root, and root is not refused by a mode: the write succeeds, the assertion fails, and build-and-test has been red on every push to master since the test arrived. The test now probes the refusal it depends on -- one write into the directory it just locked -- and skips where that write goes through. Probed rather than keyed on the uid, because what the test needs is the refusal itself, and a filesystem mounted without permission checks would pass a uid test and fail this one all the same. |
||
|
|
4f31123b0c |
Let the user choose which SCRFD finds their faces
faces.md §12.3 measured what the cheapest detector costs: the small faces in every group shot, and a dog embedded a dozen times. Which trade is right depends on the machine doing the sweep — a desktop left overnight and a tablet on a battery want different answers — so the detector is now a per-device setting, Fast / Balanced / Thorough on the settings page beside the indexing button, persisted with the rest of the settings file. A detector is half of a model id. Every face, marker, shard and calibration is keyed on faces.model_id precisely so that a model change is a new id and a re-index rather than a silent change under existing data, and a detector change is a model change: it decides which faces exist and where the landmarks that align them land. So each choice names its own pipeline. 500M keeps the bare "w600k_mbf" every existing library was written under, so an upgrade disturbs nothing; the others are qualified. Choosing one restarts coverage from zero under the new id, the sweep re-detects, confirmed names carry across by box overlap, and the sync shards are keyed by the same id so a peer on another setting neither adopts nor pollutes them. The library controller carries the id into the sync the same way it carries the cache budget, because the sync starts from places that have no settings in reach. All three shape-fixed exports ship — APK, Arch, Flatpak — since a tablet has no other way to obtain the one it was not installed with; the APK grows by twenty megabytes for the choice. |
||
|
|
9d35addd86 |
Measure what the cheapest SCRFD actually costs in faces
§1 chose scrfd_500m on FLOPs and never measured the recall it gave up. A dr-ui example now runs several detectors over the same sample of stored proxies, matches boxes by IoU against the first, buckets the result by face size, times each, and writes contact sheets of the disagreements in both directions — because a count of extra faces says nothing until someone has looked at whether they are faces. Over 400 proxies from the reference library: 2.5G finds 14% more faces for 12% more time, 10G a further 12% for 3.1× the time. The extras are small real faces. The 86 faces only 500M found are a dog a dozen times, a stop sign, a wheel and the backs of heads. Recorded in faces.md §12.3. |
||
|
|
3d6d69ec90 |
Wrap the face-sweep repair match the way rustfmt wants it
Benchmarks / CPU and I/O (per commit) (push) Successful in 4m5s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 39m17s
Build and test / Layer separation (push) Successful in 1m24s
🐳 Android image / Build and push (push) Successful in 10s
Build and test / android-image (push) Successful in 10s
Traceability / Requirement traces (push) Successful in 55s
Build and test / Android (aarch64) (push) Successful in 27m8s
CI's Desktop job failed at the Format step on
|
||
|
|
16f3fb41a3 |
Measure the faces already found rather than finding them again
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m13s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 54s
Build and test / Layer separation (push) Failing after 1s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 2s
Traceability / Requirement traces (push) Successful in 52s
Build and test / Android (aarch64) (push) Successful in 30m6s
Every face stored before its quality was kept holds a unit vector, and V14 forgot the run marker of each image holding one so that the next sweep would look again. Looking again meant detecting again: a whole re-detection per image, with every suggestion on it thrown away and the confirmations carried across by box overlap, to recover one number. The sweep now has a measuring pass between the proxy repair and the un-indexed images. It lists every image holding an unmeasured face, fetches the original once, warps each stored face from the landmarks it already has, embeds it, and writes the raw vector and its length over the old row. Ids, boxes and identities are untouched; the marker is re-written fresh so the sync exports the measured vectors. A face whose landmarks no longer make a warp is dropped, as detection would have refused to store it. `faces_unindexed` leaves those images to the measuring pass, so the V14 deletion no longer costs a second detection. |
||
|
|
8b3abdb787 |
Keep each face's quality, and never compare against a poor one
The embedder's raw output has a length, and the length is a reading of how recognisable the crop was: a blur, an occlusion or a hard profile comes out short. Normalising threw it away. A short vector sits near the middle of the sphere and matches a little of everyone, which is how one bad crop bridges two people in a grouping pass. So the length is kept — the store now holds the raw vector, re-normalised on load, with the length beside it as `faces.quality` — and a face under MIN_GALLERY_QUALITY (14) is a probe: measured against the gallery and placed where it fits, but never what another face is measured against. Two probes are never paired, and a probe is nobody's evidence for a confidence. The People screen shows the number as "Quality 17.3", dimmed below the floor. Faces indexed before this stored unit vectors and have no reading; they are admitted to the gallery, and schema V14 forgets the run marker of every image holding one so the next indexing pass measures them. A peer's unmeasured shard faces are not adopted, or a sync would write that marker back. |
||
|
|
a87139b838 |
Give every mask an eye and a colour, and put the brush where the mask is
The first build of seeing a mask showed the selected layer's, in one global style, from a strip at the top of the panel. It answered the wrong question and answered it somewhere nobody looked. What a photographer asks of two masks is how they meet — where the sky's edge sits against the building's — and that needs both on screen at once, in colours that can be told apart. So each row of the stack has an eye, drawn in the colour its mask is shown in, and each mask has six swatches to choose that colour from. Several can be open at once; a new one comes up open, in the first colour nothing else is using. The style — tint, alpha, outline — is the one setting that stays global, above the stack, because three styles at once are three pictures that cannot be read against each other. Alpha now draws every shown mask, each in its colour, on black. In the pipeline a `Reveal` is a list of `(layer, colour)` rather than one layer, and every reveal block carries its own colour. The brush moves too. Select, Paint and Erase and the three sliders under them sat at the top of the panel, appeared only once a row was selected, and said nothing about which mask they acted on — so "how do I paint" and "how do I correct the model's outline" both had the same answer and nobody found it. They sit under the selected mask's parts now, beside the swatches, and on a subject or a category the hint says what a stroke there does: it becomes a part of this mask, joined to the model's, and can be taken out again. Eyes and colours are viewing state, on the session and not on the layer, so a photograph reopened has every eye closed — the stored-mask round-trip test asserts it. |
||
|
|
ac0aea70ec |
Show a mask as soon as it is made
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m52s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 39m53s
Build and test / Layer separation (push) Successful in 1m0s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Successful in 46s
Build and test / Android (aarch64) (push) Successful in 25m19s
Choosing a category is asking what it selected, and for a subject or a category that question had no other answer on screen: the model's outline is not derivable from anything visible, a fresh layer carries no adjustment to judge it by, and the list it was chosen from says "architecture 23%" without saying which 23%. The control that draws the mask existed but had to be found and pressed, a panel's height away from the list the choice was made in. So a new layer arrives with its mask showing, from the resting position only. Somebody who has chosen the alpha or the outline keeps it, and nothing re-arms in the background — every caller is a press that asked for a new mask. That makes the canvas depend on how a layer arrived, which is correct and worth stating: a session that has just made a mask draws a frame that a session which read the same mask out of a sidecar does not. Viewing state is not edit state and does not travel in a file, and `a_stored_mask_renders_exactly_what_the_model_rendered` now says so at both ends. |
||
|
|
5d175cc668 |
Let a press on the photograph reach the tool that was armed for it
The brush did nothing, and neither did three other things nobody had tried lately: clicking a subject on the photograph to select it, placing a repair, and sampling a neutral. All four are TouchAreas over the canvas, and all four sat behind the pan/zoom area, which is full-canvas and enabled for everything but a crop. It took every press in the viewport and they were never offered one. Slint hit-tests siblings front-to-back (`send_mouse_event_to_item` visits children `TraversalOrder::FrontToBack`), a TouchArea answers `GrabMouse` on any press it is enabled for, and the first grab aborts the traversal. Front means *last declared*. Each of the four carried a comment saying it sat "above the pan/zoom area so a click reaches it first" — true of the order they were written in, and backwards. Nothing about the geometry decides this, so nothing about the geometry could have fixed it. The pan area is declared first now, as the backstop it always meant to be, and the rule it leaves behind is that the general case goes above the specific ones. `GradientHandles` is the other end of that rule and is why dragging a handle has worked all along while everything between it and the pan area did not. The order is asserted in a test, because this is a fault that compiles, passes every other test, and silently removes four tools at once. |
||
|
|
76ad667fd6 |
Offer a mask that is nothing but a hand
Every route to a layer began with a selection — a gradient, a band, a subject, a category — and painting was reachable only by making one of those and joining a painted part to it. So the answer to "brush a correction onto this corner of the sky" was "add a radial gradient you do not want, then paint into that", which is not an answer. Paint sits beside Linear and Radial and makes a layer whose base is a brush. It covers nothing until a stroke lands in it, so pressing it arms the brush and shows the mask as well: a row that appeared and changed no pixel, with the pointer still in "select", is indistinguishable from a button that did nothing. |
||
|
|
c045702a47 |
Show the photographer the mask they are shaping
Nobody can refine an edge they are not being shown. The only thing drawn on the canvas was the region overlay — a false-coloured picture of what the model *detected* — which knows nothing of a layer's feather, its falloff, its morphology, its invert or its opacity, and nothing at all about a gradient, a range or a stroke. Every control added for mask editing therefore acted on something invisible, which is why the whole feature reads as absent rather than as unfinished. A layer's finished mask now draws over the photograph in one of three styles: a tint for whether the right thing is selected, an alpha for where the edge is, an outline for whether that edge is registered against the detail the other two hide. The hard part is not the shader. A selection with no adjustment on it changes no pixel, so it is not active, so it holds no slice of the mask array and is never rasterised — and that is exactly the layer somebody wants to look at, for the whole of the time between choosing a subject and deciding what to do to it. So `MaskStack::rendered` is `active()` plus the layer being looked at, and the rasteriser, the composer and the distance-field builder all index by position in it. Which is also why the design's "two uniforms, no recompile" is not available: a uniform can select a slot, it cannot conjure one. The reveal is never on the graph. It reaches the pipeline as an argument to `compose_revealing`, and `compose_for` — which the exporter, the thumbnail and the neutral probe all call — has no way to ask for one. A flag on the graph would have been shorter, would have type-checked, and would have been one forgotten reset away from a red tint baked into an exported file. And the tools that shape a mask now arm. `Masking.tool` is an `in` property only Rust may write, and the handler wrote nothing back, so the strip reported "Select" however many times Paint was pressed and the paint area was never enabled — the brush, the parts and the whole of FR-DEV-19b reachable from no control in the application. The region overlay stands down while a mask is being shown, and its button now says what it hides: two overlays that look alike and mean different things is worse than either. |
||
|
|
193b35a249 |
Start a category mask where the photograph can bear it
Clicking "architecture" made a layer whose mask was gone. Every category layer began at STRICTNESS_DEFAULT, and that constant was fitted on the synthetic sky the refine tests build — its own note warns that a real photograph's noise "moves every crossing down together", which turns out to be a considerable understatement. Measured over seven ordinary frames, half scale removes 76% to 99.5% of `architecture`, 36% to 93% of `ground` and 18% to 91% of `vegetation`. Only sky, the category the number was calibrated against, survives it. An empty mask is indistinguishable from a broken one: the layer is listed, the adjustment moves, and no pixel changes. So what this looks like from outside is that the segmentation does not make masks at all. No smaller constant fixes it either, because a nat of evidence means different things over a smooth sky and over a stone facade — the useful position is above 5 on one frame and below 1 on the next. So the frame is asked instead: `Refinement::gentle` walks down from half scale and takes the first rung whose gate removes no more than a sixth of the category's weight, and the model's own outline when none of them does. One `apply` on a friendly photograph and four on an unfriendly one, paid when a layer is made rather than for eight categories nobody masked. The slider's reset went to 4 as well, so taking the control back to its "default" emptied the mask. It goes to zero now, which is the one position documented to mean something: exactly what the model weighted. |
||
|
|
404fea47a8 |
Wrap the lines the merge resolution left long
Benchmarks / CPU and I/O (per commit) (push) Successful in 2m59s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 33m34s
Build and test / Layer separation (push) Successful in 55s
Traceability / Requirement traces (push) Successful in 42s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Successful in 23m43s
`cargo fmt --check` failed the desktop job, on three files and for one reason: routing the mask handlers through the `Masking` global was done by substituting the call prefix, which is a text edit rather than a Rust one. It left `window.global::<Masking>().on_part_join_picked(...)` on a line that had been short enough as `window.on_mask_part_join_picked(...)` and no longer was. Formatting only. The whitespace-stripped source is identical in the two `ui/` files; the third differs by the trailing commas rustfmt adds when it breaks a call across lines. The matrix moves with it, because the tags shift by a few lines and the check compares line numbers. |
||
|
|
2d878c2117 |
Offer the film stock in its own group, and let its list scroll itself
Two faults in one control, both reported from the tablet. The stock picker appeared in every group. It is not a parameter, so it is not a row, so the filter that hides every other control when a group is chosen never saw it — "Kodachrome" sat at the top of Light, of Colour and of Detail alike. Three places it does not belong, and the one it does no more prominent than the rest. The descriptor has said `Effect` and only `Effect` since the film moved there; nothing was asking it. So the panel now asks. It cannot ask directly — a generated panel may not know which operation a control belongs to — so the session answers, from what the operation declares it is about, and a stock re-declared as something else would move on its own. The flag is recomputed when the group changes as well as when the film does, which is the half that would have made it stale exactly when it mattered. And the open list was unbounded, so it made the develop column taller and the column scrolled as one: reaching Velvia dragged every slider below it off the screen, an answer given once pushing aside the controls used constantly. It now scrolls within a bounded height of its own. That viewport is counted rather than measured, for the reason the tool rail records a few files away: a viewport that asks a layout how tall it wants to be, while the layout takes its height from the viewport, is a cycle Slint settles by handing back the height it was given — and the content is then clipped in silence rather than scrolling. Every row here is one fixed height, so multiplying is exact. The group rule has a test. The scrolling does not, and cannot: it is a layout, and a layout fault is invisible to the compiler and to every assertion that can be written about it. |
||
|
|
0a5eab0487 |
Wire the develop panels through globals, so a second copy is one line
Every panel in the develop column declared its inputs and its callbacks and had `app.slint` bind each one to a property or a callback on the window root. That is fine while a panel is drawn once. N9 draws them a second time, in the portrait dock, and the wiring is what would have to be copied: `MaskPanel` alone ran to forty lines of forwarding, and a callback added to one copy and not the other compiles, renders, and simply does nothing on the layout nobody was looking at. So the wiring moved to Slint globals. A panel reads the global and calls the global; Rust hooks the global instead of the window; and the instantiation in the column is now the panel's name and a pair of braces — every one of the ten children of the column, with no property that differs by placement left to supply. There is a global per panel family rather than one for all of them, and the reason is an import cycle. Each panel's model struct — `ParamRow`, `MaskRow`, `HistogramView` — is declared in the panel's own file, so a single global holding `[MaskRow]` and `[ParamRow]` would have to live in a file importing `masks.slint` and `adjust.slint` while both imported the global back, which Slint rejects. Breaking that needs six model declarations relocated, which is a change to the data model and not to the plumbing this is about. A global beside the panel it serves also lets each name drop the prefix it was carrying only because the window root is one flat namespace: `root.spot-radius` is `Repair.radius`, and `root.peaking-on` is `Peaking.showing`. `session.slint` is new and holds the two facts every family needs and none of them owns: whether there is an open photograph to edit, and which mode the view is in, with the three readings of the mode derived once instead of at each of the dozen places that tested one. `ViewMode` moves there from `adjust.slint`, where it was only ever a lodger. Nothing on screen changes. What is not here: the tool rail and the status strip still take their properties at the instantiation, because they are drawn once and N9 does not copy them; the preset sheet's own state stays on the window, because the library grid opens the same sheet and a global cannot bind the window's state — which is why `Transfer.open-presets` is handled in `presets.rs`, beside the summary it already had to compute. |
||
|
|
dd14243dba |
Lay the develop view out by coordinate, so the column can sit below
Slint cannot turn a layout on its side, and that is what D-N7 asks for. So the HorizontalLayout holding the rail, the canvas and the develop column becomes a plain Rectangle and each of the three states its own x, y, width and height. With `column-below` false those come out where the layout put them to the pixel — a HorizontalLayout has no spacing or padding of its own, the rail and the column took their declared widths at the two edges, and the canvas was the only child that stretched. The alternative was the column subtree declared twice under two `if`s, which is four hundred lines of bindings copied, in a file whose own notes record a conditional child in a layout as the shape that has produced binding loops here before. The column is the same column either way: the same contents, the same Flickable, the same toggle. `panel-visible` collapses the dock's height exactly as it collapsed the column's width, so `column-width` and `dock-height` are each zero unless the column is both open and on that axis, and the canvas can subtract both without asking which case it is in. The stack inside stretches to the dock's width on its own — a layout that is the direct child of a Rectangle fills it, and the Flickable's viewport was already bound to its own width. Nothing is reflowed; N9 does that. `dock-height` is mandated in style.yaml for the reason `panel-width` beside it is, on the other axis: a dock that sizes itself to its contents is a photograph that changes height when a caption wraps. 480 until N6 measures the device. The seam follows the column round: a hairline down its left edge beside the photograph, along its top edge under it, so it stays between the two. |
||
|
|
5f0b11c1f4 |
Ask the window how tall it is, and say when the column belongs below
D-N7 puts the develop column under the photograph on a tall window, and the axis it turns on is aspect rather than width: a 960-wide portrait tablet is expanded by width and wants the dock, a 1500-wide landscape desktop is expanded by width and does not. So this cannot be folded into the layout class, and it is not remembered per class either — closing the column in landscape closes the dock in portrait, because it is the same column. `window-resized` reported width alone and now reports both, from a `shell-height` that subtracts the safe-area insets exactly as `shell-width` subtracts them: on Android the strips the status and navigation bars occupy are on the axis being measured, so the aspect of the window and the aspect of the space the interface actually gets are not the same number. `column_below` is the decision, with two thresholds rather than one. It is read on every resize event, and a single threshold means a window dragged along its own diagonal crosses it several times a second while the pointer is still down. Entering at 1.25 and leaving at 1.15 is a dead band no plausible drag re-crosses. The comment on EXPANDED_MIN_WIDTH claimed a tablet in portrait gets the compact layout. It does not — its panel is about 960 logical pixels across, which clears 820 — and that mistaken example is the one D-N2 reasoned from. Corrected in the same breath, since this is the commit that says what portrait actually changes. |
||
|
|
30468c4c69 |
Put the window-metrics doc on the function it describes
The comment explaining why both coordinate systems go on one line was written for `log_window_metrics` and sat above `window_metrics_level`, where it read as the start of that function's much longer note. Two doc blocks ran into each other and the one that prints had none. |
||
|
|
428d8c4a51 |
Say what size the window actually is, in both coordinate systems
Every figure in D-N7's table was computed at a guessed scale factor. The tablet's panel is 3000 by 1920 physical and nothing in this repository has ever recorded the density Android reports for it, so the dock's width is either 900 or 1037 and its available height is 200px either way. N6 asks for the measurement; this is the line that carries it. Beside the existing `apply_layout_class` call, because that is where the window is already being asked for its size and its scale, and again on every resize, so turning the tablet over records the other orientation in the same logcat. Both coordinate systems on one line: a logical size cannot be checked when the scale is the thing in doubt, and a physical size that does not divide by the scale printed next to it says the reading is of something other than the panel. The level is not fixed, because none of the three obvious choices works. `android_main` caps the facade at info, so debug never leaves the device and a debug-only line answers nothing. A drag emits a resize per frame and each accepted record is also appended to the on-disk log, so info on every resize is not a diagnostic. And the first reading is not the settled one: on X11 the window reports 0x0, then 360x320 at scale 1.0, then 1100x720 at scale 2.0, so reporting only the first would put a number in logcat that is not the window's. So the pair that decides is the scale factor and the orientation -- exactly what N6 is asking for, and exactly what a drag leaves alone. A window with no area is not a reading and records nothing. Every later change to either half, the scale resolving or the tablet turning over, is a new answer and goes out at info; everything else is debug. |
||
|
|
df741a8a49 |
Let one mask be built from more than one selection, and paint into it
A mask the model draws arrives approximately right — stopping inside a shoulder, leaking into the hair — and FR-DEV-3's edge controls move the *whole* boundary, so no value of feather or dilation fixes two errors that go opposite ways. What fixes them is a second selection joined to the first, and a layer that held exactly one source had nowhere to put one. The brush the core has had all along was reachable from no control in the application. A layer is now an ordered list of parts. Each names a source and how it joins the mask before it — added to it, or taken out of it — and carries its own edge treatment, because a model's soft coverage and a stroke painted where it stopped short do not want the same feather. Invert and opacity stay on the layer, where the composed shader already reads them. The sidecar grows `[part]` blocks and nothing else. A layer of one part writes exactly the bytes it always did; a mask block with no part blocks after it reads back as one part; and a stroke, a join or a source this build cannot read costs that part rather than the layer. So every sidecar in every library still parses to the edit it always was. On the device the parts fold into the layer's one slice, so eight layers still cost eight channels: union is a `max` blend and subtraction is the erase blend the brush already used. A part is drawn into a scratch texture before it is joined, and that is not incidental — an erase stroke means a hole in *that part*, not a hole in the mask, and drawn straight onto the accumulator it would punch through the subject underneath. A layer of one part skips all of it and takes the path it always took. In the interface: a part list under the selected layer with a chip saying which way each joins, Add and Subtract beside it, a Select/Paint/Erase strip with the brush's size, hardness and flow, and a drag on the photograph that paints. Pressing Paint on a mask that cannot hold a stroke joins a part that can, rather than explaining that a subject is not a brush. A whole stroke is one step in the history. The edge controls now shape the part that is selected rather than the layer, which is the one behaviour change to an existing control: with a correction selected, the feather slider softens the correction and leaves the model's mask alone. |
||
|
|
1e171c6d31 |
Let a collection be picked up, rearranged, and emptied after the fact
Collections could be made and filled and never reorganised. Nesting had a drag; un-nesting had nothing, in either direction — "All photographs" refused every drop, which is right for a photograph and wrong for a collection, which has a top level to be returned to. So a collection put inside another was in there permanently. Right-click deleted an *empty* collection outright and refused otherwise, which is wrong in both directions at once: destructive with no confirmation, and no way at all to delete a collection that held anything without emptying it by hand, child by child. And a photograph could only leave the collection the grid was scoped to, since that is the only one a button in the header can name — the cell's badge says a photograph is in three collections and never which three. Three ways in, one vocabulary: **Hold a row.** The tree is inside a Flickable, which claims any drag beginning inside it, so with a finger a drag on a row is a scroll until something says otherwise. The hold is that something. It lifts the row — drawn before anything moves, so the gesture says it has been understood — and then what the user does decides which of two things they meant: move, and it is a rearrangement; let go, and it is the row menu. The same fork the grid already uses to tell hold-to-select from drag-to-file. `decide_release` is that fork, and it is tested, because getting it wrong one way puts a sheet over every tidied tree and the other way makes the menu unreachable by touch. **The row menu.** Rename, new collection inside, move to top level, keep offline, delete. Deleting asks once when there is anything to lose and says what survives: the photographs stay in the library, and nested collections move up rather than going with it — which is what the catalog does, and what a user would never assume. An empty collection goes on the first press, because a dialogue about losing nothing is how people learn to dismiss dialogues. **"Collections…" on a selection.** Every collection the selection is filed in, each with a count — "3 of 40", so nobody takes forty photographs out of a collection thirty-seven were never in — and a way out of any of them without navigating there first. The long press used to open the offline question by itself. That question is one item in this menu now: there is one hold per row, and while it was spent on a single action nothing else the tree can do had a touch route at all. Nothing is lost — the tray on the row keeps its tap, and the question gains a full-width control in place of a 30px icon in a row shorter than the touch minimum. The row-press handler moves to `collections_ui` with the rest of what a collection row does; it lived in `library_ui` only because it opened that prompt. |
||
|
|
577bbd82b0 |
Return the action the drag offered, not the one this row prefers
Slint negotiates a drag action between source and target, and the runtime clamps whatever `can-drop` returns against the set the source allowed: an action outside it becomes `none`. Both drop targets here named a constant instead of echoing what was on offer, and each named the wrong one for half its traffic. A collection row takes two kinds of payload. Photographs come from a DragArea allowing `copy`; a collection being nested comes from one allowing `move`. The row asked for `copy` unconditionally, so images filed correctly and every collection dropped on a collection was refused — nesting by drag has never worked. The trash had the same fault mirrored: it insisted on `move` while the grid's cells allow only `copy`, so it refused every photograph dragged to it. Neither failure had anything to see. A clamped action is delivered as a refusal, which looks exactly like a target that declined on purpose, so the drag simply did nothing and left no error to search for. `decide_drop`'s Reparent branch was tested and passing throughout. It tests the decision, not the negotiation, and nothing was reaching it. |
||
|
|
af89433aee |
Offer the lens profile as a tick box, since applying it silently reads as absent
The develop panel's Optics group is three manual sliders: distortion, chromatic aberration and lens vignetting. The automatic correction was already there — the file's EXIF lens is matched against the bundled Lensfun database on open and the coefficients are fanned out to all three — but nothing in the interface said so except a line of grey text under the camera reading "· corrected", and there was no way to decline it. From the outside that is indistinguishable from the feature not existing, which is how it was read. `dr-lens` states the rule this breaks: an automatic correction that silently does nothing is worse than one the user can see is unavailable. The caption satisfied the letter of it and not the point — a photographer looking for "apply the lens profile" found three sliders and no switch. So the profile is now a control. It is a capability rather than a flag on the session, because everything a photographer sets travels one road: the capability list feeds the generated panel, `Preset` captures it, the sidecar stores it and the undo stack replays it. A bool on the side would have needed adding to each of those four by hand and would have been forgotten in at least one — which is exactly how the mask stack came to be missing from the history. It is on by default, which is what `switch_on` is for: the coefficients are a measurement of the lens that took the photograph, so accepting them is neutral and declining them is the edit. The sidecar therefore stores nothing for the ordinary case and the correction still happens. The switch appears only where a profile was matched. A tick box on a photograph whose lens the database has never heard of would be a control that looks available and does nothing, which is the failure the rule above names rather than an instance of following it — those photographs are told "· no profile" in words instead, and one whose box is unticked now says "· profile off", which is a third fact and not either of the other two. Two things had to be built underneath. `ParamKind::Bool` was in the core's closed enum and mapped to a row kind here, and had no control behind it in `adjust.slint`: a parameter declaring itself a switch was flattened into a row that drew nothing at all. Nothing shipped had one until now, so the gap cost nothing and was invisible. And `Check` self-toggled, which is right for a settings page that owns its value and wrong for a panel row that is a view of the edit graph — the click would have answered by replacing the binding with a literal, and the next undo or pasted preset would have moved the value with the tick left where the finger put it. It now takes `controlled`, and the generated row uses it. The manual sliders are unchanged and still trim whatever the profile leaves, so switching it off is "correct this by hand" rather than "stop correcting". |
||
|
|
2cd49d1cb7 |
Measure the rail by counting it, so it can scroll instead of clipping
With the adjustment groups in the rail, a short window silently lost them. At a 1500x680 window the rail drew Photo, Compose, Local, Repair, the seam and "All", and then stopped: Optics, Light, Colour, Effects and Detail were not scrolled off, they were gone, with nothing on screen to say so. The develop view's primary navigation, unreachable by any means. The Flickable was put here to prevent exactly that, and it could not, because its viewport asked the layout how tall it wanted to be while the layout was already taking its height from the viewport. Slint settles that cycle by handing back the height it was given, so `max(self.height, preferred-height)` could never exceed `self.height` and there was never anything to scroll. Counting breaks the cycle. Every entry here is a fixed height by construction — a tool is `rail-entry-height`, a group is a touch target — so the content is six plus four fifty-fours plus a gap plus a touch target for each group and "All", which is exact rather than an estimate and depends on nothing that depends on it. Four tools and five groups come to 498, against the 340 a 680-pixel window leaves at 2x, and the difference is now scrollable rather than absent. Found by shrinking the window with the groups forced into the rail. The interaction itself is unverified: synthetic input does not reach a Slint window on this desktop, and the tablet was disconnected, so what is confirmed is the arithmetic and the clipping it explains, not the scrolling it should restore. |
||
|
|
3f6dbce2aa |
Name a lone control after its operation, so three cannot all read "Amount"
Benchmarks / CPU and I/O (per commit) (push) Successful in 10m31s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 1h15m4s
Build and test / Layer separation (push) Successful in 37s
Traceability / Requirement traces (push) Failing after 1m28s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Successful in 1h3m13s
The Detail group ended with three consecutive sliders labelled "Amount" and nothing to tell them apart. They are dehaze, clarity and texture: each declares exactly one parameter, and `ops/README.md` tells an author to reach for the `amount` kind first, so all three named it the same thing. The panel withholds a heading from a group of one, and the reasoning it gives is sound — a lone control names itself, and the group reset it loses costs nothing because the slider already resets on double-click. But that argument rests on the parameter being named after what it does. It holds for exposure, contrast, vibrance, saturation and brilliance, whose single parameter shares the operation's name, and it fails for the three whose parameter is called after its kind rather than its subject. So a lone parameter now takes its operation's label — the name the withheld heading would have carried. For the five that already agreed, nothing changes. Found on the tablet, and only there: every row was correct, every label resolved, and the panel was still unusable. The test that guards it asserts over the real chain rather than a fixture, because the fault was a property of what is actually declared — a fixture would have had to be written to reproduce it, and would then only have proved itself. |
||
|
|
124b2d99c6 |
Take the first control that moves the picture, not the first one drawn
`showing_the_original_leaves_the_edit_exactly_as_it_was` set row zero to its maximum and then asserted the photograph was modified. It was not, and the test failed on its own premise rather than on the thing it exists to check. `EditGraph::capabilities` puts the lens corrections at the head of the list, matching where they sit in the shader. Those carry profile coefficients rather than parameters, so with no profile loaded a slider on one is a control with nothing behind it: the graph stays neutral and the premise assertion fires. The test was written against a panel whose first row happened to be an adjustment, and it stopped being one. So it now walks the rows until it finds a control that actually changes the edit, which is what it meant by "row zero" all along. Still addressed by index, so it still names no operation, and it no longer depends on where in the chain the first *adjustment* happens to sit. |
||
|
|
3994caba12 |
Write down the develop gestures, since only their author knew them
FR-UI-4 says a gesture with no visible counterpart is a feature only its author knows about, and the vocabulary the application actually publishes had two sections in it — the library grid and people. Develop had none. Every one of its gestures was documented in the comment beside the `TouchArea` that implements it, which is where the previous sixteen were before this scanner existed, and unreachable to anybody not reading the source. Thirteen now carry tags: magnify by pinch or wheel, pan a magnified frame, fit and 1:1, hold to see the original, sample a neutral, undo and redo, step through the folder, reset one control, show or hide a mask layer, choose a group of adjustments, and copy and paste the settings. Each has a pointer and a touch route, so none of them is keyboard-only. Three bindings were genuinely missing and are added here rather than merely described. Ctrl+C and Ctrl+V for the settings clipboard, which the Settings panel's own comment has claimed existed for as long as the panel has and nothing bound; and [ and ] to step through the adjustment groups. The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can name one of them — stepping is the binding that survives a node being added, and "everything" is part of the cycle rather than a way out of it. Two are described and not bound. Resetting a control from the keyboard and toggling a mask layer from the keyboard both need a notion of which control or layer has focus, and the generated panel has none — the rows are a model the repeater rebuilds, and inventing a focus ring for them is a larger change than a keyboard shortcut. Both are reachable by pointer and by finger, and the tags say so rather than promising a key that is not there. |
||
|
|
901f51e6c4 |
Point at something grey and let the pipeline work out the rest
FR-DEV-3 has asked for "white balance (temperature/tint, and picker)" since it was written, and only the first half existed. `WidgetKind::WhitePoint` was in the vocabulary and `develop::supported` answered false for it, so the node degraded to two sliders — correct behaviour that had quietly become the only behaviour. Sampling a neutral is the first move of the global tonal pass and every colour judgement afterwards is measured against where the grey was put, so guessing at two sliders until a wall stops looking green is the wrong way round. The awkward part is that a picker genuinely needs to know how far a hundred units of temperature move red against blue, and that number is declared in the node's own file. So the inversion lives in `dr_pipeline::neutral` rather than in the interface: the canvas hands over a colour, the core finds the operation that asked to be driven by a pixel and bisects its declared response until the sample comes back grey. Nothing in `ui/` names white balance, and nothing holds a second copy of a response that would be wrong the first time somebody adjusted the range. A bisection rather than a closed-form inverse because only monotonicity is part of the bargain — the expression is free to become a table tomorrow. The result is rounded to the precision the control is drawn at, which is not cosmetic: unrounded, sampling something already neutral lands a ten-thousandth off zero, and the photograph comes back modified with an undo step for a correction of nothing. On the panel side this needed one distinction the generated path was missing. `is_on_canvas` was being read as "and so the panel draws nothing for it", which is right for a crop — four edge fractions are not controls anyone drags in a list — and wrong for an eyedropper, which *writes* temperature and tint and leaves them exactly the controls a photographer reaches for next. So a sampling widget keeps its sliders and puts the affordance that arms the canvas in the group's heading, built like the reset beside it. One click, one sample, one history step: `Edit::Action` never coalesces, and there is no hover preview to fill the stack with temperatures nobody chose. Declaring the presentation also groups temperature and tint under one undo step, where they were two. That follows from what `Presentation` means and reads correctly — white balance is one decision — but it is a change, and worth saying so. |
||
|
|
2584b9ecbc |
Hold one key to see the photograph before you touched it
FR-DEV-7 asks for the current edit against the unedited original and nothing implemented it. What the develop view had was history navigation, which *changes* the edit rather than previewing against it — so the only way to look was to undo, look, and redo, and that puts two real steps on the stack at exactly the moment a photographer suspects they have overcooked a frame and is least sure of what they are doing. Holding the "Before" button, or backslash, renders the graph with every adjustment stripped and hands it straight back afterwards: the same suspend-render-restore shape the crop overlay already uses to show an uncropped frame and an export uses to suspend the zoom. Nothing is recorded, no rows are re-synced, and the photograph is still modified when the key comes up — the panel goes on describing the edit the photographer has, because only the canvas is answering a question. The framing deliberately stays on. A held comparison is a question about tone and colour, and re-cropping the canvas under someone's thumb would move the detail they are comparing; worse, the zoom is a rectangle of the *framed* image, so dropping the crop at 4× would quietly show a different part of the photograph rather than the same part unedited. What the crop took away is already compared in Compose, which shows the whole frame. Not a split screen: that halves the working image on the tablet this column was sized for, and the comparison photographers describe making is a flick back and forth rather than two pictures side by side. Press-and-hold is one gesture on a finger and on a mouse, which is what FR-DEV-3b's mapping wants, and it has no mode to be stranded in — the button reports both edges, so a press the system cancels puts the original down too. |
||
|
|
9b674a88d8 |
Let the photographer look at the pixels, and keep looking
Noise reduction and capture sharpening are judgements about individual pixels, and at a fitted view several of the file's pixels are averaged into each one on screen. The frame therefore looks cleaner and softer than it is, the photographer corrects for a softness the display invented, and over-sharpening is the documented result. Nothing in the develop view reached 1:1 at all: the wheel and the pinch zoom by ratios, the double tap dropped straight to fit, and the only readout was a percentage nobody was aiming at. So the double tap now does what FR-UI-4 always said it did — toggle fit and 1:1 — and the zoom readout, which used to be a dead "Fit" button on a fitted photograph, becomes the way in when there is nothing to clear. Z does the same from the keyboard, and the back gesture goes out through the same toggle so putting the magnifier down really puts it down. 1:1 is computed from the file's own resolution against the viewport rather than fixed at some multiple, because that is the only version of it that answers the question the two detail controls are asking. The point being inspected and whether the magnifier is up are held beside the session rather than in it, on the argument focus peaking already makes: a session is one photograph and this is a way of looking at a folder of them. Checking the same eye across forty portraits is the reason to reach 1:1 in the first place, and a magnification that reset with the session would make that forty zooms and forty pans instead of forty keystrokes. It stays a viewing state throughout — the view is kept out of `is_active`, `output_size`, the sidecar and the export, and an export still suspends it — so none of this reaches the file. |
||
|
|
1c5c55b4c9 |
Let the photographer say which frame the burst stands for
`choose_representative` has been in the catalog since the grouping landed, with tests behind it and nothing calling it. So the frame a folded burst drew was always the earliest one, and the only way to disagree was to open the group and leave it open — which is to say there was no way to disagree at all, because a burst that stays open is a burst that was never collapsed. The earliest frame is the right default and it is deliberately not a judgement: nothing here scores a photograph, and FR-CULL-5 names the failure that rule avoids. But the whole point of a burst is that one of the twelve is better than the other eleven, and the person who knows which is the one looking at them. So a ring on each frame of an open group, ticked on the one the group folds to. It is drawn only while the burst is open, because that is the one moment the alternatives are on screen to be compared — offering the choice on a folded burst would be asking about frames it is hiding. Bottom right, opposite the count in the other corner, clear of the flag and the collection badge and, deliberately, of the trash target: a slip between the ring and the fifth star sets a rating, which is the harmless direction for an ambiguous press. The mark stays live on the frame that already wears it. A disabled TouchArea would let the press fall through to the cell behind it, so tapping the one ring that is ticked would have opened the photograph — and pressing it is a thing the user may mean anyway: it records the choice the default was making silently, which then survives a regroup that finds an earlier frame. Choosing repaints the badges instead of reloading the window, which is what separates it from folding a group up. Folding changes what the grid's query returns; this changes only which cell wears the tick, and the tick has to leave the frame that was carrying it, so the whole window is refilled in the one statement `sync_badges` already runs. The gesture is documented where FR-UI-4 requires it to be documented: in a tagged comment beside the control, which is the only copy. The gesture book, the gesture document and the requirements matrix are regenerated from the tree alongside it. |
||
|
|
68ebf5d78b |
Let a mask start from a tone or a colour, not only a shape
Every local adjustment began from a shape: painted, drawn with a handle, or found by a model. So the only way to hold back a sky was to draw a line near where it ended, and the only way to warm skin was to paint round it — both of which put the edit's edge where the photographer put a gesture rather than where the picture changes. A gradient across a treeline halos, and an adjustment traced round a face stops on the outline of a hand. MaskSource grows two variants that select by what a pixel *is*. Luminance carries two bounds on the perceptual tone scale plus a softness; Colour carries an arc of hue, a range of chroma, and one softness for every edge of both. Five floats and three, so they diff, sync and merge per field under FR-NC-9 exactly as a gradient's geometry does — the property a stored raster has none of, and the reason the model's coverage had to sit beside its source rather than inside it. The pixels are the shader's business and nowhere else's. `mask.wgsl` takes the demosaiced source as a sixth binding and two new modes read it: decode, balance, pull a clipped photosite back to neutral, apply the camera matrix, then weigh the band. Nothing crosses to the CPU but the numbers and the matrix, and each mask texel averages its own footprint in the source, so a band lands on the tone an area is rather than on whichever texel a proxy grid happened to land on. The photograph it measures is the one the camera recorded, before this edit. A band over the edited result would slide out from under the edit as the edit was made — raising the highlights would change which pixels counted as highlights, and the slider would chase its own mask. Feather, falloff and morphology stay off a range layer, which is what `shapeable` already meant. All three are functions of the signed distance from a boundary, and a range has no boundary to be at a distance from; its edge is the softness of its own band, in the band's units. Offering them would be four controls that move and change nothing. |
||
|
|
81b1ae8c42 |
Measure the haze from the picture, and divide it back out
Four files named dehaze as a member of the compositional detail family — `detail.rs` twice, `dr-gpu`'s detail module, `ops/README.md` and `capture_sharpen.rs` — and no such node existed. Every one of them was describing the family by listing clarity, texture and a control the photographer could not reach. Haze is the one degradation the controls already in the chain cannot remove, and the reason is spatial rather than tonal. Scattering composites an airlight over the scene in proportion to distance, so the lift is per-pixel: a black point that clears the mountains crushes the foreground, and a contrast curve that clears the mountains does the same. So the node has to estimate the transmission at every pixel, which is the dark-channel prior — the local minimum over the channels and over a patch is the airlight that has been added there — and then invert the scattering model with it. The airlight is taken as neutral and as unit, which removes the one part of the published method this stage cannot perform. Estimating it properly is a whole-frame reduction, and the detail chain has none: it hands each pass the pass before it. It is also unnecessary, because white balance is the first node in the chain and has already driven the illuminant to grey, so only the magnitude is unknown — and an unknown magnitude on the veil is a scale factor on the amount slider, which the photographer is setting by eye regardless. The patch is a fraction of the frame's shorter edge, through `RenderScale::frame_fraction`, and never a count of pixels. It has to be wide enough to contain something dark and narrow enough that what it measures is still local, and both of those are statements about how much of the composition it covers — so it must cover the same proportion of the picture on a proxy as in the export, or the file is sharpened for a patch three times narrower than the one that was tuned on screen. Affording it needs an identity a Gaussian does not have. Erosions compose by adding their structuring elements, so the minimum over a run of d followed by the minimum over k points spaced d apart is the exact minimum over the whole kd window. At the square root that is 16 taps rather than 61 at 4K, and it is the same filter rather than an approximation of one — which is the difference from the strided kernel `local_contrast` refuses, where sampling an image that is not band-limited aliases into the base and comes back as mottling. It runs first among the compositional detail nodes, at order 125: after noise reduction, because dividing by a transmission below one amplifies the noise in the veiled distance by exactly the factor it recovers the contrast by, and before clarity and texture, coarse before fine, so that their base is computed on the picture the veil has left rather than on a modelling about to be divided out. What it cannot honour is the placement dehaze most wants. It shifts colour — it subtracts a grey term and rescales, so saturation changes wherever the veil is thick — and the colour work would ideally be correcting the picture that leaves here. The detail stage runs as a group after every point operation, because a neighbourhood pass is a separate dispatch over a texture the fused pass has finished writing, so an order placing this node ahead of `vibrance` would be a lie the chain cannot tell. Interleaving would mean splitting the fused pass in half around it, at the cost of a second full-frame dispatch and intermediate for every edit in the catalogue whether it dehazes or not. The declaration records that rather than leaving it to be rediscovered. FR-DEV-18 is added to the requirements register alongside it. The tag had nowhere to point, and an orphan tag fails the traceability gate rather than quietly counting for nothing. |
||
|
|
7c3e1d2c54 |
Let the shadows and the highlights carry a colour the picture never had
The colour mixer is the only chromatic control in the chain, and it can only turn a hue that is already in the frame. Ask it for cool shadows against warm highlights and it has nothing to take hold of: the shadows of a correctly balanced photograph are near enough neutral that there is no band there to turn, and a monochrome conversion hands it a picture with no hue in it at all. Split toning is the oldest look in the book and every developer worth comparing against ships it; there was no way to reach it from here. So colour_grading, declared like any other node — a hue and a strength for the shadows, the midtones and the highlights, and a global cast over the frame. It targets a tonal range rather than a hue, which is the whole difference between the two controls: it puts colour where none was rather than turning what it finds. It sits at 105, after the mixer has had the last word on the colours that are in the picture and before the detail stage. The mechanism is one helper. Three cosines 120 degrees apart are the hue wheel written directly as an RGB direction, and their sum is zero at every angle, so exp2 turns them into three gains whose product is exactly one — a cast tilts the balance without moving the level. A grade that doubled as an exposure change is the failure that has the photographer chasing brightness with a colour slider, and it is corrected with a control that cannot reach it. The three tonal weights partition the scale rather than overlapping, the midtones being whatever the two ends leave, so setting all three to one hue is exactly the global cast and a split tone does not colour its own midtones as a side effect of its halves meeting. Full strength is half a stop on the leading channel, the ceiling white balance already holds itself to. Neutral is declared rather than inferred, which is what `active:` is for. A hue with no strength behind it is a direction with no distance, so under the default rule nudging one would have put the node into every fused shader for a change nobody can see. Summing the strengths is zero exactly when all four are, and they cannot go negative to cancel each other. The opposite reading — neutral as "nothing has been touched" — fails the other way round: red is hue zero, so a grade toward red never moves a hue off its default and would never have been applied at all. It asks for a colour wheel, the widget the descriptor vocabulary has been carrying with no operation behind it. Nothing draws one yet, and that is fine by construction: the panel takes the first widget it implements and falls through to sliders otherwise, so this arrives as eight ordinary controls that work. Each parameter is named for its own range for exactly that reason — in a flat list, four sliders called "Hue" are four controls nobody can tell apart. FR-DEV-12 is written into requirements.md beside it. A TRACES tag naming a requirement that is not defined there is an orphan, and the traceability gate fails on those rather than quietly counting them. The label catalogue gets one line for the operation's display name; the eight parameters derive correctly and are left to. |
||
|
|
59917c5183 |
Call the tool Compose, since that is what its panel says
Benchmarks / CPU and I/O (per commit) (push) Successful in 14m35s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 1h20m20s
Build and test / Layer separation (push) Successful in 51s
Traceability / Requirement traces (push) Successful in 2m8s
🐳 Android image / Build and push (push) Successful in 5s
Build and test / android-image (push) Successful in 5s
Build and test / Android (aarch64) (push) Successful in 1h5m51s
The rail entry read "Crop" while the panel it opens is headed COMPOSE and the button leaving it said "Done Cropping". One mode, three names, and the odd one out was named after a single control rather than after the decision — which is what made cropping look like a category of its own in the first place. Straightening, the quarter turns and the flips are already in that panel, and perspective will be. `ViewMode.crop` keeps its name: it identifies a canvas interaction, which is exactly what it still is. Found by looking at the running application rather than by reading, which is also how the two halves of this were noticed to disagree at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |