532360805133552a1265526ee0b698f107e83e66
22
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5323608051 |
Draw the repairs, before anything sharpens what they removed
A spot set now composes detail passes of its own, one per round, and they go ahead of every operation's kernel. That placement is the decision worth recording: a sharpening pass reads a neighbourhood, so sharpening a dust mark before removing it smears its edge into pixels the repair's disc does not cover, and what survives is a faint over-sharpened ring around an otherwise perfect patch. It also disagrees with ARCH §5.2, which draws spot removal after clarity — docs/spot-removal.md §5.1 is where that is argued out. Every length reaching the shader is in render pixels, converted here where the framing is in scope. Both the centre and the source go through `Framing::output_at` — the same map the fused pass applies to every pixel — so a rotated photograph rotates the offset with no trigonometry, and the radius is found by mapping a point one radius above the centre and measuring, rather than by multiplying by a ratio this function has no business knowing about. The tests turn and crop the frame and expect the mark to stay gone, which is the property that arrangement buys. compose_full now takes the spot set, because a photograph with a repair and no sharpening still has a detail stage: a fused pass that encoded its own output there would quantise twice and bind to a texture of the wrong format. compose_detail_for takes the source size for the same kind of reason — a RenderScale describes the region on screen, and a spot is stored against the photograph. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4b2ee0ac50 |
Count the silver instead of adding noise
An emulsion is a suspension of crystals. Light sensitises some; development
turns a sensitised one opaque, all or nothing. So a patch of film's density
is a *count* of developed grains, and a count of independent yes/no events
has a variance whether or not anyone wanted texture:
mean = D
variance = D * (Dmax - u * D) / N
That expression is the whole feature. It peaks in the middle of the density
range and vanishes at both ends -- clear film has nothing developed to vary,
black film has nothing left to develop -- so grain lives in the midtones as a
consequence rather than as a "midtone bias" slider.
I was wrong earlier that this needs the detail stage. Nothing in it reads a
neighbouring pixel; the only reason to move it was that grain must be fixed in
film space rather than screen space, and that solves itself: N is grains *per
pixel*, so it scales with the film a pixel covers. Zoom out, each pixel
averages more grains, less variance -- correct, with nothing super-sampled and
nothing filtered. It stays in the fused pass.
Grain goes on the density and *before* the dye, which is the physical order
and not cosmetic. Perturbing the finished colour -- what an effect does --
tints highlights wrong, because that noise never passes through the dye.
Crystal habit lives in `rms_granularity`, the number every datasheet
publishes, now a profile field. It measures exactly what differs between a
cubic emulsion and a tabular one: at equal speed, tabular crystals present
more area per unit silver, so the film reads finer. Delta 100 is quoted near 9
where HP5 is near 12, and that gap *is* the habit. Adding a stock whose grain
is its whole reputation is therefore editing one line, not writing a model.
Three things this cost, all of them worth writing down:
- The default granularity is a colour negative's, blue coarsest. Applied to
Tri-X it put *colour* speckle on a black and white photograph. Monochrome
stocks collapse it at parse, where every other per-layer table is already
replicated from the one measured channel.
- Helpers cannot read uniforms. The composer prefixes a uniform with its
operation's id and rewrites references inside a fragment body only;
helpers are shared and deduplicated, so a bare `gn0` names nothing.
`film_lut` already took its size as an argument for this reason, and now
says so.
- The end-to-end test compares the shader against the CPU model, and grain
is stochastic, so that comparison now runs with grain off. Which means a
grain that never left the CPU would look exactly like a passing suite --
hence a second test that grain off is bit-identical, one grain per pixel
moves it, and ten thousand move it less.
Not here, deliberately: no grain slider. The parameters are physical and
`rms_granularity` is the honest place to scale one from, but its range wants
choosing rather than guessing. Nor a film format -- 35 mm is assumed, and
medium format at the same stock is far less grainy per unit of picture.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
56978fdf35 |
Clear the clippy warnings that were failing CI before this branch
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Failing after 9m6s
Build and test / Layer separation (push) Successful in 26s
Traceability / Requirement traces (push) Failing after 23s
Build and test / Android (aarch64) (push) Failing after 22m38s
Nothing here is film simulation. These are lints that fail master today,
under the -D warnings CI runs with, mostly from a toolchain that learned
new ones rather than from anybody's code -- `is_multiple_of` and the
derivable `Default` did not exist as lints when this was written.
They are fixed rather than allowed, and by hand rather than by trusting
`cargo clippy --fix` wholesale: its automatic pass split a derive in two
and left a stray blank line, which is the sort of thing that is correct
and still wrong to commit.
The four that needed a decision rather than a rewrite:
- The distance transform's inner loop writes through its iterator now.
`q` stays, because it is the position the parabola is evaluated at as
well as the index it is written to -- the lint is about the write.
- `to_source` and `to_proto` take `self` by value. Their receiver is
`Copy`, so this is the same machine code and the honest signature.
- The export path's return type is five levels deep and now has a name,
plus a line saying why the `Option` wraps the `Result`: `None` is
cancellation, which is not a failure and has no error to report.
- A test fills a range instead of looping over one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
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> |
||
|
|
4a2fcb6d22 |
Render a film stock on the GPU, and let it take over the rendering
The stock model landed in dr-film with no way to see it. This is the pipeline node, the two texture bindings it reads, and the end-to-end test that proves the shader agrees with the model. The design point is that a film simulation is not an adjustment. Every other node changes a picture; this one makes it. A stock's characteristic curve does the camera profile's base curve's job -- from measurements rather than from a curve somebody drew -- so running both renders the scene twice: the camera's rendering, and then a film's rendering of that. It looks like neither, and it reads as a colour-management bug with no colour-management bug to find. So `Operation::renders` is new. A node declaring it takes camera RGB and hands back linear sRGB, and the composer emits neither the base curve nor the conversion out of camera space. Both halves move together, and the composer keeps them as one string precisely so that getting half of it right is impossible. The tables are not parameters, for the reason vignetting's coefficients are not: they are measurements. dr-pipeline declares the layout as a plain struct and keeps its no-dependency property; the two crates share no types on purpose. `EditGraph::set_film_tables` offers them to every node rather than to the one that wants them, because knowing which concrete type is which is what the graph is organised not to know. Bindings 4 and 5 follow the masks precedent: declared unconditionally so one bind group layout serves every generated shader, bound to 1x1 placeholders when no stock is loaded. Both are interpolated by hand with textureLoad -- this pipeline binds no sampler, and adding one for two lookups would cost a binding in every shader. Uploads are keyed on content so an unchanged stock does not push half a megabyte across the bus per frame. The end-to-end test earned its place immediately: it found the density lookup being filled z-fastest while a 3D texture upload wants x-fastest, so the red and blue axes were transposed. Green matched exactly, which is what that bug looks like -- a plausible photograph of the wrong colour, and one that every unit test on either side of the seam passes. dr-film now pins the layout in a test that needs no device, and states it where the field is declared. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
c963dafd09 |
Merge branch 'worktree-agent-afd449f5e7a01e341' into integration
# Conflicts: # core/dr-gpu/src/adjust.rs # core/dr-pipeline/ops/README.md # core/dr-pipeline/src/lib.rs # docs/traceability.md |
||
|
|
7f60a2547c |
Merge branch 'worktree-agent-a75dc051d9bf691de' into integration
# Conflicts: # docs/traceability.md |
||
|
|
7407a82aa7 |
Let an operation read the pixel next to it, and settle where sharpening belongs
The fused pass hands a fragment a colour and no coordinate. That is what buys one dispatch for a whole edit, and it is also a wall: sharpening, noise reduction, clarity, texture, dehaze and spot removal are each defined by what the neighbours are doing, and FR-DEV-3 and FR-DEV-8 ask for all six. None of them could be written at any price. So there is now a detail stage. An operation implements `Operation` for its parameters exactly as before — the panel, the sidecar, the history and the presets all work unchanged — and additionally returns `Affects::Detail` and a `DetailStage` yielding one pass per dispatch. `Affects` grows the third variant `docs/requirements.md:250` designed and nothing had cut. Where the stage sits is a colour-science decision, not an arrangement of convenience. It runs after every point operation and every mask layer, so an amount chosen against a tone curve survives the curve moving; in linear sRGB after the camera matrix, because camera RGB has no luminance to sharpen against; and before the output transform and the clip, because FR-DEV-2 allows one quantisation and a highlight clipped before a convolution grows a dark ring. The fused pass therefore ends one of two ways, and when a detail stage follows it hands on unclipped f16 and the last detail pass encodes. At render resolution rather than on the source, which is the whole of FR-DSP-1: a pass before the framing prologue would cost 24 MP to draw a 2 MP preview. `RenderScale` is what makes that survivable — a radius is stored as a fraction of the frame's shorter edge, exactly as a mask feather already is, or as a count of source pixels, and converted per render. It also reports when a radius is smaller than a proxy pixel rather than drawing a plausible lie; zooming to 1:1 makes the preview exact with no second path. `Invalidation` gives FR-DEV-3d something to mean. Moving a detail parameter leaves the colour key alone, so `AdjustPass` keeps the linear intermediate and skips the fused dispatch: dragging a sharpening slider costs a convolution. Moving exposure does re-run the detail passes, because they read what the colour pass wrote, and there is no arrangement of keys that avoids it while keeping sharpening after tone. Validated by a separable box blur that is not a develop operation, behind the `detail-probe` feature and absent from a shipping build. An abstraction with no consumer is a guess; a box blur's answer is known in closed form, so the tests assert every byte of the ramp rather than that the edge got softer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
743fefe7f1 |
Render each body through the profile its own files describe
Colour came from whichever matrix rawler happened to key `D65`, the second one was discarded, and the rendering was left linear. That is the dcraw default, and FR-DEV-3e names it as the reason people abandon a converter in the first hour: correct in the abstract, flat and poor on skin in practice. The decoder now builds a camera profile. - `ColorMatrix1/2` and `CalibrationIlluminant1/2`. rawler surfaces these as an illuminant-keyed map — for DNGs from the tags, and for native formats from its own camera database — so a Canon CR2 arrives with a tungsten matrix and a daylight matrix exactly as an Adobe DNG of the same frame would. Dual-illuminant support is therefore not a DNG feature here. - `ForwardMatrix1/2`, read straight from the root IFD, because rawler parses them and never surfaces them. Where a file carries both, they replace the inverted colour matrix: the same relationship measured in the direction rendering actually wants, rather than an inversion that amplifies the measurement error exactly where skin lives. - `AsShotNeutral`, used to estimate what the scene was lit by and to interpolate between the two calibrations in mireds. The estimate is circular — the temperature needs a matrix and the matrix needs the temperature — so it is a fixed point, three rounds, as Adobe's SDK does it. Bodies calibrated at neither D65 nor A stopped rendering uncalibrated as a side effect: a Phase One IQ3 carries D55 and D75 and used to get no matrix at all. And a base curve, applied per channel in camera RGB between the last adjustment and the conversion out of camera space — a toe, a steep midtone and a shoulder, which is the difference between a photograph and a scan of one. It is not an edit: no slider, nothing in the sidecar, because it belongs to the body rather than to anything anyone decided, and a sidecar is shared between bodies. It is not a develop node either, and `ops/README.md` now records why. It evaluates on the tone curve's own spline rather than a second copy, so a profile author placing a control point and a photographer dragging one mean the same thing by it. The curves are data. `core/dr-decode/profiles/base_curves.yaml` ships inside the binary as a floor and is superseded by any copy on disk carrying a higher `version:`, so a body can be added and distributed without a release — and, under the GPL, contributed. The comparison runs both ways: a stale pack cannot hold an upgraded binary back at last year's rendering. Canon EOS 6D and R6, Nikon Z 6 and D750, Sony A7 III and Fujifilm X-T3 ship with their own curves. Every other body gets a conservative default, which is much closer to right than the identity is for any of them. A JPEG gets none — it has already been rendered once, by the camera. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
96a7b405c2 |
Say which photograph the sliders are pointed at
Selecting a mask layer silently re-points about thirty controls at that layer's chain. Same panel, same order, same sliders, different meaning — and the only thing that said so was a sentence in the panel above, which a photographer reaching for the exposure slider has no reason to read. An exposure change lands on the whole frame when it was meant for a face, or the reverse; both are silent, and both are discovered later. `ui-navigation.md` §1.1 calls it the dangerous one and it is: the others in that document cost time, this one costs work. The remedy is the classic one for a modal fault — make the mode visible — and the application already had the pattern. Crop arms a canvas interaction, draws an overlay, gives the column one job and is left by the control that entered it. Local masking is the same animal built as a peer panel, and that is what created the ambiguity. So `crop-mode` stops being a bare boolean and becomes one value of a three-state mode, which is the point: two modes could both be on before, and now that is not a state the interface can be in rather than one it is tested against. **One strip, not two.** The mode control was going to sit beside the group strip that filters the adjustments, which is two controls above one column answering the same question — what am I working on. They are one control now, `Crop · Local │ All · Light · Colour`, which is the shape Lightroom Mobile's bottom strip has for the same reason. The two halves are different kinds of state and are drawn differently: a mode is a chip that fills with the accent when it is on, a group is a word with a rule under it. That difference is what lets both be read at once, which they routinely are — picking Light while a mask is selected filters *that layer's* chain and does not leave the mode. Dropping the scope on a group press would be the same fault coming back from the other end, and would make Light mean two things depending on where it was pressed. The strip stays pinned above the develop column rather than moving to the top of the canvas as the document proposed. The half that filters the column belongs to the column, and the photograph is the subject. The canvas keeps one button, which now names the mode it leaves rather than saying "Done" — that was unambiguous with one mode and would not be with two — because the column can be closed on a narrow window and no mode may be inescapable. Entering a mode is a side effect, so Rust owns it rather than the strip writing the property: crop drops the zoom, local turns the overlay on, and leaving clears the selection. That last one is the fix. The "Overlay" and "Select" toggles are gone because they armed things that are simply what the mode *is* — a mode that has to be switched on separately is one you can enter and have do nothing. Escape and the Android back gesture join `back_step` as one `LeaveMode` rather than a second exit concept, and the mode is left before the zoom is: it was entered later, and it is the bigger step back. The heading is where the scope goes. Not a caption beside the panel, the heading *of* the panel that changed — `ADJUST` becomes the layer's name, the same string the selected row in the stack shows. That is the difference between describing a hazard and removing it. **Handles on the photograph.** A linear or radial mask could be created and then not moved, so a radial sat at the centre of the frame at its default size for ever. Three faults stood in the way of drawing one. The first is that a gradient did not render at all until the model had run. The rasteriser was built on the way out of `segment` and the array's size was read *off* the segmentation, so a gradient added to an unsegmented photograph produced nothing — silently, in the same way exports and thumbnails once did: the shader still emits the layer's block and the empty placeholder multiplies it by zero. The proxy size is a property of the photograph. Both are derived from it now, and deliberately at the same size rather than by coincidence, because a subject's distance field is sampled against that array. The second is hit-testing. A handle is drawn in output coordinates and stored in source ones, and between them lie the crop, the zoom, the pan, the straightening and the turns. `Framing::source_at` is `wgsl_prologue` evaluated on the CPU, kept in that file beside it so that keeping the two in step is one file's problem — a handle mapped through anything less drifts off the mask the moment the view moves, which is exactly what masks are rasterised in source space to avoid. The third is that a drag is a displacement, not a destination. Each handle answers to the movement of the pointer since the press, applied to where the mask was when the press landed. Snapping the handle to the pointer instead jerks it by up to half a touch target on the first press, and the target is finger-sized because a tablet has no hover to reveal a control and no modifier to qualify it. A ramp gets three handles — centre, width, angle. An ellipse gets three too: centre and one per semi-axis, the major one carrying the direction as well as the length, because where an axis is put says both. It had a fourth, and it is gone: standing off the shape by a fixed distance, the rotation arm began outside the photograph at the size a new radial is created at, so the first thing anyone saw was a control they could not reach without first shrinking the mask. Two faults here were found by looking at the screen rather than at the source, both of the kind that cannot be found any other way. A `1px` rule with a size and no position is *centred* by Slint, so the seam between the photograph and the column was a hairline down the middle of the panel, through the histogram and every slider under it — twice, once in `app.slint` and once in `AdjustPanel`. And handing Slint a fresh model for the handles on every pointer event made the repeater rebuild its items, taking the `TouchArea` holding the gesture with them: the handle jumped once and then went dead under a finger that was still down. `develop.rs` carries the same warning about the parameter rows, where it broke slider drags; the model is rewritten in place now. The tests worth having are the ones about ambiguity and about the map. That the same row reads the frame's value, then the layer's, then the frame's again is §1.1 in one assertion. That dragging a handle onto another gradient's matching handle *produces* that gradient closes the loop between the two directions of the framing map, through a view that is cropped, zoomed, panned, straightened and quarter-turned at once — a one-legged map is invisible when the framing is neutral, because then both legs are the identity. Not done here: the histogram still reports the whole frame while the sliders edit a layer. That disagreement is real and is N3's, which this unblocks. The strip has room for a Brush entry beside Crop and Local when the painted masks land in the core, and it needs nothing here but the canvas interaction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1d7106c94d |
Apply the masks to the thumbnail and the export, not only the screen
Reported as a thumbnail bug; the export had it too, which is the serious half. You would have exported a photograph missing every local adjustment. Both called the unmasked `render`, and the failure is silent by construction: the generated shader always declares the mask binding and always emits a block per active layer, so binding the empty placeholder multiplies each of them by zero. No error, no warning, no missing texture — the adjustments are simply not there. From inside either path there is nothing to see. Every path that produces pixels now goes through one helper that binds the array, and that is the point of it being one helper rather than three correct call sites. The array is rasterised in source space at proxy size and sampled through the framing map, so one array serves every output size: a 256px thumbnail and a 24 MP export bind the same texture. Three tests, and the first is the fault stated directly — render the same edit with and without the array and assert they *differ*. If binding it ever stops mattering, the masks have stopped reaching the shader. The third checks the masked share of the frame is the same at 32px and 128px, because "both non-empty" would pass while a mask that scaled wrongly still ruined every thumbnail. |
||
|
|
7421837c8a |
Let an operation say what it is about, so the panel can group without naming
Tool tabs need a taxonomy, and the taxonomy was the problem: a table in `ui/` mapping operation to tab breaks FR-DEV-3a, and a `group:` field risks what `ui-refinement.md` condemned `starts-group` for — the core deciding where the panel draws things. `Attribute` threads the needle. It says what an operation *is* — tone, colour, detail, optics, geometry, effect — which is the same category as `ParamKind` and squarely on the core's side of ARCH §4.3a's line. What is drawn, where it sits and whether it is visible stay the frontend's. There is no attribute for "the third tab", the enum's order is declaration order rather than screen order, and a frontend may render these as tabs, as headings, or ignore them. The payoff is that a tab strip can be *derived*: the groups are the attributes present in the capability list, so the interface names no operation and needs no table to keep in step. An operation joins the right group by declaring what it is, which is the one thing its author is well placed to say. Plural, because the tone curve is genuinely both — an RGB curve is tonal and the per-channel curves are chromatic, and filing it under one would hide it from half the people looking for it. Required and non-empty, enforced in `build.rs`, and the failure was checked by removing the line rather than assumed. An operation with no attribute is invisible to a panel that groups by them; a build that stops costs ten seconds, a control nobody can find costs more. The vocabulary is closed for the same reason: a typo would otherwise invent a category holding exactly one operation, which looks like a deliberate one until somebody counts. Six tests over the real chain, including the hand-written operations that `build.rs` never sees and so cannot check. |
||
|
|
c6a846a1f9 |
Brighten her face without touching the sky behind her
A mask layer is an ordinary develop chain plus a rule about where it applies. Nothing in the chain knows it is being masked, so every operation that works globally now works locally and a newly declared op in `ops/` arrives with local support already done. The composer emits each layer after the global chain and before the conversion out of camera space, which is what a photographer means by "and *then* lift the shadows on her face". Op fragments write to a `c` they expect to own, so a layer block shadows it and copies the result back out through a carrier — assigning the outer one from inside is impossible precisely because it is shadowed. The fused dispatch survives: three global adjustments and two masked ones remain one shader, one read, one write. Masks rasterise on the GPU and never exist in CPU memory (ARCH §5.4). That is the whole reason darktable's brush masks lag, and it is architectural rather than tuning, so it is not a thing to inherit and fix later. The rasteriser is a render pass rather than the compute shader it obviously wants to be, and the format is why: R8Unorm is not a core storage format, so a compute path has to widen masks to four bytes per pixel — 768 MB across eight layers of a 24 MP export, against 192 MB at one byte. A colour attachment takes R8Unorm happily. The array slice comes from the attached view, so no slot uniform exists to disagree with where the pass writes. Region masks index a compacted label field rather than the watershed's raw basin roots, because a root is a sparse index into pixel space and indexing a per-region array by one would need a table the size of the image. Changing a selection then costs a few kilobytes, not a re-upload. Stored as region ids, not as pixels: diffable, mergeable per-field under FR-NC-9, and cheap in a sidecar. The ids only mean anything alongside the segmentation that produced them, so each layer carries that signature and is treated as stale rather than applied when it does not match — a confidently wrong mask being much worse than an absent one. Seven device tests render actual frames and read them back. The unit tests either side check halves that would both pass if the two agreed with each other and were both wrong; a mask sampled with x and y swapped satisfies them and fails these. |
||
|
|
ca833b6d2b |
Give every colour band its own compiled shader
🐳 Android image / Build and push (push) Successful in 5s
Build and test / android-image (push) Successful in 5s
Build and test / Desktop (Linux) (push) Failing after 57m33s
Build and test / Layer separation (push) Successful in 35s
Traceability / Requirement traces (push) Failing after 35s
Build and test / Android (aarch64) (push) Failing after 9m42s
The colour mixer emits a code block and a uniform only for the bands that are set, so which bands are adjusted is part of the shader's structure. The pipeline cache key was not: it hashed the set of *active operations*, which is "colour_mixer" whichever band that is. So a red adjustment and a blue one hashed alike. The second render was handed the first's compiled pipeline while its uniform was uploaded into a slot that shader had assigned to another band — whichever band compiled first kept acting on every subsequent move, and every other slider did nothing at all. Red is the first band declared, and the one reported as the only one working. The hash is now taken over the generated WGSL, because the source is what gets compiled and therefore is the structure. A summary of what went into it has to be kept in step with every operation's code generation by hand, and this one had fallen out of step. Values still do not enter it: no operation writes a parameter value into its source, so a slider drag regenerates identical text and reuses the pipeline, and one that did inline a value would have to recompile to be correct anyway. `each_colour_band_gets_its_own_pipeline` in dr-gpu renders a blue pixel through one pass with red set first and then blue, and fails on the old hash with the reported symptom — the blue slider returning the pixel unchanged to the byte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dea826811e |
Render a blown highlight white instead of magenta
Every clipped sky came out bright pink. Measured, not guessed: developing _MG_8596.CR2 and looking at the export, the subject renders correctly and only the saturated region is wrong. A fully clipped pixel reaches the shader as (1, 1, 1) — three photosites that stopped counting, carrying no colour at all. The as-shot multipliers are not neutral, so balancing sends it to (1.93, 1.00, 1.68) on this body, and the camera matrix turns that into R 2.88, G 0.51, B 2.03. Red and blue clip at one; green, whose matrix row is far less positive-heavy, does not. Red and blue high with green low is magenta. Nothing upstream was at fault, which is why the two previous attempts missed it: the white balance is correct, the matrix is correct, and the sensor normalisation is correct. The input simply was not a colour, and correct arithmetic on a non-colour produces a confident wrong answer. So saturation is detected before the balance is applied — the last 1.5% of range, smoothstepped rather than switched, because a hard threshold draws a visible rim around every highlight and a backlit edge on skin is where that shows. Above it the pixel is pulled to the neutral of its own brightness, so it keeps its luminance and loses only the cast. Verified end to end on the file itself: the sky is white, the skin, the black dresses and the stone are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
914d14ec0d |
Tell the shader which colour space it is encoding for
The generated shader ended with `encode_srgb` and a clamp, so every photograph leaving DarkRoom had been through sRGB's gamut whatever the settings page said. Export refused the other three spaces rather than tag clipped pixels with a gamut they did not contain — correct, and not something an encoder could fix. So the output space becomes a parameter of composition. `compose_for` emits a constant primaries matrix after the camera matrix and before the clip, and generates the transfer function to match: the sRGB curve for sRGB and Display P3, a pure 2.199 gamma for Adobe RGB, 1.8 with a linear toe for ProPhoto. The ordering the camera matrix depends on is untouched — operations still run in camera space — and sRGB emits no conversion at all, so the shader compiled on nearly every frame is byte-for-byte what it was. The numbers live in dr-types, derived from four chromaticity pairs per space rather than tabulated. That is not tidiness: the shader encodes the pixels and the ICC profile describes them, and a file whose profile disagrees with its own contents is worse than one with no profile. One derivation makes them agree by construction, and can be checked against the values the specifications publish. Profiles are generated here too — minimal v2 matrix/TRC, about 2 KB, pure Rust, no lcms to satisfy under the NDK. A JPEG carries it in APP2, a PNG in iCCP, a TIFF in tag 34675. sRGB gets one as well, because untagged does not mean sRGB, it means guess. The refusal survives in a sharper form. A `Frame` now carries the space it was rendered in, and export refuses to label it anything else. The develop session still composes for sRGB, so a P3 export from the interface fails with an accurate error instead of producing a file that lies — the frontend half is a separate change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5786977a51 |
Develop a JPEG through the same pipeline as a RAW
DemosaicedImage gains a second producer, from_rgba8, alongside the CFA path. Nothing about the type is CFA-specific — it is "an image on the GPU, ready to adjust" — which is what lets develop mode work on a JPEG without the edit graph or any operation knowing the source was not a RAW file. The one real difference is the transfer function: sensor data is linear, a JPEG is gamma-encoded. Every operation assumes linear scene-referred colour (exposure is a multiply, and doubling a gamma-encoded value is not a stop), so the shader prologue linearises once, at the only point where the two source kinds still differ. The flag rides in as_shot_wb.w, which was padding. For a JPEG the white balance uniform is neutral and the colour matrix is identity, so both stay unconditional multiplies rather than becoming branches. max_dimension is exposed because it is a hardware limit the caller must plan around, not a failure to report afterwards: a 13728x8928 film scan exceeds the common 8192 texture limit, and fitting it first is the only way to develop it at all. Assisted-by: LLM |
||
|
|
f630a3ff81 |
Wire the launch screen into the app
The app now opens on the login screen when there is nothing else to show
— no local paths and no configured library — and goes straight to the
images otherwise. Making someone click past a login they already
completed is pure friction.
launch.slint imported by app.slint, replacing the window rather
than overlaying it: there is no library to look at
until an account is configured
launch_ui.rs the Slint wiring, kept out of lib.rs so the launch
flow can change without touching the develop window
Login runs on a worker thread and posts results back through a channel,
since Slint's event loop is single-threaded and a 20-minute browser wait
cannot block it. The system browser is opened via xdg-open, never an
embedded webview (FR-NC-1).
Sign-out deletes the local credential even if server-side revocation
fails: a network error must not leave a usable secret on the machine.
Format tick-boxes persist on each toggle, so a selection survives a
crash before the library is opened.
Two things deliberately incomplete rather than faked:
- "Choose folder" lists the account's folders and reports them, but
there is no picker widget yet, so selection still happens via the
connect example.
- "Open library" logs the request. Opening a remote library needs the
scan-and-cache path, which belongs with the catalog work in flight.
Earlier I broke the other in-flight dr-ui work by calling
slint_build::compile twice, which replaces the generated module. The
correct wiring is an import inside app.slint, which is what this does.
30 dr-ui tests passing; both launch paths verified by running the app.
|
||
|
|
09e3043f4c |
Add secure credential storage, sessions, and a launch screen
Login now persists properly rather than through the JSON file the test
harness was using.
dr-plat SecretStore trait plus a Secret Service backend.
Verified against the live GNOME Keyring: store,
retrieve, delete, confirm-gone all round-trip.
Session/SessionStore splits credentials from settings — the app
password goes to the keyring (FR-NC-2), while
server, login, chosen root and format selection are
ordinary config. A test asserts the credential never
appears in the config file.
LaunchModel the launch-screen state machine, testable without a
display server: sign in, approve in browser, choose
folder, tick formats, sign out.
launch.slint the screen itself, in its own file.
Absence of a secrets daemon is an explicit degraded mode, not a silent
fallback to plaintext — the screen says sign-in will not persist rather
than letting the user find out next launch. Android's Keystore backend
fails loudly for the same reason: a no-op store would look like it
worked and then lose the credential.
Two bugs caught by tests rather than by running it:
- fail() after busy() signed the user out, because busy() had already
discarded the session. A failed *scan* would have logged you out.
Busy now carries the session.
- normalise_server upgrades http:// to https:// rather than accepting
it. NFR-SEC-3 requires TLS, and silently sending a credential in the
clear is not a decision to make on the user's behalf.
launch.slint is not yet wired into app.slint. Calling slint_build::compile
twice replaces the generated module rather than adding to it, which broke
the other in-flight work on dr-ui; I reverted that immediately. Wiring it
needs an import inside app.slint, which is that work's file to change.
419 tests passing across ten crates.
|
||
|
|
c8bb08e661 |
Add folder scan with format selection; validate A3 on a real library
Library setup as the user described it: pick a folder, choose which RAW
types to look for, scan recursively.
dr-types::FormatFilter the tick-box selection, seeing through VFS
placeholder suffixes so a dehydrated CR2 still
matches as a CR2
dr-sync::scan recursive walk, Depth:1 per directory, pruning
unchanged subtrees where the backend propagates
directory ETags
Verified against nextcloud.tourolle.paris (34.0.2) on a real library:
browse root 32 entries, 98ms
scan PhotosRaw 17,185 RAW files in 334 directories, 34.1s
(7,836 CR2 + 9,349 DNG)
range read 262KB of a 21.5MB DNG in 119ms — 1.22% of the file,
and enough to read "Canon EOS 6D | ISO 100"
That last line is assumption A3 validated on real data. Cataloguing this
library by whole-file fetch would move roughly 370GB; the range path
moves a few MB.
Pruning is capability-gated rather than assumed: with per-entry ETags a
probe costs a request and proves nothing about children, so it is skipped
entirely. A test asserts zero probes in that case.
Still unresolved: /core/preview returns 400 for every parameter
combination tried, including on a JPEG the server reports as having a
preview. Not a request-shape bug — it fails identically bare. Recorded
rather than worked around; ARCH §6.7 already treats server previews as
opportunistic, so nothing depends on it.
|
||
|
|
78e3e6b846 |
Add the develop pipeline: demosaic and seven raw adjustments
Decode through display, on the GPU: black/white normalisation, Bayer demosaic, camera colour transform, and the first seven adjustment operations — white balance, exposure, highlights/shadows, blacks/whites, brilliance, vibrance, saturation. Composable shaders. Each operation contributes a WGSL fragment rather than owning a pass, and dr-pipeline fuses the *active* ones into a single compute shader. One texture read and one write per frame regardless of how many adjustments are in play, while the operations stay independent in Rust — adding one is a new file, with no central shader to edit. An operation at neutral settings contributes no code, no uniform and no branch. Uniforms are prefixed per operation so two may both declare `amount`; helpers dedupe by name from a single source of truth. Pipelines cache on a structure hash covering the op-set and its order but not the values, so dragging a slider uploads uniforms and reuses the compiled pipeline. Measured on a 24 MP CR2: 0.60 ms re-render, one pipeline compiled across ten slider positions. The UI is generated, not written. EditGraph::capabilities() reports parameters with their kinds, ranges, defaults and current values; the panel builds one control per entry chosen by ParamKind. No file in ui/ names an operation, and dr-pipeline has no wgpu dependency, so codegen is testable without a device (ARCH §6.5a). Three defects found against real files, each silent: - rawler 0.7.2's `xyz_to_cam` is all zeros — deprecated and no longer populated. The live matrices are in `color_matrix`, keyed by illuminant. Reading the old field yields no colour transform at all. - `cam_to_xyz_normalized()` returns all NaN on any Bayer sensor: it divides each of four rows by its own sum, and the unused fourth (emerald) row sums to zero. Inverting the 3x3 ourselves avoids it. `wb_coeffs[3]` is NaN for the same reason and is normalised at decode. - As-shot white balance reached the uniform block but no shader read it, so the first render of a real CR2 came out violently green. Green photosites collect roughly twice the signal of red and blue. Now applied unconditionally before any operation, with tests on ordering. Demosaic is Malvar-He-Cutler rather than bilinear: gradient-corrected interpolation at one 5x5 neighbourhood per pixel, where bilinear leaves visible zippering on any high-contrast edge at 1:1. Two of the four packed CFA constants were wrong on the first attempt, so all four layouts are asserted to reconstruct the same colour. Crop origins at odd coordinates re-phase the pattern; without that, red and blue swap. X-Trans reports GpuError::UnsupportedCfa rather than approximating with the Bayer path, which would look like a corrupt file. 206 tests, including GPU tests proving every operation and the full seven-operation chain generate compilable WGSL. Known gaps: the display path still reads back to the CPU each frame, which ARCH §6.1 forbids and AC-8 asserts against — it is gated behind the `readback` feature and waits on spike S1 wiring Slint's texture import. Curve shapes are a first draft and want tuning against real photographs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |