757133d2a8f229f126d0bb40b2061cb5b9187cb3
157
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
31bcc3a462 |
File presets in folders that open and close, as collections do
The presets menu and sheet listed every preset under flat section headings, seventy rows to scroll past. They now list folders, closed until opened, with how many presets each holds; opening one shows what is inside it, folders and presets indented beneath. A category is spelled in the name: "Portraits/Warm skin" is Warm skin in a Portraits folder under Yours. The file format does not change, so an older build lists the whole path as the name; renaming a preset is how it moves, and saving or renaming into a folder opens the way to it. A Lightroom import names what it reads after the folders below the one chosen, and a "/" in a displayed name becomes "∕" so it files nothing. The shipped film sections become Film › Colour, Cinema and Black and white. The tree is built and flattened in Rust (PresetTree), each row carrying its depth, and which folders are open is remembered for the session. A PopupWindow keeps the size it was shown at, so a folder opened in the menu pushed its contents under "Save or manage…"; the menu is shown again after each toggle to take its new height. That is a function on the rail because Slint 1.17 generates Rust that does not compile for a popup's close() reached from inside the popup. The sheet's list takes a preferred height of up to 400px, since a Flickable reports next to nothing and an opened folder showed three rows. The manual describes the folders and naming. Its pictures show the menu with Film › Colour open, and a black-and-white stock applied from Film › Black and white; the scenes aim popup rows from the rail's entry, and pick the menu's "Film" over the develop column's film chooser. |
||
|
|
89b464db0c |
Accept a tile halo that the frame's edge cuts short
The test demanded a full halo on every side except the frame's own edge, but a tile one step in from it is grown to the edge and no further, exactly where the untiled render stops too. |
||
|
|
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. |
||
|
|
1eab723c90 |
Say how far a detail chain reaches, for a tile's halo
radius() is the widest single pass. The chain's passes run in sequence, so what a tile has to be grown by is their sum. |
||
|
|
e601bd806c |
Keep clarity's radius when the develop view zooms in
A frame fraction was taken of the render's short edge, and render_scale folds the zoom into the region the render stands for. Zoomed to 1:1 on a corner, clarity, texture and dehaze drew a quarter of the halo the export gets, though the docs promise they preview honestly at any zoom. RenderScale now carries the whole framed frame (within), and a frame fraction is measured against it; at fit nothing changes. |
||
|
|
cfc1fea25a |
Let the fused shader sample a window of a larger source
A photograph larger than one texture has to be developed from a part of it or from a reduced copy of it. The generated prologue measured the frame by the bound texture, so either would have moved every crop, warp and grain seed. Two uniform vec4s now say which part of the frame the texture holds and how large the frame is; the sampler maps into the texture through to_window, and a texture holding the whole frame samples exactly as before ((uv - 0) / 1 is uv to the bit). |
||
|
|
0682d05f95 |
Let the tone curve continue past 1.0, and test that nothing clips
The tone curve clamped its input to [0, 1] and its output back to 1.0 around a 2.2 gamma, mid-chain — master and per-channel both. So any edit with a curve made every scene value above 1.0 the same number before the view transform ever saw it: D19's fourth finding. Beyond its last point the curve now continues along its last span, whose secant is the tangent the spline already gives that point, so the join is smooth and an identity curve stays the identity to any height. The only clamp left is the floor at zero, where there is no light to curve. FR-DEV-2's acceptance test is how the rule stays true. `scene_referred_until_the_view` wraps every point operation, at non-neutral settings, between a gain of sixteen and one of a sixty-fourth, with an identity in the view transform's place, and renders a ramp from 0.4 to 15.2 times sensor saturation through it. The output must still increase with the input and still separate the top of the ramp. Run against the previous commit's tone curve it fails with [21, 29, 33, 33, 33, 33, 33, 33]; every other operation already passed. It renders on a device because dr-pipeline has none, and the spec's pointer to it says so. |
||
|
|
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. |
||
|
|
db7b84795c |
Convert to the working space before the edits, not after
Every point operation ran in camera RGB and the camera matrix came after them all, against the order ARCH §5.2 draws. So `luminance()` applied Rec.709 weights to a body's own primaries, a band in the colour mixer was a different hue on every make of sensor, and the vibrance skin guard tested channel order in a space where skin does not have one. Operations now declare a `Stage`. White balance is the only camera-stage node — its multipliers scale the sensor's channels, and after a matrix that mixes them the same numbers are a different correction — and says so with `stage: camera` in its YAML, a key both the build-time generator and the load-time declared op read. The composer emits the camera nodes, then the matrix, then the rest, each group in graph order; an empty chain still gets the matrix. Film simulation stops converting out of camera space itself, since it is now handed working-space colour like every other scene node. The base curve stays where it was, after the operations, and so now acts on working-space colour; the next commit replaces it (D19). |
||
|
|
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. |
||
|
|
fc54523093 |
Apply a mask layer's settings as offsets to the global ones
A local adjustment ran as a second chain after every global operation, then blended by the mask. So global contrast -30 with -20 on a face was contrast -30, the rest of the chain, and contrast -20 again on the result, rather than -50 where contrast runs. The two edits compounded in ways neither slider showed; a flattening applied to an already flattened picture is how the shadows of a night shot went magenta. A layer's setting is now an offset from its default, added to the global setting (clamped to the parameter's range; a moved switch or choice replaces it) and run at that operation's own place in the chain. At each operation the global fragment and each touching layer's combined fragment read the same input colour, and the pixel moves by each layer's weighted difference: c_g + sum w_i (c_i - c_g). At full weight that is the combined setting exactly, at zero the global result exactly, and no setting is applied twice. An offset that brings an operation back to neutral emits an empty version, which undoes the global setting inside the mask. Blending the colours rather than the uniforms is deliberate: the tone curve and colour mixer emit code only for the channels and bands that are touched, so the global and combined versions of one operation need not share a uniform set. A photograph with no masks compiles to the same shader byte for byte. Test: global -30 with a whole-frame layer at -20 renders within one count of global -50. |
||
|
|
8392cf772e |
Flatten contrast toward grey instead of scaling shadows by a ratio
Reducing contrast turned every black in a night photograph pink. The fragment lifted each pixel's luminance to its target by multiplying the colour by target/luma. For a pixel at 0.001 on the way to 0.09 that is a gain of ninety, and in the deepest shadows the channels are sensor noise: after white balance the red and blue noise sits above the green, their multipliers being nearly twice its, so ninety times that noise is magenta. Flattening now mixes the colour toward middle grey, which gives the same luminance and adds the lift as a neutral. A black goes to grey and its noise stays the size it was. The same fragment clamped luma/0.36 into the curve's 0..1 domain, which scaled every tone above twice middle grey down to 0.36 in either direction: contrast +10 took a 230 grey to 162. Those tones are now left where they are, which is continuous with the curve's top (value 1, slope 0). |
||
|
|
23abfd1827 |
Ship four presets for a bluer sky
Blue sky, Deep blue sky, Polariser, and Blue sky with golden land, in a Skies section after Essentials. Each darkens the colour mixer's azure and blue bands and adds chroma to them — what a polarising filter does to a clear sky — and brings the highlights down with it, so a white cloud does not read as a cut-out against the deeper blue. The stronger ones nudge azure towards blue and add dehaze. They are looks and work only on the hues a sky occupies, so an overcast frame is left nearly alone: there is no blue for them to deepen, and tinting grey cloud blue would be worse than doing nothing. Tuned by eye on the demo library's alpine and Manhattan frames, with an overcast Étretat frame as the control. |
||
|
|
bee5c5866f |
Erode dehaze's window in one pass per axis, and recover in the second
Dehaze cost 22.9 ms of a 2560x1600 frame on the reference laptop RTX 3050, and 54.1 ms at 3840x2160, with the memory clock held at 810 MHz by the power cap (graphics 1762 MHz). It ran five passes: a run and a span erosion along x, the same along y, and the recovery. At those clocks a detail pass costs what it reads and writes, not what it taps: a pass with an empty body - one render-sized rgba16float read and write - measured 4.0 ms, and each dehaze pass 4.4-4.6 ms, so the taps were about 2 ms of the 22 and the four hand-offs between passes were the rest. Each axis is now one pass that takes the minimum over the whole window directly, and the recovery rides in the y pass, which already holds the veil and the pixel's own colour. That is 36 texture reads per pixel at 2560x1600 in place of 12, nearly all of them cache hits, and two passes in place of five. The picture is the same bits. A minimum is exact in any order, and the window is the one Split always covered, the surplus pixel on the far side included (Split::first and Split::width). The veil crossing the removed hand-offs was already exactly representable in rgba16float - a minimum of channels read from rgba16float, floored at zero - so storing it between passes never rounded anything that the fused form now keeps unrounded. Measured with a scratch probe that renders the synthetic 60 MP frame from examples/frame_budget.rs, only a detail parameter moving so the fused pass is reused, 30 frames per scene after six of warm-up, five runs of each binary alternated, median of the per-run p50: scene before after dehaze 2560 fit 22.88 ms 9.06 ms dehaze 2560 1:1 23.41 ms 9.52 ms dehaze 3840 fit 54.09 ms 28.12 ms all detail 2560 fit 53.11 ms 39.97 ms (NR, sharpen, clarity, all detail 2560 1:1 67.48 ms 56.42 ms texture, dehaze) every op 2560 fit 57.59 ms 44.19 ms (with film) every op 2560 1:1 71.83 ms 57.93 ms controls without dehaze (NR, sharpen, clarity, texture): within +-2% The rgba8 output hashed identically before and after for every scene - dehaze alone, all five detail operations, every operation with film, and each other detail operation alone - at fit and 1:1, at 2560x1600, 3840x2160, 1917x1203 and 333x211: 64 of 64. |
||
|
|
a7b090cf36 |
Ship presets with the application instead of seeding them
The six starter presets were copied into the photographer's own library on a first run and were theirs from then on. That cannot grow into a real collection: a copy is frozen at the release that wrote it, so an improved preset reaches nobody who had the old one, and re-seeding would overwrite a preset someone had tuned. `dr_pipeline::bundled` now holds the shipped presets as `.drpl` files compiled into the binary, in sections — Essentials (the former six) and three sections of film presets, one per measured stock in dr-film, printed on the paper its profile names — and never writes them to the user's file. Every shipped preset is a look (`Reach::Named`), so applying one keeps the corrections a photograph already has. A name links a photographer's copy to a shipped preset. Saving over a shipped name makes their version the one that name applies; it is listed in the shipped section, marked as changed, and deleting it reverts to the shipped one. Renaming it makes it one of their own and the shipped preset reappears. Keyed on the name because that is what the photographer sees and chooses by. Copies an older first run seeded are forgotten on load where they are still exactly as seeded — otherwise all six would list as changed and stay frozen at their old values. A tuned one is kept and now overrides. The sheet lists "Yours" first, then each shipped section, with headings. Shipped rows apply and nothing else; a changed row offers Revert where the photographer's own offer Delete. A dr-ui test checks every shipped film names a stock this build can bake, on that stock's own paper, because dr-pipeline does not link the profile database. The film presets name stocks by id; the measurements behind them are spektrafilm's (CC BY-SA 4.0), attributed in each file as in dr-film. |
||
|
|
90c0695c05 |
Let a preset name its film, and let a look reach only what it names
A preset could not choose a film stock. The stock is a choice of material rather than a parameter, so `Preset` — a map of `op.param = value` — had nowhere to hold it, and "Portra 400, printed" could not be saved, copied or shipped as a look. Worse, the film node's own sliders *were* parameters: a paste moved one stock's exposure and push onto whatever stock the target was on, and left the target's tables baked from the values it had just replaced. A preset now carries a `FilmRef` beside its parameters. It travels under whichever scope carries the film node, so the stock and its sliders are never split, and by the replacement rule every other parameter follows: applied at that scope, a preset without a film develops the target without one. `Preset::apply` returns the `FilmRebake` it owes, as `EditGraph::set_state` already did, because this crate cannot bake a stock; the develop session pays it before recording the step, and the batch paste writes the stock into each sidecar through `film_for`. The library file spells it `film =` / `film_print =`, as a sidecar does, and an older build keeps those lines as ones it does not understand. `EditState` keeps the film in its own field only: the parameters it captures leave it out, so one edit has one place to say which stock it is on. Second, a preset now has a reach. Replacement is right for a copy of a whole edit — "make these match" — and wrong for a look: a stock-only "Portra 400" applied that way would put the photograph's exposure, white balance and noise reduction back to default. `Reach::Named` replaces only the operations a preset names (whole operations, so a look that sets the blacks resets the whites beside them) and the film only if it names one. Saved edits and the clipboard keep `Reach::Whole`; the line `reach = named` is written only for the other, so existing libraries write the same bytes. |
||
|
|
d430ec9528 |
Drop a detail pass that changes nothing when it sits between two others
Capture sharpening at a scale too coarse to draw its radius emits one pass with an empty body (`nothing_to_sharpen`), so that a chain still ends in something that performs the output transform. At fit on any modern sensor that is most of the time. When another neighbourhood operation follows it - dehaze, clarity, texture - the pass is not last and does nothing: it reads the rgba16float intermediate and writes the same texels to the other one. It still cost a full render-sized read and write every frame: scene before after sharpen+clarity 2560x1600 fit 18.66 ms 14.16 ms sharpen+clarity 3840x2160 fit 37.93 ms 27.88 ms every operation 2560x1600 fit 71.80 ms 67.24 ms clarity alone 2560x1600 fit 14.23 ms 14.20 ms (control) sharpen+clarity 2560x1600 1:1 23.01 ms 22.97 ms (control: resolves) (Laptop RTX 3050 held at 420/810 MHz by its power cap, synthetic 60 MP source, median of five alternated runs of forty frames each.) `compose_detail_with` now drops such a pass where dropping it is exact: not the last pass, whose output transform would otherwise move onto the previous pass's f32 result and round differently; and not a pass right after a reduced one, because a full-resolution pass is what closes the reduced chain for the operation after it. `DetailPass::is_identity` says what "changes nothing" means: full size, nothing bound at binding 3, and a body with no code in it. The rgba8 output is bit-identical: every scene above hashed the same before and after, and the sharpen+clarity frame hashes the same as clarity on its own, which is the claim in one line. A new dr-pipeline test pins the three cases - dropped ahead of another operation, kept when last, kept when alone. |
||
|
|
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. |
||
|
|
d748527a4c |
Keep colour labels in the sidecar so they survive and travel
A rating and a flag are written to DarkRoom's sidecar as well as the catalog, because the catalog is a disposable index and the sidecar is how a judgement reaches the photographer's other devices. A label had no place there, so once labels could be set, one would have lived only in the catalog of the device it was set on and gone with it. The sidecar version now carries `label` (0 none, 1-5 as the catalog codes it), written only when set. It merges under the rating's rule, so a device that never labelled a frame cannot clear another device's label, and a code this build does not know reads as none rather than as some other colour. A judgement write carries the catalog's label with the stars, and the scan takes a sidecar's label into the catalog when it has one. An older build keeps the line as an unknown key and writes it back. |
||
|
|
fc0ea8824d |
Format the crop-orphan measurement
rustfmt wraps two tuples in dr_pipeline::orphan that the previous commit left on one line past the width limit. No change in behaviour. |
||
|
|
9772785f81 |
Measure which mask layers a crop takes out of the frame
Mask geometry is stored in source coordinates, so re-cropping tighter never destroys a layer. It makes it invisible: the layer stays in the panel and the sidecar, its adjustment lands on pixels nobody will see, and nothing says so. The spec had no clause for this; FR-DEV-17 now states it, under the ID issue #10 reserved. dr_pipeline::orphan samples each layer's mask on a 64x64 lattice over the source, with the gradient, radial, brush and model-raster geometry the mask shader uses, folds the parts by their joins and inversions, and maps the samples through the framing to see how much of the coverage the crop keeps. `hidden_by_crop` reports the layers whose share fell below a tenth, and only those the change newly hid, so an already stranded layer is not announced again on every later adjustment. Ranges follow the picture and region selections need a label map this crate does not hold, so a layer that adds either is never reported: a false alarm on the common path would teach the notice to be dismissed unread. |
||
|
|
8cdad3863d |
Keep only where two selections agree, as a third way to join a mask part
A layer's parts could be added to the mask or taken out of it, and nothing else. The selections that need composing most are the ones that are neither: the sky that is also bright, the subject that is also skin. With union and subtract alone, "this and that" had to be spelled as "this minus everything that is not that", which needs a second part that selects the complement and rarely exists. Join gains Intersect, stored as "intersect" in the part block of a sidecar. It is the product of the two coverages, dst * src, which is one more fixed-function blend state beside union's max and subtract's dst * (1 - src) (mask-editing.md 5.2): the same scratch texture, the same three vertices, no shader arithmetic. The product equals the minimum wherever either side is fully in or out, and is the softer reading where two soft edges overlap. Join::apply spells the three operations on the CPU so the GPU tests can be held to one definition. A layer that intersects with a part covering nothing now reports that it covers nothing, so it is not rasterised as an empty slice. Old sidecars never contain the word, so they read as before; a build from before this reads "intersect" as a union, the existing unknown-join fallback, which keeps the part visible rather than dropping it. Join::ALL keeps union and subtract at indices 0 and 1 so a stored panel index still means the same join. |
||
|
|
84fade99ec |
Put the developer docs under docs/dev and index the folder for users first
docs/ had 26 developer documents flat beside the manual, and the two audiences are very differently sized: most readers want the manual and the gesture reference, a few want the register, the designs and the measurements. The manual and gestures.md stay at the top; everything for someone changing the code moves to docs/dev/, and the two documents that name their own successors — the v0.1 milestone and the UI-refinement plan — go to docs/dev/archive/ rather than being deleted, since both are still cited. docs/README.md is the index, users first. Every reference follows: code comments, Cargo manifests, the workflows, the pre-commit hook, the bench and traceability tools (which locate the repo root by docs/dev/requirements.md now), packaging, the Docker READMEs, CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level deeper and is regenerated. Links out of the moved documents into the tree gain a level; a link checker over every Markdown file finds none broken. |
||
|
|
12f8990e09 |
Average a patch under the white balance picker, not one photosite
The probe's comment said a 192px render "averages a small neighbourhood into each of its pixels". It does not: the composed shader fetches the source at one position per output pixel - nearest for an unrotated frame, four photosites blended otherwise - so the probe was a point sample of a noisy sensor, and two painted-white air conditioners on the same wall answered +37 and -50. The tap is now narrowed to the patch of the canvas around the click, a couple of percent of its width and square on screen, and rendered at 64x64 with interpolation forced on, which puts a sample on every sensor pixel under it at any ordinary zoom. The samples are averaged, with the void and clipped ones left out rather than allowed to pull the mean, and fewer than half surviving is refused. compose_camera_probe takes the patch; the merge's compose_camera_linear keeps its nearest sampling. The readback shrinks from six megabytes to sixty-four kilobytes. A frame of alternating warm and cool columns, averaging neutral, moves the controls by at most two units; a point sample swung them to sixty. |
||
|
|
4576499c3b |
Refuse a clipped highlight as a neutral
Sampling the overcast sky on a Canon 6D frame set tint to -100 and temperature to -15 for a patch the canvas showed as pure white. A clipped photosite is sensor white, not a colour: every channel stopped counting, so what the tap hands back is the as-shot multipliers themselves, which are strongly magenta, and the solver dutifully drove green to its stop. The display shader already fades such a pixel to a neutral of the same brightness before any operation runs, so the picker was balancing against something the photographer could not see. The probe now refuses a sample with any channel at or above the onset the shader fades from, the way the solver already refuses black. The threshold is one constant, CLIP_ONSET, formatted into the shader and read by the probe, so the two cannot drift apart. |
||
|
|
2f47087223 |
Measure the white balance probe in camera RGB, where the gains multiply
Pressing "pick" and clicking a near-neutral wall on a Canon 6D frame set tint to -77 and turned the whole photograph green. The white balance operation runs first in the chain, on camera RGB, before the body's base curve and colour matrix; the probe was read off a display render after all three, and the solve treated that sRGB triple as if the gains multiplied it directly. On a JPEG the two spaces coincide, which is why the existing tests passed while the picker was broken on every raw file. The probe now reads the camera-space tap a merge stitches from, composed under the edit's own framing so a fraction of the canvas is a fraction of the probe, and puts the as-shot balance on itself - exactly the value the operation's gains are about to multiply. No operations run in the tap, so nothing has to be stripped and restored, and the display target is left alone, so a sample that found nothing usable no longer needs a redraw. A raw-frame test with the 6D's matrix and a typical as-shot balance samples a warm grey and asserts the rendered pixel comes back neutral; it fails on the previous probe. |
||
|
|
2fd7690b6f |
Mark a pixel the lens correction pushed off the sensor with alpha 0 in the camera-space tap
The fused shader stored black with alpha 1 for a pixel whose source coordinate left the frame, and the merge's warp averaged it in like any other: a dark, badly interpolated fringe along every frame's edge, visible as a seam wherever a frame ended and, later, as the edge the border fill continued. The display keeps its opaque black; CameraLinear stores alpha 0 and the warp weights each sample by the alpha it interpolated, dropping a sample that has none. |
||
|
|
42d11d919b |
cargo fmt and clippy across the panorama work, and one lint master carried
The dr-face comparison is master's: a negated partial-order test on the eye box's width, rewritten as the two conditions it meant. |
||
|
|
75d2ceb23c |
Provenance in the sidecar, a launch hook for the page, and where it stands
derived_from and merge are top-level sidecar fields (FR-MRG-6): one line per source in order, and how the composite was made. A build that predates them keeps the lines as unknown and writes them back. The job writes the sidecar beside the composite and stages it with its own record when the composite goes through the outbox. DARKROOM_START_MERGE=a.CR2,b.CR2 lands on the merge page at startup with the job running on local files, on the model of DARKROOM_START_IDENTITY, for looking at the page where synthetic clicks do not reach it. The fetch and the start are shared with the grid's button. panorama.md §11 records what exists, the fixture's figures, and the six things still open, auto-crop first. |
||
|
|
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. |
||
|
|
ed4460cb9c |
Tag three requirements the code already meets
R5 says in its own note that zoom_resolution.rs establishes it as a pixel equality; that file was tagged FR-DSP-5 alone. FR-DEV-19's three sub-clauses carry eighty-three tags between them while the parent had none; MaskLayer, which is the thing they edit, now carries it. And NFR-R3 — a crash in decode does not take down the application, the image is marked failed — is exactly what the decoder's panic guard and the face sweep's unreadable mark do, tagged FR-RAW-4 and NFR-SEC-1 and not the clause that asked for them. |
||
|
|
696bafa9d5 |
Undefer AI subject masking, which shipped, and give it a clause
§7 still listed "AI subject masking — deferred per D11" while MaskSource::Subject and MaskSource::Category, backed by dr-segment's instance and semantic models, had been the primary way a local adjustment is made for weeks. The code was tagged FR-DEV-3, which names gradients and brushes and says nothing about a model. FR-DEV-3i now states what exists: a subject or a category found by a local model, stored as identity with the run's signature so that it merges per field and reads as stale rather than wrong, then treated as any other layer by the edge, stroke, composition and reveal clauses. The one place it departs from FR-DEV-19 — coverage written run-length coded beside the layer, so a stored subject renders without a model — is recorded in the clause instead of left for the next audit to find. The segmentation crate and the UI's selection module are tagged to it. |
||
|
|
d3b6127db6 |
Let a photographer name the state they liked, and go back to it or look at it
FR-DEV-5 asked for named snapshots of an edit state and FR-DEV-7 for a comparison against a chosen one, and neither existed. The history stack is per sitting and forgotten with it, on purpose — the gap that mattered was an automatically saved mis-drag with no way back, and that was closed first. What was left was the other half: a state the photographer wants to keep *because* it is worth keeping, which is a different thing from a step and is not served by making the steps last longer. A snapshot is an edit state, and an edit state is exactly what a sidecar version stores, so it is stored as one: a `[version]` block carrying `snapshot-of = <uuid>`. The parameters, the masks and their parts, the repairs and the film all arrive through the blocks that already carry them, a merge keys on the uuid as it does for any version, and a build that predates the key reads the block as a named version and keeps it — the right failure. Only the pointer is new. The one reader that has to know is `default_version`, which must never answer with a snapshot: a file whose edit is missing is not a file whose edit is one of its saved moments. The snapshots of an edit are listed by that pointer, oldest first, the same on every device. Writing them back removes what this sitting deleted and puts in what it holds, and leaves standing whatever it never saw — a snapshot the other device took since the photograph was opened here is not this device's to remove by not knowing about it. That is the rule the version merge already keeps, applied one level down, and it is why the save carries the deleted ids rather than replacing the list wholesale as the masks are. Each is re-pointed at the uuid the save settled on, because the default may have been fused onto its canonical identity since the snapshot was taken. Restoring is one history step, so undo takes it back whole, as a paste is. Taking and deleting are not steps: they change nothing about the photograph, and an undo that removed a snapshot would be undoing a decision to remember. Holding the eye beside one renders the snapshot and hands the edit straight back — the same suspension "Before" uses, against a point the photographer chose rather than the file. Two sessions on the same photograph get ids that cannot collide, stamped with the second and a random word, because the merge folds equal ids into one. |
||
|
|
4574c35236 |
Let a part be left out of a mask without being taken out of it
A layer built from parts was missing the one control a correction most often wants: seeing what it did. The question a subtracted gradient raises is whether it took only the sky, and the question a stroke raises is whether it filled the shoulder — and the only way to ask either was to remove the part and look, which answered the question and lost the part. The layer's own ring answers a different question, about the adjustment, and hiding eight layers to check one correction is not an A/B anybody performs. So a part carries `hidden`. It is an edit and a history step, as the layer's switch is, and it is folded into the render fingerprint because hiding a part changes the mask as surely as removing it does. Where the mask is built the shown parts are walked rather than the parts, which is what makes a hidden base hand the fold to the first part that is shown — and a revealed layer whose every part is hidden clears its slice rather than leaving whatever the last rasterisation put there to be read back. `covers` asks the same shown parts, so a layer whose only adding part is hidden costs no slice at all. In the sidecar the key is `hidden`, in the part's block or, for the base, in the mask block — under a word that cannot be confused with the layer's `enabled`, which has always meant the layer. Absent means shown, so no file written before the switch existed reads any differently. The row wears the same ring the layer does, one row down, because it is the same question about a smaller thing. |
||
|
|
a87139b838 |
Give every mask an eye and a colour, and put the brush where the mask is
The first build of seeing a mask showed the selected layer's, in one global style, from a strip at the top of the panel. It answered the wrong question and answered it somewhere nobody looked. What a photographer asks of two masks is how they meet — where the sky's edge sits against the building's — and that needs both on screen at once, in colours that can be told apart. So each row of the stack has an eye, drawn in the colour its mask is shown in, and each mask has six swatches to choose that colour from. Several can be open at once; a new one comes up open, in the first colour nothing else is using. The style — tint, alpha, outline — is the one setting that stays global, above the stack, because three styles at once are three pictures that cannot be read against each other. Alpha now draws every shown mask, each in its colour, on black. In the pipeline a `Reveal` is a list of `(layer, colour)` rather than one layer, and every reveal block carries its own colour. The brush moves too. Select, Paint and Erase and the three sliders under them sat at the top of the panel, appeared only once a row was selected, and said nothing about which mask they acted on — so "how do I paint" and "how do I correct the model's outline" both had the same answer and nobody found it. They sit under the selected mask's parts now, beside the swatches, and on a subject or a category the hint says what a stroke there does: it becomes a part of this mask, joined to the model's, and can be taken out again. Eyes and colours are viewing state, on the session and not on the layer, so a photograph reopened has every eye closed — the stored-mask round-trip test asserts it. |
||
|
|
c045702a47 |
Show the photographer the mask they are shaping
Nobody can refine an edge they are not being shown. The only thing drawn on the canvas was the region overlay — a false-coloured picture of what the model *detected* — which knows nothing of a layer's feather, its falloff, its morphology, its invert or its opacity, and nothing at all about a gradient, a range or a stroke. Every control added for mask editing therefore acted on something invisible, which is why the whole feature reads as absent rather than as unfinished. A layer's finished mask now draws over the photograph in one of three styles: a tint for whether the right thing is selected, an alpha for where the edge is, an outline for whether that edge is registered against the detail the other two hide. The hard part is not the shader. A selection with no adjustment on it changes no pixel, so it is not active, so it holds no slice of the mask array and is never rasterised — and that is exactly the layer somebody wants to look at, for the whole of the time between choosing a subject and deciding what to do to it. So `MaskStack::rendered` is `active()` plus the layer being looked at, and the rasteriser, the composer and the distance-field builder all index by position in it. Which is also why the design's "two uniforms, no recompile" is not available: a uniform can select a slot, it cannot conjure one. The reveal is never on the graph. It reaches the pipeline as an argument to `compose_revealing`, and `compose_for` — which the exporter, the thumbnail and the neutral probe all call — has no way to ask for one. A flag on the graph would have been shorter, would have type-checked, and would have been one forgotten reset away from a red tint baked into an exported file. And the tools that shape a mask now arm. `Masking.tool` is an `in` property only Rust may write, and the handler wrote nothing back, so the strip reported "Select" however many times Paint was pressed and the paint area was never enabled — the brush, the parts and the whole of FR-DEV-19b reachable from no control in the application. The region overlay stands down while a mask is being shown, and its button now says what it hides: two overlays that look alike and mean different things is worse than either. |
||
|
|
df741a8a49 |
Let one mask be built from more than one selection, and paint into it
A mask the model draws arrives approximately right — stopping inside a shoulder, leaking into the hair — and FR-DEV-3's edge controls move the *whole* boundary, so no value of feather or dilation fixes two errors that go opposite ways. What fixes them is a second selection joined to the first, and a layer that held exactly one source had nowhere to put one. The brush the core has had all along was reachable from no control in the application. A layer is now an ordered list of parts. Each names a source and how it joins the mask before it — added to it, or taken out of it — and carries its own edge treatment, because a model's soft coverage and a stroke painted where it stopped short do not want the same feather. Invert and opacity stay on the layer, where the composed shader already reads them. The sidecar grows `[part]` blocks and nothing else. A layer of one part writes exactly the bytes it always did; a mask block with no part blocks after it reads back as one part; and a stroke, a join or a source this build cannot read costs that part rather than the layer. So every sidecar in every library still parses to the edit it always was. On the device the parts fold into the layer's one slice, so eight layers still cost eight channels: union is a `max` blend and subtraction is the erase blend the brush already used. A part is drawn into a scratch texture before it is joined, and that is not incidental — an erase stroke means a hole in *that part*, not a hole in the mask, and drawn straight onto the accumulator it would punch through the subject underneath. A layer of one part skips all of it and takes the path it always took. In the interface: a part list under the selected layer with a chip saying which way each joins, Add and Subtract beside it, a Select/Paint/Erase strip with the brush's size, hardness and flow, and a drag on the photograph that paints. Pressing Paint on a mask that cannot hold a stroke joins a part that can, rather than explaining that a subject is not a brush. A whole stroke is one step in the history. The edge controls now shape the part that is selected rather than the layer, which is the one behaviour change to an existing control: with a correction selected, the feather slider softens the correction and leaves the model's mask alone. |
||
|
|
af89433aee |
Offer the lens profile as a tick box, since applying it silently reads as absent
The develop panel's Optics group is three manual sliders: distortion, chromatic aberration and lens vignetting. The automatic correction was already there — the file's EXIF lens is matched against the bundled Lensfun database on open and the coefficients are fanned out to all three — but nothing in the interface said so except a line of grey text under the camera reading "· corrected", and there was no way to decline it. From the outside that is indistinguishable from the feature not existing, which is how it was read. `dr-lens` states the rule this breaks: an automatic correction that silently does nothing is worse than one the user can see is unavailable. The caption satisfied the letter of it and not the point — a photographer looking for "apply the lens profile" found three sliders and no switch. So the profile is now a control. It is a capability rather than a flag on the session, because everything a photographer sets travels one road: the capability list feeds the generated panel, `Preset` captures it, the sidecar stores it and the undo stack replays it. A bool on the side would have needed adding to each of those four by hand and would have been forgotten in at least one — which is exactly how the mask stack came to be missing from the history. It is on by default, which is what `switch_on` is for: the coefficients are a measurement of the lens that took the photograph, so accepting them is neutral and declining them is the edit. The sidecar therefore stores nothing for the ordinary case and the correction still happens. The switch appears only where a profile was matched. A tick box on a photograph whose lens the database has never heard of would be a control that looks available and does nothing, which is the failure the rule above names rather than an instance of following it — those photographs are told "· no profile" in words instead, and one whose box is unticked now says "· profile off", which is a third fact and not either of the other two. Two things had to be built underneath. `ParamKind::Bool` was in the core's closed enum and mapped to a row kind here, and had no control behind it in `adjust.slint`: a parameter declaring itself a switch was flattened into a row that drew nothing at all. Nothing shipped had one until now, so the gap cost nothing and was invisible. And `Check` self-toggled, which is right for a settings page that owns its value and wrong for a panel row that is a view of the edit graph — the click would have answered by replacing the binding with a literal, and the next undo or pasted preset would have moved the value with the tick left where the finger put it. It now takes `controlled`, and the generated row uses it. The manual sliders are unchanged and still trim whatever the profile leaves, so switching it off is "correct this by hand" rather than "stop correcting". |
||
|
|
901f51e6c4 |
Point at something grey and let the pipeline work out the rest
FR-DEV-3 has asked for "white balance (temperature/tint, and picker)" since it was written, and only the first half existed. `WidgetKind::WhitePoint` was in the vocabulary and `develop::supported` answered false for it, so the node degraded to two sliders — correct behaviour that had quietly become the only behaviour. Sampling a neutral is the first move of the global tonal pass and every colour judgement afterwards is measured against where the grey was put, so guessing at two sliders until a wall stops looking green is the wrong way round. The awkward part is that a picker genuinely needs to know how far a hundred units of temperature move red against blue, and that number is declared in the node's own file. So the inversion lives in `dr_pipeline::neutral` rather than in the interface: the canvas hands over a colour, the core finds the operation that asked to be driven by a pixel and bisects its declared response until the sample comes back grey. Nothing in `ui/` names white balance, and nothing holds a second copy of a response that would be wrong the first time somebody adjusted the range. A bisection rather than a closed-form inverse because only monotonicity is part of the bargain — the expression is free to become a table tomorrow. The result is rounded to the precision the control is drawn at, which is not cosmetic: unrounded, sampling something already neutral lands a ten-thousandth off zero, and the photograph comes back modified with an undo step for a correction of nothing. On the panel side this needed one distinction the generated path was missing. `is_on_canvas` was being read as "and so the panel draws nothing for it", which is right for a crop — four edge fractions are not controls anyone drags in a list — and wrong for an eyedropper, which *writes* temperature and tint and leaves them exactly the controls a photographer reaches for next. So a sampling widget keeps its sliders and puts the affordance that arms the canvas in the group's heading, built like the reset beside it. One click, one sample, one history step: `Edit::Action` never coalesces, and there is no hover preview to fill the stack with temperatures nobody chose. Declaring the presentation also groups temperature and tint under one undo step, where they were two. That follows from what `Presentation` means and reads correctly — white balance is one decision — but it is a change, and worth saying so. |
||
|
|
68ebf5d78b |
Let a mask start from a tone or a colour, not only a shape
Every local adjustment began from a shape: painted, drawn with a handle, or found by a model. So the only way to hold back a sky was to draw a line near where it ended, and the only way to warm skin was to paint round it — both of which put the edit's edge where the photographer put a gesture rather than where the picture changes. A gradient across a treeline halos, and an adjustment traced round a face stops on the outline of a hand. MaskSource grows two variants that select by what a pixel *is*. Luminance carries two bounds on the perceptual tone scale plus a softness; Colour carries an arc of hue, a range of chroma, and one softness for every edge of both. Five floats and three, so they diff, sync and merge per field under FR-NC-9 exactly as a gradient's geometry does — the property a stored raster has none of, and the reason the model's coverage had to sit beside its source rather than inside it. The pixels are the shader's business and nowhere else's. `mask.wgsl` takes the demosaiced source as a sixth binding and two new modes read it: decode, balance, pull a clipped photosite back to neutral, apply the camera matrix, then weigh the band. Nothing crosses to the CPU but the numbers and the matrix, and each mask texel averages its own footprint in the source, so a band lands on the tone an area is rather than on whichever texel a proxy grid happened to land on. The photograph it measures is the one the camera recorded, before this edit. A band over the edited result would slide out from under the edit as the edit was made — raising the highlights would change which pixels counted as highlights, and the slider would chase its own mask. Feather, falloff and morphology stay off a range layer, which is what `shapeable` already meant. All three are functions of the signed distance from a boundary, and a range has no boundary to be at a distance from; its edge is the softness of its own band, in the band's units. Offering them would be four controls that move and change nothing. |
||
|
|
81b1ae8c42 |
Measure the haze from the picture, and divide it back out
Four files named dehaze as a member of the compositional detail family — `detail.rs` twice, `dr-gpu`'s detail module, `ops/README.md` and `capture_sharpen.rs` — and no such node existed. Every one of them was describing the family by listing clarity, texture and a control the photographer could not reach. Haze is the one degradation the controls already in the chain cannot remove, and the reason is spatial rather than tonal. Scattering composites an airlight over the scene in proportion to distance, so the lift is per-pixel: a black point that clears the mountains crushes the foreground, and a contrast curve that clears the mountains does the same. So the node has to estimate the transmission at every pixel, which is the dark-channel prior — the local minimum over the channels and over a patch is the airlight that has been added there — and then invert the scattering model with it. The airlight is taken as neutral and as unit, which removes the one part of the published method this stage cannot perform. Estimating it properly is a whole-frame reduction, and the detail chain has none: it hands each pass the pass before it. It is also unnecessary, because white balance is the first node in the chain and has already driven the illuminant to grey, so only the magnitude is unknown — and an unknown magnitude on the veil is a scale factor on the amount slider, which the photographer is setting by eye regardless. The patch is a fraction of the frame's shorter edge, through `RenderScale::frame_fraction`, and never a count of pixels. It has to be wide enough to contain something dark and narrow enough that what it measures is still local, and both of those are statements about how much of the composition it covers — so it must cover the same proportion of the picture on a proxy as in the export, or the file is sharpened for a patch three times narrower than the one that was tuned on screen. Affording it needs an identity a Gaussian does not have. Erosions compose by adding their structuring elements, so the minimum over a run of d followed by the minimum over k points spaced d apart is the exact minimum over the whole kd window. At the square root that is 16 taps rather than 61 at 4K, and it is the same filter rather than an approximation of one — which is the difference from the strided kernel `local_contrast` refuses, where sampling an image that is not band-limited aliases into the base and comes back as mottling. It runs first among the compositional detail nodes, at order 125: after noise reduction, because dividing by a transmission below one amplifies the noise in the veiled distance by exactly the factor it recovers the contrast by, and before clarity and texture, coarse before fine, so that their base is computed on the picture the veil has left rather than on a modelling about to be divided out. What it cannot honour is the placement dehaze most wants. It shifts colour — it subtracts a grey term and rescales, so saturation changes wherever the veil is thick — and the colour work would ideally be correcting the picture that leaves here. The detail stage runs as a group after every point operation, because a neighbourhood pass is a separate dispatch over a texture the fused pass has finished writing, so an order placing this node ahead of `vibrance` would be a lie the chain cannot tell. Interleaving would mean splitting the fused pass in half around it, at the cost of a second full-frame dispatch and intermediate for every edit in the catalogue whether it dehazes or not. The declaration records that rather than leaving it to be rediscovered. FR-DEV-18 is added to the requirements register alongside it. The tag had nowhere to point, and an orphan tag fails the traceability gate rather than quietly counting for nothing. |
||
|
|
7c3e1d2c54 |
Let the shadows and the highlights carry a colour the picture never had
The colour mixer is the only chromatic control in the chain, and it can only turn a hue that is already in the frame. Ask it for cool shadows against warm highlights and it has nothing to take hold of: the shadows of a correctly balanced photograph are near enough neutral that there is no band there to turn, and a monochrome conversion hands it a picture with no hue in it at all. Split toning is the oldest look in the book and every developer worth comparing against ships it; there was no way to reach it from here. So colour_grading, declared like any other node — a hue and a strength for the shadows, the midtones and the highlights, and a global cast over the frame. It targets a tonal range rather than a hue, which is the whole difference between the two controls: it puts colour where none was rather than turning what it finds. It sits at 105, after the mixer has had the last word on the colours that are in the picture and before the detail stage. The mechanism is one helper. Three cosines 120 degrees apart are the hue wheel written directly as an RGB direction, and their sum is zero at every angle, so exp2 turns them into three gains whose product is exactly one — a cast tilts the balance without moving the level. A grade that doubled as an exposure change is the failure that has the photographer chasing brightness with a colour slider, and it is corrected with a control that cannot reach it. The three tonal weights partition the scale rather than overlapping, the midtones being whatever the two ends leave, so setting all three to one hue is exactly the global cast and a split tone does not colour its own midtones as a side effect of its halves meeting. Full strength is half a stop on the leading channel, the ceiling white balance already holds itself to. Neutral is declared rather than inferred, which is what `active:` is for. A hue with no strength behind it is a direction with no distance, so under the default rule nudging one would have put the node into every fused shader for a change nobody can see. Summing the strengths is zero exactly when all four are, and they cannot go negative to cancel each other. The opposite reading — neutral as "nothing has been touched" — fails the other way round: red is hue zero, so a grade toward red never moves a hue off its default and would never have been applied at all. It asks for a colour wheel, the widget the descriptor vocabulary has been carrying with no operation behind it. Nothing draws one yet, and that is fine by construction: the panel takes the first widget it implements and falls through to sliders otherwise, so this arrives as eight ordinary controls that work. Each parameter is named for its own range for exactly that reason — in a flat list, four sliders called "Hue" are four controls nobody can tell apart. FR-DEV-12 is written into requirements.md beside it. A TRACES tag naming a requirement that is not defined there is an orphan, and the traceability gate fails on those rather than quietly counting them. The label catalogue gets one line for the operation's display name; the eight parameters derive correctly and are left to. |
||
|
|
efa9d84aad |
Correct the lens first and settle the grain last
`Attribute::ALL` has claimed since it was written to be roughly the order a photographer works in, and |
||
|
|
e235e99cce |
Move the film to Effect in the descriptor that is actually read
An earlier commit claimed to move `film_sim` from `[tone, colour]` to `[effect]` and did not. It edited `ops/film_sim.yaml`, where `attributes:` is read, validated against the vocabulary, and then dropped: a `rust:` node publishes its own descriptor, and the type still said tone and colour. The stock went on appearing in the Light group beside exposure and again in Colour beside white balance, exactly as before, and every test passed. Nothing caught it because nothing could. The declaration parsed, the parity tests compare ids rather than attributes, and an operation filed under the wrong groups renders perfectly. It surfaced only on screen, as a missing Effects tab — which is indistinguishable from a category that genuinely has nothing in it, and is precisely how `Optics` looked for as long as it was empty. So three changes rather than one: `FilmSim`'s descriptor declares `Attribute::Effect`, which is the move the earlier commit described. `attributes:` joins the keys a `rust:` node may not carry, beside `params`, `uniforms`, `wgsl`, `helpers`, `define` and `label`. The rule was already written — "its descriptor comes from the type" — and attributes were the one field that slipped past it. A key that is silently ignored is worse than one that is rejected, because it reads as though it worked; the eight hand-written declarations lose a line that never did anything. And a test asserts that every attribute the chain carries reaches the tab strip. That is the property that was actually broken, and its failure mode is invisible from every direction: the controls exist, they are in the shader, and there is no way to filter to them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
474dcf0bf6 |
Let Compose lead the attributes, as their own doc always said
`Attribute::ALL` claims to be "roughly the order a photographer works in" and then listed framing fifth, behind tone, colour and detail. Framing is the first decision made about a photograph and the one every later judgement is made inside — there is no sense balancing tones across a frame about to lose a third of its width. The contradiction was harmless while the list only fed a row of chips nobody reads in order. It stops being harmless now that the same list drives a column read top to bottom. `declared::Attr::ALL` moves with it. The two are separate spellings of one vocabulary and a test asserts they agree, which is what caught this rather than the order silently disagreeing between the YAML front end and the crate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |