3689b06c35cab4ba85db63582e28ede4d30f485b
54
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
185e134ead |
Describe the measured tone and vibrance in DarkRoom's own terms
The calibration commits named DarkRoom's default curve after another product and described vibrance as doing what another editor's does at the same value. The curve is the DNG SDK's reference, so it is called that; vibrance is scaled to deliver the strength its value names, as measured against the photographer's earlier exports. Two test names follow. The measurements and where they came from are unchanged. |
||
|
|
a4ff7ec2b9 |
Render raws through the DNG reference curve by default, at contrast 1.5
Decides D21 by measurement. The photo gallery holds Lightroom 6 exports of raws in the library, each carrying its Camera Raw settings; clustered by those settings, 663 had no look applied. On 60 of them with their raws, a third held out, the held-out MSE against Lightroom's JPEG was about 1200 for 0.20.0's sigmoid (0.7 EV darker and flatter), 224 for the DNG reference curve after baseline exposure, and about 140 once its input is bent by 1.5/1.4 about grey. So the curve choice defaults to the DNG reference, keeping its index (sidecars record it), and the default contrast is 1.5. Contrast under the DNG reference curve is now a power relative to REFERENCE_CONTRAST (1.4), where the table is untouched; the sigmoid at that contrast still matches the retired base curve. JPEGs are unaffected: the view transform skips a rendered source. |
||
|
|
1e8594724e |
Keep the sigmoid as the default curve; Camera Raw's tone is a choice
The Camera Raw default rested on comparing against Lightroom previews of photographs that carry the user's Lightroom edits — HSL saturation Blue +58, Aqua +50 and more, Highlights -40, Blacks -20, in every DNG's XMP — so it measured the house look, not Camera Raw's base rendering. Under the ACR3 curve _MG_9080 renders brighter than its Lightroom preview (mean 0.39 against 0.31). So the curve choice's first variant, the default, is the sigmoid again and every raw renders as in 0.20.0 apart from baseline exposure. D21 and camera-profiles.md §12 now say the default is open, to be decided by measuring against Lightroom exports of unedited photographs. Tests that are about Camera Raw's tone choose it explicitly. |
||
|
|
db7593f3dc |
Render raws through Camera Raw's tone by default, after baseline exposure
The rendering half of camera-profiles.md §11-§12 (D21). The view transform gains a curve choice — Camera Raw (the default) or D19's sigmoid. Camera Raw converts to linear ProPhoto, clips to [0, 1], runs the curve on the largest and smallest channel and places the middle one at its old fraction between them (RefBaselineRGBTone), and converts back: hue kept, saturation raised where the curve is steep, which is where Adobe Standard's look desaturated. White sets the input scale (1 at its default, so sensor white is display white) and contrast bends the input about grey (1 at its default). The curve rides in the profile buffer after the tables: the profile's own, else the ACR3 default, which the placeholder every profile-less source binds also carries — so a CR2 with no .dcp still gets Camera Raw's tone. Baseline exposure is a gain folded into the rendering matrix at upload; RawImage::color_matrix stays the file's for the merge's linear DNG. camera_raw::apply_reference is the CPU statement; GPU tests hold the shader to it on 256 colours and on greys against the ACR3 table. The sigmoid's own tests now choose it explicitly. |
||
|
|
38d414912c |
Read baseline exposure and profile tone curves; carry the ACR3 curve
The decoding half of camera-profiles.md §11-§12. RawImage gains baseline_exposure: the file's BaselineExposure plus the chosen profile's BaselineExposureOffset, as the DNG SDK sums them (+0.25 for the library's 6D DNGs). A profile copied out of a DNG carries that DNG's baseline as its offset, so the body's CR2s, which have none, land at the same total. ProfileTables gains the profile's ProfileToneCurve, resampled at decode onto 1025 points with a natural cubic spline; an identity curve counts as none. dr-types now holds Camera Raw's ACR3 default curve, RawTherapee's adobe_camera_raw_default_curve copied value for value, for every raw whose profile has no curve. Nothing renders through either yet. |
||
|
|
c02b401a9a |
Apply a camera profile's HueSatMap and LookTable after exposure
The second half of D20: a camera_profile scene operation at order 25 that converts working colour into linear ProPhoto, runs the DNG SDK's HSV lookup through the HueSatMap and then the LookTable, and converts back. Hue and saturation do not change under the uniform gains before it, so a 2.5-D HueSatMap gives the same answer as straight after the matrix, and the look sees the photographer's exposure as it does in the SDK. Two departures for scene-referred values: value is not clamped on the way out, and a colour outside ProPhoto passes through. The operation holds only the switch (on by default) and a look strength of 0-200 %. It is composed while the switch is on — a new Operation::composes() separates "does something" from "moved from the defaults", so an untouched raw renders through its profile and still writes nothing. The tables come from the source: dr-gpu uploads the ones DemosaicedImage carries into a storage buffer at @binding(8), whose two-entry header tells the fragment whether there is anything to apply, and binds a header of zeros for every other source. apply_reference is the lookup on the CPU. The GPU test holds the shader to it over 256 colours, through synthetic tables strong enough that a wrong index shows, and through the library's real Adobe Standard tables when the 6D DNG is present. |
||
|
|
f6a3f3f4e2 |
Read DCP camera profiles: embedded in a DNG, or a .dcp beside the app
The first half of D20. dr-decode now finds a camera profile's HueSatMap and LookTable in the order camera-profiles.md §4 gives: the profile a DNG embeds, then a .dcp in the profiles directory whose UniqueCameraModel names the body, then none. A .dcp brings its own matrices, since its tables were measured against its forward matrix. The HueSatMap is blended for the frame's colour temperature with the same mired weight the matrices use, once per decode, and the result rides on RawImage as profile_tables beside color_matrix, so every path that renders a decoded file gets the same profile without a setter to forget. Nothing applies the tables yet. A profile whose embed policy allows copying can be written back out as a .dcp (rawler's TIFF writer with the RC magic patched in), which is how the library's 6D CR2s will get the Adobe Standard their DNGs carry. The table type lives in dr-types because decode, pipeline and GPU all need its layout. Tests read the library's 6D DNG when it is present. |
||
|
|
affdaecaee |
Stop describing a base curve the pipeline no longer has
D19 retired the per-body base curve, moved the matrix ahead of the edits and the film into the view transform's place, but a dozen doc comments still listed the curve among what a pixel passes through, or said the film skipped it. The detail stage's module doc still drew the matrix after the edits and the last detail pass encoding, which the view pass took over. The film crate's README gave the base curves as its reason for being data, and the ops README's list of hand-written nodes had neither the view transform nor three of the five kernels. FR-MRG-2 gave the base curve as why the merge cuts below the profile; the view transform is why now. The decision table still said colour defaults were a per-body curve, and FR-DEV-3j said only the default view transform skips a JPEG, where the node skips one whatever its sliders say. frame-budget.md records the view pass as unmeasured. |
||
|
|
0007fa459f |
Develop a linear DNG from windows and reduced copies of it
DemosaicedImage::linear_rgb16_window uploads part of a linear DNG, or a box-reduced copy of it, and says where it sits in the frame; size() now reports the frame and texture_size() the texels, and the fused pass writes the window into the shader's uniforms. EditGraph::source_region finds the part of the source a view reads, and tiles::plan cuts a render too large for one texture into halo-grown, grid-aligned tiles. The GPU test renders frames a tile at a time from their own windows and compares them with the whole: identical for point operations, within one code value when straightened with clarity on. |
||
|
|
657f8f19ff |
Develop the film last, in the view transform's place
A film stock ran at order 25, after white balance and exposure, and everything after it — contrast, the curves, the colour mixer, the grading, every mask layer's tone — acted on the film's display-referred output, as though the frame had been scanned and then worked on. That was a display-referred rendering in the middle of the chain, which D19 removes (FR-DEV-3f, FR-DEV-3j). `film_sim` is now in `Stage::View` beside `view_transform`. While a stock is loaded the composer emits it in the view transform's place and not the sigmoid; otherwise the sigmoid. So every edit is a decision about the exposure the negative receives, and the film is the last thing that happens to the picture — after the detail stage too, in the view pass, which already binds the film's tables, the mask array and the grain's source position. Its per-layer settings blend there as they did in the fused pass. `op_renders` goes: a rendering is chosen, not suppressed, and the only render with no view transform is the camera-space tap. The YAML order moves to 190 so the panel reads in pipeline order; the stage, not the number, is what places it. Existing edits that combine a stock with tone or colour operations now render differently: those operations used to act on the print, and now act on the scene. |
||
|
|
c07f81edcb |
Run the view transform after the detail stage, in a pass of its own
The fused pass stops at "linear working values" when a sharpener, a blur or a repair follows, and the detail passes convolve what it hands on. Until now it handed on the rendering: the base curve, and since the last commit the view transform, ran before the store. So every kernel worked on display-referred values while its comments promised the opposite — D19's second finding. A fused pass composed for a detail stage now stops before the view transform, and carries a second shader, `ComposedShader::view`, composed from the same inputs. It runs the same prologue, for the positions a fragment reads (a film's grain seeds from `source_px`) and the corners it blacks out, takes its colour from the detail stage's result bound where the sample cache would be, and runs the view transform, the output transform and the mask reveal. `render_detailed` dispatches it after the last detail pass, in the same encoder. So no detail pass encodes any more. Every pass writes an intermediate, the last one included, which retires three things that existed only to make the last pass encode: `writes_output` and the runner's second layout, the body-less resolve pass for an active kernel with nothing to draw at this scale, and capture sharpening's pass-through, which now emits no pass at all. An empty chain is a whole render: the view pass reads the fused result directly. The detail stage no longer takes an output space either, so `compose_detail_for` folds into `compose_detail` and the space is named once, on the fused half. The cost is one full-render read and write per frame when a detail stage exists, and a third intermediate for a one-pass chain. |
||
|
|
92afaebd34 |
Make the view transform an operation the photographer can set
FR-DEV-3j gives the view transform two controls, contrast and the white point in stops above middle grey, persisted and held per mask layer like any other setting. A scene-referred pipeline whose white point cannot be moved hands the photographer a shoulder they cannot place. `view_transform` is a hand-written node in `Stage::View`, a new stage the composer emits last and emits whatever the node's state: a neutral operation is otherwise left out of the shader, but a photograph with no view transform is a scan. "Active" keeps meaning "moved from the defaults", so an untouched photograph writes nothing for it and `every_node_starts_neutral` still holds. A caller whose chain holds no view operation gets a default one. The composer's loop becomes an ordered list of steps — camera nodes, the matrix, scene nodes, layer-only nodes, the view — so each is emitted in exactly one place. The base curve could not be a node because it belonged to the camera; the ops README records why that argument went with it (D19). The panel shows it in the Light group as "Tone Mapping". Tests that counted the blocks of a neutral graph now count one, the view transform, and the two chain-wide tests that load a film expect the view transform to be absent, since a stock replaces it. |
||
|
|
37a6d99dc4 |
Replace the per-body base curve with a scene-referred view transform
The base curve was a five-point spline on the unit square, flat past its last point: every value above 1.0 left it as the same number, per channel. Exposure and highlight recovery put values up there, and the curve threw them away, then handed the result on as though it were still scene-linear. The six per-body curves were also, by their own file's account, hand-tuned shapes rather than measurements, and not enough is known about where they came from to keep them (D19). In their place, one view transform for every body (FR-DEV-3j): a log-logistic sigmoid per channel, with the middle channel put back between the other two so a hue survives the shoulder. Its two free constants are solved from two conditions rather than set: scene grey 0.13, where the retired default curve put it, lands on display 0.18, and the scene white four stops above grey lands on 1.0. So a highlight a stop past sensor saturation still rolls into white, and the midtones stay within 0.26 EV of the retired default between scene 0.03 and 1.0. `dr_pipeline::view` holds the CPU reference and the WGSL, and the tests there are FR-DEV-3j's acceptance criteria. It is still fixed and still in the fused pass's tail, so a detail stage still sees rendered values; the next commits make it an operation and move it after the detail stage. It is skipped for a JPEG, as the base curve was, and absent from the camera-space tap. The base curve's database, its lookup and its twelve uniform slots go. `RawImage` and `DemosaicedImage` lose the field, and the GPU test that proved a curve reached the shader is replaced by one that renders the view transform against the CPU reference and shows two highlights above 1.0 still render apart. The JPEG-and-sensor test now asserts the two differ by exactly the view transform, where before an identity fixture curve had made them match. |
||
|
|
6b99f67f47 |
Develop a mask layer's film on its own settings
A layer offered the film's sliders and they moved nothing: its copy of the node was never given the stock, so it stayed inactive. Film now works in a layer the way the other adjustments do, as offsets to the photograph's settings, but blended as settings rather than as results, since a film is a rendering and cross-fading two developments is not what a region on a pushed film looks like. - dr-film bakes no slider. Exposure is a gain in the shader; push interpolates the stock's measured processes, one curve row each; the print is split at the paper's log exposure, so print exposure is an addition between two lookups and exact at any setting. The enlarger stays balanced at the photograph's exposure. - film_sim reads all four settings as uniforms, format one-hot over a grain count per format, so every uniform is linear in what it does. - Operation::blends_settings lets the composer average each overlapping layer's uniforms with the global ones by mask weight, the global setting taking whatever weight the layers leave, and run the fragment once. Three layers at full weight give the mean of their settings. - The stock picker is hidden on a layer. Only the photograph's exposure re-solves the print balance; push, print exposure and format need no rebake at all now. |
||
|
|
1dc7b45cfe |
Read the fit view's source gather once per framing, not once per frame
At fit, every output pixel of the fused pass loads one texel from a source three or four times its width, on a stride. The memory system fetches the texels it skips along with the one it wanted, so on a 60 MP rgba16float source that gather was most of what the fused pass cost: 10.6 ms of a 2560x1600 frame against 3.8 ms for the same shader reading a contiguous window (the 1:1 view). At 3840x2160 it was 21.1 ms. Those are the laptop RTX 3050 with its clocks held at 420/810 MHz by the power cap; unthrottled the same frames were about 2.0 and 3.2 ms, and the gather is the same share of them. Which texel an output pixel reads depends only on the framing prologue, the framing and warp uniforms, the source and the render size. None of those move during a slider drag, so the gather is the same work every frame. The fused shader now takes a render-sized rgba16float cache of it (bindings 6 and 7, declared in every generated shader like the masks) and a pair of uniform flags: write what was gathered, or read it back at the pixel's own coordinate. AdjustPass keeps the cache and decides per dispatch. The composer supplies `ComposedShader::sample_key`, a hash of the prologue and those uniforms, and AdjustPass adds the image and the size; an image gets a process-unique id for this rather than being held alive by the key. The picture is bit-for-bit the same. The source is rgba16float and so is the cache, so the stored texel is the texel, and only the path that reads a texel whole takes part: an interpolated sample (straightening, lens warps, CA) is a blend that f16 could not hold exactly, so the composer gives it no key and it reads directly as before. The cache is written on the second frame with a given key, not the first: a crop or zoom drag changes the key every frame, and writing then would add a render-sized write to exactly the gestures that can afford it least. It is kept only up to 3840x2400, so an export never parks a full-frame copy on the device, and `release_caches` drops it. Measured with a scratch probe rendering the synthetic 60 MP frame from examples/frame_budget.rs, forty frames per run after six warm-up, five runs of each binary alternated, median of the per-run p50 (GPU idle apart from the power cap): scene before after neutral 2560x1600 fit 10.62 ms 3.88 ms exposure 2560x1600 fit 10.83 ms 3.87 ms nr chroma 2560x1600 fit 19.84 ms 12.69 ms neutral 3840x2160 fit 21.05 ms 7.11 ms exposure 3840x2160 fit 21.08 ms 6.94 ms clarity 3840x2160 fit 42.20 ms 27.88 ms neutral 2560x1600 1:1 3.83 ms 3.84 ms (control: nothing to gain) The rgba8 output of every scene hashed identically before and after, in isolated runs and across all 38 scene/size/view combinations of the probe. New tests walk a pass through direct, write and read frames, a slider move, a neighbourhood operation and a framing change, and compare every frame with a fresh pass that can only have read directly. |
||
|
|
5a500118ae |
Correct converging verticals with a keystone in framing
There was no perspective transform anywhere in the pipeline: framing offered a ±45° straighten, quarter turns and flips, and a building shot looking up kept its leaning walls. Framing gains a vertical and a horizontal keystone (-100..100). They are parameters of framing rather than a new stage, so they carry its Compose attribute, persist in the sidecar under framing, and are withheld from a default paste exactly as the crop is. In the prologue the keystone runs after the crop and the straightening and before the stored orientation and the lens warp, so "vertical" is the photograph's displayed height and the lens still sees its whole frame. The map takes the output frame onto a trapezoid inside the source, built as a homography from four corners and uploaded as three columns in the framing uniform block (which grows from two vec4s to five). A keystone on its own therefore never exposes an empty corner and leaves any crop valid. Combined with a straightening angle the empty area is a pulled-back quadrilateral the closed-form inscribed rectangle cannot describe, so max_inscribed_crop searches for the largest centred rectangle whose corners all have a source pixel behind them. source_at and output_at apply the same map, so masks, gradients and spot handles follow it. |
||
|
|
9b6b4942cf |
The camera-space tap: OutputMode::CameraLinear, composed with no operations
compose_camera_linear composes the fused pass with an empty operation list, the file's orientation as the baseline, a view rect for the tile, and a store of rgba32float. On the GPU, render_camera_linear is the only entry that accepts it: it fills the profile uniforms neutral — unit white balance, identity matrix, curve off — so what lands in the texture is the sensor's numbers after the lens warp and nothing else (FR-MRG-2). A third bind-group layout carries the format, as the linear one does, and the readback is generalised to any pixel width for the f32 copy. Thirty-two bits because the composite is written back at the sensor's scale: a 14-bit sensor has 16 384 steps to white and f16 keeps 2 048 of them in the top octave. |
||
|
|
a1165ef182 |
Put the coordinate-domain lens corrections into the graph
`lens.rs` has held a `Warp` trait, a composer and two implementations — distortion and lateral chromatic aberration — since they were written, and `compose_warps` was called by nothing outside its own tests. The corrections existed, were correct, and never touched a photograph. `EditGraph` now holds them, and `compose_full` emits them between the framing prologue and the fetch. Distortion first, then CA: each warp receives the position the previous one produced, and lateral CA is a magnification about the optical axis of the *undistorted* frame, so measured on a barrel-distorted one it would be fitted to a radius no profile describes. They reach the panel the way framing already does — through `capabilities`. That was the one open question and existing practice answered it: framing is also not an `Operation`, also has parameters a photographer sets, and also arrives through that list. Because `Preset::capture` walks the same list, the sidecar, the clipboard and the undo stack carry a warp's parameters with nothing registered anywhere, and no file under `ui/` names one (FR-DEV-3a). `state()` destructures `EditGraph` field by field precisely so that a new field cannot be forgotten, and it was not. Chromatic aberration is the only thing that samples per channel, and `splits_channels` is what keeps everything else from paying for it. Red and blue are fetched from positions green is not — green is the reference and never moves, so a wrong correction still leaves one channel sharp rather than softening all three. With no CA in the chain the single-fetch path is emitted instead. The interpolating sampler is now chosen by framing *or* an active warp. Asking framing alone would have nearest-neighboured a distortion correction on an unstraightened frame, and that aliasing reads as a bad profile rather than as a missing filter. The warps go in the geometry invalidation key rather than the colour one: they decide which source pixel a colour is read from, so a tile cached across a distortion change would keep drawing the previous correction. The pipeline cache needs nothing new — `hash_source` already covers the generated body, and uniform values never enter it, so arming a warp recompiles and dragging it does not. Both are asserted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
085ab766b3 |
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
ec4283e37f |
Finish reconciling the whole-chain tests the three kernels each rewrote
Sharpening, noise reduction and clarity were written in parallel and each rewrote the same two tests, which had counted one fused block per operation — true only while every operation was a point function. Kept the exclusive-or formulation: each operation must reach exactly one of the two stages. A count cannot tell "moved to the detail stage" from "vanished from both", and that ambiguity is what broke these tests three times over. The merge left two fragments of the versions it replaced — a loop over a set that no longer exists, and the tail of an assertion whose head was gone. The loop is not restored: `point ^ neighbourhood` already asserts per operation what it checked over the set. The assertion is, because it catches a different fault from the exclusive-or — a block in the shader that nothing in the chain asked for, rather than an operation in the wrong stage. |
||
|
|
d800af049b |
Merge branch 'worktree-agent-acd27f9b2974c67eb' into integration
# Conflicts: # core/dr-gpu/src/adjust.rs # core/dr-pipeline/src/detail.rs # core/dr-pipeline/src/ops/mod.rs |
||
|
|
5852e14a5c |
Merge branch 'worktree-agent-a75c901968abfa183' into integration
# Conflicts: # core/dr-gpu/src/adjust.rs # core/dr-pipeline/ops/README.md # core/dr-pipeline/src/lib.rs # core/dr-pipeline/src/ops/mod.rs # ui/dr-ui/src/develop.rs |
||
|
|
eb229051ef |
Teach the whole-chain GPU tests about neighbourhood operations
Both tests composed only the fused half and rendered it through the plain path. That was correct while every operation was a point operation; with a kernel in the chain the fused pass stops short of the output transform, so the render was rejected and the operation-block count was one too high. Compose both halves and dispatch them together, and assert that each operation reaches exactly one of the two stages rather than counting blocks - so the next kernel added extends the coverage instead of breaking it. |
||
|
|
2459a759af |
Compile the detail stage in the whole-chain GPU tests
every_operation_generates_compilable_wgsl and the_whole_chain_at_once_compiles both rendered through the fused half only. A neighbourhood operation contributes no fused fragment, so its kernels went uncompiled — and once one is active the fused pass hands on linear working values, which plain render refuses. Both now compose both halves from the one graph, and the fused block count excludes the operations the detail chain names. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
88ce89428b |
Teach the whole-chain shader test about neighbourhood operations
`the_whole_chain_at_once_compiles` counted one `---- ` block per operation in the chain. That was true while every operation was a point operation, and stopped being true the moment a neighbourhood one existed: clarity and texture are active in that test, and still emit no fused block, because `compose_full` filters them out and the detail stage dispatches them separately. Counted now by asking each operation whether it has a detail stage -- the same question the composer's own filter asks -- rather than by subtracting a number someone has to remember to update. Capture sharpening and noise reduction are covered by this without another edit. The render at the end is now `render_detailed`, which is not a concession but the stronger test: with a neighbourhood operation active the fused pass hands on linear working values and the last detail pass performs the output transform, so rendering the fused half alone is the mismatch `render_detailed` exists to reject -- and the detail passes are generated WGSL with uniform blocks of their own, which is exactly what "everything at once" is here to collide. It renders at 512 rather than 32 because a compositional radius is a fraction of the frame, and on a 32-pixel target every detail kernel rounds away to nothing. Also records, in `texture_contributes_nothing_where_its_scale_does_not_exist`, the seam this uncovered: an active detail operation whose kernel rounds away composes an empty chain while the fused pass has already been composed to hand on linear values, and nothing can then encode the result. That test now asserts the property on the composed chain instead of driving the unrenderable configuration. 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 |
||
|
|
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> |
||
|
|
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. |
||
|
|
f76e024f41 |
Straighten a portrait frame in the frame the user is looking at
The framing prologue built the centred position `p` by scaling with the source's aspect, then straightened, then permuted the quarter turns. On a landscape frame those are one space and it worked. Once a turn has swapped the axes — the rotate button, or a file whose EXIF tag says the camera was held sideways — they are not: `p` was measured with the source's ruler on a frame that is no longer that shape, stretching one axis against the other by (w/h)², which is 2.25 on a 3:2 photograph. The quarter-turn permutation happened to undo that stretch, so rotation alone looked right, which is how this survived. The straighten in between did not, and a rotation in a space whose axes carry different scales is a shear. `p` is now built in `frame_aspect` — the frame as the user sees it — and the permutation becomes the one place the two rulers meet: each axis divided by the aspect it is read from, multiplied by the aspect it is written to. Asserted on pixels rather than on the generated WGSL, because reading the shader and reasoning about which space `p` lives in is how the wrong formula got written in the first place: a disc, straightened by 20° on a turned 3:2 frame, must come back circular by every route to a swapped frame — the button, the tag, and the two composed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
654c11300e |
Pin the framing geometry on pixels, not on the generated shader
Chasing a reported shear on rotate and straighten. Two tests, and what they prove is that the pipeline is not where it comes from. A circle is the shape that makes anisotropy unmissable: any transform scaling the axes unequally returns an ellipse, and the ratio of its axes is the error. Both a quarter turn and a 20° straighten, on a 3:2 frame, return a circle within 8%. Worth recording because I had a confident and wrong hypothesis first. The quarter turn carries `* aspect.x` on one component and `/ aspect.x` on the other, which reads like an anisotropy of a-squared, and the reasoning that `p` is already isotropic is plausible enough that I changed it. The existing `a_quarter_turn_corrects_for_aspect_across_the_swap` caught that immediately, and these tests then showed the original was right all along: the crop rect is expressed in the *turned* frame and the output axes swap with it, so the factors are the conversion between those spaces rather than a mistake. Reading the shader and reasoning about which space `p` lives in is exactly how a plausible formula gets written twice. These assert on real pixels off a real adapter instead, so the next person to suspect this transform can rule it out in one command. The shear is therefore in the display path — the fit from the framed size to the viewport, or the crop overlay's uncropped render — and not in the geometry the pipeline computes. Not yet fixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a8b28136a6 |
Merge the batch export, and settle the seven-branch merge
Resolves the last of the parallel work. Two conflicts worth recording, because both were semantic rather than textual: `render_for_export` gained a colour space on master while the batch branch was rewriting the single-image export path around it. Kept both: the batch request supersedes the synchronous path, and the space still has to be chosen at render time because the conversion happens in the shader before the clip to 0..1. `render_open_frame` takes it as an argument rather than reaching for a controller it does not hold. The map-wait moved into `readback::await_mapping` on one branch while another was editing the constant it used, so `READBACK_POLL_LIMIT` survived the merge with no callers. Removed rather than left for clippy to find later. 1164 tests pass, clippy clean, fmt clean. Traceability 53.0% -> 54.3%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cfff6a3302 |
Merge branch 'zero-copy-display'
# Conflicts: # core/dr-gpu/src/adjust.rs # ui/dr-ui/src/develop.rs |
||
|
|
9fc8721fa8 |
Merge branch 'histogram'
# Conflicts: # ui/dr-ui/src/lib.rs |
||
|
|
0233df4bf2 |
See what the highlights are doing: a live histogram (FR-DSP-7)
Exposure, blacks and whites were set by eye. Nothing said a highlight had blown — the canvas shows white where a channel is at 250 and white where it is at 255, and the difference is the whole question. **Counted on the GPU, not on the readback.** There is a full frame sitting in CPU memory on every canvas update right now — `AdjustPass::read_output`, the bridge spike S1 removes — and walking it would have been thirty lines and no shader. FR-DSP-7 states the mechanism and not just the feature: "these derive from a GPU-side reduction into a small buffer. Per-frame CPU readback of image data is prohibited." A histogram founded on the bridge would be correct today and deleted by S1, and would meanwhile be the reason the bridge could not go. What crosses the bus here is 4104 bytes whatever the image size. The reduction tallies into workgroup memory first and merges once per workgroup. A photograph is not noise: a clear sky puts tens of thousands of adjacent pixels in one bin, and contending for that single global atomic serialises the dispatch. **On the settled frame only.** `render_now` already knows whether a gesture is still moving — `draft` is the flag `redraw` derives from `was_coalesced` — so the dispatch and its transfer happen once when the slider stops rather than on each of the forty frames a drag emits. Nothing is lost: a histogram flickering past under a finger is not a reading anyone takes. FR-DSP-7 requires exactly this, that it not extend the FR-DSP-3 frame budget. Luma is weighted in 8.8 fixed point — 54, 183, 19, summing to 256 exactly — rather than in floats. Not thrift: it makes the shader's arithmetic reproducible bit for bit, which is what lets the test below be an `assert_eq` against a CPU count rather than a tolerance. ARCH §6.13's line about integer state, applied where it happens to also be free. **What the numbers were checked against.** A flat frame must put all 4096 pixels in one bin and one only. A 256-wide ramp must occupy every level with exactly the same count, which is what catches an off-by-one in the quantisation — a `floor` where a rounding was needed shifts the whole photograph one bin left and looks like nothing at all. And a 101x37 frame of seeded pseudo-random pixels — deliberately not a multiple of the 16x16 workgroup, so the edge tiles run off the image — is compared slot for slot against a second, obvious CPU implementation. Exact equality, no tolerance. The CPU version is a deliberate reimplementation rather than shared code: the bugs worth catching here are ones shared code would commit identically on both sides. Above that, the presentation arithmetic is unit-tested headless, because it is where a wrong answer is invisible. A histogram of the wrong shape looks exactly as plausible as one of the right shape. So: 64 columns because it divides 256 and an uneven fold draws an even ramp as a comb; the peak excludes the end columns, or a night scene scaled against its own black spike is a flat line with no information in it; heights are clamped into the plot; and "0%" is kept distinct from "<0.1%" and from "—", since an indicator reading "clipped" over a figure reading "none" is a panel contradicting itself. Clipping counts a *pixel* with any channel at an extreme, not a channel. Any, because a blown red has no gradation left in it however much green and blue still hold — and it is the saturated highlight, the sunset and the red jersey, that clips first and recovers worst. Per pixel, because counting channels can report 200% of a frame clipped, and a percentage above 100 is a readout nobody trusts again. Two affordances for it, which NFR-A11Y-3 asks for: a bar standing at the end of the plot the tones are piling against, and a figure saying how much. Either alone reads. The panel sits directly under the capture metadata and above every control, because it is what the controls are judged against. It is hand-built rather than generated, and ARCH §4.3a is untroubled: a histogram is not an operation — no parameters, changes nothing, answers a question rather than asking one — and nothing in it reads a parameter out of a descriptor. Three plot colours and a neutral luma trace join the palette. That is the swatch's exception rather than a second one: a per-channel histogram has to say which channel, and no achromatic treatment distinguishes red from blue, so the hue is data exactly as the image beside it is. Held well back from full strength for the reason the theme preamble gives. The bounded, non-parking map wait moves out of `AdjustPass` into `readback::await_mapping`, shared with the histogram's transfer. Thirty lines of load-bearing reasoning about frozen interfaces and lost devices, and two copies of it would have drifted. The histogram describes the frame on the canvas, so it is in the output colour space FR-DSP-7 asks for, and when zoomed it describes the visible region — a photographer inspecting a highlight at 4x is asking about that highlight. A device that cannot build the reduction loses the histogram and keeps the photograph. Still to do for FR-DSP-7: the pixel colour readout under the cursor. 324 tests pass, clippy and fmt clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cf8f5b632f |
Show the develop frame itself, instead of a photocopy of it
The oldest open item in the project (ARCH §6.1, spike S1, AC-8). Every frame
in develop was read off the GPU into a `SharedPixelBuffer` and handed back to
Slint to upload again: ~7 ms at 4K against a 0.28 ms compute pass, 96% of the
frame spent carrying pixels to the CPU and back so they could be drawn where
they already were.
Slint 1.17 will adopt a `wgpu::Texture` directly, and the whole of what that
needs is arrangement rather than code.
**One device, made before the window.** A texture belongs to the device that
allocated it, so the compute passes and the compositor cannot each open their
own. `GpuContext::new_shared` opens one and hands back the instance and
adapter alongside it; `dr_ui::shared_gpu` gives all four to
`BackendSelector::require_wgpu_29(WGPUConfiguration::Manual { .. })`. That
call has to come before the first window, because creating one selects a
backend for you — which is why the GPU is now opened at the top of `run`
rather than two hundred lines down beside the other controllers.
dr-gpu still names no UI type. It hands out raw wgpu and does not ask who is
compositing (ARCH §6.5a).
**Vulkan only on the shared path**, where headless keeps its GL fallback.
wgpu's GL backend reaches its display through EGL at instance creation, and
before a window exists there is no display handle to give it — so a GL
instance cannot later produce the window surface Slint needs from it. A
machine with no Vulkan gets no shared device and browses without develop,
which is the same degradation as no adapter at all.
**`renderer-femtovg` becomes `renderer-femtovg-wgpu`.** The old one is FemtoVG
over OpenGL and cannot be handed a wgpu texture at all. It is not kept
alongside as a fallback: FemtoVG-over-GL has no branch for an imported
texture, falls through to "render this image into a buffer", gets nothing, and
draws nothing — a blank canvas with no error, which is worse than the failure
it would be papering over. The consequence is stated plainly in the manifest:
the desktop app now needs a working wgpu adapter to open a window.
**Two output textures, not one, and this is the part that is not obvious.**
Slint repaints when the image property *changes*, and it decides that with
`PartialEq` — which for two images over the same `wgpu::Texture` says
"unchanged". A pass that reused a single target would have rendered every
slider move correctly on the GPU and shown none of them: right, and invisible.
`AdjustPass` alternates between two targets, so consecutive frames are
genuinely different values. It also settles the read-while-write question that
one queue was already answering.
`RENDER_ATTACHMENT` is added to both render targets. Neither pass uses it;
Slint rejects an imported texture without it, on the reasoning that a
compositor handed a texture may need to draw into it.
**`AdjustPass::read_output` is deleted rather than gated.** It and
`export_pixels` were the same transfer under two names, and the comments
explaining why they were separate are the point of the whole criterion:
reading pixels back to *display* them is the defect, reading them back to
*encode a file* is the only way a file is made. The display twin is now gone
outright, which is stronger than a feature flag — it cannot be turned back on.
`export_pixels` is untouched and still ungated. The `readback` feature comes
off dr-ui, darkroom-desktop and darkroom-android; it stays in dr-gpu, where it
still gates `RenderTarget::read_pixels` and the segmentation field readback.
`examples/develop` moves to `export_pixels`, which is honest — it writes a
PPM — and so no longer needs the feature.
Four tests, each named for what it protects and each of which fails without a
screen if the property it guards breaks:
- the adjust target satisfies every condition Slint's import checks, asserted
in the crate that owns the descriptor, because a descriptor that drifts
fails at runtime on a real display and nothing else would notice;
- consecutive renders are different textures, and the third is the first
again, so the alternation is a rotation and not an allocation per frame;
- the develop canvas has no CPU pixel buffer and does have a wgpu texture —
AC-8 itself, in the terms Slint uses;
- consecutive frames compare unequal as `slint::Image`, which is the property
the repaint actually depends on.
The zoom test's readback moves into the test module. It has to: there is no
library function that copies a displayed frame to the CPU any more, and that
is the point — the round-trip now exists in the test binary and nowhere a
shipping build can reach.
**What is not proven.** No GUI was run. What is verified is that the texture
satisfies the import contract, that the import succeeds, that the canvas is a
texture rather than a buffer, and that consecutive frames are distinguishable.
What is unverified is everything that needs a display: that Slint's FemtoVG
wgpu renderer adopts the Manual configuration on a real surface, that the
picture appears the right way up and the right colour, and the frame timing
that motivated the whole exercise. Android is untouched by testing — the
android backend routes a WGPU29 request to Skia, whose wgpu surface does
handle imported textures, but that is read from the source, not observed.
56 dr-gpu tests and 255 dr-ui tests pass, clippy clean under `-D warnings`,
fmt clean.
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> |
||
|
|
151dcc3c02 |
Make a file out of a photograph
Export existed as a settings page and nothing else: format, quality, colour
space, five sizing modes, a filename template and a metadata switch, all
configurable in detail, and no way to produce a single file. dr-export is the
other half.
**It returns bytes and a name, and writes nothing.** An export has three
destinations with nothing in common — a path on Linux, a SAF document on
Android where there is no path at all (ARCH §6.9), and a PUT to a Nextcloud
folder — so a crate that opened the file itself would serve one of them and be
rewritten for the other two. The caller places the bytes.
Resize, then sharpen, then encode, in that order and for a reason: output
sharpening compensates for the softening the resample introduced, so its
strength scales with how much scaling actually happened, and sharpening before
shrinking would throw the result away. Lanczos-3, separable, with weights
computed once per output row — FR-EXP-4 asks for Lanczos or better because a
box filter turns a distant fence into moiré.
Collision handling takes the "is this name taken" test as a closure rather
than looking at a directory, because there is no directory it could look at
that works everywhere. That shape is not politeness toward Linux: Android's
createDocument renames on collision by itself and cannot overwrite at all, so
all three CollisionPolicy settings need the answer *before* anything is
created. Overwrite, Skip and Increment are each tested, and Increment gives up
after ten thousand rather than spinning against a destination that reports
everything as taken.
Three things are honest rather than done:
- **Colour space.** sRGB only. The shader encodes and clips to sRGB before
this crate sees a pixel, so tagging a file Display P3 would claim a gamut
it does not contain. Refused with a typed error instead of mislabelled;
honouring it is a pipeline change (FR-EXP-2).
- **AVIF and JPEG XL.** No encoder. libaom and libjxl are C, ravif is slow
enough to change what a batch feels like, and the settings page offers
both because FR-EXP-1 lists them — so asking for one says so rather than
writing a JPEG under a .avif name.
- **16-bit TIFF** is a real 16-bit file carrying eight bits of information,
because AdjustPass renders to Rgba8Unorm. Widened by *257, not <<8, so
white lands on 65535 rather than a quarter-percent grey. Making it mean
what it says needs the composer told what format to write.
Metadata is not written at all, which satisfies the half of FR-EXP-8 that
matters most: strip_location defaults to on, and a file with no EXIF block has
no GPS tag. Retaining camera and copyright when asked is not implemented and
cannot be faked by omission.
Also here:
- `AdjustPass::export_pixels`, ungated where `read_output` is behind a
feature. The two are the same transfer and opposites in intent: reading
pixels back to *display* them is what ARCH §6.1 forbids and AC-8 asserts
against, while reading them back to encode a JPEG is the only way a file
has ever been made. Separate methods so the instrumentation can count one
without counting the other.
- `ExportTarget`, so a destination can be a folder on the server. On Android
that is the only destination needing no platform work whatsoever — a PUT
against create_dir, already on the RemoteBackend trait, behaving
identically on both platforms. Switching target clears the destination,
since a path is not a remote folder and carrying one across would offer to
create a folder called `home` at the library root.
Verified end to end rather than by unit test alone: `cargo run -p dr-export
--example export` decodes a frame, runs the develop chain on the GPU at full
resolution, reads it back, and writes all five formats — 27 ms for a
full-size JPEG, 165 ms with a Lanczos reduction to 1200px. ImageMagick agrees
the 16-bit TIFF is 16-bit. dr-export cross-compiles clean for
aarch64-linux-android; all three encoders are pure Rust, which is why they
were chosen. 944 tests pass, clippy and fmt clean.
Not yet wired to a button. The develop view has no export action, so nothing
in the running app can reach any of this yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
d7aeafaf84 |
Move to wgpu 29, the version Slint can share a device with
Build and test / Desktop (Linux) (push) Failing after 38s
Build and test / Layer separation (push) Successful in 24s
Traceability / Requirement traces (push) Successful in 1m3s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 9m54s
Groundwork for spike S1. Importing a texture into a Slint scene requires it
to come from the *same* `wgpu::Device` Slint renders with, and Slint hands
out a device of the version it was compiled against. Slint 1.17 offers
`unstable-wgpu-28` and `unstable-wgpu-29` and nothing older, so wgpu 23 could
never have met it: two semver-incompatible wgpu crates in one tree are two
distinct types, and the device would not typecheck across the gap.
The version is therefore not a free choice, and the manifest now says so —
Slint and wgpu move together or not at all. The Slint requirement is also
corrected from "1.9" to the 1.17 it has actually been resolving to.
Nothing about the render path changes here. The readback bridge is still in
place and still the display path, so this is verified by the tests that
already existed rather than by anything new: 39 dr-gpu tests, which compare
real pixels off a real device, and 888 across the workspace, all passing.
Zero-copy lands separately and small.
What the six releases cost, in full:
- `ImageCopyTexture`/`ImageCopyBuffer`/`ImageDataLayout` became the
`TexelCopy*` names (24).
- `Instance::new` takes the descriptor by value, and `InstanceDescriptor`
lost its `Default` — it carries a boxed display handle now, so a headless
context says `new_without_display_handle` and means it.
- `request_adapter` returns `Result` rather than `Option` (24).
- `DeviceDescriptor` absorbed the API trace from `request_device`'s second
argument and gained `experimental_features` (25).
- `PipelineLayoutDescriptor` takes `Option<&BindGroupLayout>` per slot, and
`push_constant_ranges` became `immediate_size`.
- `Maintain` became `PollType`, and `poll` is fallible.
Two of those are improvements worth having rather than churn. The error scope
is a guard whose `pop` runs on drop, so an early return from the pipeline
compiler no longer leaves a scope open on the device for whatever ran next to
fall into. And a fallible `poll` reports a lost device (NFR-R7) at the point
it happens, where before the map callback simply never arrived and the
failure surfaced later as a readback that spun out its poll limit.
Still to do for S1: dr-ui renders through `renderer-femtovg`, which is
OpenGL. Texture import needs Slint itself rendering on wgpu.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
7c57f490fe |
Declare a develop operation in YAML, and generate the rest
An operation was, in the overwhelming majority of cases, four facts: what its parameters are, what uniforms they compute, what WGSL those uniforms drive, and where it sits in the chain. Written in Rust those four facts arrived wrapped in ninety lines of trait implementation — a match on parameter id to a struct field, another match back, an is_active comparing each field to its default, a Vec<Uniform> built by hand. All mechanical, and each one a place to make a silent mistake: a param() arm returning the wrong field reads perfectly and breaks the sidecar round-trip. So the four facts are the file now. core/dr-pipeline/ops/<id>.yaml is a node, build.rs compiles it into the same Operation impl as before, and the result lands in OUT_DIR — the same reasoning as style.yaml -> theme.slint, including why it does not land beside the sources it would look exactly like. Nothing downstream can tell a declared node from a hand-written one: same &'static OpDescriptor, same fused-shader composition, same sidecar. Nine nodes moved: exposure, white_balance, contrast, highlights_shadows, blacks_whites, brilliance, vibrance, saturation, and the shared WGSL helper registry. Their prose came with them, and so did their tests — set/expect/expect_active/expect_wgsl in the declaration compile to real #[test]s, so a node file carries its own proof rather than leaving it behind in a file that no longer exists. Two stayed in Rust and say so with `rust:`. The tone curve's neutral is a relationship between five interpolated points rather than a set of values; the colour mixer generates thirty-six faceted parameters from twelve computed hue bands. A schema stretched to cover either would be a worse language than Rust aimed at one caller. They still declare their position here, because the chain's *order* is the one thing a reader comes to this directory to learn, and an order written half in YAML and half in Rust would be worse than either alone. default_chain() is generated from it. Uniforms are derived by a small expression language — exp2(exposure), blacks / 100 * 0.02 — compiled to Rust rather than interpreted, so an unknown name or a wrong arity is a build error naming the file and the key and the arithmetic costs nothing at runtime. The build script refuses a duplicate order, a filename disagreeing with its id, a default outside its own range, a test value the graph would clamp before the node saw it, a helper that does not define the function it names, and a declared node colliding with a file in src/ops. Verified by adding a scratch node and removing it again: one file, no other edit, and it joined the chain at its declared order with its test running. 237 tests pass in dr-pipeline, clippy and fmt clean. .yaml joins the traceability tool's scanned suffixes, because a node's Rust now lives in OUT_DIR where a tag could never be linked from the report. Coverage 47.7% -> 48.3%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cd75e5a4c6 |
Run the library from local data when the server is unreachable
Also carries in-flight work that shared these files: the zoom structure-key fix in the adjust pipeline, nearest-neighbour filtering past 1:1, the timeline scrub marker correction, the 423-Locked retry in the metadata sweep, and the thumbnail size-class migration. # Offline mode (FR-CAT-9) The app previously assumed the server was reachable and treated its absence as a series of unrelated per-operation failures. A launch without a connection produced an empty grid, even with a complete catalog on disk and every thumbnail already in the shards. Reachability is now inferred from traffic the app was already making, rather than probed for. `RemoteError::indicates_offline` draws the line that makes this possible: a dead connection is offline, a 403 or a 500 is not — the server answered, so blanking the library over one forbidden file would be a worse error than the one being reported. `Reachability` turns those outcomes into a state, so a library browsing happily never issues a probe at all. Going offline takes one failure, because the user is already experiencing it. Coming back requires evidence — a completed scan or a fetched thumbnail — with a capped exponential backoff behind the manual retry, so twelve sweep lanes failing together do not schedule twelve immediate probes. What keeps working: the catalog opens even when the scan that normally provides it failed, so the grid fills from the last successful scan. Thumbnails come from the shards. Rating, flagging and collecting are catalog writes that never touched the network. What stops is opening an original that was never stored locally, and it now says so in those words instead of reporting "network error: connection refused" over a photograph. Work that is pure network is refused rather than left to fail slowly: the metadata sweep, derived sync, and sidecar writes. The sweep would otherwise spend a timeout per image across the whole library while the progress bar implied something was happening. Deferring sidecars is a real gap rather than a hidden one — a rating made offline reaches its sidecar only when that image is judged again while connected — and it is recorded as such at the call site. # The "On this device" filter A chip beside the rating filters, narrowing the grid to images whose original is held locally. It composes with the rating terms rather than replacing them, so "five-star frames I can actually edit on this train" is one filter. The predicate is SQL, like the rating terms and for the same reason: the count in the header has to agree with the cells drawn. It reads `image_cache.tier_actual`, which nothing writes yet — the next commit fills it. Until then the chip honestly reports zero. `Tier` gains an explicit on-disk encoding. The variants are ordered by generosity and the derived `Ord` invites reordering them, which would silently reinterpret every cached row; the round-trip test is what holds the two in agreement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
250ff1e327 |
Poll the adjust readback instead of parking the UI thread
`read_output` waited on the copy with `Maintain::Wait`, which parks the calling thread until the GPU is done. That call is made from the UI thread, so the interface was frozen for the length of the copy — the note on this function measures it at ~7 ms at 4K against a 0.28 ms compute pass, so nearly all of it was the wait. `Maintain::Poll` drives the same callbacks without sleeping. The mapping still completes and the pixels are identical; the thread simply is not parked while it happens. The poll loop is bounded. A lost device never delivers the map callback, and spinning forever on that would hang the app rather than report the error the caller already handles. This does not remove the round-trip itself, which ARCH §6.1 forbids and spike S1 replaces by importing the texture into Slint directly. It stops the round-trip from blocking input until then. dr-gpu tests pass, including those comparing readback pixels. |
||
|
|
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 |