Commit Graph
141 Commits
Author SHA1 Message Date
dtourolle 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.
2026-10-03 14:22:47 -04:00
dtourolle 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.
2026-10-03 14:22:46 -04:00
dtourolle dd43f498fb Run the learned denoise in develop, and export with it
A Bayer photograph keeps its mosaic in the session and is offered the AI
Denoise switch. Asked for, the network runs on the decode executor from a
hot-pixel-repaired copy — the app's own pass — with the frame's noise from
its best source, and its progress in the activity bar; the classical
demosaic shows until the result lands, and the finished job says where the
noise figures came from. Keep grain is a GrainBlend of the two, made once
per value; the render draws it as its source and the adjust pass never
knows. demosaiced stays the classical result, so the raw histogram, the
white balance picker, masks and segmentation still read the sensor.

The develop view reconciles on a 250 ms poll rather than on each way an
edit can change (slider, undo, preset, version, a sidecar from another
device): two comparisons when nothing changed, and no path that can forget.
A failure is not retried until the switch is toggled. An export of a
photograph that asks for it waits for a running job or computes it.
2026-10-03 11:51:00 -04:00
dtourolle ad6bb892f3 Carry the learned denoise's switch and grain as edit settings
Whether to use the learned denoise, and how much grain to keep, are what a
photographer sets, so they travel the one road every setting does: published
as a capability, captured by Preset, stored in the sidecar, replayed by the
undo stack (FR-DEV-3c). Published only on a photograph that can take it, for
the lens switch's reason; the availability is derived from the file and is
not in the state. Off by default, grain 0; a reset returns both.
2026-10-03 11:22:29 -04:00
dtourolle 8ea3c3181a Upload the learned demosaic's result, and blend grain back into it
DemosaicedImage::from_rgb_f32 takes the network's linear camera RGB and
stands it beside the classical source of the same photograph: the matrix,
profile tables and as-shot balance are that source's, the id is new, so
nothing downstream can tell which demosaic ran and every cache keyed on the
source sees a new one.

GrainBlend is the denoise's live control. It returns only the brightness of
the noise the network removed, taken after the as-shot balance and handed
back divided by it, so the grain is neutral in the finished picture; colour
speckle and demosaic false colour stay out. It writes a new source rather
than adding a term to the adjust shader: the blend depends on two images and
one number, a 20 MP pass is milliseconds, and a fresh source id is all the
adjust pass's caches need. The test reads it back: at 0 the network's
result, at 1 the same white-balanced step in every channel.
2026-10-03 11:20:48 -04:00
dtourolle 20b7bd7663 Feed every input a model declares when probing a rung
The probe built one zero tensor from the first input and ran the session
with it. Every model so far had one input; the denoiser has two (mosaic and
sigma), so every rung failed with "Missing Input: sigma" and the role was
left on the CPU: 14.4 s for a 20 MP frame where TensorRT fp16 takes 3.1 s.
Zeros now go to each input by name.
2026-10-03 11:15:49 -04:00
dtourolle eb91fa02c2 Give the inference engine a denoiser role, kept off the Hexagon
The learned demosaic-and-denoise (denoise.md) runs through the engine like
every other model. fp16 cost it nothing measurable (0.00 dB at every ISO on
validation tiles), so it takes TensorRT's and MIGraphX's fp16 like the
detectors. int8 cost it 6 to 9 dB, far past a 0.5 dB gate, so the Hexagon
refuses the role outright rather than relying on no int8 sibling existing,
and the tablet runs it on the CPU.
2026-10-03 10:39:14 -04:00
dtourolle d4248bc0dd Run the app's hot-pixel pass alone, and dump through it
The learned demosaic replaces the classical one and takes its input, the
mosaic hot_pixels.wgsl leaves (denoise.md §2), so its training data and its
input in the app must come through that pass and not a lookalike. The pass
was recorded inline in Demosaicer::run; it is now built by hot_pass and
recorded by record_hot_pass, which run still uses unchanged, and
Demosaicer::repair_hot_pixels runs it on its own and reads the mosaic back.

mosaic_dump moves to dr-gpu to call it, records how many photosites changed,
and keeps --unrepaired for a raw readout.
2026-10-03 10:25:28 -04:00
dtourolle 1f266a4478 Dump RAW mosaics for training the learned denoise
denoise.md §4.4 requires the training repo to read photosites through
dr-decode, not LibRaw, so black and white levels, the active area and the
CFA phase match what the app will feed the network. mosaic_dump reads
`input<TAB>prefix` lines and writes the whole readout as .npy plus a JSON
of what decode and metadata report. The masked border is kept: its
optically black photosites are a free dark frame for the noise profile.
2026-10-03 10:25:27 -04:00
dtourolle eae720ce75 Say which camera profile a photograph renders through, and offer to copy it
The info panel gains a line under the lens: "Adobe Standard · in the
file", the .dcp it came from, "· off" when the photographer switched it
off, or "No camera profile · matrix only" — the ordinary case for a
CR2, worded as a fact rather than a failure. A DNG whose embedded
profile may be copied, for a body with no installed profile, also gets
"Use this profile for every Canon EOS 6D →", which saves it into the
profiles directory; the body's CR2s render through it from their next
decode.

The profiles directory is <data>/profiles, read at start-up on desktop
and Android before anything decodes. The library open path never set
the lens line; it now sets both. Labels: Camera Profile, Use Profile,
Look Amount.
2026-10-02 22:38:07 -04:00
dtourolle 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.
2026-10-02 22:38:06 -04:00
dtourolle 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.
2026-10-02 22:38:06 -04:00
dtourolle 0b06e31bf3 Let a zoomed view fill the viewport rather than keep the photograph's shape
The view was the same fraction of each axis, so it kept the frame's
aspect at every zoom: a portrait zoomed on a landscape screen stayed a
portrait strip with the screen's sides empty. Each axis now shows as
much of the frame as the viewport holds at that magnification, capped
at the whole frame, and the render is fitted to the viewed region
rather than to the frame. A redraw re-cuts a zoomed view about its
centre when the viewport or the crop changes shape.
2026-10-02 22:29:48 -04:00
dtourolle d5c93ae795 Step to a library photograph without flashing frames in between
Benchmarks / Frame budget (on demand) (push) Canceled after 0s
Benchmarks / CPU and I/O (per commit) (push) Canceled after 5s
Traceability / Requirement traces (push) Canceled after 0s
Build and test / Desktop (Linux) (push) Successful in 1h21m7s
Build and test / Layer separation (push) Successful in 46s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 5s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Successful in 47m42s
Build and test / Windows (x86_64, cross) (push) Successful in 54m42s
Build and test / Publish the release (push) Successful in 52s
A step along the roll showed up to four pictures: the grid thumbnail,
the previous photograph again (the thumbnail was dropped when the bytes
landed, while the canvas still held the last texture through the decode
and first render), the new one at its defaults when a cached original
beat the sidecar, and then its edit.

The thumbnail now stays up until render_now draws the new photograph's
first frame, and that first frame waits up to 400 ms for the stored edit
before drawing at the defaults. A later arrival still redraws.
2026-10-02 20:46:05 -04:00
dtourolle 379dd1afcc Keep a late sidecar off the next photograph
A stored edit that arrived after the view had stepped on was applied to
whatever session was open by then — the next photograph's. The wait now
stops once the open it belongs to is no longer the current one.
2026-10-02 20:44:43 -04:00
dtourolle 23a2f13b46 Apply the collision policy to exports bound for the server
A queued export could not see the server, so its name check always
answered "free" and the upload PUT over whatever was there: Increment
and Skip behaved as Overwrite on Nextcloud, and two exports of the same
name queued before either uploaded landed on one file.

The batch now names around what the album records of earlier exports
and what the outbox already holds for that folder. The outbox record
carries the policy, and the drain lists each destination folder once
and applies it against what the server holds: Increment steps past a
taken name and re-points the album's row, Skip drops the entry. A
record without a policy (older builds, a merge's composite) is sent as
named, as before. The album is recorded before the drain starts so a
rename has a row to move.
2026-10-02 19:40:27 -04:00
dtourolle 36556729f1 Let Back close the presets menu instead of the application
The presets menu at the foot of the tool rail is a PopupWindow, and
showing a popup takes focus off the develop view until it closes. The
menu held nothing focusable, so Android's Back gesture, pressed to
dismiss it, found no focus item, went unanswered, and the platform
closed the application. Slint closes a popup on Escape by itself but
not on Back.

The menu now holds a key scope, as the film list does, that closes it
on Back or Escape. The next Back leaves develop for the grid.
2026-09-30 22:11:51 -04:00
dtourolle 6f33517b35 Cut panorama overlaps along seams instead of averaging them
The merge weighted every overlap pixel by its distance from each frame's
edge, a 200 px linear cross-fade. Anything the frames disagreed on —
parallax in the near foreground, grass in the wind, a walker — came out
twice at half strength: a soft double edge at 1:1.

dr_pano::seam picks, per output texel at proxy resolution, which frame a
pixel comes from. Where a new frame overlaps the composite the cost is the
gain-corrected difference plus local detail plus nearness to either
frame's edge, taken as the worst over a small window, and the cut is a
dynamic-programming path across the overlap. merge.wgsl weights each frame
by its tent-filtered share of that map, a 64 px blend that follows the
seam, with the edge feather kept as the fallback. The page's preview uses
the same map, and examples/merge.rs takes --feather-only for comparison.
2026-09-30 21:57:44 -04:00
dtourolle 1d7115437b Date a photograph from its name when its header has none
WhatsApp strips every EXIF tag and names the file "WhatsApp Image
2023-06-15 at 07.00.42.jpeg"; Windows Phone, Android cameras and
darktable's import put the date in the name too. Those images sorted
after everything else and were absent from the timeline.

name_dates reads a date (and a time, when one follows) from the file
name, then from the innermost folder that states one. A sequence number
after a date is not read as a time, and a bare year folder is not a date.
EXIF always wins: only examined rows still undated are filled.

The sweep and the metadata repair fill as they mark an image examined,
and the open backfill fills catalogs examined by earlier builds. On the
reference library that takes 274 undated images to 10; the no-op case is
a seek on images_captured, 0.6 ms an open.
2026-09-30 21:45:54 -04:00
dtourolle caae65c78d Import from an SD card or card reader on Android
Import was switched off on Android: `imports_supported` was true only for
`target_os = "linux"`, and its comment said Android has no path to read a
card by and nowhere to write the copies. Neither holds. With "all files
access" (MANAGE_EXTERNAL_STORAGE, API 30) an app reads the root of an SD
card or a USB card reader by path, `/storage/9C33-6BBD`, and the importer
only ever writes into its own staging directory, which is a plain
directory on Android too. So the engine runs unchanged; what was missing
was finding the card and the permission.

- The manifest declares MANAGE_EXTERNAL_STORAGE, and
  READ_EXTERNAL_STORAGE up to API 29 with requestLegacyExternalStorage,
  which is the same access on 28 and 29.
- Cards.java lists the mounted non-primary volumes through
  StorageManager and opens the system "All files access" page for this
  app. dr_ui::cards is the JNI bridge, through saf's helpers.
- The import page on Android asks for the permission with an "Allow
  access" button until it has it, rather than showing an empty list that
  reads as "no card", and watches for the grant so the list fills in when
  the user comes back from settings.

Google Play restricts this permission to file managers and the like;
DarkRoom is sideloaded, so that does not apply.
2026-09-30 21:30:09 -04:00
dtourolle 4da2ec39b3 Fit the photograph clear of the roll and the floating row while composing
A crop handle straddles the picture's edge. With the photograph fitted
to the whole canvas, the bottom handles sat in the photo roll's band,
whose swipe handler takes every press inside it, and the bottom-left
one under the "Done Composing" row, which is declared after the overlay
and whose buttons press 44px tall. Both were drawn and could not be
grabbed: on a laptop-shaped window, one or the other for nearly any
photograph.

While composing, the canvas box is inset 16px from its clip and stops
above the band, the row's margin and the row's hit height. The band is
measured once, on the canvas, and the row reads it from there.
2026-09-29 21:31:27 -04:00
dtourolle 8d66fa9be5 Show the composite in the grid in the manual, and say how it gets there
The panorama scene now ends in the grid, on the composite in its wide
cell beside the twelve frames it was made from, with the thumbnail the
merge made: the picture is taken straight after the merge, before any
rescan could have found the file. The manual's library section says how
a panorama's cell is sized and packed, and its panorama section that the
composite is in the grid the moment it is written. panorama.md §9 records
the race that kept it out, the catalogue-then-register path that
replaced it, how the thumbnail is developed, and the layout's classes.
2026-09-28 19:57:37 -04:00
dtourolle e600dae3df Regenerate the traceability matrix after the doc-comment sweep
The D19 sweep reflowed comments in detail.rs, adjust.rs and demosaic.rs,
which moves the line numbers the matrix records.
2026-09-27 19:42:51 -04:00
dtourolle 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.
2026-09-27 18:37:56 -04:00
dtourolle 885864b6a0 Open and export a linear DNG too large for one texture
A 22927×8966 Lightroom panorama opened as its embedded preview with
develop withheld, because no texture could hold it. DevelopSession now
opens a linear DNG past PROXY_EDGE (8192) on a box-reduced copy, and
keeps the full resolution on the CPU. The canvas at fit, the thumbnail,
the histograms and the masks work from the copy; a render finer than
it — the canvas zoomed in, a tile of the export — samples a window cut
from the full resolution, kept while the view stays inside it. The
export renders in halo-grown tiles of 4096 and assembles them.

On the panorama: decode 1.6 s, open 210 ms, canvas 37 ms, a zoomed
window 130 ms, the full-size export 5.9 s.
2026-09-27 17:37:06 -04:00
dtourolle 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.
2026-09-27 17:35:16 -04:00
dtourolle 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.
2026-09-27 17:34:16 -04:00
dtourolle 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.
2026-09-27 17:34:16 -04:00
dtourolle 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).
2026-09-27 17:34:00 -04:00
dtourolle 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.
2026-09-27 16:52:55 -04:00
dtourolle 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.
2026-09-27 16:52:55 -04:00
dtourolle 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.
2026-09-27 16:52:54 -04:00
dtourolle 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.
2026-09-27 16:52:54 -04:00
dtourolle 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.
2026-09-27 16:52:53 -04:00
dtourolle 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).
2026-09-27 16:52:53 -04:00
dtourolle e8898a5c38 Specify a scene-referred pipeline (D19)
The spec missed Ansel, and with it Aurélien Pierre's argument that a
display-referred curve early in the pipeline throws away what every
later stage needs. Read against that argument, the code broke it four
ways: the base curve was flat past its last point and so clipped every
recovered highlight; the detail stage was handed curved, clipped values
while its comments promised linear ones; the edits ran in camera RGB
because the matrix had drifted to after them, against ARCH §5.2; and the
tone curve clamped to [0, 1] around a 2.2 gamma mid-chain.

D19 records the decision: unbounded scene-linear colour from the matrix
to one view transform, last, after the detail stage. FR-DEV-3j specifies
that view transform as an adjustable operation (contrast, white) with a
default fitted to where the retired default curve put middle grey.
FR-DEV-3e retires the per-body base curves, whose own file called them
hand-tuned shapes of unknown provenance. FR-DEV-3f makes a film stock
the view transform when one is chosen, instead of a rendering at order
25 that the later edits acted on. FR-DEV-2 gains the acceptance test
that holds the rule, and ARCH gains §6.14 and the corrected §5.2 order.
2026-09-27 16:52:52 -04:00
dtourolle 621a6b8313 Leave a panorama frame out without leaving the page
A frame that did not fit ended the job with its name, and the only way
on was Back, a smaller selection and every frame read, demosaiced and
searched for keypoints again. Each row on the page now has a box. An
unticked frame is left out and the rest are solved again from what the
first pass measured.

dr_pano::align is split for it: match_pairs does the matching and the
pairwise RANSAC once over every frame (about 5 s for twelve), and solve
takes a subset of the frames and uses only the links among them (about
0.1 s). Solving a subset by re-aligning it also moved every RANSAC seed,
which are keyed on frame position, and on the fixture set that was
enough to lose a marginal link and strand a neighbour of the frame left
out.

A frame that cannot be placed no longer stops the job either. Its row
names it and Merge stays off until it is unticked; the headless example
leaves such frames out the same way, and takes --leave-out N to try it.
2026-09-27 16:29:39 -04:00
dtourolle e98b1def98 Keep blown highlights grey in a panorama
A clipped photosite reaches the merge as (1, 1, 1), which the as-shot
balance turns magenta. The preview balanced it with no highlight rule at
all, so every blown cloud was pink on the alignment page. The DNG had the
quieter form of the same fault: a frame's gain below one moved a blown
sample off the white level, and a feather mixed it into a neighbour's
real sky, so the develop's own desaturation no longer recognised it.

The merge shader and the preview now write a blown sample, before the
gain, as the camera value the composite's balance calls grey — the
develop pipeline's neutral, fading in from CLIP_ONSET.
2026-09-27 16:29:28 -04:00
dtourolle f52c4cb8b6 Open presets from a menu at the foot of the tool rail
Presets were a "Presets…" button in develop's top bar that opened a
sheet over the photograph, so applying one was two clicks with the
picture covered. They are now an entry pinned to the bottom of the tool
rail, apart from the tools because a preset arms nothing, and it opens a
menu beside the rail: the same sectioned rows the sheet lists, one click
to apply to the open photograph. The menu's last row, "Save or manage…",
opens the sheet, which keeps saving, renaming, reverting and importing,
since those take a name or a path.

The menu is a PopupWindow for the film list's reasons, with the list in
a clipped box of its own; the Flickable alone let its last row draw over
the manage row. Choosing from it zeroes preset-apply-count before
applying, since the grid may have left its selection count there.

The manual says where presets are now and gains a picture of the open
menu. The scenes reach the sheet through the menu and aim the manage
row from the rail's entry, as the film list's rows are aimed, because
the automation reports a popup's elements in the popup's coordinates.
2026-09-27 16:08:33 -04:00
dtourolle 502023c0f4 Show a thin scroll cue on Android instead of no scrollbar at all
On Android the desktop scrollbar is off, so every scroller that has one
on a desktop (the develop column, the grid, the collections sidebar,
Settings, the help sheet and the film list) gave no sign of how long it
was or where the view was in it. That is how the black-and-white film
stocks came to look deleted when the list could not scroll.

ScrollBar now has a second mode, chosen in the one Scrolling global:
where bars are off and `cue` is on, it draws the thumb alone, 3 px wide
against the edge, while the viewport moves, and fades it 500 ms after
the last move. It has no TouchArea, so a flick that starts on it
scrolls the content. Rust sets cue on touch-first builds; a desktop
build shows it when DR_SCROLL_CUE is set, to look at it without a
device. Desktop is otherwise unchanged.

Test: on the testing backend's help sheet, a press on the cue moves
nothing, a drag starting on it carries the list with the finger, the
cue is drawn while the list moves (drag, fling, wheel) and not once it
is idle; with bars on there is no cue and the thumb still takes a drag.
2026-09-27 07:22:09 -04:00
dtourolle adbb9ac9e6 Tag the judgement dispatch R7 and record which half of FR-CULL-13 is met
apply_judgement and apply_label carry TRACES: R7, the burst
representative callback FR-CULL-13 with a note that choosing it writes
the grouping and no verdict. The register's status for FR-CULL-13 says
the write-path test is met and the evidence chips are outstanding.
Traceability matrix regenerated.
2026-09-27 07:20:41 -04:00
dtourolle 6e68f1fb3d Move a resaved test file's mtime ahead, so the scan cannot miss it
`a_resaved_file_owes_a_reread_and_loses_its_stale_hash` failed once in a
full dr-catalog run (updated 0, expected 1) and passed three times alone.
The incremental scan tells a changed file by its mtime at whole-second
resolution, and the `resave` helper wrote and renamed the file within
the same second as the scan before it, so on a fast enough pass the
resave looked like no change at all.

The helper now sets the file's and its folder's mtime two seconds ahead,
which is what a real resave some time after a scan looks like. Only the
test helper changes; the scan's rule is right for real files.
2026-09-27 07:19:04 -04:00
dtourolle 8df3dda9aa Regenerate the traceability matrix for the executors module 2026-09-27 07:08:40 -04:00
dtourolle c50d96e949 Repair hot and dead photosites before the demosaic
A hot photosite went into the demosaic as it was read, and came out as a
coloured cross three pixels wide that nothing later could take back
out. Night and long exposures showed them; the defect-map reader added
for FR-RAW-3 was never wired in, and a CR2 carries no map anyway.

A pass over the mosaic now runs ahead of the demosaic, into a second
buffer. A photosite is hot when it reads more than twice every
same-colour photosite in its 5x5 window plus 2% of the range, and more
than twice each of its eight immediate neighbours of any colour. The
second half keeps stars and glints: real light reaches the sensor
through a lens and an anti-aliasing filter and lights a patch, so the
photosites beside it are lit too, where a hot photosite's are dark. It
is replaced by its brightest same-colour neighbour, which invents
nothing. Dead photosites are the mirror case, judged only where the
neighbourhood is above 5%, so shadow noise clipped at black is left
alone.

The colour of each photosite comes from a 6x6 sensor-anchored tile, so
Bayer and X-Trans share the pass. Export and every other path that
demosaics get it too, and there is no setting: the repair only fires
where a single photosite disagrees with everything around it.

Cost, warm, on a Canon 6D frame (RTX 3050): 91-99 ms to demosaic
before, 94-98 ms after; the extra pass is inside the run-to-run noise.

Tests render a frame with and without the defect and compare the
finished pixels. Without the repair a hot photosite showed by 230 and a
dead one by 168; with it neither shows, and a 3x3 highlight at white
survives.
2026-09-27 06:20:23 -04:00
dtourolle 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.
2026-09-26 23:29:13 -04:00
dtourolle 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).
2026-09-26 20:43:02 -04:00
dtourolle c78b798cf0 Fold people of one name whose faces agree, and faces held twice (#78)
Seven names are two or three live people on both devices: Ian (756
confirmed faces, and a second Ian with none), Jessie three times,
Claudine, Mathias, Noemi, Pascal and PJ. Each was typed on its own
device and carried across by sync, which keys people on their uuid and
so keeps both. Each half of a person shows half their photographs.

dedup_people::run, in one transaction:

- Same-name people (trimmed, case-folded as the Identity screen folds
  them) merge into the one with the most confirmed faces, ties to the
  smaller uuid, through faces::merge_people_within, so confirmations,
  rejections and the survivor's name are kept. A person holding no
  faces at all merges: there is nothing to compare or to carry. Anyone
  else needs >= 2 confirmed faces per shared embedder on both sides and
  centroids at cosine >= 0.7 in each. A face confirmed as one and
  rejected as the other keeps them apart. Unnamed and set-aside people
  are never merged by name.
- Faces held twice (one image, one embedder, IoU >= 0.5, cosine >= 0.7)
  keep the stronger detector's row (FaceDetector::outranks), then the
  confirmed one, then the older. The survivor takes the confirmed
  assignment and both rows' rejections. A pair confirmed as two
  different people is left and counted.
- Judgements still on a merged-away person move to the person at the
  end of its redirects, and a redirect cycle (two devices merging one
  pair in opposite directions) is broken at the smaller uuid.

Measured on copies of the desktop catalog and the tablet's server
snapshot, w600k_mbf, confirmed faces only:
- Centroids of differently named people: 2,699 pairs, median 0.02,
  99.9th percentile 0.41. One pair reaches 0.70 (0.700 desktop, 0.705
  tablet), "Michelle Casanonve" and "Michelle Casanova", one person
  typed two ways. Next is 0.62/0.64, "Boris Jost" and "Boris". The
  highest pair that is plainly two people is 0.43/0.44.
- One person split in random halves: minimum 0.69, median 0.91 over 72
  people. Four faces against twenty-two reach 0.7 in 97% of draws.
  One face against twenty of somebody else's reached 0.74 in 3,000
  draws, and two faces reached 0.61, hence the two-face minimum.
- Pascal (22 and 4 confirmed) is at 0.57 and PJ (14 and 7) at 0.50,
  under 0.7 on both devices, so both pairs stay apart and are logged.
  The desktop's second Ian holds 4 suggestions and no confirmations,
  at 0.38 against Ian's centroid, and stays apart. On the tablet it
  holds nothing and merges.

Why a merge made here survives a peer on 0.17.0: the merged-away
person stays as a merged_into redirect with a bumped revision, which
the catalog merge has always taken on revision. The peer hides the
duplicate and never sends it back as a live person. Its own
confirmations of that person stay on the redirect, because a merge
never overwrites a local confirmation. The manual merge has always
left them there too. They follow the redirect when the peer runs this
job. A test syncs two catalog files through the previous merge code
and back, and the people converge and stay converged.

Once a catalog is clean the job reads 80 redirects, the named people,
and the face boxes from the covering faces_box index. That is ~10 ms
on the reference library. There is no schema change. The index is
created IF NOT EXISTS, as the merge already does.
2026-09-26 17:50:28 -04:00
dtourolle 6450f54199 Carry rejections when two people are merged
merge_people moved a person's faces onto the target and left the
"not this person" rejections on the redirect. A rejection there binds
nothing: once Annie is Anna, the grouping pass is free to suggest the
face the user pushed away from Annie as Anna, which is the behaviour
rejections exist to prevent. The Identity screen's merge has done this
since it was written, and the deduplication job for #78 merges through
the same function, so it would have done it for every same-name pair.

The source's rejections now move to the target (INSERT OR IGNORE, so
one the target already holds is not doubled). Where the two halves
disagree about one face, confirmed as one and rejected as the other,
the confirmation stands, as `confirm` already rules for one face; and a
moved rejection withdraws a suggestion of the same face, as `reject`
already does. Confirmations and names are unchanged.

The body is split into merge_people_within, taking the caller's
transaction, so a job that merges several pairs commits once
(unchecked_transaction cannot nest). merge_people keeps its signature
and its one transaction.
2026-09-26 17:41:55 -04:00
dtourolle 7a56d16df1 Count the whole library in "All photographs" whatever is scoped (#76)
The sidebar's "All photographs" row was bound to library-total, which
is the scope's count: the header's "412 images" and the scrollbar's
size. Under a collection, and now under an album, the row read as the
album's size — 4 where the library holds 70.

It reads a separate library-whole-total now: the same number as the
scope's when nothing is scoped (no second count), and otherwise the
unscoped count under the same filter, read only when the view's facts
move, so scrolling inside an album does not recount the library.
2026-09-26 16:19:54 -04:00
dtourolle e57c5b8182 Regenerate the traceability matrix after the rebase 2026-09-26 14:26:10 -04:00