Commit Graph
596 Commits
Author SHA1 Message Date
dtourolle 33779a70bd Answer a thumbnail miss with the other stored class before the network
Offline, a grid zoomed past 256px was blank wherever it had not been
zoomed over before. The store was asked only for the exact class the cell
wanted, and the sweep stores only the grid class, so every zoomed cell
missed and went to a server that was not there — with its 256px thumbnail
sitting in the store the whole time. Online it cost the same round trip,
just without the blank cell at the end of it.

The split now tries the other class on a miss. A smaller one stands in
and the fetch for the real class still goes out; a larger one answers
the request outright, since there is nothing a fetch would improve on.
`ThumbnailReady` carries the class its pixels are, and the drain records
that rather than the batch's class, so a stand-in is replaced on the next
reload instead of being counted as served. `already_served` counts a
held large thumbnail as serving the grid class too, so zooming back out
does not re-read the store for pixels already on screen.

The split moves into `split_by_store` so it can be tested without a
worker thread or a network.
2026-10-03 16:44:58 -04:00
dtourolle b69fb3e191 Give the denoise work's test images a baseline exposure
The learned-denoise branch merged while this one was open, and two of
its test fixtures build a RawImage without the baseline_exposure field
this branch added; zero is the no-op value.
2026-10-03 14:44:48 -04:00
dtourolle 8294e6b59f Call the reference curve what it is, and drop wording that reads as copying
The view transform's second curve is the DNG SDK's published reference
rendering — the ACR3 default curve applied by RefBaselineRGBTone — so
it is the "DNG Reference" curve in the panel, D21 and the code, not a
name borrowed from another product. Comments and docs that justified a
choice by another editor doing it ("as their Amount", "so a
photographer arriving from it finds the name") now give the actual
reason. The Vivid presets no longer describe themselves as reaching
for another editor's look; they are DarkRoom's own.

Factual mentions stay: which program wrote the library's DNGs, what
was measured against, and preset import. camera-profiles.md gains §15,
on starting a photograph from the edit it already carries.
2026-10-03 14:22:49 -04:00
dtourolle e12783da9f Open a photograph with the edit it already carries, when DarkRoom has none
The library's DNGs carry the photographer's earlier develop settings
in their embedded XMP — the house style their photographs were made
with. A photograph opened with no edit of DarkRoom's now starts from
that earlier edit, translated (HSL bands, highlights, blacks and the
rest), as one undoable step named "Earlier Edit"; from there it is an
ordinary edit, saved with the photograph. Export does the same, so a
photograph never opened exports as opening it would show.

Only on positive evidence that there is no DarkRoom edit: a local file
with no sidecar beside it, or a server that answered "no such file"
with nothing in the cache. The stored-edit fetch now says which
(FetchedSidecar::absent). Offline, unreachable or unreadable never
counts — the earlier edit would otherwise be saved over a real edit
that merely failed to arrive.
2026-10-03 14:22:49 -04:00
dtourolle 0730ef1016 Sync camera profiles through the library, and name the tone curves
camera-profiles.md §13: the derived sync pass gains a profiles step,
after the catalog and before the place, that exchanges the profiles
directory with <library>/.darkroom-derived/profiles. Profiles are
immutable and named for what they hold, so name and size decide: it
uploads what the server lacks or holds at another size and downloads
what this device lacks — parsed before it is kept, written beside its
name and renamed — then reloads the set, so a profile copied out of a
DNG on the desktop renders the body's CR2s on the tablet after its
next sync. Like the place it never fails the pass. Not a catalog
table: a schema change would stop an older peer merging at all.

Labels for the view transform's new curve choice: Curve, Camera Raw,
Sigmoid.
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 abefb94daa Give the export-ignores-the-viewport tests the viewport zoom now takes
0b06e31b ("Let a zoomed view fill the viewport...") added the
viewport's size to DevelopSession::zoom_about and left this
integration test calling it with three arguments, so dr-ui's tests did
not compile. The photograph here is 64×64; a 64×64 viewport keeps the
zoomed view the tests were written against.
2026-10-02 22:44:17 -04:00
dtourolle cb7ad0bbe7 Ship a Vivid section of presets
Five looks for "more colourful than the default": Vivid, Vivid strong,
Vivid landscape, Vivid warm and Vivid portrait. They lean on vibrance,
which lifts muted colours most and holds skin back, and use saturation
sparingly on top; landscape and portrait work the colour mixer's bands
so foliage and sky get richer while skin does not. A sixth, Stronger
camera look, pushes the camera profile's look table to 175 %, about the
step from Adobe Standard to Adobe Vivid, and only moves vibrance where
a photograph has no profile.

All change only what they name, so they keep a corrected exposure or
white balance, and the shipped-preset tests bound every key and value.
2026-10-02 22:38:07 -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 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 1bc04870c3 Let a crop be trimmed from one edge
Four bar handles at the midpoints of the sides, each moving only its
own side along the axis across it. The overlay reports an edge as 0.5
on the axis it does not move, so the anchor Rust takes is the middle
of the far side.

Under a ratio lock the dragged axis leads. with_aspect grew the short
axis onto the ratio, which for an edge pulled inward made the untouched
axis the leader and pushed the edge straight back out.
2026-09-29 21:31:27 -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 34630ff752 Name the folder lookup the merge page is handed, for clippy 2026-09-28 19:57:37 -04:00
dtourolle 3b7d7129ff Learn a photograph's shape from its header
A panorama the merge did not make, a stitch from another program or a
phone's sweep, never had a width or height in the catalog, so it could
never be given a wide cell. The header read that dates a photograph now
records its size as it is seen, orientation applied, alongside the date.
2026-09-28 19:57:37 -04:00
dtourolle 6050a8e703 Size a panorama's cell and thumbnail by class: two, three or four columns
One lookup, natural_span, maps a photograph's aspect to the columns its
cell spans, and the same number names its thumbnail class, Wide2, Wide3
or Wide4, 512 pixels of long edge per column, so a 4:1 panorama is as
sharp across four columns as a frame is in one. The boundaries are
where the two neighbouring cells would leave the same share of
themselves undrawn, sqrt(s(s+1)): 2.45 and 3.46, with the first at 1.9
so a 3:2 frame stays a frame. A grid too narrow for the class falls
back to the widest that fits, the tablet gives the whole row, and a
cell asks for the class it is actually drawn at: its span, but never
more than its own class. The merge renders each wide class up to the
composite's own, which covers every fallback.
2026-09-28 19:57:37 -04:00
dtourolle ae4e1a0f07 Give a panorama a wide cell in the grid
A 4:1 composite drawn in one square cell is a strip a few pixels high.
A photograph about twice as wide as it is tall (1.9 and up) now spans
two columns, three from 2.9, with a thumbnail class of its own whose
long edge is sized for that width; on the tablet, or where the columns
are too few to put it beside anything, it takes the whole row.

Rows are computed in one place, library_ui::layout. The grid is a
lattice of slots: each cell is drawn at the slot Rust gives it, and a
wide cell that would not fit in what is left of a row starts the next
one, leaving the gap empty so the grid still reads in capture order.
The scrollbar spans the slots, a scroll reports a slot that the layout
turns back into an ordinal, and scrubs, restores and the cursor go
through the same conversion. Up and down step by rows through the
layout rather than by a row's worth of ordinals; left and right, a
shift-click's run, burst folding and the timeline are ordinal-based
and unchanged.

The window's own read carries each photograph's w and h, so the cells
know their shape with no query per cell. Where the wide ones sit in
the whole list is one query, run when what the grid lists changes or
when a window finds the layout out of date, and a library with no
panorama answers it from a partial index created on first use
(images_wide), not a schema bump. The merge makes the wide thumbnail
for a wide composite along with the others.
2026-09-28 19:57:37 -04:00
dtourolle ce5b7d72e3 Thumbnail a composite during the merge, as develop first shows it
The DNG a merge writes has no embedded preview, and an embedded preview
is all the grid's thumbnail path reads, so a composite stood in the grid
as a blank cell until it was opened. Reading an 800 MB file back to make
one would cost what the merge already has in hand.

The bands are box-reduced as they are written, after the border fill,
into a copy 4096 pixels long. That copy is written as a linear DNG in
memory with the composite's own profile, header and crop, and opened
through open_session, the function develop opens every file with: the
same decode, the default graph and view transform, the as-shot white
balance and the conversion to the display's space. The grid and large
thumbnails are rendered from that session, staged beside the payload
before the rename releases it to a drain, and put in the store under the
file id once the upload has learned it. Until then the grid draws them
from memory, so the cell is not blank while the file is uploading.

A test develops a synthetic composite both ways, the whole file as
develop opens it and the merge's reduced copy, and holds the thumbnail's
mean, 95th and 99.5th luma percentiles to within 3-4 levels of develop's
render; the naive balanced-and-gamma picture the merge's preview draws
misses by 13.
2026-09-28 19:57:37 -04:00
dtourolle 98a67393d9 Catalogue a merged panorama the moment it is written
A finished merge drained the outbox and started a rescan beside it. The
scan raced the upload: on a folder library the 800 MB copy was still
running when the folder was listed, on Nextcloud the upload takes
minutes, and either way the listing lacked the composite, recorded the
folder's validator, and nothing looked again until the next sync pass.

The job now reports what the catalog needs (the name, the size of the
picture it opens on, the capture time it wrote into the DNG) and the
library writes the row at once, in one transaction, keyed where the scan
will list the file; a composite with no time of its own takes its
sources' earliest. The grid reloads and shows it beside its sources.

The upload then gives the row what only the server knows: after sending
a file the catalog already has a row for, it lists the folder once, takes
the file id the server assigned, and records it (and, once the merge
makes them, the thumbnails waiting beside the payload) under that id.
Every drain now rescans when something landed in the library, after the
upload rather than beside it. A second merge of the same frames is no
longer named over the first: the name is checked against the catalog's
names in that folder, which is all that knows it once the outbox is empty.
2026-09-28 19:57:37 -04:00
dtourolle 37136f7377 Let one outbox drain run at a time
A finished merge drained the outbox and the sync pass that followed
drained it again beside it: each read the 800 MB composite into memory
and sent it, and the later one found its record cleared underneath it
and logged the file as missing. Drains now take a lock and the one that
waited finds the queue empty.
2026-09-28 19:57:37 -04:00
dtourolle e2e2181469 Write the composite under a .part name until it is whole
The merge writes its outbox record before the DNG, which takes minutes,
and a drain that ran in the meantime took whatever lay at the record's
name: a sync pass that fired mid-merge uploaded the first part of the
composite and cleared the record. The file is now written as x.dng.part
and renamed into place once the last strip is in; the drain skips a
record whose payload does not exist yet.
2026-09-28 19:57:37 -04:00
dtourolle 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.
2026-09-27 19:42:41 -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 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 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 b1d1c47261 Start every worker thread through the executors module
Thirty-nine spawn sites in dr-ui, and one in the Android entry point,
called std::thread::spawn or a Builder of their own, and most of the
threads they started were <unnamed> in a panic message or a profiler.
Each now calls executors::spawn with its executor and a role, so the thread is
named <executor>:<role> — net:sync, decode:thumbs, io:catalog-open —
and knows which executor it is on. The three that already set a name
(automation, import, prefetch) keep their name as the role.

Behaviour is unchanged: each job still gets a thread of its own when it
starts, and spawn panics where std::thread::spawn did.

The module's documentation now says how a job is assigned: by what it
spends its time on, so a sweep that fetches bytes and then decodes them
is Decode, and a sidecar write that touches the catalog is Network.

Left as they were: the segmentation and refine workers in masks_ui.rs,
which another change is reworking, and test-only threads.
2026-09-27 07:08:37 -04:00
dtourolle 7be1efff32 Name the executors and fail a block_on on the UI thread
architecture.md §7.1 stated five executors and their thread counts, and
no code named them. dr_ui::executors now does: the Executor enum with
each one's thread name and the count §7.1 gives with its reason, and
spawn, which starts a thread named <executor>:<role> and marks it with
the executor it belongs to. The counts are the stated budget, not yet a
bound: a job still gets a thread of its own when it starts.

run marks its own thread as the UI executor before it builds the
window. net_runtime::build now returns a NetRuntime whose block_on
asserts, in debug and test builds, that the caller is not that thread;
everything else derefs to the tokio runtime. The login, folder-list and
remote-folder workers built the same runtime by hand and now take it
from net_runtime, so their block_on is guarded too.

Tests: a block_on on a thread marked as the UI executor panics naming
the UI thread; the same call on a worker returns; a spawned thread
carries its name and executor.
2026-09-27 07:08:37 -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 94ea2569ee Tag the SAF export path FR-EXP-10 only, not FR-PLAT-AND-1
saf.rs and the export path's SAF branch shipped tagged FR-PLAT-AND-1,
and the matrix counted the requirement as covered. Its subject is the
library — reached through SAF grants — and Android still reaches a
library over a Nextcloud account or a folder path. What the SAF code
does is give an album a folder on the tablet, which is FR-EXP-10.

outstanding.md said the figure overstated it and should be read with
this one subtracted; it now says the tags were narrowed, and coverage
reads 161 of 192.
2026-09-26 16:19: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 faf52f6dbd Ask the prefetch's cache questions on one held connection
holds_original, the prefetch worker's check that a neighbour's original
is already cached, opened the catalog for every neighbour it asked about.
Its own comment called it a row check; the open around it was four of
the five opens a develop landing made.

The worker now keeps one Catalog for the batch it is serving, opened at
the first check and reopened only if the batch names another catalog
file. The connection runs in autocommit, so each check still sees what
fetch_original committed in between. fetch_original is unchanged.

With the backfill no longer run on every open, a landing whose
neighbours are all cached goes from five opens to two, and from ~80 ms
of CPU to ~1-2 ms on a copy of the reference catalog.
2026-09-26 14:26:10 -04:00
dtourolle 9b580c3720 Satisfy rustfmt and clippy on the album and folder picker changes
rustfmt over the files the albums work touched, and the album merge's
incoming row as a named struct rather than an eight-field tuple, which
clippy's type_complexity refused.
2026-09-26 14:13:54 -04:00
dtourolle 92d4b23bed Give an album a folder on the tablet, through Android's folder picker
Android's only export destination was the library on the server
(ExportTarget::available), because writing to the device goes through
the Storage Access Framework and nothing did. An album's folder on the
tablet is now chosen in the system's tree picker — which has its own
"Create new folder" — and exports are written into it with
DocumentsContract.

The picker answers through onActivityResult, and the main activity is
NativeActivity, whose result is not ours. FolderPicker is a translucent
activity that only asks: it starts ACTION_OPEN_DOCUMENT_TREE, takes a
persistable grant (a folder is chosen once and exported to for months),
leaves the URI in a static, and finishes. Rust polls it from a Slint
timer — one static call, rather than a registered native method and a
thread to deliver on.

Two things the first build on the tablet got wrong, recorded where they
are fixed:

- Our classes must be loaded through Context.getClassLoader(). The
  class of what ndk_context holds is a framework class from the boot
  loader, which reports every class in the APK as not found.
- What ndk_context holds is the application context, not the activity,
  and starting an activity from it throws without FLAG_ACTIVITY_NEW_TASK.

Saf.write creates the document (or, under Overwrite, reopens the one of
that name with "wt" so a shorter file does not keep the old tail) and
returns the name the provider actually gave it, since SAF renames on a
collision by itself; the album records that name. A tree URI reads in
the sidebar as its folder ("Pictures/Web"), not as a content:// string.
2026-09-26 14:13:53 -04:00
dtourolle 7cbcacc02e Export to an album instead of a folder in the settings
Export took a path typed into the settings page, or a folder inside the
library on the server. The first is how exports end up somewhere nobody
looks; the second put JPEGs into the tree a scan catalogues, where they
came back as photographs beside the RAWs they were made from.

The destination is now an album (FR-EXP-10), chosen by name in the
export sheet. Albums are listed under the collections in the sidebar;
"+" there, or "New album…" in the sheet, opens a sheet for its name and
its folder — on this device through the platform's dialogue, or on the
server through the browser with "New folder". A server folder inside
the library is refused, and the sheet says why. Selecting an album
narrows the grid to the photographs behind its files: library::Scope
is Collection or Album, and scope_clause is the one place the two are
spelled, which also retires the two copies of the collection predicate
total_images_scoped and read_cells_scoped had inlined.

A batch resolves the album when it starts, and refuses in words when
none is chosen, it has gone, or its folder is local to another device.
Each item reports the image it came from, and the files written are
recorded against the album in one transaction when the batch ends.

A server album lives outside the library, so its queued uploads are
relative to the account root. That is a third line in the outbox's
.dest record rather than a leading slash, because a record written
before albums may carry a stray slash and must keep the meaning it was
written with.

An export folder set before albums becomes an album called "Exports"
on first open, so upgrading does not lose where exports were going.
The old destination fields stay in ExportSettings so older settings
files still read.
2026-09-26 14:13:53 -04:00