9b6b4942cf200ba8f0032e84034726f7ded0864f
106
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d3b6127db6 |
Let a photographer name the state they liked, and go back to it or look at it
FR-DEV-5 asked for named snapshots of an edit state and FR-DEV-7 for a comparison against a chosen one, and neither existed. The history stack is per sitting and forgotten with it, on purpose — the gap that mattered was an automatically saved mis-drag with no way back, and that was closed first. What was left was the other half: a state the photographer wants to keep *because* it is worth keeping, which is a different thing from a step and is not served by making the steps last longer. A snapshot is an edit state, and an edit state is exactly what a sidecar version stores, so it is stored as one: a `[version]` block carrying `snapshot-of = <uuid>`. The parameters, the masks and their parts, the repairs and the film all arrive through the blocks that already carry them, a merge keys on the uuid as it does for any version, and a build that predates the key reads the block as a named version and keeps it — the right failure. Only the pointer is new. The one reader that has to know is `default_version`, which must never answer with a snapshot: a file whose edit is missing is not a file whose edit is one of its saved moments. The snapshots of an edit are listed by that pointer, oldest first, the same on every device. Writing them back removes what this sitting deleted and puts in what it holds, and leaves standing whatever it never saw — a snapshot the other device took since the photograph was opened here is not this device's to remove by not knowing about it. That is the rule the version merge already keeps, applied one level down, and it is why the save carries the deleted ids rather than replacing the list wholesale as the masks are. Each is re-pointed at the uuid the save settled on, because the default may have been fused onto its canonical identity since the snapshot was taken. Restoring is one history step, so undo takes it back whole, as a paste is. Taking and deleting are not steps: they change nothing about the photograph, and an undo that removed a snapshot would be undoing a decision to remember. Holding the eye beside one renders the snapshot and hands the edit straight back — the same suspension "Before" uses, against a point the photographer chose rather than the file. Two sessions on the same photograph get ids that cannot collide, stamped with the second and a random word, because the merge folds equal ids into one. |
||
|
|
4574c35236 |
Let a part be left out of a mask without being taken out of it
A layer built from parts was missing the one control a correction most often wants: seeing what it did. The question a subtracted gradient raises is whether it took only the sky, and the question a stroke raises is whether it filled the shoulder — and the only way to ask either was to remove the part and look, which answered the question and lost the part. The layer's own ring answers a different question, about the adjustment, and hiding eight layers to check one correction is not an A/B anybody performs. So a part carries `hidden`. It is an edit and a history step, as the layer's switch is, and it is folded into the render fingerprint because hiding a part changes the mask as surely as removing it does. Where the mask is built the shown parts are walked rather than the parts, which is what makes a hidden base hand the fold to the first part that is shown — and a revealed layer whose every part is hidden clears its slice rather than leaving whatever the last rasterisation put there to be read back. `covers` asks the same shown parts, so a layer whose only adding part is hidden costs no slice at all. In the sidecar the key is `hidden`, in the part's block or, for the base, in the mask block — under a word that cannot be confused with the layer's `enabled`, which has always meant the layer. Absent means shown, so no file written before the switch existed reads any differently. The row wears the same ring the layer does, one row down, because it is the same question about a smaller thing. |
||
|
|
a87139b838 |
Give every mask an eye and a colour, and put the brush where the mask is
The first build of seeing a mask showed the selected layer's, in one global style, from a strip at the top of the panel. It answered the wrong question and answered it somewhere nobody looked. What a photographer asks of two masks is how they meet — where the sky's edge sits against the building's — and that needs both on screen at once, in colours that can be told apart. So each row of the stack has an eye, drawn in the colour its mask is shown in, and each mask has six swatches to choose that colour from. Several can be open at once; a new one comes up open, in the first colour nothing else is using. The style — tint, alpha, outline — is the one setting that stays global, above the stack, because three styles at once are three pictures that cannot be read against each other. Alpha now draws every shown mask, each in its colour, on black. In the pipeline a `Reveal` is a list of `(layer, colour)` rather than one layer, and every reveal block carries its own colour. The brush moves too. Select, Paint and Erase and the three sliders under them sat at the top of the panel, appeared only once a row was selected, and said nothing about which mask they acted on — so "how do I paint" and "how do I correct the model's outline" both had the same answer and nobody found it. They sit under the selected mask's parts now, beside the swatches, and on a subject or a category the hint says what a stroke there does: it becomes a part of this mask, joined to the model's, and can be taken out again. Eyes and colours are viewing state, on the session and not on the layer, so a photograph reopened has every eye closed — the stored-mask round-trip test asserts it. |
||
|
|
ac0aea70ec |
Show a mask as soon as it is made
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m52s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 39m53s
Build and test / Layer separation (push) Successful in 1m0s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Successful in 46s
Build and test / Android (aarch64) (push) Successful in 25m19s
Choosing a category is asking what it selected, and for a subject or a category that question had no other answer on screen: the model's outline is not derivable from anything visible, a fresh layer carries no adjustment to judge it by, and the list it was chosen from says "architecture 23%" without saying which 23%. The control that draws the mask existed but had to be found and pressed, a panel's height away from the list the choice was made in. So a new layer arrives with its mask showing, from the resting position only. Somebody who has chosen the alpha or the outline keeps it, and nothing re-arms in the background — every caller is a press that asked for a new mask. That makes the canvas depend on how a layer arrived, which is correct and worth stating: a session that has just made a mask draws a frame that a session which read the same mask out of a sidecar does not. Viewing state is not edit state and does not travel in a file, and `a_stored_mask_renders_exactly_what_the_model_rendered` now says so at both ends. |
||
|
|
76ad667fd6 |
Offer a mask that is nothing but a hand
Every route to a layer began with a selection — a gradient, a band, a subject, a category — and painting was reachable only by making one of those and joining a painted part to it. So the answer to "brush a correction onto this corner of the sky" was "add a radial gradient you do not want, then paint into that", which is not an answer. Paint sits beside Linear and Radial and makes a layer whose base is a brush. It covers nothing until a stroke lands in it, so pressing it arms the brush and shows the mask as well: a row that appeared and changed no pixel, with the pointer still in "select", is indistinguishable from a button that did nothing. |
||
|
|
c045702a47 |
Show the photographer the mask they are shaping
Nobody can refine an edge they are not being shown. The only thing drawn on the canvas was the region overlay — a false-coloured picture of what the model *detected* — which knows nothing of a layer's feather, its falloff, its morphology, its invert or its opacity, and nothing at all about a gradient, a range or a stroke. Every control added for mask editing therefore acted on something invisible, which is why the whole feature reads as absent rather than as unfinished. A layer's finished mask now draws over the photograph in one of three styles: a tint for whether the right thing is selected, an alpha for where the edge is, an outline for whether that edge is registered against the detail the other two hide. The hard part is not the shader. A selection with no adjustment on it changes no pixel, so it is not active, so it holds no slice of the mask array and is never rasterised — and that is exactly the layer somebody wants to look at, for the whole of the time between choosing a subject and deciding what to do to it. So `MaskStack::rendered` is `active()` plus the layer being looked at, and the rasteriser, the composer and the distance-field builder all index by position in it. Which is also why the design's "two uniforms, no recompile" is not available: a uniform can select a slot, it cannot conjure one. The reveal is never on the graph. It reaches the pipeline as an argument to `compose_revealing`, and `compose_for` — which the exporter, the thumbnail and the neutral probe all call — has no way to ask for one. A flag on the graph would have been shorter, would have type-checked, and would have been one forgotten reset away from a red tint baked into an exported file. And the tools that shape a mask now arm. `Masking.tool` is an `in` property only Rust may write, and the handler wrote nothing back, so the strip reported "Select" however many times Paint was pressed and the paint area was never enabled — the brush, the parts and the whole of FR-DEV-19b reachable from no control in the application. The region overlay stands down while a mask is being shown, and its button now says what it hides: two overlays that look alike and mean different things is worse than either. |
||
|
|
193b35a249 |
Start a category mask where the photograph can bear it
Clicking "architecture" made a layer whose mask was gone. Every category layer began at STRICTNESS_DEFAULT, and that constant was fitted on the synthetic sky the refine tests build — its own note warns that a real photograph's noise "moves every crossing down together", which turns out to be a considerable understatement. Measured over seven ordinary frames, half scale removes 76% to 99.5% of `architecture`, 36% to 93% of `ground` and 18% to 91% of `vegetation`. Only sky, the category the number was calibrated against, survives it. An empty mask is indistinguishable from a broken one: the layer is listed, the adjustment moves, and no pixel changes. So what this looks like from outside is that the segmentation does not make masks at all. No smaller constant fixes it either, because a nat of evidence means different things over a smooth sky and over a stone facade — the useful position is above 5 on one frame and below 1 on the next. So the frame is asked instead: `Refinement::gentle` walks down from half scale and takes the first rung whose gate removes no more than a sixth of the category's weight, and the model's own outline when none of them does. One `apply` on a friendly photograph and four on an unfriendly one, paid when a layer is made rather than for eight categories nobody masked. The slider's reset went to 4 as well, so taking the control back to its "default" emptied the mask. It goes to zero now, which is the one position documented to mean something: exactly what the model weighted. |
||
|
|
2d878c2117 |
Offer the film stock in its own group, and let its list scroll itself
Two faults in one control, both reported from the tablet. The stock picker appeared in every group. It is not a parameter, so it is not a row, so the filter that hides every other control when a group is chosen never saw it — "Kodachrome" sat at the top of Light, of Colour and of Detail alike. Three places it does not belong, and the one it does no more prominent than the rest. The descriptor has said `Effect` and only `Effect` since the film moved there; nothing was asking it. So the panel now asks. It cannot ask directly — a generated panel may not know which operation a control belongs to — so the session answers, from what the operation declares it is about, and a stock re-declared as something else would move on its own. The flag is recomputed when the group changes as well as when the film does, which is the half that would have made it stale exactly when it mattered. And the open list was unbounded, so it made the develop column taller and the column scrolled as one: reaching Velvia dragged every slider below it off the screen, an answer given once pushing aside the controls used constantly. It now scrolls within a bounded height of its own. That viewport is counted rather than measured, for the reason the tool rail records a few files away: a viewport that asks a layout how tall it wants to be, while the layout takes its height from the viewport, is a cycle Slint settles by handing back the height it was given — and the content is then clipped in silence rather than scrolling. Every row here is one fixed height, so multiplying is exact. The group rule has a test. The scrolling does not, and cannot: it is a layout, and a layout fault is invisible to the compiler and to every assertion that can be written about it. |
||
|
|
df741a8a49 |
Let one mask be built from more than one selection, and paint into it
A mask the model draws arrives approximately right — stopping inside a shoulder, leaking into the hair — and FR-DEV-3's edge controls move the *whole* boundary, so no value of feather or dilation fixes two errors that go opposite ways. What fixes them is a second selection joined to the first, and a layer that held exactly one source had nowhere to put one. The brush the core has had all along was reachable from no control in the application. A layer is now an ordered list of parts. Each names a source and how it joins the mask before it — added to it, or taken out of it — and carries its own edge treatment, because a model's soft coverage and a stroke painted where it stopped short do not want the same feather. Invert and opacity stay on the layer, where the composed shader already reads them. The sidecar grows `[part]` blocks and nothing else. A layer of one part writes exactly the bytes it always did; a mask block with no part blocks after it reads back as one part; and a stroke, a join or a source this build cannot read costs that part rather than the layer. So every sidecar in every library still parses to the edit it always was. On the device the parts fold into the layer's one slice, so eight layers still cost eight channels: union is a `max` blend and subtraction is the erase blend the brush already used. A part is drawn into a scratch texture before it is joined, and that is not incidental — an erase stroke means a hole in *that part*, not a hole in the mask, and drawn straight onto the accumulator it would punch through the subject underneath. A layer of one part skips all of it and takes the path it always took. In the interface: a part list under the selected layer with a chip saying which way each joins, Add and Subtract beside it, a Select/Paint/Erase strip with the brush's size, hardness and flow, and a drag on the photograph that paints. Pressing Paint on a mask that cannot hold a stroke joins a part that can, rather than explaining that a subject is not a brush. A whole stroke is one step in the history. The edge controls now shape the part that is selected rather than the layer, which is the one behaviour change to an existing control: with a correction selected, the feather slider softens the correction and leaves the model's mask alone. |
||
|
|
af89433aee |
Offer the lens profile as a tick box, since applying it silently reads as absent
The develop panel's Optics group is three manual sliders: distortion, chromatic aberration and lens vignetting. The automatic correction was already there — the file's EXIF lens is matched against the bundled Lensfun database on open and the coefficients are fanned out to all three — but nothing in the interface said so except a line of grey text under the camera reading "· corrected", and there was no way to decline it. From the outside that is indistinguishable from the feature not existing, which is how it was read. `dr-lens` states the rule this breaks: an automatic correction that silently does nothing is worse than one the user can see is unavailable. The caption satisfied the letter of it and not the point — a photographer looking for "apply the lens profile" found three sliders and no switch. So the profile is now a control. It is a capability rather than a flag on the session, because everything a photographer sets travels one road: the capability list feeds the generated panel, `Preset` captures it, the sidecar stores it and the undo stack replays it. A bool on the side would have needed adding to each of those four by hand and would have been forgotten in at least one — which is exactly how the mask stack came to be missing from the history. It is on by default, which is what `switch_on` is for: the coefficients are a measurement of the lens that took the photograph, so accepting them is neutral and declining them is the edit. The sidecar therefore stores nothing for the ordinary case and the correction still happens. The switch appears only where a profile was matched. A tick box on a photograph whose lens the database has never heard of would be a control that looks available and does nothing, which is the failure the rule above names rather than an instance of following it — those photographs are told "· no profile" in words instead, and one whose box is unticked now says "· profile off", which is a third fact and not either of the other two. Two things had to be built underneath. `ParamKind::Bool` was in the core's closed enum and mapped to a row kind here, and had no control behind it in `adjust.slint`: a parameter declaring itself a switch was flattened into a row that drew nothing at all. Nothing shipped had one until now, so the gap cost nothing and was invisible. And `Check` self-toggled, which is right for a settings page that owns its value and wrong for a panel row that is a view of the edit graph — the click would have answered by replacing the binding with a literal, and the next undo or pasted preset would have moved the value with the tick left where the finger put it. It now takes `controlled`, and the generated row uses it. The manual sliders are unchanged and still trim whatever the profile leaves, so switching it off is "correct this by hand" rather than "stop correcting". |
||
|
|
3f6dbce2aa |
Name a lone control after its operation, so three cannot all read "Amount"
Benchmarks / CPU and I/O (per commit) (push) Successful in 10m31s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 1h15m4s
Build and test / Layer separation (push) Successful in 37s
Traceability / Requirement traces (push) Failing after 1m28s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Successful in 1h3m13s
The Detail group ended with three consecutive sliders labelled "Amount" and nothing to tell them apart. They are dehaze, clarity and texture: each declares exactly one parameter, and `ops/README.md` tells an author to reach for the `amount` kind first, so all three named it the same thing. The panel withholds a heading from a group of one, and the reasoning it gives is sound — a lone control names itself, and the group reset it loses costs nothing because the slider already resets on double-click. But that argument rests on the parameter being named after what it does. It holds for exposure, contrast, vibrance, saturation and brilliance, whose single parameter shares the operation's name, and it fails for the three whose parameter is called after its kind rather than its subject. So a lone parameter now takes its operation's label — the name the withheld heading would have carried. For the five that already agreed, nothing changes. Found on the tablet, and only there: every row was correct, every label resolved, and the panel was still unusable. The test that guards it asserts over the real chain rather than a fixture, because the fault was a property of what is actually declared — a fixture would have had to be written to reproduce it, and would then only have proved itself. |
||
|
|
124b2d99c6 |
Take the first control that moves the picture, not the first one drawn
`showing_the_original_leaves_the_edit_exactly_as_it_was` set row zero to its maximum and then asserted the photograph was modified. It was not, and the test failed on its own premise rather than on the thing it exists to check. `EditGraph::capabilities` puts the lens corrections at the head of the list, matching where they sit in the shader. Those carry profile coefficients rather than parameters, so with no profile loaded a slider on one is a control with nothing behind it: the graph stays neutral and the premise assertion fires. The test was written against a panel whose first row happened to be an adjustment, and it stopped being one. So it now walks the rows until it finds a control that actually changes the edit, which is what it meant by "row zero" all along. Still addressed by index, so it still names no operation, and it no longer depends on where in the chain the first *adjustment* happens to sit. |
||
|
|
901f51e6c4 |
Point at something grey and let the pipeline work out the rest
FR-DEV-3 has asked for "white balance (temperature/tint, and picker)" since it was written, and only the first half existed. `WidgetKind::WhitePoint` was in the vocabulary and `develop::supported` answered false for it, so the node degraded to two sliders — correct behaviour that had quietly become the only behaviour. Sampling a neutral is the first move of the global tonal pass and every colour judgement afterwards is measured against where the grey was put, so guessing at two sliders until a wall stops looking green is the wrong way round. The awkward part is that a picker genuinely needs to know how far a hundred units of temperature move red against blue, and that number is declared in the node's own file. So the inversion lives in `dr_pipeline::neutral` rather than in the interface: the canvas hands over a colour, the core finds the operation that asked to be driven by a pixel and bisects its declared response until the sample comes back grey. Nothing in `ui/` names white balance, and nothing holds a second copy of a response that would be wrong the first time somebody adjusted the range. A bisection rather than a closed-form inverse because only monotonicity is part of the bargain — the expression is free to become a table tomorrow. The result is rounded to the precision the control is drawn at, which is not cosmetic: unrounded, sampling something already neutral lands a ten-thousandth off zero, and the photograph comes back modified with an undo step for a correction of nothing. On the panel side this needed one distinction the generated path was missing. `is_on_canvas` was being read as "and so the panel draws nothing for it", which is right for a crop — four edge fractions are not controls anyone drags in a list — and wrong for an eyedropper, which *writes* temperature and tint and leaves them exactly the controls a photographer reaches for next. So a sampling widget keeps its sliders and puts the affordance that arms the canvas in the group's heading, built like the reset beside it. One click, one sample, one history step: `Edit::Action` never coalesces, and there is no hover preview to fill the stack with temperatures nobody chose. Declaring the presentation also groups temperature and tint under one undo step, where they were two. That follows from what `Presentation` means and reads correctly — white balance is one decision — but it is a change, and worth saying so. |
||
|
|
2584b9ecbc |
Hold one key to see the photograph before you touched it
FR-DEV-7 asks for the current edit against the unedited original and nothing implemented it. What the develop view had was history navigation, which *changes* the edit rather than previewing against it — so the only way to look was to undo, look, and redo, and that puts two real steps on the stack at exactly the moment a photographer suspects they have overcooked a frame and is least sure of what they are doing. Holding the "Before" button, or backslash, renders the graph with every adjustment stripped and hands it straight back afterwards: the same suspend-render-restore shape the crop overlay already uses to show an uncropped frame and an export uses to suspend the zoom. Nothing is recorded, no rows are re-synced, and the photograph is still modified when the key comes up — the panel goes on describing the edit the photographer has, because only the canvas is answering a question. The framing deliberately stays on. A held comparison is a question about tone and colour, and re-cropping the canvas under someone's thumb would move the detail they are comparing; worse, the zoom is a rectangle of the *framed* image, so dropping the crop at 4× would quietly show a different part of the photograph rather than the same part unedited. What the crop took away is already compared in Compose, which shows the whole frame. Not a split screen: that halves the working image on the tablet this column was sized for, and the comparison photographers describe making is a flick back and forth rather than two pictures side by side. Press-and-hold is one gesture on a finger and on a mouse, which is what FR-DEV-3b's mapping wants, and it has no mode to be stranded in — the button reports both edges, so a press the system cancels puts the original down too. |
||
|
|
9b674a88d8 |
Let the photographer look at the pixels, and keep looking
Noise reduction and capture sharpening are judgements about individual pixels, and at a fitted view several of the file's pixels are averaged into each one on screen. The frame therefore looks cleaner and softer than it is, the photographer corrects for a softness the display invented, and over-sharpening is the documented result. Nothing in the develop view reached 1:1 at all: the wheel and the pinch zoom by ratios, the double tap dropped straight to fit, and the only readout was a percentage nobody was aiming at. So the double tap now does what FR-UI-4 always said it did — toggle fit and 1:1 — and the zoom readout, which used to be a dead "Fit" button on a fitted photograph, becomes the way in when there is nothing to clear. Z does the same from the keyboard, and the back gesture goes out through the same toggle so putting the magnifier down really puts it down. 1:1 is computed from the file's own resolution against the viewport rather than fixed at some multiple, because that is the only version of it that answers the question the two detail controls are asking. The point being inspected and whether the magnifier is up are held beside the session rather than in it, on the argument focus peaking already makes: a session is one photograph and this is a way of looking at a folder of them. Checking the same eye across forty portraits is the reason to reach 1:1 in the first place, and a magnification that reset with the session would make that forty zooms and forty pans instead of forty keystrokes. It stays a viewing state throughout — the view is kept out of `is_active`, `output_size`, the sidecar and the export, and an export still suspends it — so none of this reaches the file. |
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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.
|
||
|
|
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> |
||
|
|
4efab496c2 |
Put the refinement on a slider, per layer
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m27s
Benchmarks / Frame budget (on demand) (push) Skipped
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Desktop (Linux) (push) Failing after 1h14m30s
Build and test / Layer separation (push) Successful in 43s
Traceability / Requirement traces (push) Failing after 46s
Build and test / Android (aarch64) (push) Successful in 1h8m56s
The evidence was being gathered and spent immediately at one strictness nobody could see or change. This makes it a control. `MaskLayer::refine` is shaping, like the feather, and it lives on the layer for the layer's reason: two layers may sit on the same category and want different amounts of it, and the model ran once for both. ## What the segmentation now stores `CategorySummary` keeps the **coarse** mask and the `Refinement` beside it, rather than a refined mask. That is what gives the control an off position that is bit-for-bit the model's own weighting, and what stops a strictness change from needing the model again. `category_mask_at` borrows at zero and wherever no refinement could be fitted, so a layer nobody has touched costs nothing over the old path. The fit moved to the far side of the orientation permutation. The verdict is a per-pixel field over the same grid as the mask it gates, so fitting it upright would mean permuting a proxy-sized buffer afterwards to match — a second rotation, and a second chance to get one wrong. One logit cell is the same number of pixels either way: the letterbox scales by the longer edge and a permutation does not change which edge that is. The two frames became a `Frames` struct rather than six parameters. This function reads the picture twice for opposite purposes — the model needs it upright or it recognises far less, the refinement needs the sensor's grid — and a transposed pair produces a plausible mask over slightly the wrong pixels, which is the failure this module is most prone to. ## It rebuilds the field, and the signature says so `refine` is mixed into `subject_signature`. Unlike a feather, which is read off a field that is already correct, this changes which pixels are in the mask at all — so it changes the coverage the field is measured from. Omitting it is the bug where the slider moves and nothing happens until some unrelated control invalidates the cache. That puts it in the same cost class as a close or an open, which is why the row takes `SliderRow::changed` — already once-per-gesture, since that row takes `SliderTrack`'s `committed` internally — rather than a live stream. The slider is offered only where there is something to move: a category source, *and* a refinement the frame actually gave enough to fit. A control that moves and does nothing is worse than an absent one. ## Two defaults that are deliberately different A layer added from the panel starts at 4.0, because a category's edges are twenty proxy pixels wide before anything is done to them and a photographer adding a sky mask wants the sky rather than the sky plus every chimney in it. A layer read from a sidecar with no `refine` key starts at **zero**. A file written before this control existed has to render as it did then, and a default of 4 on absence would quietly re-grade every stored category mask in the catalogue. `a_categorys_refine_strictness_survives_and_defaults_off` holds both halves, and `an_out_of_range_refine_is_clamped` holds the file to the scale — past the top of it every colour fails and the mask deletes itself, which reads as lost work rather than as a bad file. `MAX_REFINE` is dr-pipeline's own constant mirroring `dr_segment::STRICTNESS_MAX`, following `Falloff` and `Morphology`: this crate holds the description of an edit and must not depend on the crate that runs a model. dr-ui is where the two meet, and the only place that converts. Verified: fmt clean, clippy --workspace -D warnings clean, 488 dr-pipeline and 60 dr-segment tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
94b1b9e0cc |
Reconcile what four branches each built separately
Three seams, found by the first compile after the merge. Two branches implemented 'can this scope be reordered' independently: one from the collections model, one from the catalog through orders_manually, on every re-read and excluding the trash. The second is the better answer and is what survives; it only needed to set the property app.slint declares. Two lints from scene-mask-ui, which was merged mid-flight and had never been through -D warnings: an is_none check spelled out where clippy wants ?, and a return in a cfg block's tail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
86260b5028 |
Bind a category layer to its own distance field
A category mask showed nothing and its adjustment covered the whole
photograph. Both from one line: the loop in `MaskPass::rasterise` picks a
distance field by matching `layer.source`, that match named only `Subject`,
and a `Category` layer fell through to `_ => (&self.empty_subject, 0)` — a
1x1 placeholder. No field, so nothing to draw and nothing to confine the
adjustment.
The comment three lines above the arm I missed describes the failure I then
shipped:
an absent mask that defaults to "everything" would apply the
adjustment to the whole photograph
There are *two* matches on `layer.source` in that loop — one choosing the
field, one building the params. Adding the category to the second and not
the first compiles, runs, and is wrong in exactly the way the first one
warns about.
## Also: a missing mask must still be the right size
Both model-backed arms of `ensure_subject_fields` used `unwrap_or_default`,
which yields an empty `Vec` when the coverage is gone. `SubjectMasks::upload`
rejects a wrong-sized field and fails the whole batch, so `self.subjects`
becomes `None` and *every* layer in the stack loses its mask — one stale
reference silently unmasking the others.
Pre-existing, and it mattered less when the only model-backed source was a
subject: an instance index goes missing rarely. A category name goes missing
whenever the descriptor is edited, which is a thing the descriptor exists to
allow. A full-size empty field costs one layer instead of all of them.
Neither of these is reachable from a test on this machine — both live past a
GPU adapter and a real segmentation — so they surfaced the only way they
could, by someone opening the app and looking.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
763dfd353a |
Weigh the categories in the same precompute, and mask with them
The scene model shipped with a decoder and no caller. This runs it. ## Beside the instance pass, not instead of it `compute` now does both on the same upright frame and lays both back down the same way, so instance masks and category masks index into one grid — the sensor's. A failure in the scene half is logged and dropped rather than propagated: no scene model is an ordinary state, and a photograph that can still be masked by subject should not become unopenable because the categories are missing. Categories under half a percent of the frame never reach the cache. A control that does nothing when moved is worse than an absent one, and each one it skips is a proxy-sized buffer not allocated. ## The shader needed nothing A category reaches `dr-gpu` as a soft coverage buffer at proxy resolution, turned into a distance field — which is exactly what a subject is. So they share `MODE_SUBJECT`. That is not a shortcut taken for speed: the shader has no way to tell them apart and no reason to want one. What differs is only which model produced the coverage, and that has already happened by then. Feather, falloff, dilation and erosion therefore work on a category on the day it arrives, because they were never subject-specific. ## Where the weights come from `scene-model` compiles the graph in and the desktop app takes it; Android leaves it off and reads the copy `install_bundled_models` unpacks, because 24 MB of constant is worth avoiding in a mobile install and not worth the plumbing to avoid on a desktop one. Embedded is tried first — a build that has the weights compiled in should not be silently overridden by a stale file in a data directory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4b6c110816 |
Count the sensor's own numbers, so a cull can see headroom the render hides
FR-CULL-3's remaining two bullets. What existed was a *display* histogram tagged FR-DSP-7: it binds AdjustPass's Rgba8Unorm output, recovers an 8-bit code value, and counts clipping as `r == 255`. Its own documentation says a clipped bin means "a highlight that is actually gone rather than one the transform might still recover", which is the opposite of what a culling decision needs. FR-CULL-3 asks for the histogram of the sensor data, on the explicit grounds that a rendered image "systematically lies about what is recoverable in the raw", and a readout that measures the render cannot answer that however it is presented. So this is a second instrument beside the first rather than a setting on it. Both are true; they are true about different things; the panel offers both behind a chip row and the words travel with the numbers, because a raw saturation figure drawn under a heading saying Highlights would be mislabelled exactly where the difference matters. **What is reduced over, and what it cost to decide.** ARCH §5.5 specified the pre-demosaic CFA samples. This reduces over the demosaiced scene-linear texture instead, and §5.5 is amended to record the choice rather than let the specification and the code disagree in silence. The texture is camera-native — unbalanced, unmatrixed, uncurved — and normalised by the sensor's own black and white levels, so 1.0 is saturation by construction and the distribution below it is the headroom question with no calibration to carry. Retaining the CFA samples would mean keeping the packed u32 buffer Demosaicer::run currently drops: 48 MB at 24 MP, 120 MB at 60 MP, resident per open photograph whether or not anyone looks at the histogram, on a platform §6.2 exists because memory is scarce on. Three things it therefore cannot say, written into the module docs and into §5.5 rather than left to be discovered: it counts pixels not photosites, so a saturated site drags its interpolated neighbours up and per-channel clipping is smeared by about a demosaic kernel; it cannot see above white, because demosaic.wgsl clamps each photosite at 1.0 for its own good reasons (a Canon 6D reads to 16383 against a declared 15070) so "at saturation" and "a stop past it" share a bin; and it is measured after the CFA pattern is gone, so it can name which colour clipped in the reconstructed image but not which photosite went first. The axis is stops below saturation, 16 bins per stop over 256 bins — the same bin count the display reduction uses, so the fold into drawable columns is shared and a divergence between the two plots would have to be deliberate. A linear axis spends half its width on the top stop, which is why nobody has ever drawn a useful linear raw histogram. The fourth series is the brightest channel rather than luma: these values are unbalanced, so any weighted sum of them is a number about nothing, and the brightest channel is the one that saturates first and so the one the headroom question is actually about. It is a property of the file and not of the render, which has two consequences. It is computed once per photograph and cached — nothing downstream of the demosaic can move a count in it — so a cull does not pay the display histogram's per-frame cost three thousand times. And it describes the whole frame rather than the visible region, deliberately opposite to DevelopSession::histogram: a crop changes what is on screen and changes nothing about what the sensor recorded. Tags are on the reduction, the type, its constructor and the presentation arithmetic, each of which has a test that fails if the behaviour goes. The Slint panel and the push from lib.rs keep their reasoning as prose: nothing asserts them, and a tag would claim coverage the assertions are not making. |
||
|
|
82d9d077b0 |
Merge: answer Android's memory warnings, and stop reporting a lost root as an empty library
FR-PLAT-AND-5 in full, FR-PLAT-AND-2 in part -- the recovery is built and live for Nextcloud roots, the SAF cause it names does not exist yet. FR-PLAT-AND-4 and FR-PLAT-AND-6 are not here, both blocked behind the same gap: assemble-apk.sh compiles no Java, so the APK cannot carry a Service or a FileProvider. The container has JDK 17 and build-tools 36; the build step is what is missing. Verified: fmt, clippy --workspace --all-targets -D warnings, and 1043 tests across dr-catalog, dr-sync, dr-sync-folder, dr-sync-nextcloud, dr-plat and dr-ui. The aarch64 target was checked before the branch was finished but not after; no device was available. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2b812ebe21 |
Give memory back in the order the user will miss it least
FR-PLAT-AND-5. Android asks for memory back through onTrimMemory and kills the process if it is not given; until now nothing listened, so the answer was always "no". A tiered registry answers instead: GPU caches first, then proxies, then thumbnails, driven from android_main on MainEvent::LowMemory and MainEvent::Stop. The order is the argument. A backgrounded app has no window to draw and therefore no use for a render pipeline, while its thumbnails are exactly what the user will be looking at half a second after they come back -- so going into the background frees only the GPU tier, and only being measured against death frees everything. Sinks register beside the cache they free and hold weak handles, so the registry cannot keep a controller -- and every decoded portrait in it -- alive past the interface it belonged to. `try_borrow_mut` and skip: a warning can land mid-render, freeing textures under the code drawing with them is worse than missing one, and a warning not acted on is always followed by another. The GPU test is the one that matters: an eviction must change no pixel. A freed intermediate pool whose `colour_key` promise still stands renders an empty texture, and nothing else would have caught it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2168cdd1c4 |
Mark what is in focus, so a frame can be judged without zooming to 100%
FR-CULL-3's focus peaking. One compute dispatch measures local contrast in WGSL and writes an overlay texture; on desktop it reaches Slint through the same zero-copy wgpu import the canvas uses, so nothing per-pixel touches the CPU on the frame path. With peaking off the cost is zero and structurally so: focus_overlay opens with `let settings = self.peaking?;` before the frame is touched, and clearing drops both overlay textures, so no VRAM is held either. NFR-P14 is met by construction rather than by measurement -- one dispatch, no second render, no pipeline compile after session open, and a test asserting allocations stay at 2 over eight frames. The budget test asserts 50ms at 4K rather than a tight bound, deliberately: a tight bound fails on a loaded machine and gets deleted, which is worse than a loose one that still catches the regression that matters. TD-1 is amended rather than joined by a TD-6: on Android the overlay rides the readback that already exists there, roughly doubling that transfer while peaking is on, and TD-1's own "Done when" removes both because both are the same missing capability. Verified: cargo fmt clean; clippy --workspace --all-targets -D warnings green, which also compiles peaking.slint through dr-ui's build.rs; 11 focus GPU tests and 79 baseline dr-gpu tests pass; 511 dr-ui tests pass. Not verified: the cfg(target_os = "android") arm, which the host-target clippy never compiled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5133e53bc8 |
Give back what a straighten took, when the angle comes back
Build and test / Desktop (Linux) (push) Successful in 2h16m6s
Build and test / Layer separation (push) Successful in 52s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Successful in 51s
Build and test / Android (aarch64) (push) Successful in 47m10s
The auto-crop only ever shrank. Straighten to 20 degrees and the corners are cropped away correctly; come back to 3, or all the way to zero, and the crop stays at the size 20 degrees demanded. Nothing on screen explains why the photograph is still small, and the only way back was undo. The cause was that each correction was computed from the previous correction's output, so it accumulated: every angle the slider rested at took its cut and none was ever returned. The fix is to stop accumulating and recompute. The applied crop is now always the user's own rectangle fitted into the current angle's safe area, so as the angle falls and that area opens up the crop grows back — and stops, exactly, at the rectangle they chose. At zero the safe area is the whole frame and the fit is the identity, which is what carries it the last of the way home. There is deliberately no early exit for the upright case now: that exit is precisely what would strand the crop small. **The intent is remembered as a pair, so it repairs itself.** The session keeps `(applied, intended)` — what the correction wrote, and what it was derived from — and trusts the remembered intent only while the graph still holds `applied`. Every other route to the crop leaves something else there: a handle dragged, a ratio chosen, a sidecar loaded, a paste, an undo. That mismatch is the signal the memory is stale, and the current rectangle becomes the new intent. The alternative was a write into this field from each of those paths, which is the kind of bookkeeping that is correct until someone adds a seventh path. Dragging a handle therefore *is* the user choosing, including at a non-zero angle: the correction will not later grow the crop past what they dragged it to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
52c655e6cf |
Turn the ratio lock with the photograph when it is turned
A quarter turn carries the crop with it — that is what makes turning a photograph keep its composition rather than sliding the selection onto a different part of the picture. So a rect locked to 16:9 comes out of the turn at 9:16, of a frame whose axes have also swapped, and the lock was left claiming landscape over a portrait rect. The next drag would then snap it back upright and undo what the turn had just done. The orientation switch now turns with it, on odd numbers of quarters. `Original` is deliberately excluded, and getting that wrong flips it twice: it is resolved against the framed size every time it is asked for, and the turn has already swapped that frame's axes — so it has turned by the time anything asks. `turns_with_the_frame` is the one predicate that separates the two cases, with a test that pins both. `CropAspect` arrived without tests of its own; it has them now, including the round trip this fixes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d9eb8faffd |
Crop away the corners a straighten exposed, once the slider is let go
Turning a rectangle inside its own bounds exposes its corners: there is no source pixel out there, and the shader renders it black. Nothing in the render prevents that, deliberately — a free angle does not change the output size, which is what leaves the frame where the user put it while the slider moves. Correct during the drag; four black wedges on the finished photograph. Letting go of the slider now pulls the crop inside the area the angle leaves defined. `Framing::max_inscribed_crop` already computed that bound and had no caller; this is the caller its doc comment described. **Once, at the end of the gesture.** Applied per frame it would shrink the crop on every step of the slider and never grow it back, so a user who overshot to 20° and came back to 3° would be left with a crop ratcheted down by the excursion rather than by the angle they settled on. Per gesture it is bounded by the angles actually rested at, and undo steps back through them. **The crop is fitted into the bound, not replaced by it.** A crop placed deliberately off-centre is a decision, and an automatic correction that recentred it would undo the user's work to fix a problem they did not have. `CropRect::fitted_into` scales only as far as the bound demands and then slides the rect the shortest distance needed to be inside — so a ratio locked in the crop panel survives the straighten too, since the shape is never touched. It returns the rect unchanged, bit for bit, when nothing needed to move. That matters more than it looks: this runs on every release of the slider, including releases at zero, and a rect that drifted by a rounding error each time would be an edit recorded for no reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e6ad906bc1 |
Let the crop be held to a ratio while it is dragged
A photographer cropping for a print, a phone wallpaper or a 16:9 frame is not choosing four edges — they are choosing one edge and a known shape. Free-dragging every corner made them do that arithmetic by eye on every drag, and get it slightly wrong. The panel now offers Free, Original, 1:1, 3:2, 4:3 and 16:9, with a Portrait switch for the ones that have two orientations. Original follows the frame rather than naming a number, so it stays right on the next photograph from another body and after a quarter turn. **The ratio is of output pixels, and the rect is not.** `CropRect` is stored in fractions of a frame that is not itself square, so holding a shape needs the frame's size — `ratio * height / width` of the frame. Skipping that gives a "1:1" crop that is square only on a square photograph, which is the one case nobody would test on, so the conversion lives in `CropRect::with_aspect` where it is explained and pinned by a test that asserts the fractions are *not* equal. Two decisions worth recording: The reshaped rect **grows** onto the ratio rather than shrinking onto it, then scales down only as far as the frame's edge demands. Fitting inside instead makes a one-axis drag do nothing at all — the other axis clamps the first straight back, and the handle simply refuses to move. The overlay now reports **which corner the drag is holding**, because reshaping onto a ratio has to know which corner is nailed down and only the handle that took the press knows that. A move reports no corner and keeps its shape: reshaping about a centre would pull an over-moved rect smaller instead of sliding it along the edge. The lock lives with the window rather than the session. A `DevelopSession` is per image, and cropping a set of frames to one shape is exactly when the lock earns its place. It is not an edit and reaches no sidecar — what is saved is the rectangle it produced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fb1b44ce47 |
Merge branch 'worktree-agent-a1e5c8cb565255f5b' into master
# Conflicts: # docs/traceability.md |
||
|
|
b858fc029a |
Record what per-display colour actually cost
The status table said FR-DSP-8 was absent and §5.2 assumed Slint reports window moves. It does not — there is no `on_moved` on any backend — so the position is sampled instead. §5.4 records the two trades that are worth someone finding later: a display profile is matched to the nearest of four spaces rather than applied through a CMM, and on Wayland the canvas follows the first output rather than the window, because a Wayland client is never told where its window is and the protocol's own answer needs a `wl_surface` that Slint does not expose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0c3b8cb1c4 |
Hand out descriptors a declaration could produce
`Operation::descriptor()` returned `&'static OpDescriptor`, and that lifetime
is the whole reason a build-time node is free and a run-time node is
impossible: only a compile-time literal can satisfy it, so no amount of
reading `ops/*.yaml` at startup could ever produce a descriptor the rest of
the application would accept. FR-PLG-2 says a bundled operation and a
third-party plugin are the same kind of thing, differing only in where the
file was found — and a lifetime outsiders cannot meet is exactly the second,
weaker format that requirement forbids.
So a descriptor is now owned and handed out as `Arc<OpDescriptor>`, with `Vec`
where it held `&'static` slices. `Arc` rather than a `&self`-borrowed
reference because the callers want to *keep* it: the develop panel collects
descriptors and then mutates the graph, and a borrow would tie the
descriptor's lifetime to a borrow of the operation it came from, which is the
one thing `&'static` was doing right.
The identifier newtypes deliberately did not follow. `ParamId` is `Copy`, is
compared in `match` arms against generated constants, is a map key in the
sidecar and history, and reaches Slint model rows; an `Arc<str>` there would
cost a refcount on every one of those and would take `match id { EXPOSURE =>
.. }` away from the generated code. They gain an interner instead, which is
honest about its lifetime rather than pretending to one — the set of ids is
bounded by deduplication and is process-lifetime by construction, because the
sidecar on disk names its parameters and an id has to stay resolvable for as
long as any edit naming it can be opened.
No behaviour changes. Every descriptor that was a `static` is a `LazyLock`
initialiser now, `Operation::helpers` borrows from `self` instead of being
`'static` so a future run-time node can own its list, and `Warp` and `Framing`
follow `Operation` so there is one shape rather than two.
The one place a descriptor is read per frame is `compose_full`, which takes
`descriptor().id` to prefix each active operation's uniforms, and `dr-ui`
composes on every frame it draws. That is a dozen atomic increments beside a
composition that is already building several kilobytes of WGSL on the same
call; it is noted at the trait method rather than left for a profiler to find.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
131004393d |
Follow the canvas from one display to the next
The rest of FR-DSP-8. The develop session now carries the space its canvas is encoded into, and `render` composes for it instead of for sRGB — which is the whole of the change to the pixel path, because the output space was always a parameter of composition and always entered the structure hash. A display change is a recomposition. The space is set on the way into every render rather than pushed when the window moves, so a photograph opened while the window already sits on the second monitor is right on its first frame instead of flashing the wrong colour until the next poll. Which display that is comes from sampling the window's position and scale factor twice a second — Slint reports neither a move nor a display change — and re-surveying only when they differ. Settings shows what came back under ABOUT: the display, the space, why, and the other monitors, because the failure FR-DSP-8 names is one that is invisible from the display you are reading the page on. Fractional scaling: the canvas is now rendered at the physical pixel size of the box it occupies rather than the logical one, so the compositor presents it 1:1. At 1.25 it was previously handed 1600 samples to fill 2000 device pixels, and the softness that produces reads like a bad demosaic rather than like a scaling bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b846b312b8 |
Run the formatter over the face branch before it reaches CI
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Build and test / Desktop (Linux) (push) Successful in 1h21m32s
Build and test / Layer separation (push) Successful in 37s
Traceability / Requirement traces (push) Successful in 25s
Build and test / Android (aarch64) (push) Failing after 33m58s
The merge of the SCRFD/MobileFaceNet work brought 69 rustfmt diffs across
dr-catalog, dr-face and dr-ui with it, so `cargo fmt --all -- --check` fails
on master and the Desktop job stops at its Format step — before clippy, the
tests or the release build have run at all. That makes the whole desktop
half of CI blind: a real compile error behind this would look exactly the
same from the outside. There was nothing behind it, as it turns out — with
the formatting fixed, clippy, the test suite and the release build all pass.
Every .rs hunk is `cargo fmt --all` on the pinned 1.92.0 toolchain, not a
hand edit, but it is worth being precise about what that moved, because it
is more than whitespace. Besides reflowing signatures and call chains,
rustfmt reordered the `pub mod` and `pub use` items in dr-face/src/lib.rs so
the `#[cfg(feature = "inference")]` entries sort in place, added the trailing
semicolon inside `let ... else { return }` bodies in identity_ui.rs, wrapped
a bare closure body in braces in cluster.rs, adjusted trailing commas, and
dropped a stray blank line at the end of identity_ui.rs. All of it is
semantically inert; none of it changes behaviour.
docs/traceability.md rides along because it has to. The matrix records each
TRACES tag by line number, and reflowing develop.rs, lib.rs, faces.rs,
identity.rs and identity_ui.rs moved them — FR-CAT-8, FR-CAT-9, FR-CULL-10,
FR-DEV-3, FR-DEV-3a and FR-DEV-3c all shift by a line or two. The matrix was
verified up to date on
|
||
|
|
d777f7f44d |
Merge branch 'master' into worktree-faces-scrfd-mbf
Build and test / Desktop (Linux) (push) Failing after 25s
Build and test / Layer separation (push) Successful in 22s
Traceability / Requirement traces (push) Successful in 58s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 33m26s
# Conflicts: # docs/traceability.md # ui/dr-ui/src/develop.rs # ui/dr-ui/src/segmentation.rs |
||
|
|
7e1c33ebed |
Draw the subjects on the photograph, not on the sensor
Build and test / Desktop (Linux) (push) Successful in 19m42s
Build and test / Layer separation (push) Successful in 27s
Traceability / Requirement traces (push) Successful in 34s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Build and test / Android (aarch64) (push) Failing after 33m12s
"Find subjects" would recognise a person on a portrait frame and then paint the outline into the hillside behind them. The detection was right and the mask was right; what was wrong was the picture drawn to show them. Instance masks live in sensor space, and correctly so — the generated shader samples them at `uv_src`, after the framing map, which is what keeps a mask on its subject through a zoom, a pan and a crop. The overlay is the one consumer that is *not* sampled by that shader. It is a flat image handed to the compositor to lay over a photograph that has already been through the framing map, so it has to arrive in the same space that photograph is in, and it did not. On a frame from a camera held sideways the outlines were drawn a quarter turn away from the subjects they described. `overlay_clip` had the same fault one layer down, and it is the more insidious of the two because it looks right. The crop and the viewport are fractions of the photograph as the user sees it — the prologue maps an output pixel through `crop_rect` *before* it unturns the frame — and they were being measured against the sensor's width and height. Two numbers, correct type, wrong axis. Both now go through `Orientation::into_shown`, so the overlay and its clip are in the photograph's space and the turn is the same one the render and the thumbnails make. Neither was noticeable until this week, and the reason is worth writing down: before the detector was given an upright frame it found almost nothing on a portrait photograph, so there was rarely an outline to be in the wrong place. Fixing the detector is what made this visible. Landscape frames were never affected, which is most of them, and is why an overlay that ignored orientation entirely survived this long. Verified on `_MG_9080.CR2`, a portrait frame of two people and a dog: the overlay was a 1599x1066 image drawn onto a 1066x1599 canvas, with the colour sitting in the mountainside above the subjects. It is now 1066x1599, and each outline is on the thing it names. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f00b92a0e6 |
Turn face boxes back into sensor space before matching regions
Faces are found on the thumbnail, which is cached the right way up -- the grid would lie on its side otherwise. Segmentation runs on a proxy rendered through a neutral edit graph, which carries no orientation and is therefore in sensor order. For anything shot in portrait the two differ by a quarter turn, so a face and the person containing it were being compared in spaces 90 degrees apart: no match, or worse, a match against somebody else's region. The transform goes on the face rather than on the proxy. Instance masks are defined in the proxy's space and sampled long afterwards, so turning that space would be a far larger change than naming a region warrants. Also two things the first screenshot of the running app showed that no test would have: 110 of 23,528 displayed as "0%", which reads as the feature having done nothing. One decimal below ten percent, and a floor so real progress never shows as none. The rail picked some near-black covers, because the largest face in a group is often the nearest one in a badly lit frame and a black square beside a name identifies nobody. It now cuts the best few and takes the first legible one, falling back to the largest when a person's every photograph is dark -- which happens, and showing it beats showing nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b55812812a |
Name segmented people from the faces already recognised in them
The segmenter knows it found a person; the face index knows which person. Joining them turns "person" in the mask list into "Anna", which is the difference between a vocabulary of eighty COCO classes and one that includes the user's family. Selecting a subject in a group photograph stops being a guessing game between three identical rows. Containment, not IoU. A face is a small part of the person it belongs to, so a correct pairing has an IoU near zero and anything IoU-based would reject every true match. Confirmed names only. A suggestion is the system's guess, and printing a guessed name onto a mask region would launder it into a fact. Writing the tests corrected the design once: a tight head-and-shoulders portrait, where the face fills most of the person box, is the case where naming is most certain, not least. An earlier guard rejected exactly that and has been removed, with the reasoning left as a test because it is easy to get backwards a second time. The names hang on the develop session, set when the image opens because that is the one moment the catalog and the image id are both in reach. Every segmentation run afterwards picks them up for free, and a library with no face indexing behaves exactly as it did before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d7a81375ee |
List the steps, and let a photographer step straight to one
Build and test / Desktop (Linux) (push) Successful in 19m30s
Build and test / Layer separation (push) Successful in 25s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Traceability / Requirement traces (push) Successful in 23s
Build and test / Android (aarch64) (push) Failing after 33m5s
Undo answers "take back the last thing", which is the question asked about a mistake just noticed. It is the wrong instrument for one noticed six adjustments later: eight presses, each changing the picture, with no way to see how far back the mistake is without passing through it. A step is a whole state, so arriving from six away costs what arriving from one does — which is what makes a row worth making clickable rather than decorative. `Edit::Discrete` had to go for the list to be worth drawing. Seventeen call sites recorded the same anonymous step, which is fine for deciding whether two changes are one gesture and useless for a panel: seventeen rows reading "Discrete" is not a history. Every variant now carries enough to name itself, and the compiler enumerated the sites that had to start saying so. A step that moved a parameter is still named out of the descriptor, so an operation added as a YAML declaration appears in the history correctly named with nothing written for it (FR-DEV-3c). Choosing a film stock was not undoable at all. The pick went straight to `choose_film`, which nothing on the history's path ever sees. `pick_film` records it, and is separate because the same call is also how a *restored* edit gets its tables back — recording that would push a step for the undo the photographer had just asked for. The list is rebuilt off a revision rather than off every redraw. A drag ends in a redraw per frame while folding into one step, so the unconditional version would tear down and recreate every row sixty times a second to arrive back at the list already on screen. The counter is process-wide: a per-instance one starts every photograph at the same number, so a frontend holding "the revision I last drew" would keep the previous image's steps on screen — invisible while every image opens with one identical row, and a wrong-photograph bug the moment persisted history means it does not. The step names that no descriptor can supply are constants with a roll, and a test walks the roll rather than a second copy of it. `resolve` splits so that "is this catalogued?" can be asked: `derive` turns `history.mask_toggled` into "Mask Toggled", which names a field rather than an act and, being perfectly readable, is a mistake nobody would look at twice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b89f1cfece |
Snapshot the whole edit in the history, so a drawn mask can be taken back
The undo stack snapshotted a `Preset` — the parameter map — and a mask layer is deliberately not a parameter. So drawing one changed nothing the history could see: `record` returned `false`, no step opened, and the layer the photographer had just painted had no way back. The interface went on calling `record` in good faith, including from the mask controls, and nothing failed. A film stock went missing the same way. The snapshot is an `EditState` now, so the history is complete by construction rather than by anyone keeping a list in their head. `undo` and `redo` return a `Step` rather than a `bool`. Stepping is not the only outcome a caller has to act on — a step across a change of film leaves the graph without its tables, and only the caller can bake them — and a `bool` would let that be dropped by writing nothing at all, which is the shape of mistake this module had already made once. `DevelopSession` settles the debt either way; a step that found nowhere to go is left alone, since clearing the film because undo hit the floor would take the stock off the picture. Five tests, all of which fail against the old snapshot: a drawn layer is undoable and redoable, a layer's own settings are a step of their own, a change of stock is a step and names what it needs baked back, clearing the film is undoable, and an exposure move does not deep-copy the mask stack. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
93efdf27a6 |
Keep the film stock when an edit is saved
`Version::update` is the write path an automatic save goes through. It copied the parameters and the masks and said nothing about the film, so a photograph developed on a stock was written back without it and opened the next time without its emulsion. Nothing reported a failure — the line was simply not there. It is the third of the three routines that captured "the edit" and the only one that got it wrong, which is the argument for not having three. All of them now destructure one `EditState`, so `from_graph`, `update` and `apply` cannot disagree about what an edit consists of, and the next part of one cannot be lost by anybody writing a line too few. Two tests, both of which fail without the fix: the field survives `update`, and the stock survives the round trip through the file. `apply` returns the `FilmRebake` it always implicitly owed, so `apply_version` now reads the debt off the call rather than off `version.film` — and pays it in both directions, since a version with no film has to clear the adjust pass too or it keeps textures bound that nothing will sample. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4a82753d22 |
Show the detector the photograph, not the sensor's scanlines
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Successful in 19m7s
Build and test / Layer separation (push) Successful in 25s
Traceability / Requirement traces (push) Successful in 23s
Build and test / Android (aarch64) (push) Failing after 33m10s
"Find subjects" was handed the proxy in the sensor's own orientation, so every frame shot on a body held sideways reached the model lying on its side — and a model trained on upright photographs is very bad at those. Measured end to end on a 22 MP frame of two people and a dog: `person 0.36` and nothing else, against `dog 0.82, person 0.61, person 0.49` for the same pixels stood up. Nothing failed; the panel simply offered one poor subject where there were three good ones. The orientation was never dropped on purpose. The proxy is deliberately rendered through a *neutral* graph — the detection has to survive an exposure change, or every slider would invalidate the masks built on it — and neutral took the file's orientation with it along with everything else. Landscape frames were unaffected, which is why it stood for as long as it did. The turn is `Orientation::source_pixel`, the same function the grid's thumbnails already go through, so the detector and the thumbnailer now agree about which way is up rather than holding two opinions. What it is turned by is `Framing::effective_orientation` — the file's EXIF tag and the photographer's own rotations composed into one permutation, by the group law rather than by adding the turns, which is a distinction `Framing` already had to make and had already tested. Rotating the picture and pressing the button again therefore does what it looks like it does. The proxy stays in sensor space and the masks come back into it. That is not a detail to be tidied later: the generated shader samples the mask array at `uv_src`, *after* the framing map, so a mask stored upright would sit a quarter turn off the subject it was drawn around. That is a wrong mask rather than a weak one, and nothing announces it. So the picture is stood up for the model and laid back down for everything else, and `upright`/`lay_down` are returned as a pair because calling one and forgetting the other is silent. Both directions are the one function: `upright` gathers through `source_pixel` and `lay_down` scatters through it. A quarter turn is a bijection of the pixel grid, so the round trip is exact — no filter, no resampling, and no hole to fill — and an inverse written out by hand would be a second thing to keep in step, whose way of being wrong is a mask mirrored about the wrong axis, which still looks like a mask. The orientation joins the confidence and the tiling flag in the segmentation signature, and for the same reason: turning the photograph changes what the model recognises, so two runs either side of a rotation are different instance lists. Two that happened to come out the same length would otherwise share a signature and a stored layer would be silently re-indexed from one into the other. The refine pass had it too — it re-runs the model over a crop rendered in the same sensor space — so it makes the same turn, and would otherwise have handed back a worse mask than the one it was asked to improve, on the subject the photographer had just pointed at. `dr-gpu`'s `local` example is fixed with it. It exists to be the shipping path with pictures attached, and a diagnostic that reproduces the bug it is meant to catch is a trap for whoever reads it next. Seven tests. The round trip is the identity over all eight EXIF tags on a non-square asymmetric grid; a turn carries whole pixels rather than shearing the channels apart; a sideways frame reaches the model upright; a box comes back in sensor pixels, worked out by hand for the one turn a portrait frame actually writes; a restored box still reads low-to-high for every tag, since the rest of the pipeline takes `x1 - x0` without checking the sign; and the eight tags cannot collapse into one signature key. The existing composition test now runs against `effective_orientation` itself, over all 8 x 16 baseline-and-user pairs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e0cb968e04 |
Merge remote-tracking branch 'origin/master' into worktree-spot-removal
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Build and test / Desktop (Linux) (push) Successful in 20m9s
Build and test / Layer separation (push) Successful in 39s
Traceability / Requirement traces (push) Successful in 26s
Build and test / Android (aarch64) (push) Failing after 33m10s
# Conflicts: # docs/traceability.md |
||
|
|
bc07c32611 |
Merge branch 'master' into worktree-spot-removal
# Conflicts: # docs/traceability.md |
||
|
|
e14bc34a9e |
Ask which frame this was taken on, because grain is enlargement
Build and test / Desktop (Linux) (push) Successful in 19m6s
Build and test / Layer separation (push) Successful in 28s
Traceability / Requirement traces (push) Failing after 26s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 33m7s
A crystal is a fixed size in micrometres. How grainy a photograph looks is therefore not a property of the emulsion alone -- it is film size against output size, and the frame is the half a digital file cannot supply. This assumed 35 mm for everything. The same emulsion on 4x5 averages about 3,800 crystals into the pixel that holds 300 on 35 mm, so it renders roughly 3.5 times smoother at the same print; every large-format photograph was being rendered as grainy as a half-frame. `Format` now carries the real image widths -- the gate, not the nominal inches, since a "4x5" exposes about 121 mm -- and the film node asks for it. It is a genuinely fixed list, unlike the stocks, so it is a declared `enum` parameter and gets its control, its sidecar entry and its undo step for nothing. It is also the first enum in the develop chain, and it broke two tests by being one. A row has to compare equal to itself across two builds or `sync_rows` replaces it on every parameter event -- destroying the elements built from it, including whichever TouchArea holds the current gesture, so the format picker would have fought every slider drag in the panel. `ModelRc` compares by identity and the row built a fresh choices model each call. `no_choices` already shares one empty model for exactly this reason, and the build site already said "see no_choices for why the identity matters". The fix follows it: memoise the model per variant list. Curve rows solve the same problem the other way, writing values through the existing model, which is not needed here -- a variant list is fixed at compile time, so one model can serve forever. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |