Commit Graph
18 Commits
Author SHA1 Message Date
dtourolle 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.
2026-09-12 01:08:10 +02:00
dtourolle 7c44740d9f Skip the read-only-directory test where modes are not enforced
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m41s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Successful in 1h44m9s
Build and test / Layer separation (push) Successful in 48s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Traceability / Requirement traces (push) Successful in 36s
Build and test / Android (aarch64) (push) Successful in 1h1m51s
`a_failed_overwrite_puts_the_original_back` makes the presets directory
read-only and expects the overwrite to fail. CI's Desktop job runs in a
container as root, and root is not refused by a mode: the write succeeds,
the assertion fails, and build-and-test has been red on every push to
master since the test arrived.

The test now probes the refusal it depends on -- one write into the
directory it just locked -- and skips where that write goes through.
Probed rather than keyed on the uid, because what the test needs is the
refusal itself, and a filesystem mounted without permission checks would
pass a uid test and fail this one all the same.
2026-09-11 22:22:46 +02:00
dtourolle 0a5eab0487 Wire the develop panels through globals, so a second copy is one line
Every panel in the develop column declared its inputs and its callbacks and
had `app.slint` bind each one to a property or a callback on the window root.
That is fine while a panel is drawn once. N9 draws them a second time, in the
portrait dock, and the wiring is what would have to be copied: `MaskPanel`
alone ran to forty lines of forwarding, and a callback added to one copy and
not the other compiles, renders, and simply does nothing on the layout nobody
was looking at.

So the wiring moved to Slint globals. A panel reads the global and calls the
global; Rust hooks the global instead of the window; and the instantiation in
the column is now the panel's name and a pair of braces — every one of the ten
children of the column, with no property that differs by placement left to
supply.

There is a global per panel family rather than one for all of them, and the
reason is an import cycle. Each panel's model struct — `ParamRow`, `MaskRow`,
`HistogramView` — is declared in the panel's own file, so a single global
holding `[MaskRow]` and `[ParamRow]` would have to live in a file importing
`masks.slint` and `adjust.slint` while both imported the global back, which
Slint rejects. Breaking that needs six model declarations relocated, which is a
change to the data model and not to the plumbing this is about. A global beside
the panel it serves also lets each name drop the prefix it was carrying only
because the window root is one flat namespace: `root.spot-radius` is
`Repair.radius`, and `root.peaking-on` is `Peaking.showing`.

`session.slint` is new and holds the two facts every family needs and none of
them owns: whether there is an open photograph to edit, and which mode the view
is in, with the three readings of the mode derived once instead of at each of
the dozen places that tested one. `ViewMode` moves there from `adjust.slint`,
where it was only ever a lodger.

Nothing on screen changes. What is not here: the tool rail and the status strip
still take their properties at the instantiation, because they are drawn once
and N9 does not copy them; the preset sheet's own state stays on the window,
because the library grid opens the same sheet and a global cannot bind the
window's state — which is why `Transfer.open-presets` is handled in
`presets.rs`, beside the summary it already had to compute.
2026-09-07 20:01:01 +02:00
dtourolle 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.
2026-09-07 20:00:40 +02:00
dtourolleandClaude Opus 5 601c984894 Fold a photograph's rival default versions back into one
Picking the newer of two default versions stopped the wrong edit being shown,
but it did not close the split: the losing version stayed in the file, and a
device holding disjoint work — a crop made here, an exposure change made there
— still contributed only one of the two.

Worse, the next write made it larger. `amend` looks its version up by uuid,
neither of the two was ours, so the miss minted a *third* `default = 1` block
and the file grew one rival per device per photograph.

`Sidecar::fuse_default_versions` folds them down. The version with the highest
`(revision, modified)` is the accumulator and every other default is merged
into it as the remote, which is what makes the fold order-independent —
`Version::merge` raises its own revision to `max + 1` as it goes, so merging a
chain in ascending order stops being ascending after the first step and a third
device would be dropped. Contested values resolve to the winner, disjoint keys
survive from both sides because the merge is key-wise, and ratings come across
under `merge_judgement`, so a device that never judged the frame cannot erase
one that did.

The result is a function of the file's bytes alone, so two devices that fuse
independently reach the same document and converge instead of overwriting each
other.

Called wherever a sidecar is parsed:

- `amend`, with the write's own uuid, so the fold lands on the identity this
  device is about to use and the lookup below it hits instead of missing.
- `spawn_sidecar_fetch`, so opening a photograph shows everything done to it
  rather than whichever half won.
- `drain_one`, because `merge_into` reconciles by uuid and would otherwise
  publish the split rather than resolve it.
- `presets::load_local` and `save_local` — a local sidecar's folder may be
  synced by something else entirely, and gets the same split.

A file with one default under the expected uuid comes back byte-identical, so
this costs nothing on the ordinary write and no sidecar is uploaded merely for
having been read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:13:53 +02:00
dtourolleandClaude Opus 5 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>
2026-09-05 14:34:03 +02:00
dtourolle 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.
2026-08-30 21:28:54 +02:00
dtourolleandClaude Opus 5 754ab91347 Bring a Lightroom library across, and start with something in the list
Two halves of the same complaint: a preset sheet that opens on "No
presets yet" is homework, and a photographer with ten years of presets
in Lightroom has no way to bring them.

`dr-preset-xmp` reads Camera Raw `.xmp`. The mapping turned out to be
mostly a rename rather than a conversion, because Adobe and this
pipeline already agree: exposure is in stops in both, and contrast, the
four recovery controls, clarity, texture, vibrance and saturation are
all ±100 in both. That is not imitation, it is the convention raw
developers converged on — `highlights_shadows.yaml` cites it in as many
words. Only sharpening needed arithmetic, Adobe's 0…150 against our
0…100.

The white balance does not come across, and says so rather than
guessing. Adobe writes absolute Kelvin for a raw file where ours is a
relative nudge from what the camera recorded, so converting needs the
*target image's* as-shot white balance — exactly what a preset cannot
carry, since the same preset lands on a frame shot at 3200K and one shot
at 7000K. A guess would be wrong on most images and invisibly so.

A folder is read as readily as a file, nested, because that is the shape
an exported preset folder is in and importing ninety files one at a time
is asking someone not to bother.

`dr_pipeline::starter` is six presets a first run begins with, written
against this pipeline in its units and deliberately mild — a starting
point, not a caricature. They are seeded when the library *file* does
not exist rather than when the library is empty, so deleting all six
does not hand them back on the next launch.

Both of these name operations, and `ui_names_no_operation` was right to
stop them living in `ui/`. That test exists because the failure is
silent and cumulative, and it caught exactly what it was written for: a
preset called "Punch" is a statement about contrast, clarity and
vibrance, and a table mapping Adobe's vocabulary to ours is a statement
about the pipeline. Neither is a fact about an interface. So the starter
set went into `dr-pipeline`, and the importer into its own crate —
between two walls, since `dr-pipeline` depends on nothing on purpose and
XMP is real XML not worth hand-rolling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 22:00:53 +02:00
dtourolleandClaude Opus 5 bb35665bd2 Let a paste carry some kinds of edit and not others
FR-DEV-6 asks for presets "covering a subset of the edit graph". What
landed with the named presets covered two subsets: everything, and
everything but the crop. "Match the colour but not the sharpening" had
no way to be said.

`Scope` is now a set of `Attribute` — the same six kinds every operation
already declares and the develop panel already builds its tabs from. The
photographer ticking "tone and colour" is naming the groups they
navigate by, and neither this module nor the interface has to name an
operation to do it (FR-DEV-3c).

The pleasing part is what left. Framing used to be excluded by an
explicit test against one operation's id; it is now excluded because
Geometry is not in the default set. The special case dissolved into the
general rule, and the argument for it — a crop is a decision about *this*
photograph, and carrying it across forty destroys forty compositions —
is now a statement about a kind of edit rather than about a node. All
thirty-three existing preset tests pass unchanged, which is the evidence
that the generalisation kept its promises.

One decision that is a field rather than a rule, because the two cases
genuinely differ. An operation this build cannot classify — from a newer
version, arriving over sync — travels under "everything" and "everything
but the crop", because those are claims about the whole edit and an
unrecognised operation is part of it (FR-NC-8). It does not travel under
a hand-picked set, because that is a claim about kinds, and an unknown
kind is not one of the kinds that were ticked.

The settings page's "Copy crop and rotation" checkbox is gone, replaced
by the same chips the preset sheet draws. It asked the right first
question — geometry is the kind whose accidental travel destroys work —
but it was the only question a boolean could ask. The field stays in
`Settings`, read exactly once to seed the new set, so anyone who had
ticked it keeps their behaviour.

The chips are deliberately not in the develop column. Six of them there
would set the width of the whole sidebar, which is the bug `ChipGrid`'s
comment records at length.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:38:19 +02:00
dtourolleandClaude Opus 5 5a8327824f Keep an edit under a name, not just on the clipboard
FR-DEV-6 asks for three things — named presets, copy/paste between
images, and batch-apply to a selection. The last two have been here for
a while; this is the first.

The format is the sidecar's, deliberately. A preset *is* the non-default
half of a version, so the lines are the same lines keyed the same way,
which makes the two files diffable against each other and lets someone
debugging an edit paste a block from one into the other. One file rather
than one per preset: a preset per file makes the name a path, and every
name then has to survive a filesystem — a `/` becomes a directory, a
name differing only in case collides on one platform and not another,
and renaming becomes two operations that can half-fail. As a key in a
document it is none of those.

Unknown *parameters* needed no machinery. `Preset` already holds
whatever keys it is given and resolves them against the descriptors only
at apply time, so one written by a newer build survives by being stored.
Only lines that are not `op.param = float` at all are preserved
verbatim, which is the sidecar's version-skew promise made here too.

Applying is the paste path with a different source, so a preset reaches
a selection through the sidecar read-modify-write that was already
there: no graph, no decode, no GPU, forty files or one.

Two smaller decisions worth the record. A library that fails to parse is
held empty in memory and *not* written back over — settings regenerate
themselves and this is work, so a parse failure must not be the moment
it is destroyed. And every save persists immediately and rolls the
in-memory copy back if the write fails, so the sheet never lists a
preset the file does not have.

The grid's "Presets" button is gated on the selection alone, unlike the
"Paste to 40" beside it. That button needs a clipboard armed this
session; the preset list is whatever was saved last month, and hiding it
behind an unrelated action is what makes a feature only its author knows
about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:07:14 +02:00
dtourolleandClaude Opus 5 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>
2026-08-26 22:53:00 +02:00
dtourolleandClaude Opus 5 3b5952769b Emit floats an f32 can hold, and drop the format! that formats nothing
CI runs cargo fmt --check and clippy -D warnings, and this branch had
never been through either. Both would have failed it.

The bulk was the generated colour tables: eight significant figures where
an f32 carries about 7.2, so the eighth is noise that rounds away at
compile time and clippy's excessive_precision says so 109 times over.
Fixed in the generator rather than only in the file, so it stays fixed --
and the file is trimmed in place rather than re-derived, because
regenerating it needs a colour-science stack that has nothing to do with
the defect.

The format! in the composer is mine too, from extracting the rendering
tail: the braces in it were escaped because the text used to live inside a
larger template, and once extracted the escapes are noise and the call
formats nothing.

Also here, and clearly not mine: an unused import and a shadowed binding
in dr-gpu, and an unused import in a test. They are pre-existing --
clippy has been failing on master before this branch existed, on lints
like is_multiple_of that arrived with a toolchain rather than with
anyone's code. Fixed because CI cannot go green around them, and called
out because a merge commit is a bad place to quietly edit someone else's
crate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 22:28:14 +02:00
dtourolleandClaude Opus 5 6d18517d28 Ship every stock that exists, black and white included
Three profiles was what the first cut needed to prove the model. This is
the rest of the open data: 23 camera stocks and 9 papers, which is all of
spektrafilm.

Black and white was the gap, and it turned out not to be a gap in the
data -- it was a gap in where I looked. Upstream's `main` has 28 colour
profiles and nothing monochrome; `dev` has three more, and they are
Tri-X, Double-X and the 2302 print film they go onto. So the answer to
"do we have B&W" was yes all along, and it needed the dev branch rather
than a fortnight digitising Ilford's datasheet graphs by eye. Those three
are pinned to `dev` per stock; the colour stocks stay on the released
branch.

A monochrome profile is single-channel -- one emulsion, not three -- and
spreading that one layer across all three is exact rather than an
approximation: three layers with identical sensitivity and identical
curves respond identically, which is what one layer does. The dye is the
trap. The renderer *sums* the three layers' contributions, so replicating
it unchanged renders every frame three times too dense -- neutrally, and
therefore plausibly. A third each reconstructs the single emulsion, and
two tests hold both halves: that the densities stay equal, and that they
sum to one emulsion and not three.

Double-X and 2302 ship five curves apiece, measured at five development
times -- 4 to 12 minutes for Double-X. That is push and pull processing as
measured data. The standard 6.5 minutes is what ships; the rest is in the
upstream file waiting for a control to ask for it.

Two stocks are `support: film` and are nevertheless what a negative is
printed *onto*: the cine projection films 2383 and 2393, which the
Vision3 stocks print to. Filtering the picker on support alone offered a
projection stock as something to load in a camera, so it filters on stage,
with a test saying so.

The picker had to change shape twice over. Chips were right for three
stocks and off the edge of a 280px column at twenty-four, and the column
that replaced them was a thousand pixels standing between the
photographer and every slider below. It is a disclosure now: one row
carrying the answer, opened to change it, closed again on choosing. That
is the opposite of the argument this panel used to take the lids off its
sliders, and deliberately so -- an instrument you compare wants to be
visible, and a list you consult once wants to be out of the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 20:30:11 +02:00
dtourolleandClaude Opus 5 baa8957e80 Let a photographer choose the film, and remember which one
The stock model rendered correctly and nothing could ask for it. This is
the picker, and the sidecar key that makes the choice outlive the session.

How the choice persists was the open question, and the answer was already
written down twice in sidecar.rs: `rating` is a top-level key "because a
rating is not an edit", and `masks` are one "because a layer is not a
scalar". A stock is that kind of thing -- a choice of material, not a
number a slider moves -- so it is a top-level key too.

It stores the **id**, not an index. Stocks are files that users add, so an
index would mean installing a profile silently changed which film every
existing photograph had been developed on. A name this build has no
profile for still round-trips untouched, because the alternative is that
syncing to an older phone quietly un-develops the picture.

Only the names travel. Turning one back into tables needs the profile
database, which dr-pipeline deliberately does not link, so `Version::apply`
clears the film and the session re-bakes -- after the parameters, because
the bake reads the film's own exposure sliders and the print balance is
solved against them. That is also why moving those sliders rebuilds the
lookup where no other control in the panel does: an enlarger's filtration
depends on how the negative was exposed.

The panel keeps its rule. It still names no operation and still generates
every control from a declared parameter kind; the stock gets a bespoke
control beside those, exactly as the mask stack does, and for the same
reason. The film's exposure and print exposure arrive as ordinary
generated sliders.

Two defaults worth stating. Picking a colour negative prints it, because
an unprinted one is an orange strip and offering that as the first thing
somebody sees after choosing Portra reads as a bug rather than as a
choice -- the toggle is there for anyone who wants the scan. And a paste
carries no film: a preset is a parameter map, and a stock is not a
parameter, so pasting one would paste a choice the clipboard never took.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:57:41 +02:00
dtourolleandClaude Opus 5 fbf286d504 Keep the mask when the photograph is closed
A local adjustment survived until the session ended and then did not exist.
Nothing reported it, because nothing had failed.

The sidecar format has carried masks since they were added, `Version::apply`
restores them into the graph, and `Version::update` captures them — that work
landed complete and was never called. The autosave writes `copy_settings()`,
which is a `Preset`: a map from (operation, parameter) to a number. A mask is
not a parameter. It is a rule about *where*, with a chain of its own, so it
fell outside the only thing being written, and the read path had nothing to
read.

The save now carries the stack beside the preset, on both the local and the
remote path. Deliberately not merged but replaced wholesale: this is the stack
as it stands, so a layer the user deleted has to leave the file too. Merging
two devices' stacks is `Sidecar::merge`'s job and belongs to sync (FR-NC-9).

A paste still carries no masks, and the `Option` is how that is said. Settings
travel between photographs; a mask does not, because it is drawn against one
frame and describes nothing on another — and `Scope` cannot express that,
since it filters parameters and a mask is not one.

The test fails against the old save path with "the mask must come back with
the photograph", which is the whole of the defect: not a crash, not an error,
just an edit that was not there in the morning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 12:29:41 +02:00
dtourolleandClaude Opus 5 c75849040c Format the tree the way the gate asks for it
`cargo fmt --check` is a required step and had drifted across 45 files. Most of
it arrived this week: several operations were written in parallel worktrees and
merged by hand, and a hand-merge resolves conflicts without ever running the
formatter over the result.

No behaviour changes — this is `cargo fmt --all` and nothing else, kept as its
own commit so the next reader can skip it wholesale rather than search it for
one that matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:16:34 +02:00
dtourolle caf61d41a5 Re-thumbnail a photograph from its own edit
A thumbnail comes from the file's embedded preview, which is the camera's
idea of the photograph and knows nothing about what has been done to it
since. So a frame could be cropped, turned upright and pulled two stops
back, and the grid would go on showing the original — making the library,
where a photographer spends most of their time, the one view in which an
edit is invisible.

The render is the framed output, not the sensor: `output_size` is what a
crop, a quarter turn, a flip and a straighten all act on, so a thumbnail
taken from the raw frame would be the right pixels in the wrong shape and
still the wrong way up. It is the same path an export takes, at a size
the store wants rather than at full resolution, and always sRGB — this is
a JPEG in a shard that syncs between devices and is drawn as a cell, not
a file anyone is finishing.

Both size classes are replaced. The store keys on the class, so
refreshing only the one the grid happens to be drawing leaves the other
holding the unedited preview, and a zoom across the boundary would show
the edit undoing itself. Each is rendered rather than downscaled from the
larger, which would be a second and worse resampler than the GPU has
already applied.

It runs on the way out of develop, after the sidecar write is queued and
never instead of it — the edit is what must not be lost, and a render
that failed must not take the save down with it. Two cases are worth the
work: an edit made in this sitting, which `can-undo` records even when it
ends back at neutral, and an image opened with an edit already in its
sidecar and left untouched, whose cached thumbnail has never shown that
edit at all. A neutral image nobody touched fails both and costs nothing.

Not covered: a batch paste onto a selection, which deliberately never
opens a session — there is no rendered frame to take a thumbnail from,
and downloading forty RAWs to make forty is exactly what that path exists
to avoid.
2026-08-21 22:48:10 +02:00
dtourolleandClaude Opus 5 e00c99b864 Let a photograph leave: an export button, and a cache to leave from
dr-export could turn a frame into bytes and nothing could ask it to. This is
the button, and the place the bytes go.

**Everything is staged first.** An export bound for the server is written to a
local outbox and uploaded afterwards; offline is not a special case, it is the
same path with a drain that finds the server absent. Doing it the other way —
upload directly, stage only on failure — makes the failure path the one that
is rarely exercised and always broken, and a network drop mid-batch leaves
some exports existing and some not with nothing recording which. Staged first,
an export is finished the moment it is written and the upload is a promise
kept later.

The outbox sits beside the catalog rather than under the cache. dr_catalog's
cache already draws that line: passive entries are a convenience and go under
LRU, pinned ones are a promise and never do. An export awaiting upload is a
promise — the user was told it succeeded — and sweeping it for disk would
destroy the only copy. Bytes are written before the destination record, so a
kill between the two leaves an orphan the drain ignores rather than a record
pointing at nothing.

The status line says "Queued for Exports/2026", never "Exported to Nextcloud",
until it has actually landed. There is a test asserting that wording, because
the tempting shorter sentence is a claim the app cannot keep.

The drain runs on the sync pass, before the shards: a thumbnail shard can be
rebuilt from the originals and the catalog is an index, but a queued export
exists nowhere else.

`DevelopSession::render_for_export` renders the framed size rather than reusing
the frame on screen, which is deliberately viewport-sized (FR-DSP-1) — encoding
that would hand the user a soft, screen-sized file with nothing to say anything
had been lost (FR-EXP-9).

One compromise, recorded rather than hidden: the export runs synchronously on
the UI thread, so the window is unresponsive for the few hundred milliseconds
a full-resolution render and encode takes. Moving a DevelopSession and its GPU
pass to a worker is a larger change than one button earns, and it is batch
export that makes the wait intolerable rather than merely noticeable.

Still missing: the Nextcloud folder *picker*. The destination is typed into
Settings for now. `FolderBrowser` in launch.rs is already the reusable model
for it — it browses a remote tree and nothing about it is specific to choosing
a library root — but wiring it into the settings page needs a listing worker
and browser UI there, which is its own piece of work.

Carries in-flight work from a parallel session — presets, the develop copy and
paste, and the node schema's `presentation` and `enum` support. One misplaced
callback in settings_ui.rs is moved from `render` to `wire`: registered in
`render` it borrowed a `&SettingsController` into a 'static closure and would
not compile, and that file's own docs say render pushes properties while wire
connects callbacks.

992 tests pass, clippy and fmt clean. Traceability 48.3% -> 51.0%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:58:17 +02:00