c826fed60582d27d608617f8adcc2f7700c50884
253
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
5fa4c0772b |
Speak the sidecar format every other editor already reads
FR-CAT-13 asked for standard XMP and nothing in the tree parsed or wrote a byte of it. `keywords.rs` mentioned `dc:subject` in a comment about what a keyword's text is for, `dr-export`'s metadata module said "neither is read by `dr-decode` today" about its own half, and `dr-preset-xmp` reads a different file for a different requirement. So a library imported from Lightroom could come in and never go back out: a one-way door, which is not a thing a photographer walks their archive through. `core/dr-xmp` reads and writes the properties the requirement names — `dc:subject`, `lr:hierarchicalSubject`, `xmp:Rating`, `xmp:Label` and the IPTC core fields — from whichever shape the file happens to use. A property may arrive as an attribute or as an element, inside a Bag, a Seq, an Alt or no container at all, because the specification is not what wrote the file; so one collector takes whatever is in a property and the declared shape decides only how many values survive. `xmp:Rating="-1"` is modelled as Adobe's rejection rather than folded into zero stars, since DarkRoom keeps those on two axes and the mapping belongs where both are visible. Writing is a rewrite rather than a serialisation, and that is the whole design. An XMP sidecar is a shared document: the file beside a raw carries somebody else's `crs:` settings and comments and namespaces, and rendering our record over it would be data loss on every photograph but the first. The rule is stated once, in the crate documentation and in `PROPERTIES`: DarkRoom owns exactly those properties, identified by namespace URI and never by prefix, and nothing else in the document. Everything unowned is copied through byte for byte. A `Description` left empty once our properties come out of it is withdrawn, which is what keeps a rewrite idempotent instead of adding a husk to the file on every save. Precedence is settled conservatively, because a standard XMP carries no revision and no device and there is nothing in it to order two edits by. Keywords union, following the rule `dr_catalog::merge` already makes for assignments; every other field is taken only where DarkRoom holds none, following `Version::merge`'s judgement rule, and a genuine disagreement is reported rather than resolved so a caller can offer the reload the requirement asks for. What is deliberately left open — when a reload may happen without asking — is written down in the module rather than picked silently. No new dependency: quick-xml was already in the tree for WebDAV and for Lightroom presets. Nothing above the crate calls it yet, and `outstanding.md` now says so along with the two smaller gaps, GPS and the filename convention. |
||
|
|
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. |
||
|
|
efa9d84aad |
Correct the lens first and settle the grain last
`Attribute::ALL` has claimed since it was written to be roughly the order a photographer works in, and |
||
|
|
e235e99cce |
Move the film to Effect in the descriptor that is actually read
An earlier commit claimed to move `film_sim` from `[tone, colour]` to `[effect]` and did not. It edited `ops/film_sim.yaml`, where `attributes:` is read, validated against the vocabulary, and then dropped: a `rust:` node publishes its own descriptor, and the type still said tone and colour. The stock went on appearing in the Light group beside exposure and again in Colour beside white balance, exactly as before, and every test passed. Nothing caught it because nothing could. The declaration parsed, the parity tests compare ids rather than attributes, and an operation filed under the wrong groups renders perfectly. It surfaced only on screen, as a missing Effects tab — which is indistinguishable from a category that genuinely has nothing in it, and is precisely how `Optics` looked for as long as it was empty. So three changes rather than one: `FilmSim`'s descriptor declares `Attribute::Effect`, which is the move the earlier commit described. `attributes:` joins the keys a `rust:` node may not carry, beside `params`, `uniforms`, `wgsl`, `helpers`, `define` and `label`. The rule was already written — "its descriptor comes from the type" — and attributes were the one field that slipped past it. A key that is silently ignored is worse than one that is rejected, because it reads as though it worked; the eight hand-written declarations lose a line that never did anything. And a test asserts that every attribute the chain carries reaches the tab strip. That is the property that was actually broken, and its failure mode is invisible from every direction: the controls exist, they are in the shader, and there is no way to filter to them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
474dcf0bf6 |
Let Compose lead the attributes, as their own doc always said
`Attribute::ALL` claims to be "roughly the order a photographer works in" and then listed framing fifth, behind tone, colour and detail. Framing is the first decision made about a photograph and the one every later judgement is made inside — there is no sense balancing tones across a frame about to lose a third of its width. The contradiction was harmless while the list only fed a row of chips nobody reads in order. It stops being harmless now that the same list drives a column read top to bottom. `declared::Attr::ALL` moves with it. The two are separate spellings of one vocabulary and a test asserts they agree, which is what caught this rather than the order silently disagreeing between the YAML front end and the crate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1ad35e2b87 |
Read the library's sidecars, so a cull done elsewhere arrives
Judgements only ever travelled outward. A rating went to the catalog and to the photograph's sidecar, the sidecar reached the server, and there it stopped: the scan indexes files, `derived_sync` exchanges thumbnails, face shards and collections, `dr_catalog::merge` reconciles everything in a catalog except `versions.rating` and `versions.flag`, and the one sidecar reader that existed ran when a single photograph was opened in develop and handed its answer to the develop graph. `JobKind::ReadSidecar` was declared for exactly this when the job queue was written and was never enqueued or handled anywhere. The grid draws `versions.rating`. So a day of culling on the tablet could not reach the laptop by any path the application had, and the laptop's catalog says so plainly: 23,568 images, one of them judged. `pull_sidecars` closes it, off the back of work the scan already does. `dr_sync::scan` reports the `.drsc` files it meets in listings it was making anyway — no extra request, and a directory whose ETag is unchanged is still pruned before it is listed at all. A new `sidecars` table records the ETag of each one this device has taken in, so the fetch is one GET per sidecar that genuinely changed rather than one per photograph. A library nobody has edited costs nothing. The judgement is taken rather than maximised. The sidecar is the authoritative store and the fuse has already settled any contest between devices on `revision`, so lowering a rating from four to one on the tablet lowers it here — taking the larger would have refused every demotion the photographer ever made, which is most of what a second pass over a shoot is. A zero is the exception: it means *never judged*, not "judged zero", so a sidecar carrying none cannot erase a star this device holds. That is `merge_judgement`'s asymmetry and it carries the same known cost — clearing a rating does not propagate. A sidecar names a stem, so both halves of a RAW-and-JPEG pair are judged: they are one photograph (FR-CAT-11) sharing one document, and judging only one of them would leave the grid disagreeing with itself over which it drew. The `LIKE` that finds them is a filter, not the decision — `sidecar_path` is applied to every candidate, because a folder is entitled to contain a `%` and a rating landing on the wrong frame would be silent and permanent. Failing to read one is not a failure to scan: the ETag goes unrecorded, the ratings already here stay where they are, and the next scan tries again. The count is reported to the status line as well as the log, because a grid that silently gains three hundred stars is indistinguishable from one that has gone wrong — and because while this number was structurally zero there was nothing to tell the photographer their cull had not arrived. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ca2a135e28 |
Let two devices name the same photograph's version the same way
A version's uuid is the identity a cross-device merge keys on, and it was minted at random, per catalog, per image. Two devices indexing one Nextcloud library therefore held two different uuids for the same photograph — so the sidecar they shared collected a `default = 1` block each, `Version::merge` was never handed a matching pair to reconcile, and an afternoon's culling on the tablet did not exist as far as the laptop was concerned. `crate::merge` has said so in a comment since it was written: version uuids do not reconcile across devices, a uuid-keyed join unions nothing, so keywords are landed on the local default version instead. It named the problem and worked around it. `rating`'s own comment asserted the opposite — that generating the uuid here was what made it a cross-device identity — and `library::amend` repeated the claim. Uniqueness was never the difficulty; agreement was. `derived_version_uuid` computes it from `oc:fileid` instead. The server assigns that integer, every client pointed at the library sees the same one, and it survives a server-side rename and move — the three properties that already made `ASSIGN_BY_FILE_ID` prefer it to a content hash. The layout is a UUIDv8 (RFC 9562, an application-defined form) carrying all sixty-four bits verbatim across the variable fields with a fixed tag in the node field, so the mapping is injective by construction rather than by a hash's good behaviour, and a uuid in a sidecar can be read back to the file it belongs to by eye. A library with no server behind it has no shared identity to derive and keeps a generated one. The split is still reachable there if the folder is synced by something else; `Sidecar::fuse_default_versions` repairs that case rather than preventing it. Deriving it for new rows alone would have fixed nothing — every image in an existing library already has a version, so every one of them would have carried on writing to its own rival identity. `align_default_version_uuids` moves them, and runs from `schema::backfill` on every catalog open. It selects on the tag in SQL, so a catalog already realigned matches no rows and writes nothing, and it declines rather than fails where a virtual copy already holds the target. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
601c984894 |
Fold a photograph's rival default versions back into one
Picking the newer of two default versions stopped the wrong edit being shown, but it did not close the split: the losing version stayed in the file, and a device holding disjoint work — a crop made here, an exposure change made there — still contributed only one of the two. Worse, the next write made it larger. `amend` looks its version up by uuid, neither of the two was ours, so the miss minted a *third* `default = 1` block and the file grew one rival per device per photograph. `Sidecar::fuse_default_versions` folds them down. The version with the highest `(revision, modified)` is the accumulator and every other default is merged into it as the remote, which is what makes the fold order-independent — `Version::merge` raises its own revision to `max + 1` as it goes, so merging a chain in ascending order stops being ascending after the first step and a third device would be dropped. Contested values resolve to the winner, disjoint keys survive from both sides because the merge is key-wise, and ratings come across under `merge_judgement`, so a device that never judged the frame cannot erase one that did. The result is a function of the file's bytes alone, so two devices that fuse independently reach the same document and converge instead of overwriting each other. Called wherever a sidecar is parsed: - `amend`, with the write's own uuid, so the fold lands on the identity this device is about to use and the lookup below it hits instead of missing. - `spawn_sidecar_fetch`, so opening a photograph shows everything done to it rather than whichever half won. - `drain_one`, because `merge_into` reconciles by uuid and would otherwise publish the split rather than resolve it. - `presets::load_local` and `save_local` — a local sidecar's folder may be synced by something else entirely, and gets the same split. A file with one default under the expected uuid comes back byte-identical, so this costs nothing on the ordinary write and no sidecar is uploaded merely for having been read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
98fcf8e98e |
Ask which edit is newer, not which uuid sorts first
A photograph edited on two devices ends up with two `[version]` blocks in one sidecar, both marked `default = 1`. Five of the thirty-eight sidecars in the local cache are in that state right now. `default_version` answered with the first `is_default` it met in map order, and the map is keyed on uuid — so which device's work the photographer saw was decided by which randomly minted uuid happened to sort lower. On `IMG_20130625_0033` that is `0545c20a` over `679fe872`: a four-star rating from the twenty-first of August standing in front of the one-star made on the thirtieth, with nothing anywhere saying the newer judgement existed. Resolved by `(revision, modified)` instead, which is the discriminator `Version::merge` already uses — revision first so that a device with a skewed clock cannot win by claiming a later timestamp (FR-NC-8), and the timestamp only to break an exact tie. This makes the reader pick the right one. It does not make the two converge: the edit that lost is still in the file, and a device that holds disjoint work — a crop here, an exposure change there — still only contributes one of them. That is the next commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c4ddcbe0f7 |
Look the lens up and say plainly whether one was found
`dr-lens` has held a complete Lensfun lookup — distortion, TCA and vignetting coefficients from a lens name, a focal length and an aperture — with no dependents anywhere in the workspace. The three corrections it feeds now exist in the graph, so this connects the two and finishes the chain. The coefficient structs stay duplicated. `dr-pipeline` is organised around having no dependencies so its codegen is testable without a device or a database (ARCH §6.5a), and `dr-lens` carries an XML parser and 5.5 MB of profile data. Neither crate can convert to the other, so the conversion goes above both, in `develop.rs`, which is the only place that sees them together. Both traits grow the same defaulted door. The optical corrections do not sit on the same side of the fetch — distortion and CA rewrite coordinates and are `Warp`s, vignetting applies a gain to the pixel already there and is an ordinary node — and fanning a profile out by which trait each happens to implement would make the caller reason about that distinction. Each correction takes its own share of the whole profile instead, and `set_lens_profile` walks both lists identically. The lookup happens in `set_source_metadata` rather than in its caller, because that is the one place a session is told which file it came from. Doing it there makes it unforgettable, in the shape `FilmRebake` already uses for the other derived thing — and, more to the point, makes *clearing* unforgettable: a session that opened a second photograph while still holding the first one's profile would correct it for the wrong optics, invisibly, in a way that looks exactly like the lens. It needs the whole shot and not just a name. Distortion is interpolated across a zoom's focal range and vignetting depends strongly on aperture — a fast prime can be two stops down in the corners wide open and clean by f/8 — so a lookup missing either returns coefficients measured for a shot nobody took. Missing any of the three refuses rather than guesses. A profile is derived, not persisted: it comes from the file's EXIF and a database, so it is not a parameter, not in the sidecar and not undoable. What is an edit is the manual trim beside it, which each correction composes with the measurement — so a photographer can lean on it, override it, or work without one. `InfoPanel` gains a lens line, and it distinguishes three cases rather than two. `dr-lens` states the rule it exists for: an automatic correction that silently did nothing is worse than one the user can see is unavailable. A session with no header draws nothing, a header naming no lens reads "Lens not recorded", and a lens the database has never heard of reads "· no profile". Collapsing the last two would send somebody hunting for a profile that was never missing — which, for third-party and adapted glass, is the ordinary case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a1165ef182 |
Put the coordinate-domain lens corrections into the graph
`lens.rs` has held a `Warp` trait, a composer and two implementations — distortion and lateral chromatic aberration — since they were written, and `compose_warps` was called by nothing outside its own tests. The corrections existed, were correct, and never touched a photograph. `EditGraph` now holds them, and `compose_full` emits them between the framing prologue and the fetch. Distortion first, then CA: each warp receives the position the previous one produced, and lateral CA is a magnification about the optical axis of the *undistorted* frame, so measured on a barrel-distorted one it would be fitted to a radius no profile describes. They reach the panel the way framing already does — through `capabilities`. That was the one open question and existing practice answered it: framing is also not an `Operation`, also has parameters a photographer sets, and also arrives through that list. Because `Preset::capture` walks the same list, the sidecar, the clipboard and the undo stack carry a warp's parameters with nothing registered anywhere, and no file under `ui/` names one (FR-DEV-3a). `state()` destructures `EditGraph` field by field precisely so that a new field cannot be forgotten, and it was not. Chromatic aberration is the only thing that samples per channel, and `splits_channels` is what keeps everything else from paying for it. Red and blue are fetched from positions green is not — green is the reference and never moves, so a wrong correction still leaves one channel sharp rather than softening all three. With no CA in the chain the single-fetch path is emitted instead. The interpolating sampler is now chosen by framing *or* an active warp. Asking framing alone would have nearest-neighboured a distortion correction on an unstraightened frame, and that aliasing reads as a bad profile rather than as a missing filter. The warps go in the geometry invalidation key rather than the colour one: they decide which source pixel a colour is read from, so a tile cached across a distortion change would keep drawing the previous correction. The pipeline cache needs nothing new — `hash_source` already covers the generated body, and uniform values never enter it, so arming a warp recompiles and dragging it does not. Both are asserted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e17b909d41 |
Connect the lens vignetting correction to the pipeline
`ops/vignetting.rs` has carried a complete descriptor, polynomial, helper and test suite without an entry in `ops/`, so it was never in `chain()`. It reached no photograph and no panel, and `Attribute::Optics` was an empty category in consequence — filtered out of the tab strip for having no rows, by a chain that had never been given its only member. Declaring it needs the one thing the operation was written against and which did not exist. `wgsl_body` reads `radius`, and the module claimed "the composer publishes `radius` in the shader prologue for exactly this reason". It did not. `sample_source` now does, in both sampling branches, beside the `source_px` it already published for the same class of caller. It is corner-normalised there, which is the part that is easy to leave out. `p` spans ±0.5·aspect, so its length at the corner is 0.5·length(aspect) — about 0.901 on a 3:2 frame, not 1. Lensfun's polynomials are fitted against a corner radius of 1, so passing `length(p)` straight in evaluates every one of them short of where it was measured, by a factor that changes with the aspect ratio. It would have read as a correction that is simply too weak, which is indistinguishable from a bad profile. Both `lens.rs` and `framing.rs` asserted the normalisation `p` does not have; corrected. `order: 5` puts the correction ahead of the tonal stages, and the ordering is load-bearing rather than tidy. Recovering a corner means dividing by an attenuation below one — about two stops for a fast prime wide open — so run after the highlights have been rolled off and clipped, the lift has nowhere to go and the corners posterise instead of brightening. `layer_chain` now drops `Optics` as well as the neighbourhood operations. A local vignetting slider would have worked, which is what makes it worth excluding: `radius` measures from the centre of the whole photograph and a mask cannot move the optical axis, so it would lay a frame-centred radial ramp across the picture and multiply it by the mask. The existing exclusion covers operations that move and do nothing; this one covers an operation that moves and does something its name does not promise. The rule both share is now written down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8e7b1350bf |
Name the frame's category for the decision, not the maths
`Attribute::Geometry` becomes `Attribute::Compose`, and `film_sim` moves from `[tone, colour]` to `[effect]`. Two categories were doing the wrong job. "Geometry" describes what crop, straighten and the quarter turns do to coordinates — but it describes lens distortion correction exactly as well, and that is not a compositional choice at all. Naming the attribute for the photographer's decision is what separates it from `Optics`: one is what the lens did, the other is what they chose. The maths the two have in common is not the thing worth filing them under. A film stock declared both `tone` and `colour`, so "Kodachrome" appeared in the Light group beside exposure and again in Colour beside white balance — two places, neither of which is where anyone looks for it. It is neither: `Effect` is defined in this same file as "applied rather than corrected — a look, not a fix", which is what a stock is. That it moves tone and colour is true of every look, and is not what the attribute is for. `from_name` still accepts "geometry" on the way in. That string is persisted in `develop.copy_attributes`, and an entry it fails to parse is not an error — `presets::scope_for` logs it and drops it — so without the alias an existing settings file would have quietly narrowed what a paste carries. `name` writes the current spelling, so the file migrates itself the first time it is saved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
dabf63ed6d |
Regenerate the matrix over the watershed and sidecar work
`Traceability` has been red on master. The matrix records every tag as
`file.rs:LINE`, so it goes stale two ways at once, and both happened here:
- the `cargo fmt --all` sweep (
|
||
|
|
710fcbc1bd |
Format the face work with the workspace's own rustfmt
Not authored in this session. `cargo fmt --all` reformats every crate, so running it while working on `dr-segment` picked up five files from the recent face and library work that had been committed unformatted. Committed on its own rather than swept into the change that happened to produce it: the diff is pure whitespace, and mixed into a commit that alters an algorithm it would be noise in exactly the place someone is trying to read carefully. `cargo fmt --all -- --check` is a CI gate (tools/ci-local.sh), so this had to land somewhere regardless. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
89c4ff1820 |
Store what the model found, so a reopened photograph keeps its masks
A subject or category layer was written to the sidecar as identity alone —
which run, which instance, which category — on the reasoning that the pixels
are reproducible by running the same model over the same image. They are, but
only by *running the model*, and nothing runs one except a photographer
pressing "find subjects". So on every path that did not already have a run in
memory the layer resolved to no coverage, `MaskPass::render` logged "has no
distance field; skipping", and the adjustment was silently absent:
- reopening an edited photograph rendered it without its local adjustments,
and then saved that state back on the way out;
- a batch export from the grid could not have them at any point, because
`render_from_library` opens a session, applies a version and renders, and
there is no model anywhere on that path. Three hundred files written
without the edits their photographer made, over a log warning.
Neither failure announced itself. The generated shader still emits the layer's
block and the empty placeholder multiplies it by zero, so the result is a
well-formed frame that is simply missing an edit — `mask_is_stale` already
named the state and called it "not stale, just unrenderable".
The coverage now travels in the file, as one `coverage = w h levels payload`
line at the end of the layer's block.
Two levels, and that is not a compromise. The model hands out a byte per pixel
but `Shaped::build` measures its distance field from `coverage >= 128` and
throws the shoulder away on the first line; everything soft about the rendered
edge comes afterwards from the layer's feather and falloff, which are read off
the distance. So one bit per pixel is not an approximation of what the model
said — it is exactly the part of it that reaches a pixel, and the stored mask
renders the identical frame. Storing all 256 levels would have stored 1.7 MB
of bilinear interpolation to reconstruct a predicate, and would not even have
compressed: a model mask is a bilinear upsample of a coarse grid, so almost no
two adjacent bytes are alike. Measured on a simulated sky and a simulated
figure at 1600x1067, against 1.71 MB raw: 4.0 kB and 6.5 kB at two levels,
46 kB and 76 kB at sixteen, 835 kB and 1.43 MB at all 256. The level count is
still written into the line, so a later build that finds a use for the
shoulder can write sixteen and this one will read them rather than misreading
a stream of lengths as pairs.
The coder is hand-rolled — run-length pairs in a base-64 varint — because
`dr-pipeline` links nothing, which is the property that lets the descriptor
and codegen logic be tested without a device. `flate2` would have been fewer
lines and a dependency in the one crate that has none.
Where it lives matters more than how it is coded. The raster sits on
`MaskLayer` beside the source, not inside `MaskSource::Subject`: the source is
*identity*, which is what makes it diff as a handful of numbers and merge per
field under FR-NC-9, and a raster in there would have given the merge a binary
blob to arbitrate. It takes no part in `MaskLayer`'s equality for the same
reason — a device that has run the model and one that has not hold the same
edit, and counting the difference would raise a conflict over a cache and let
`remote_wins` answer it by discarding the only copy of the pixels.
Encoding happens in `masks_for_storage`, on the save path, rather than in
`ensure_subject_fields` where every coverage already funnels through.
`ensure_subject_fields` runs on a drag — dilating a mask with a compound
morphology rebuilds the field every frame — and encoding a megapixel raster
per frame is the kind of work NFR-P5 exists to keep off a gesture. Saving
happens once, when the photograph stops being the open one, and already costs
a network round trip.
Version skew holds both ways. A file with no `coverage` line reads exactly as
it did before, which is a layer that needs the model run; an unreadable one
costs the pixels and not the layer, because the layer is the edit and the
raster is a cache of it. An old build reading a new file drops the key it does
not understand, which costs a model run and no work. And a payload that will
not compress is refused rather than truncated: a checkerboard would encode to
twice the raster it came from, so past 64 kB nothing is stored and the
behaviour falls back to what it was — half a mask would render as a mask that
is confidently wrong, which is the failure that tells nobody.
|
||
|
|
6acc98baad |
Carry the photograph's header into an export made from develop
The same file exported from the library grid kept its camera, its lens,
its capture date and its rights statement. Exported from the develop
button it kept none of them, and `{date}` in a filename template
resolved to nothing at all. Two buttons, one photograph, two different
files -- and the develop one was the version the photographer had just
finished working on.
A session now remembers the header it was opened from, and
`open_session` takes that header rather than the orientation read out of
it, so a photograph cannot be opened for editing without saying which
file it came from. `render_open_frame` clones it onto
`Source::Rendered`; both arms of `export_one` -- the worker's own decode
and the frame handed over already rendered -- turn a header into a
`{date}` and a `SourceMetadata` through the same function, so the two
paths cannot come to different readings of one file. What of it actually
reaches the exported bytes is still decided inside `dr-export` from the
settings, which is what keeps the location-stripping option working here
rather than giving it a second implementation to disagree with.
The alternative was to hang the metadata on `Source::Rendered` alone and
keep it beside the session in the interface. That touches less, but it
makes the header and the pixels two cells to hold in step across the six
places an image is opened, replaced or fails to open, and the failure
mode of getting that pairing wrong is not a missing tag: it is one
photograph exported under another's byline and coordinates, silently.
Kept on the session, the two travel together or not at all.
The header is stored decoded rather than transcribed at open time,
deliberately. `dr-export` argues that source metadata is a parameter and
not a field on `Frame`, because two exports of one frame may legitimately
disclose different amounts; by the same reasoning a session may remember
where its pixels came from without that being a decision about what to
publish, and the allowlist that decides remains the single function in
`export.rs`.
A file with no header is left with none -- an empty `{date}` and nothing
for the encoder to copy -- rather than today's date standing in for a
capture time nobody recorded.
|
||
|
|
353382c07f |
Hand the photographer's place between devices
A place recorded on the tablet should be where the desktop opens. Exchanged through `.darkroom-derived/place.json`, beside the thumbnail shards and the catalog snapshot. Newest timestamp wins outright: unlike the catalog this is replaced rather than merged, because two devices cannot both be where the photographer is and so there is nothing of theirs inside ours to preserve. It still refuses to upload over a copy it could not read, for a smaller version of the reason `sync_catalog` does: a record we have not compared against may be the newer one, and overwriting it would move the other device's photographer without ever having seen where they were. Last in the pass, and its failures are logged rather than reported. Everything else in that folder is *derived* -- a faster way to learn what the device could work out for itself -- so losing it costs time. A place is a fact only the other device knew, and losing it costs a scroll. A sync that ran out of connectivity should spend what it had on the shards. The full pass runs after a thumbnail sweep or when Sync is pressed, neither of which happens on an ordinary launch -- so a handover would arrive one launch late, which is one too many for a feature whose whole claim is picking up where you stopped. `spawn_place_fetch` is the small half: one GET of a few hundred bytes, started beside the scan. And it can still be refused. A handover is welcome on the way in and unwelcome once the photographer has started: a grid that jumped elsewhere mid-scroll because a round trip finally landed would have lost their place to the feature meant to keep it. Any scroll, scrub, scope change, filter or opened photograph closes the latch, and a record arriving after that is written to disk and takes effect next launch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d44bffa4a8 |
Remember where the photographer was
Opening the application was always a fresh arrival at the beginning of the library, whatever you had been doing when you closed it. What is written down is the view, the scope, the rating filter and the photograph on screen -- the open one in develop, the first visible one in the grid. Not just a scroll position: a position without the filter that produced it names a row of a list that no longer exists. Restoring them has an order for the same reason -- scope, then filter, then position, then the view -- because each step changes what an ordinal *means*. Addressed by remote path and collection UUID, never by an ordinal or a row id. `images.id` and `collections.id` are local to one catalog, and a grid ordinal is local to one ordering; a record naming either would land somewhere arbitrary on a second device and after any filter change on this one. Where the ordinal is needed, `library::ordinal_of_path` computes it through the grid's own `ORDER BY`, taken verbatim by a window function rather than spelled a second time as an inequality -- which is the mistake `grid_order_for` already warns about, and which a manually ordered collection would make unreadable. Every failure degrades rather than reports. A collection this device has not merged leaves the scope at the whole library; a photograph that has since been deleted falls back to when it was taken, which puts the grid in the right week; a torn file yields no place and the library opens at the top. Reopening develop is the one thing that requires an exact match, because a canvas on a path that no longer resolves is a filename over an empty frame. The record lives in `dr-types` beside `Settings` and the store lives here beside `SettingsStore`, for the reason `dr-types`' manifest gives: a JSON serialiser in `core/` would be paid for by every crate there. Two files and two lifetimes, though -- resetting preferences must not forget where you were. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8bf5e13faf |
Centre the photo roll on the frame it opens with
The roll brought the open photograph into view by the shortest move, which is right for stepping along it and wrong for the first look: a frame near either end of the loaded window arrived hard against an edge, with nothing on that side to give it any context. It now centres on the first settle of a develop session and steps minimally after that. A one-shot request that the strip itself clears -- the only thing that knows the request has been honoured is the code honouring it -- rather than something recomputed on creation, because the strip is created far more often than a session begins: leaving develop for Settings and coming back rebuilds it, and re-centring then would undo a roll the user had scrolled by hand. Raised on the two ways into develop from the grid, and not on a pick along the roll, which is a step within a session rather than the start of one. Centring is clamped to the ends: the third photograph of a window cannot be centred without scrolling empty space in beside it, and a strip that begins with a gap reads as broken rather than as centred. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0ff1e01ec3 |
Come back from develop on the photograph you were editing
Leaving develop returned to where the *grid* was, which after a walk along the photo roll can be a thousand rows from the frame you had just finished. So the one photograph you were certainly interested in was the one the grid came back without. Two positions, and the rule is not to pick one of them. The grid seeks to the remembered position, then reveals the keyboard cursor -- which is now put on the open photograph, and which moves the viewport as little as will bring its row into view. A frame inside the remembered screenful moves nothing at all; one outside it scrolls exactly far enough. One rule, both behaviours. The cursor rather than the selection, deliberately: `place_cursor` also rewrites the selection, and a set of forty photographs assembled in the grid must survive having one of them opened. `reveal()` now also runs on the grid's `init`, since `cursor-row` is initialised rather than changed when the subtree is rebuilt and no handler would otherwise fire. Both it and the roll's centring defer while the element has no height yet -- `init` runs before layout, where a height of zero makes every row look off screen -- and a latch brings the first real height back to the cursor without letting every later resize haul the viewport around. The capture-time marker follows the same move, for the same reason: `load_window` rebuilds the axis only when the scope, the filter or the total has changed, and none of them has. It is the same library seen from a different row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
eed27eb36d |
Keep the grid's place when another screen covers it
Opening Settings, Import or People and coming back landed at the top of the library however deep in it you had been. The grid is gated on an `if` in the markup, so every route away from it destroys the subtree and rebuilds it. A Flickable being destroyed passes its viewport through zero on the way out, and that reaches `on_library_scrolled` looking exactly like the user having flung the grid to the top. The handler already guarded against it -- but on `show-library`, which means "the library rather than develop" and stays true while any of those four screens replaces the window. So the guard covered the develop route and none of the other three: `resume_at` was overwritten with 0 on the way out, and the position was gone before anything could restore it. The condition the `if` is actually spelled with is now computed once, in `app.slint`, and Rust reads that. The two cannot drift apart again because there is only one of them. That fixes the overwrite. The second half is that nothing replayed the position on the way back in: `on_back_to_library` does it by hand, and Settings, Import, People and the launch screen do not go through it. Rather than teaching three more modules to call it, `scroll-to` is now kept current on every scroll. It is read by `seek()`, which runs on a token change and on `init`, so writing it without bumping the token cannot move the grid on screen -- and is exactly what the next grid reads when it is built. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4dc954f01a |
Take the photo roll's grab band off the buttons that end a mode
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m53s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 58s
Build and test / Layer separation (push) Successful in 45s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Failing after 1m0s
Build and test / Android (aarch64) (push) Failing after 31s
"Done Cropping", "Done Repairing", "Done Masking" and "Fit" float over the foot of the canvas. So does the photo roll's swipe handler, and a gesture handler is not a layout box — it is an input surface. A press inside one is delayed, then offered to that handler's own children and to nothing else: `input_event_filter_before_children` returns `DelayForwarding`, which aborts the hit-test traversal outright, and the replay afterwards visits only the handler's subtree. Everything behind it is never asked, hover included. The band was `strip-height + reach` — 136px along the bottom — whether the roll was out or away. So the button that ends a mode was drawn, was lit, and did nothing for as long as a library was open, which is the whole time anybody is developing from one. The tool rail kept working because it is a sibling of the canvas rather than behind the roll, which is exactly why this looked like two dead buttons rather than a dead region. The band now goes where the roll goes. The handler carries the strip instead of standing still while the strip animates inside it: closed, only `reach` is on screen and the rest hangs below the window where nothing can press it; open, it still covers the thumbnails, which is what lets a swipe down anywhere across them put the roll away. The 180ms travel moved from the strip onto the handler, so the drawn positions in both states are what they were. The controls are then positioned against that band rather than against the bottom of the canvas, and ride up with the strip when it comes out. Reordering them in front of the roll would have been the other fix, and it is the wrong one — the band would become the thing that cannot be reached, and a gesture nobody can start is worse than a button with a second way out. `roll-strip` and `roll-reach` are tokens now, because two files have to agree on where that band is for either of them to keep out of it. The bottom of the photograph comes back with it: the crop's lower handles and a repair placed near the bottom edge were inside the same 136px and had the same fault. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3d248cfb79 |
Export the photograph, not the canvas
Zooming the develop view changed the exported file. `Framing::view` is kept out of the sidecar, out of `is_active` and out of `output_size` precisely so that it cannot — but those exclusions keep it out of the *edit*, and an export is a *render*. `visible_rect` deliberately folds the view into the single rect the fused shader's prologue samples, so `render_for_export` inherited it: at 4:1 it wrote the middle of the frame, magnified to fill the file at the full output size, with the detail kernels scaled four times over because `render_scale` folds the view in as well. `render_thumbnail` did the same to the grid. `render_uncropped` already suspends the view for this exact reason, so the fix is its pattern: one `render_the_file` that both file-producing paths go through, composing inside the suspension since the view reaches the shader as a uniform baked at composition time. Restored whatever happens — leaving the graph un-zoomed after a failed export would throw away where the photographer was looking. Nothing caught it because the guard checked the wrong things. `zooming_does_not_change_the_exported_image` asserted the output size and the crop; both held perfectly throughout. Renamed to `zooming_does_not_change_the_size_or_the_crop`, which is what it tests, and the pixels are now guarded where pixels exist. The new test uses a ramp rather than quadrants deliberately: a four-quadrant frame is self-similar under a centred zoom, and the first version of this test passed against the bug because of it. Traces FR-EXP-9, which asks for the full-quality pipeline "regardless of what the display was showing". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4af3b93dfa |
Index faces from the native render, not from a preview of it
Implements the FR-CULL-8 written two commits ago. The sweep fetched the JPEG preview embedded in each RAW and used that one buffer for both detection and the crop; it now fetches the original, renders it through the same path export uses, reduces that for the detector, and warps the crop back out of the native frame. Three pieces, and each exists for a reason worth stating. dr_face::Pixels lets the warp sample 8-bit RGBA directly. A 24 MP native frame is 96 MB as RGBA and 288 MB converted to the f32 RGB align.rs was written against, and the warp reads about forty thousand pixels out of it. Converting the whole frame to sample 0.2% of it is NFR-RES-2's budget spent on a copy, per image, for a whole library. The variant costs one branch per sample and a test asserts both layouts produce identical crops. The detector gets a box-filtered reduction to 1600px, not the native frame and not a point-sampled one. Averaging rather than sampling because the detector's job is finding small faces and decimation is precisely the operation that removes them: at 4x, fifteen of every sixteen pixels are discarded and a 40px face survives or not depending on where it falls relative to the sample grid. 1600 rather than 640 leaves the letterbox a mild 2.5x rather than a 9x, and bounds the f32 buffer at 20 MB. Landmarks come back in the reduction's coordinates and are scaled to native in one place before any crop pixel is read. This is the failure mode that would not announce itself -- unscaled landmarks put every crop near the top-left corner, which yields faces of something else, cleanly embedded and confidently clustered. The sweep fetches SWEEP_LANES-wide and renders sequentially. Not a placeholder for a parallel version: there is one GPU, so concurrent renders queue on it regardless, and each materialises a native frame. Overlapping them would multiply the one allocation that threatens the memory budget while buying parallelism that does not exist. The chunk drops from 96 to 6 for the same reason -- 96 held 8 MB previews, this holds whole RAWs. The stored edit is deliberately not applied, which is where this departs from export::render_from_library. Face geometry is normalised to the frame, so indexing a cropped render would record boxes against a frame that changes whenever the user changes their mind, and every stored box would quietly become wrong. Orientation is applied: that is a fact about the file rather than an edit. examples/face_native.rs renders one file and indexes it both ways, so the claim behind all of this can be checked against photographs rather than re-read out of the catalog it came from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e7b526c550 |
Specify face indexing at native resolution, and say what the proxy cost
FR-CULL-8 said detection runs against the thumbnail or proxy tier and never a full decode, and faces.md §5 said the aligned crop is sampled from that same proxy. Both are wrong in the same place: they treat detection and cropping as one resolution problem when they are two, with opposite answers. Detection does not care. §4.1 fixes the graph's input at 640x640 and letterboxes whatever arrives, so a face filling 2% of the frame reaches the model at 12px whether the buffer handed over is 1024px or 6000px. Every pixel above the detector's own input is discarded before inference. The crop cares about nothing else. §5's warp produces the fixed 112x112 ArcFace sees, so source resolution converts directly into whether those 112 pixels were photographed or interpolated. Reading crop_px across the 18,671 faces the proxy-tier implementation stored: 47.3% were upsampled to reach the embedder, 314 of them by more than 2x, the smallest from 34 source pixels. An upsampled crop does not fail loudly -- it yields a confident embedding of detail that was never there, and the damage appears three stages later as clusters that will not separate. So FR-CULL-8 now specifies four stages with the resolutions named separately: render native through FR-EXP-9's pipeline, downscale for the detector, map boxes and landmarks back to native, crop and align from the native render. The affordability the old rule bought is met instead by when the pass runs -- background, preempted, resumable -- and the requirement says plainly what it now costs on a remote library: the original rather than FR-NC-3's byte range, 412 GB across the reference library's 19,107 images, so a whole-library pass is a transfer under FR-NC-6 rather than something that may start on its own. MIN_CROP_EDGE replaces the MIN_DETECT_EDGE this branch briefly had. Same number, guarding the quantity that turned out to matter. faces.md §7b records both measurements, and marks the second as unexplained rather than dressing it as a finding. Grouped by the buffer detection ran against, faces per image was 0.078 at 1024 or below and 1.82 at 2048 or better, controlled for file type and size. That gap is real and reproducible and I cannot account for it, because the letterbox above says detector input should not matter. M4 is where it gets settled. The crop measurement does not depend on it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
144d2e4e84 |
Revert the detection floor: it guards the wrong resolution
Reverts 53f7cdf and e92d22d. The floor those added sat on Detector::detect, refusing any buffer under 1025px on the reasoning that a small buffer finds no faces. That reasoning does not survive §4.1: the detector letterboxes every input to 640x640, so a face occupying 2% of the frame presents at 12px to the model whether it is handed a 1024px buffer or a 6000px one. Detector input is precisely the quantity that does not matter. Worse than merely useless, it blocks the design FR-CULL-8 now specifies, where the detector is deliberately fed a downscale and the crop is taken from the native render. A guard on detect() rejects exactly that call. What the measurement actually supports is a floor on the *crop* source, which is where resolution converts into embedding quality, and which faces.crop_px already records: 47% of the reference library's faces were upsampled to reach 112x112. That floor is a separate change against the native-resolution path and does not belong on the detector. The 23x faces-per-image gap by source_edge that motivated the original commit is kept in faces.md §7b, restated as the unexplained observation it is rather than the causal claim it was written as. V12 stands: those runs cropped at 1024 whatever detection did, and that is reason enough to look at them again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6783d0c723 |
Settle the images whose best preview is under the floor
The floor introduces a state the sweep had no arm for. An image whose largest embedded preview is genuinely smaller than 1025px now comes back from index_preview as an error, lands in the generic Err arm, and is counted as failed -- which means no face_index row, which means it is still outstanding, which means the next sweep fetches exactly the same bytes and refuses them again. For ever, on every run, at one range request each. The previous behaviour was wrong but at least terminated; this would not. So ProxyTooSmall gets its own arm, and it records a marker at the true edge rather than nothing. That is the difference between "we looked and found nothing" -- which would be a lie, since nothing was looked at -- and "this was examined at 900px, which is the best this file has". The first is unrecoverable; the second is a fact source_edge was added to carry, and a later floor or a bigger proxy can select on it deliberately the way V12 just did. Counted separately from failures all the way up, because they are not failures and reading them as such would misdescribe a library of small scans as a broken network. The summary line says how many and why. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0a6509de93 |
Forget the face runs made on proxies too small to see a face
The floor added in the previous commit stops this happening again; it does nothing about the 1,824 images in the reference library that already carry a face_index row written against a proxy of 1024 or less. Those rows are why the damage is permanent rather than merely past. The work list is "images with no row for this model", so an image examined against a 1024px proxy -- 0.078 faces per image, nine in ten finding nothing -- is indistinguishable from one examined properly, and no later pass will ever offer it to the detector again. V12 deletes exactly those markers, and nothing else. The faces those runs did find stay in place and keep drawing the People screen until a better pass replaces them, and record_detections re-attaches the user's confirmed names across that replacement by box overlap, so a library somebody has spent an evening naming does not lose that evening. The cost is a re-fetch of the affected images. Deleting the marker rather than teaching the work-list query to select on source_edge, which was the other option and is worse. A standing `source_edge < floor` predicate never lets go: an image whose largest embedded preview is genuinely smaller than the floor would be re-fetched on every sweep for ever, because the next pass cannot do any better than the last one did. A one-off deletion gives each affected image exactly one more attempt through the good path and then lets the ordinary "has a row" rule settle it. The threshold is written out in the SQL instead of referring to dr_face::MIN_DETECT_EDGE. A migration has to keep meaning what it meant when it ran; binding it to a constant someone may raise later would quietly change what an old catalog gets migrated to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f87bf6ebc0 |
Charge a colour mode for its rarity, and keep the verdict
The refinement worked and could not be controlled. Pruning modes below a share threshold made the flag's removal a *discrete* event: below the line its Mahalanobis distance was enormous and nothing rescued it, above the line it sat at zero and nothing removed it. A control over that would appear dead through most of its travel and then start eating sky. So the prune is gone. A mode is charged `−ln(share × k)` nats, floored at zero, and that cost enters both tests — doubled in the chi-square, which is a squared distance, and directly in the log density. Rarity becomes a distance rather than a threshold, and the things a photographer wants to remove separate along it. Measured on the synthetic frame the tests build: a flag holding 1.6% of the sky is more than half gone by **2.95 nats** and a cloud bank holding a third of it survives to **5.75**. The whole interval between them is somewhere a control can sit. `the_flag_goes_before_the_cloud_does` pins the ordering, which is the property that makes one slider worth offering at all. Measured against an even split rather than against one, so raising `clusters` describes a category more finely without making every colour in it look rarer. Floored at zero so a dominant mode earns no *discount* — a bonus there would let the commonest colour outvote a bad chi-square, which is the one direction this must not bend. `Refinement` holds the per-pixel verdict, quantised to a byte over ±16 nats — an eighth of a nat per step, far finer than the narrowest transition the gate can be asked for, and the same size as the coverage buffer it sits beside. `apply` is then a smoothstep, and the model is never consulted again. That is `distance.rs`'s arrangement deliberately: there a signed distance field is computed once and feather, grow and shrink become arithmetic on it, "which is what makes those live controls rather than ones that stall on every drag". Same shape, different field. The blur moved with it, from the gate to the verdict. Smoothing the evidence rather than the decision means it is paid for once in `compute` instead of on every frame of a drag, and it is the better thing to smooth in any case. `apply` at `STRICTNESS_OFF` returns the weights untouched without reading the verdict at all. A control whose off position is *very nearly* the unrefined mask cannot answer "is this helping"; one whose off position is the unrefined mask can. `strictness_zero_changes_nothing` holds it to that, and `strictness_is_monotonic` holds the rest of the travel to only ever removing more — a slider that gave weight back partway up would be one whose direction nobody could predict. The synthetic sky is smooth enough to sit on `VARIANCE_FLOOR`, where a real one has noise and therefore a real spread, which moves every crossing down together. The ordering survives that; the placement is a calibration. Which is the honest argument for a control rather than a constant, and why the default sits at half scale instead of at the flag's measured crossing. The example sweeps the whole range and writes a frame per nat, because the question a photographer asks of a slider is where to put it, and that needs the travel rather than a point on it. Verified: fmt clean, clippy -D warnings clean, 60 dr-segment tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4f4abd335f |
Cut a scene category back to the pixels that agree with it
A flag in the sky came out weighted as sky, and no feather setting fixed it. The scene model's logits are `[1, 150, 80, 80]`, so one cell is eight input pixels; at the 1600px proxy the letterbox scale is 0.4 and **one cell is 20 proxy pixels**, which `rasterise`'s bilinear then spreads across one more either side. A flag is a handful of cells whose softmax is dominated by the sky around it. The information was never in the grid, so nothing downstream of the grid can recover it. Tiling is the answer for an instance and is not available here: a category has no bounding box to tile over — sky is wherever the sky is. But the photograph is at full proxy resolution even though the weights are not, and it knows exactly where the flag is. So the model says *what*, and the pixels say *which of them*, which is the division of labour arm C already draws between the instance model and the watershed. ## Seeds, and why the erosion radius is not a guess Threshold the weights high, take `signed_distance`, and keep what is more than 1.5 cells inside. One cell *is* the model's resolution and the bilinear spreads it across one more, so the band either side of the boundary is smear rather than evidence. Deriving the radius from `Scene::cell_pixels` rather than picking a pixel count means it stays right if the proxy edge or the export changes. The mirror of that set is a confident *exterior*, free from the same field. ## Dropping small modes is the step that makes it work Four k-means modes per side, not one Gaussian: sky is blue at the zenith, white where the cloud is and pale at the horizon, and one blob over all three rejects two of them. Then modes holding under 3% of a side are discarded, and without that step the whole thing fails on the case it was built for. A small flag deep in the sky has both a high weight and a large distance from the boundary, so it lands in the interior sample and teaches the model its own colour. It cannot be excluded geometrically. It can be excluded by share. Luminance is weighted at a quarter against chrominance for the same reason the watershed's gradient is. Sky's variance is dominated by luminance, so at equal weight the distribution is a long bright streak that a mid-grey flag sits comfortably inside. A flag is separated by chrominance; a cloud is separated by luminance alone. Not zero, or a dark bird against a bright sky survives. ## Two tests, because either alone is wrong Absolute — is this colour plausible under the category, as a chi-square on the Mahalanobis distance. Comparative — is it likelier inside than outside. A pixel must pass both. The absolute test is what catches the flag, whose colour is far from *both* sides and which the comparative test alone would leave at even odds. The comparative test is what stops the absolute one needing a constant tuned per category. ## What this cannot do, written down rather than left to be discovered An intruder large enough to hold its own mode is kept. By share, a flag over a fifth of the sky and a cloud bank over a fifth of the sky are the same object, and colour does not separate them either — a white cloud is as far from blue sky in chrominance as many intruders are. So `min_cluster` is not a threshold with a correct value waiting to be found; it is the trade-off itself, set where a photographic intruder falls. Both ends are pinned by tests — `a_flag_in_the_sky_is_removed` and `an_intruder_larger_than_min_cluster_survives` — so that moving the number reads as moving the trade-off rather than as fixing a bug. The case left open is a large unrecognised object in a clean category, which wants the boundary snapped to watershed basins and is a different mechanism. ## Safe to apply without a control It is subtractive: the output is the input times a factor in `0..=1`. The worst failure available to it is losing part of a real sky, never gaining a region, so a blue car below the horizon that was never in the mask cannot be pulled into it. And a factor in `0..=1` cannot raise a sum, so `scene.rs`'s partition still holds when every category is refined independently — the weight taken off the flag lands in the unlisted remainder, which is where a flag belongs, ADE20K having no class for one. Every path without the evidence to judge returns the weights untouched and says which path it took. A refinement that silently did nothing is indistinguishable from the feature being off, and an empty seed set fitted to a distribution would reject every pixel. The signature is deliberately unchanged: categories are addressed by name, not by index, so a sharper mask cannot create the stale-index hazard the signature exists to guard against. The example writes `<prefix>-<category>-refined.ppm` beside the coarse one, never instead of it — whether this is an improvement is a comparative judgement and one image cannot answer it. Verified: fmt clean, clippy -D warnings clean, 57 dr-segment tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8131706394 |
Regenerate the matrix over the merged rail work
The merge moved tagged lines in identity.rs, identity_ui.rs and the two Slint files; the matrix tracks line numbers, so it goes stale on a move alone. A merge commit does not run the pre-commit hook that would normally have staged this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0845d4ee20 |
Regenerate the matrix over the merged branches
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
80f0a0eeac |
Merge branch 'master' into feat/library-toolbar
# Conflicts: # docs/gestures.md # docs/traceability.md |
||
|
|
677146883f |
Decide what the header gives up when it cannot hold everything
Three faults, all invisible in the source and all found by looking at the window. `alignment: start` on the selection bar. Slint only hands space to `horizontal-stretch` children under the default `stretch` alignment; under `start` every child takes its preferred width and the stretch is ignored without complaint. That was harmless while the bar held four controls. Now that it holds every selection verb it is the difference between a count that gives way and a row that can only scroll, so the alignment goes and the stretch does its job. The title collapsed to "…". An eliding Text has a minimum of nothing, so once the buttons had taken their own minimums the title was the cheapest thing in the row to give away — the window stopped saying which library was open while the byte counts beside it stayed. It gets a 90px floor. And the status line does not get one. After the sidebar takes its 232, a 1100pt window leaves this header about 868, and the buttons want most of that before a character is drawn — so something must degrade, and the order is the whole question. A floor here bought a readable status by pushing Settings off the right edge, reachable only by knowing to flick-scroll, which is precisely the fault the row's own Flickable comment warns about. A control you cannot see is worse than a sentence you cannot finish. The status line is also the most redundant thing in the header — the sidebar states the library's count and the filter chips state it again — so it is what gives, and it grows back the moment there is room. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9994bb4ce7 |
Regenerate the matrix over the third wave
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c7e1822f77 |
Put the two collection actions in the collections panel
"Keep offline" and "Change library" were buttons in the library header, in a row that is otherwise entirely library-wide. Both refer to the tree instead. "Keep offline" could only ever mean the scoped collection, while sitting nowhere near the tree that says which that is — and while the sidebar already offered the same question twice, on each row's tray and on a held row. It now sits under the tree, in the panel whose selection decides what it acts on, in the same place and shape the trash already gives Restore and Empty. It stays a labelled control rather than being dropped for the tray: a 26px row's tray is a small thing to hit, and "Kept offline" spelt out for the collection you are looking at is the discoverable version. "Change library" was wedged between Sync and Rescan, two buttons that act on the library you already have. Reading it as one of that group is a way to lose a scan by aiming badly. It is now the last thing in the panel, under the tree it replaces wholesale, behind a rule. On a tablet both are one tap further away, behind the sidebar toggle that leads the header. That is the panel a user is already in when they scope the grid to a collection, so it is where they are when either of these becomes the thing they want. The grid keeps `pin-done`/`pin-total` and its progress bar: the transfer is worth reporting wherever it was started from, including a row the grid is not scoped to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f4fbabf18b |
Say what the grid is showing in one line, not four
The header carried four readouts beside the title: the image count, the selection count, the scan's status and where in the library the visible window sits. Each had `horizontal-stretch: 1`, which is what actually lets a Text shrink in Slint — so between them they claimed about a third of a 768px header and left the buttons to scroll off the end of it. The selection count goes to the bar at the foot of the grid, beside the buttons that act on it. The other three are one subject and are now one sentence with separators, sharing one stretch. Two of them were also saying the same words while a sweep ran: "indexing 300 / 12 480" appeared both as the status and, redundantly, in place of the window position. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
18063152f9 |
Ask where these photographs go once, not twice
The selection bar had "Add to collection" and "New collection" side by side. Both answer the same question — where do these go? — and the bar asked it before showing the list that decides it: a user who wants a collection they already have and a user who wants a new one press different buttons before either has seen what exists. "New collection…" moves into the filing sheet, under the list of collections. That is where you look after failing to find the one you wanted, and it is the only place the choice can be made informed. It also fixes the sheet's empty state, which said "Make one with + in the sidebar" — advice that cannot be followed on a tablet, where the sidebar is instantiated but not drawn. The first collection can now be made from the sheet that noticed there were none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8510a2f6a7 |
Give the selection one bar instead of two ends of the window
"What can I do with these twelve photographs?" was answered in two places. Six buttons in the header — Add to collection, Keywords, Presets, Paste to N, Export N, Remove from collection — and four more on the floating bar at the foot of the grid, beside the count that says what they would act on. The header half was the worse of the two. Those six appeared and disappeared from the middle of the row as photographs were picked, so Sync, Settings and everything beside them slid several hundred pixels sideways at the exact moment a hand was already travelling toward one. On a tablet the header scrolls sideways, so they were often not on screen at all. All six move to the bar. It now reads left to right as shaping the selection — Clear, Select all, Select to… — then acting on it, with a gap between the two thoughts. The header keeps only what belongs to the library, and changes only when the library does. Two consequences worth stating. The bar holds ten controls in the worst case, so it scrolls sideways like every other row in this view, for the reason set out on the header's Flickable: a layout given less width than its children need overruns rather than shrinking, and the buttons past the edge are simply gone. And the bar now stays up for a running export whatever the selection has since become, because Cancel export lived on a button that used to have its own `|| exporting` escape hatch — the grid's viewport inset follows the same condition so the last row of thumbnails is never trapped underneath. `settings-summary` went with them: threaded from the window into the grid and into HeaderActions, and never once drawn. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
458025c607 |
Merge the scene model: a second, ADE20K-trained graph for per-category grades
Five commits. `models/` becomes one tree at the repository root so the weight the application carries is a single `du -sh`; the export script stops building its multi-gigabyte venv in RAM; `yolo26s-sem-ade20k` joins the instance model rather than replacing it; the decoder turns its logits into a partition of unity over eight photographic categories; and four packaging routes put the file somewhere each platform can find it. The instance model stays exactly where it was. A semantic model merges every pixel of a class into one region, so it cannot separate two people, and separating two people is what clicking a subject needs. The scene tab grades whole categories and does not care. docs/segmentation.md §16. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> # Conflicts: # docs/traceability.md |
||
|
|
8df6000e4b |
Decode the scene model into per-category weights
The weights landed last commit with nothing to read them. This is the decoder, and the shape of it follows from one property worth stating before the code: the categories must partition the image. ## Why a partition, and not a mask per category The scene tab applies one grade to every pixel of a category — lift the sky, desaturate foliage — and both grades meet at the horizon. If each category carried an independent mask, feathering them outward would make the boundary band belong to both, so both grades would land there and every horizon would acquire a visible seam. Feathering has to *blend* there, not accumulate. So `marginalise` takes one softmax over all 150 channels and sums within each category. Grouping cannot change a total of one, so the listed categories plus the unlisted remainder sum to one at every pixel, by construction rather than by normalising afterwards. `parse_categories` refuses a descriptor that claims a class twice, because that is the one input that would quietly make the property untrue. ## The descriptor is data, and hand-written `models/scene/categories.txt` groups ADE20K's 150 classes into the eight a photographer would recognise. It is a file rather than a table in Rust for the reason `models/LICENCE.md` predicted — a vocabulary is model metadata — and it is line-oriented with comments rather than JSON like the `.classes.json` beside it, because that file is generated and this one is argued. Why `swimming pool` is water and not architecture belongs next to the line that says so. Classes are named, not indexed. An index is silently wrong after a re-export; a name is loudly wrong, and the loader refuses one the model does not have. ## Resolution, kept visible `Scene` holds the native 80×80 logit grid and resamples on demand rather than upsampling once at load. The coarseness is real — it is what the graph produces — and a type that hides it behind an early resize invites callers to expect detail that was never there. `rasterise` is where the letterbox inverse lives, once. `Letterbox` and `Window` become `pub(crate)` and `to_proto` generalises to `to_grid`, because both dense outputs this crate reads are an even fraction of the same letterboxed square and differ only in the divisor. ## Verified by looking, which is the only way this gets verified `examples/scene.rs` writes the photograph dimmed outside each category. A transposed axis or an off-by-one in the inverse produces perfectly plausible weights over slightly the wrong pixels, and no unit test catches that. On an indoor frame the person mask lands on the person, including the outstretched arm, and sky reads ~5% against a bright ceiling. It doubles as the benchmark, because every timing quoted while this model was chosen came off a laptop compiling other things and none of them belong in a document. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
258aae71d6 |
Regenerate the matrix without the tool's own fixtures
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ef07e6ca3e |
Give the canvas tools a rail of their own, and the column one width
Build and test / Desktop (Linux) (push) Failing after 1h14m38s
Build and test / Layer separation (push) Successful in 48s
🐳 Android image / Build and push (push) Successful in 16m30s
Build and test / android-image (push) Successful in 16m31s
Traceability / Requirement traces (push) Successful in 1m47s
Build and test / Android (aarch64) (push) Successful in 1h0m21s
Crop, Local and Repair were chips at the head of the develop column, sharing a row with the adjustment groups and told apart from them by the shape of their highlight. Three things followed from that, and only the last is cosmetic: the column closes, so the way out of a mode went away with the way in — hence the duplicate "Done Cropping" over the canvas; the chips are generated from the operation set, so the widest thing in the sidebar was a row nobody had chosen the contents of; and a mode and a filter are different kinds of state wearing one control. They are a fixed 60px rail down the left now, generated from a single table in toolrail.slint. A tool is one row of it plus a drawing plus a ViewMode variant; nothing in app.slint is touched to add one. What is left of the strip is the group filters, so it is GroupStrip. The column stops measuring itself. Every panel published a content-width and declared it as min-width, and the column took the largest — which spent the photograph's pixels on whatever happened to be widest, and moved the image sideways when switching tools swapped one set of panels for another. It is panel-width now, one number in style.yaml. That number is 360 and it is measured, not picked: the contents report a minimum of 344 in every mode, and they do not compress below it because a Text that does not elide reports the same minimum as preferred. 320 was tried and sliced Paste down the middle. The Flickable's viewport is floored at the layout's minimum rather than its preferred width for the same reason — content that is never told how much room it has cannot adapt to having less. Removing the eight content-width declarations repairs three comments an earlier edit had spliced sentences into. The raw histogram's note on keeping its hint short is rewritten rather than dropped: an over-long hint no longer widens the column, it pushes the column's minimum past the width it has and clips the panel, which makes that constraint sharper rather than obsolete. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b120ce48ec |
Float the selection bar over the grid instead of above it
Selecting the first photograph inserted a 40px row into the view's vertical flow, so every cell in the grid moved down by it. The act of selecting shifted the thing being selected out from under the finger — and a second tap aimed at the neighbour landed on the row below it, which is the worst possible response to a gesture whose whole job is to say "this one". The bar is a floating one now, at the foot of the view. Nothing above it is re-laid out, so selecting changes what is drawn and never where. The grid's viewport grows by the same 40px while the bar is there rather than the Flickable shrinking, which is what keeps that change invisible too: every cell stays exactly where it was and there is simply further to scroll, so the last row can be brought clear of the bar instead of being trapped under it. It also swallows presses that land on it. A bar floating over the grid is a bar a thumb can reach for and miss into. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |