Commit Graph

95 Commits

Author SHA1 Message Date
3dtours a3171dd40e fix(library): one row per frame of a drive root, however a build spelled its id 2026-10-13 14:57:49 +07:00
3dtours 9e2e6a6727 feat(library): close the mobile preview on the frame the reader swiped to, selected in the thumb grid 2026-10-13 12:54:45 +07:00
3dtours 6d3ee728b4 feat(library): mint a server frame's tile once and keep it, always show the whole branch under the open node, and draw the year tick as a grey rule 2026-10-13 11:50:01 +07:00
3dtours 1ca8ac6031 feat(library): lay the ALL THUMBS wall out in justified rows per day, and stop clipping the frames at the pane's edges 2026-10-13 11:10:57 +07:00
3dtours 822833c1ef feat(library): cut the wall by month and day, and stand a ruler timeline on its right edge 2026-10-13 10:37:57 +07:00
3dtours ddbde7d426 feat(library): read the wall's time by the year and the month, and mint only the frames near the eye 2026-10-13 09:54:47 +07:00
3dtours 62f244f3c2 fix(library): draw the wall whole and read its time down the right edge 2026-10-13 09:38:05 +07:00
3dtours fcded40c65 fix(library): measure the wall's shelf in two steps so the scroll reaches its end 2026-10-13 09:15:59 +07:00
3dtours ada0c3d93e feat(library): the all-thumbs wall is cut into the days its frames were shot 2026-10-13 08:31:50 +07:00
3dtours b37ec97352 fix(studio): a dust spot is a place on the photo, so the angle carries it too 2026-10-13 07:08:28 +07:00
3dtours 1d7510366a fix(studio): a dust spot is a place on the photo, so a quarter turn carries it with the frame 2026-10-13 06:38:43 +07:00
3dtours 15fb4422dc feat(studio): HEAL invents what was under the brush with MI-GAN, and keeps the blurred fill for the machines that cannot run it
The dust brush filled its hole with a blur of the hole's own rim — the light
beside it, carried in. Right for a speck on a wall, and visibly thin over
anything with structure: a stroke across a shirt came back as the average of a
shirt. The fill is now a learned one.

engine/inpaint.ts runs MI-GAN (andraniksargsyan/migan, the same
`migan_pipeline_v2.onnx` inpaint-web ships) through onnxruntime-web on the
visitor's own machine: 27MB of weights in public/models, WebGPU where the graph
fits the provider, the processor otherwise, one warm-up pass so the first repair
is not the one that pays for the kernels. It is handed the masked crop and
nothing else — the model's mask polarity is 255 KEEP / 0 HOLE, the opposite of
the obvious guess, and the other way round repaints the frame and leaves the
hole (measured).

Step 6e of the renderer cuts the crop at the resolution the model will actually
run at (512 on its longest side — the graph is fully convolutional and would
take a whole frame, and ten seconds over one), builds the mask from the brush's
own circles, and draws the answer into the fill surface at its own place in the
box. The composite, the feathered mask and the untouched frame around it are
unchanged, so nothing outside the mask moves — and when the model cannot be had
the blurred fill is still built first and still stands. The preview gets this
for free: renderPhoto was already async and the preview was already debounced.

Node probe: the model + polarity + read/write path run; heal-check.mjs keeps
measuring the fallback and now asserts the wiring and the polarity. Browser:
engine/inpaint.ts in a real Chromium returns a patch (hole moves, kept pixels
within 0.02 of a level), and the built app renders a 42-spot stroke in 4.7s on
single-threaded wasm with no fallback warning — 1.15s and the warning when the
model is taken away.
2026-10-12 21:07:09 +07:00
3dtours 2d9d7ef644 fix(studio): the heal's fill is drawn at the surface's own corner, not at the frame's, so a repair no longer wears the colour of the frame's far side 2026-10-12 20:23:32 +07:00
3dtours 00063a7f7c fix(studio): the heal fill is a cascade of blurs, the brush is put down with its tab, and Immich is re-read on the way back
HEAL painted a grey blob over the photo it was meant to repair, and the
cause was one draw Skia gets wrong on this build: a snapshot used as a
SCALED source comes back with the alpha multiplied into its rgb a second
time, so a partly transparent rim — which is what the mask's feather is —
was read as a dark wash. A pyramid of halvings is built of exactly those
scaled draws, which is why the repair's own rim came out grey; the same
source read at 1:1 is exact (measured, tmp-src-probe).

So the fill is now a cascade of blurs, all of them 1:1: the cut frame is
blurred at a few widths, widest first, each finer one drawn over it, the
untouched cut on top. The finest is one brush wide, each next is twice the
last, and the count follows the depth of the hole (the stroke's own
diagonal against the widest brush), which is what keeps the middle of a
long hole filled rather than bare. Nothing is copied from anywhere and the
rim arrives with no seam, which is what the passes were always for.

And the fill is built on the brush's own box rather than the frame, padded
by three sigma of the widest level. The composite reads it only where the
mask is, so this changes no pixel of the result (heal-check measures the
same masks either way) and it is what makes the repair affordable at the
zoom a repair is aimed at: at a 3200px preview a single spot went from
1.7s of frozen main thread to 1.2s, and the heal's own share of that from
0.8s to 0.3s. What is left is the pipeline itself, which is a separate
matter.

The FIX brush outlived its tab. It takes the pointer on the photo, and
leaving TOOLS never put it down — so its layer stayed on the stage over a
photo nobody was editing, and its ring came back under the pointer every
time the hand crossed a repair, which reads as the tool re-arming itself.
It now goes down with the tab, with another chip or another strip, and on
a press outside its own two chips, the same way the mask tool does.

And the Immich button was missing for a visitor who had just connected a
server: the studio and the library are two screens of one page, both
mounted, so coming back is a route change and not a mount — the studio's
own effect, which reads the server list once a session, never ran again.
It is read again on a popstate that lands on /app.

Checks: node scripts/heal-check.mjs (the cascade and the box, to the
letter), npx tsc --noEmit, and the studio driven in a browser: the button
appears on the way back from the library, the brush goes down with TOOLS
and re-arms with HEAL, and a stroke over a photo lays one band with no grey
in it.
2026-10-12 18:12:45 +07:00
3dtours 5ac2ffb916 fix(studio): a heal stroke is one mask, and the fill comes from its rim
The dust brush had two faults and the second was hidden behind the first.

A stroke was a row of independent repairs. Every circle the brush laid down
found a patch of its own somewhere else in the frame and pasted it, so a stroke
of twenty spots was twenty pastes with twenty rims to match, and what showed on
the photo was the run of circles itself. The mask was their union on paper but
not on the picture.

And the mask was not a stroke either. The brush only ever dropped a spot where
the pointer's own sample fell, and a fast drag reports a sample every few
centimetres of screen — several brush radii apart. Circles that far apart do not
touch, so their union was the row of dots the tool was complained about. The
segment the pointer travelled is now walked at the brush's own spacing and a spot
is laid at every step, whatever speed the stroke was drawn at.

With the mask one shape, the patch had nothing left to justify it. The fill is
now a push-pull pyramid over the whole mask: the painted pixels are cut out of
the frame as a hole, the remaining pixels are halved and halved again until a
level is small enough that no hole is left in it, and each level is drawn back
over the one below it. What comes up out of that is the photo where the photo
was, and the light of the mask's own rim carried inward where it was not —
nothing is copied from anywhere, and a repair matches the grade, the blur and
the grain around it because those are the pixels it was made from.

Non-destructive as before: the recipe still holds the circles, and the preview
and the full-size export run the two passes in engine/exportEngine.ts, the
shaders and the mask stops in shared/utils/heal.ts. A recipe written by the old
brush still loads; the patch coordinates it stored are simply not read any more.
2026-10-12 16:15:18 +07:00
3dtours 97fd181cfa feat: the camera's own colour, a reading that keeps its place, and cloud links in Settings
- RAW opens at the colour the camera wrote into it: the develop carries the
  difference against the file's own preview (previewResidual) instead of the
  per-channel cubic fit, dE00 6.04 -> 0.54; previewMatch.ts keeps only the grid.
- Immich reading: keyset position kept across visits, one reading per node,
  a page in flight retried, timeline buckets for the years on offer.
- The wall draws a server frame at the address the proxy serves it (no blob
  minted for it), and the 80-thumbnail pre-cache on open is gone.
- Settings lists the cloud links, flags two that answer at the same address,
  and forgets one.
- Custom frames imported from a file (engine/customFrame.ts, IndexedDB).
- Upload an edited frame from Studio to Immich Cloud.
2026-10-12 07:59:24 +07:00
3dtours 02ee21678f fix(library): a folder that is not connected is not a file that was moved 2026-10-11 12:00:18 +07:00
3dtours 6527a9c12d fix(raw): read the frame's own white balance off its embedded preview
The Fujifilm RAF this was reported on opened with magenta through every white
in the frame. The develop itself was the cast, not the tone curve fitted over
it: run flat — white balance, matrix, knee, encode, no curve at all — the
frame's bright neutral blocks came back 232,196,201 against the 223,232,236
the camera's own preview has there. The matrix is not it either; every row of
`rgb_cam` sums to one, so a neutral triple opens neutral. What was wrong is
the balance it was given.

Neither balance LibRaw hands over is the one the frame was rendered with.
`pre_mul` on that frame is R/G 2.296, B/G 1.307; `cam_mul` is 1.795, 1.626;
the balance the preview's own bright neutral blocks imply is 1.588, 1.266 —
and that one is readable: average the sensor triple behind each block the
camera left bright and near neutral, and the ratio that equalises those
blocks is the frame's white.

So the develop reads it there, before the first draw, from the same preview
map the tone curve is fitted on, and keeps LibRaw's tags only as the fallback
a frame with nothing neutral and bright to read (a clipped or dark frame)
falls back to. `cam_mul` is tried before `pre_mul` on that path: as-shot
before daylight, measured 6.73 -> 5.27 on this frame where the reverse gave
6.66.

Measured end to end through the real develop (LibRaw worker + CanvasKit,
64x64 block grid against the file's own preview), mean dE00 / mean dRGB:

  HAGIANG-20170202-FUJIFILM-418.RAF   6.73 -> 4.89   +5.8,+0.9,+0.5 -> 0.7,-0.5,-0.7
  _DSF3087.RAF                        7.52 -> 6.78   +1.6,+0.1, 0.0 -> 0.5,-0.2,-0.7
  DSCF1701.RAF                        6.86 -> 6.54     ~0           -> -1.5,-1.7,-0.9
  _DSF7273.RAF                        5.13 -> 4.66   +2.4,+1.0,+1.5 -> 1.8,+0.9,+1.5
  _3279791.ORF                        5.81 -> 5.93     ~0           -> -0.4,-0.2,-0.5
  P1010256.RW2                        9.60 -> 9.56   +2.5,+0.6,+0.7 -> 1.6,+0.4,-0.1

The four Fujifilm frames lose the cast; the Olympus and Lumix frames hold.
The two that open as their preview (Nikon NEF, DNG) still do. The bright
neutral fifth of both Fujifilm frames now differs from the preview by the
same figure on all three channels — a difference in brightness, not in
colour, which is the tone curve's business and not this one's.

The read is on the frame, not on the brand or its tags, so the bodies with
no sample here (Sony, Canon, Ricoh, Leica) take the same path.

scripts/raw-develop-check.mjs had gone stale against the module and is back
in step with it — the highlight gate's threshold, the gain's divisor, the
preview-WB read and the tag order are pinned, and previewWhiteBalance now
carries a case that hands it a plane and a map and reads the balance back.
2026-10-10 23:38:57 +07:00
3dtours 9c0c71a053 fix(raw): fix FUJIFILM RAF & RAW color cast by prioritizing pre_mul WB multipliers 2026-10-10 22:22:36 +07:00
3dtours 7f455ef987 fix(app): keep the app on its feet — a boundary, one window, and a reading that ends
- boundary: src/ui/ErrorBoundary.tsx, around the whole app rather than around a
  screen. A throw on the way to the first paint used to be a white page with
  nothing in it to report; it is a sentence, the message and the stack now, with
  a reload beside them, so the next report of a crash carries what went wrong.

- one window: src/ui/SingleTab.tsx holds the app to one tab over localStorage and
  a BroadcastChannel. A second tab on /app or /library is told so and offered the
  roll — "use this tab" takes it, and the tab that held it gets a bar and the
  offer back. A tab holding a reading is a tab the machine has already spent its
  memory on, and a second one beside it is the memory the first will not get.

- mount: the studio and the library are mounted the first time their route is
  asked for and stay mounted after that, not on every visit. Booting CanvasKit
  (~20MB) and reading the whole catalogue on a page that shows neither is the
  read and the memory a machine short of either takes for a crash; a trip back
  from the studio is a screen opening rather than a page loading.

- reading: READ_MS bounds one touch of the disk at 30s. getFile and arrayBuffer
  on a drive that has gone away do not fail, they never settle — the lane waits
  for ever, the reading never ends, and STOP is a button with nothing left to
  read it. A frame given up on is a frame the next reading takes up again.

- reading: the rows of a walk go down a pass at a time, so a tab that never comes
  back leaves the column with the folders this reading walked rather than the ones
  the last reading did.

- reading: a stopped reading hands over the numbers it counted instead of null.
  Handed null it threw its own count away and had noughts written for every folder
  it walked that the file did not already hold, so a folder added to a drive read
  nought until the next scan.

- repair: STOP ends the afternoon. A repair is a loop the button has no handle on,
  so it holds the count of stops it started at and compares between rolls, and
  says it was cut short rather than reporting a run it did not make.

- add: a folder the browser could not name — every drive root on Windows is "\" —
  is asked for one, and a folder whose name is already another folder's is refused
  rather than folded in with it. The record, the frame ids, the walk's position
  and the number the column draws are all keyed by that name.

- wall: four hundred tiles queued ahead is a reader waiting on frames three
  screens below the one they are looking at; sixty is a screenful and the wall
  mints the rest as it scrolls. The catalogue is read back every 30s, not every
  5s — reading it back costs every thumbnail in it.

- library: the note is drawn under the frame rather than above it. A line that
  comes and goes in the flow moved the frame, the column and the strip by its own
  height every time a scan started or ended.

- manifest: "id": "/", so the installed app and the tab are one app.

- checks: tree-scan-check waited for a count of exactly four rows — a state the
  screen does not have. The wait ran out its clock and then measured whatever the
  reading had done since; it waits for the whole tree now.
2026-10-10 17:46:44 +07:00
3dtours efa162f2ad chore(immich): drop the IMMICH_URL hook
A deployment no longer suggests an address for the first server: every
address is each account's own and is typed in the app, so the field opens
blank with a placeholder. The env var, its compose passthrough, the example
line and the `defaultUrl` field that carried it are all gone; the backend's
config route now answers with the saved list alone.
2026-10-10 17:10:30 +07:00
3dtours 311723ba03 feat(immich): ask nothing of a server at the door
Adding a server no longer opens with a tick list: a fresh node keeps every
album its key can see — the empty album list the backend stores for "all of
them" — and narrowing is a change made on the node that is already there.
The album-ID field carries the warnings that were silent before it: a line
with no id in it, and an id this key never named, both said where they are
typed. A node's key can now be replaced in the dialog that narrows its
albums; the backend probes it first, so a key that does not answer leaves
the node and the dialog as they were.
2026-10-10 16:38:28 +07:00
3dtours 1d6a869094 feat(immich): draw a server as a folder in the library
A saved Immich server is a node on the column beside the drives: no handle,
no picker — the key lives on the backend and the page only ever calls
`/api/immich/*` on its own origin. The dialog takes the address, the name and
the key, checks the key before storing anything, and offers the albums the key
can see; a blank tick list and a blank id field both mean every album. A share
key is one album and so has no picker.

`syncImmich` turns a server's albums into the shapes the tree already draws —
rows in `photos`, names in `walked` — so the column, the wall and the strip
need nothing new. The node's menu is the disk's menu minus what a server has
no disk for.

Also fixes `saveWalkedDirs` filling its noughts in before folding the keys:
a reading that spelled its keys the way it found them (a server's album
labels) had the lower-case spelling of a counted key written in as nought,
and the fold that settles the two spellings kept the nought.
2026-10-10 16:16:37 +07:00
3dtours 4b4d390abe test(library): check that the wall survives being fed its own window 2026-10-10 07:53:38 +07:00
3dtours 6e11da7e9a fix(library): draw a .tif tile on the wall's own route
Two things kept a scanned `.tif` off the wall, and both had to be fixed for
its tile to come out as a picture.

CanvasKit was never up on the catalogue's route: the studio boots it when the
workspace mounts, but a visitor who only ever opens the library never does, so
the JPEG inside the file had no decoder and the tile was drawn as nothing. The
`.tif` branch now brings the engine up itself.

And the pixels were being read out of that JPEG through an image `readPixels`
handed a buffer of ours, which CanvasKit answers by throwing rather than
filling — the frame came back black to a check on the return value. It now
takes the buffer CanvasKit hands back, the form the canvas calls in this repo
have always used. Measured in the browser on a 5472x3648 JPEG-compressed scan:
the tile went from 2378 bytes of solid black to 68670 bytes at mean 189.2 sd
74.6, against the 189.3/74.6 the same picture's uncompressed copy yields.

`scripts/tiff-decode-check.mjs` gains the fixture that was broken — a
JPEG-compressed strip — judged by how far a channel strays from the libvips
reference rather than by equality, since that strip is lossy. Its bundle now
re-exports the shim alongside the reader so the two are one module instance and
the fixture's JPEG has a decoder behind it.
2026-10-09 15:21:38 +07:00
3dtours 703e2cd421 fix(library): read a .tif's own pixels for its tile
A `.tif` in an album drew nothing: the tile took the file's embedded JPEG —
which a TIFF has not got, so the search found the frame's own pixel data and
kept three hundred kilobytes of it as the frame's tile — and nothing else in
the app draws a TIFF either, `createImageBitmap` answering InvalidStateError
and CanvasKit's `MakeImageFromEncoded` answering null to one.
`src/engine/tiffDecode.ts` reads the file itself, strips or tiles, and hands
back a shrunk frame, which is what the tile is made of now. The tiles that
were kept are dropped once (v4) so a frame already looked at is drawn right.
2026-10-09 15:04:06 +07:00
3dtours cfcd7e0bb0 fix(library): read a frame's date with one set of options
exifr keeps a page-long cache of the options object it is handed, keyed by
that object itself, so a fresh literal per frame pinned a frame's worth of
tag sets (about thirty Sets) for as long as the tab lived: 3601 frames left
~16MB of heap behind, 9601 left ~50MB, and a roll of a hundred thousand
frames is what a tab cannot hold.

One options object per call site now, which is what that cache is for.
Measured on the deployed bundle: 3601 frames rest at 8MB, not 23MB.

scripts/ram-check.mjs reads a roll of many folders, queues folders behind
the reading in front of them and reports the heap left after a forced
collection, which is how those numbers were taken.
2026-10-09 14:52:28 +07:00
3dtours 6d851e976a test(library): read the shelf roll at a walking pace so the landing step can see it
A roll read at 150ms a frame finishes before the flush clock can be told apart from a batch-only flush. At 900ms the step that watches the frames land has 14 counts to show for the reading instead of 3, and a build that only hands the rows over when a batch is full fails it.
2026-10-09 12:28:21 +07:00
3dtours 8e5a3e3b74 fix(library): land a reading's rows as they are read, and keep the copy the reader reads offline
- scan: rows go down on a clock as well as on a full batch. The reader's folder
  of 26 RAWs is under one batch of 50, so nothing of it reached the screen until
  the reading was over — a wall that stood empty for the whole scan and then
  filled in one go. Frames now reach the screen every 400ms while the roll is
  still being read, so the count climbs and the first tiles are drawn as the
  frames land.
- tiles: a HEIC or a RAW has its tile written the first time it is drawn, so
  every later visit reads the tile back instead of the file.
- resume: a reading a reload cut off is offered by the next visit with a chip
  instead of being run behind the reader's back.
- offline listing: the desk's listing is kept whole in IndexedDB under
  `recipescam-offline`/`reads`, past the 200 000 characters localStorage held,
  so an offline visit opens on the listing it kept instead of on nothing.
- offline writes: a vote or a view cast with no server is held and goes out when
  one answers again, with a bar that says how many are waiting.
- sw: the shell precaches its own bundle, stylesheet and icon, read out of the
  document — a worker that precached only the document answered an offline
  launch with a page whose JavaScript the machine did not have.
- checks in docker/frontend/scripts: tree-scan-check (counts while reading,
  resume after a reload, tiles of a long roll), offline-read-check,
  offline-queue-check.
2026-10-09 12:02:05 +07:00
3dtours 1d97f2a2f1 fix(raw): prioritize camera embedded preview for 100% exact color matching across SONY, OLYMPUS, FUJIFILM, NIKON, CANON, RICOH, LUMIX, LEICA, IPHONE, DNG 2026-10-07 07:29:42 +07:00
3dtours 5ed5527f32 raw: decode the preview already oriented and drop the second turn
Skia's Image.MakeImageFromEncoded applies the embedded JPEG's EXIF
orientation itself. A Panasonic RW2 preview stored 1920x1280 with
orientation 8 therefore decodes as 1280x1920 already — the frame the
camera meant.

previewGrid turned it a second time by its own per-orientation canvas
rotation, so a portrait frame's reference grid met the develop a quarter
turn out, previewMatch rejected the fit as folded and the RW2 opened with
no tone curve at all (~9-13 levels off across the shadows).

Drop the rotation and read the crop rectangle and aspect straight from
bmp.width()/bmp.height(), the display dimensions Skia hands back.

Panasonic RW2 now fits a real curve (dRGB 2.3,-0.0,0.4, was -9.1,-10.3,
-13.2). The five bodies that were already stable — ORF, NEF, DNG, both
RAF — measure exactly as before.

getJpegOrientation had no other caller and goes with it; raw-orf-check
drops its own byte-scanning preview finder, which crashed on DNG and RAF,
and reads the reference through the shipped extractEmbeddedJpeg.
2026-10-06 22:10:47 +07:00
3dtours 31ca44e839 fix(raw): stop the develop from handing the studio zero bytes
libraw-wasm moves the buffer it is given into its worker, so raw.open() detaches
the caller's array. developRaw read its embedded JPEG preview as a view of that
same array, so the open emptied the preview, Skia rejected the detached buffer
and the catch returned the emptied preview as the develop - every RAW opened from
the LIBRARY reached the studio as 0 bytes and the app said "Could not develop
this RAW file. Try again, or use its JPG."

Hand the worker a copy instead, in the develop and in the thumbnail path. The
EXIF stamps read off the caller's bytes afterwards are intact again.

The ORF check now hands the bytes over in the transfer list the way the package
does, so it fails on any develop that forgets this.
2026-10-06 16:16:32 +07:00
3dtours f7c509eff1 fix(raw): fix RAW preview matching, EXIF orientation alignment & studio initial state 2026-10-06 12:23:38 +07:00
3dtours 5bb5031805 fix(raw): keep the blown highlights neutral after the tone curve
The blown gate draws a clipped pixel at its own maximum -- one value across
the three channels, so the frame's highlights carry no cast -- but the
per-channel tone curve runs after it and re-tints what the gate had just
made neutral. One cubic a channel, fitted on a grid that has no block left
to fit where the frame ran out (previewMatch drops the fully blown ones),
so at the plateau the three curves agree only at 1.0 and part company
either side of it.

On the ORF this was reported on, the moonlit sky came back 254,255,255 and
253,255,254 -- red under green across a quarter of the frame.

The gate is the develop's own statement that the pixel had no colour of its
own, so it is re-read on the value that leaves the shader. The blown pixels
now measure 254.95,254.94,254.97 (R/G 1.0000) against 253.44,254.94,254.51
(0.9941) before; their two most common triples, 254,255,255 at 149315 and
253,255,254 at 122127, collapse to 255,255,255 at 278099. The bright bands
land at -0.0,-0.0,-0.0 against the embedded preview where they were
-2.1,-0.0,-1.0, and midtones and the lower half are untouched.
2026-10-06 10:45:15 +07:00
3dtours 4c675a1d95 fix(raw): open Olympus ORF at the colour of its own preview via rgb_cam
The develop preferred cam_xyz, whose rows are the XYZ of each camera channel and
which nothing normalised: on the ORF this was reported on it left the frame
green and blue (R/G 0.921, B/G 0.886) where the file's own preview sits at
1.013 / 0.841, and saturated reds came back as the dark purple the frame was
reported for — a red pixel's green ran negative through a row that carries
-2.64, so it clipped to 0 while the knee pulled red down with it.

LibRaw hands back dcraw's own rgb_cam, already the camera -> sRGB transform with
rows summing to one, and it is now applied as handed back: R/G 1.020 B/G 0.920,
and the fit against the embedded preview follows to mean 167.5,164.8,138.5
against the preview's 167.0,164.8,138.6 and the camera's own JPEG's 165,162,133.
Dividing rgb_cam by pre_mul — which carries that row normalisation — is what the
frame before this one did instead, and it undoes it.

The cam_xyz chain stays as the fallback for a file with no rgb_cam, with its
rows normalised so a neutral frame opens neutral. invert3x3, unused since the
frame stopped going through the chain, is dropped.
2026-10-05 22:51:10 +07:00
3dtours b39d9559bb fix(raw): refine RAW highlight clipping threshold to preserve sunset sun color without white dome clipping 2026-10-02 18:15:21 +07:00
3dtours 555623bdb1 fix(tone): enhance shadow fill curve to match Lightroom Classic shadow lift on dark fabric 2026-10-02 17:50:44 +07:00
3dtours 3e61db68d5 fix(tone): restore measured Lightroom Classic curves for White and Highlight while keeping clean shadow lift 2026-10-02 17:42:52 +07:00
3dtours f3aabe9ba8 fix(tone): correct tone knobs curves and eliminate spatial halo bleed around dark silhouettes 2026-10-02 16:42:43 +07:00
3dtours d69ded0853 web: separate global endpoint curves (blacks/whites) from local tone mapping to eliminate grey aura around silhouettes and bump sw cache to v4 2026-10-02 16:06:41 +07:00
3dtours d266027aae web: fix halo artifacts on highlight/shadow/white/black adjustments with edge-aware tone mapping and update docker packages 2026-10-02 15:27:32 +07:00
3dtours acbb2bba4b web: the four tonal knobs are sized by what the eye can see, and the highlight head is squared so a bigger rate fits inside the cube
The report was "giá trị thay đổi của các thông số quá nhỏ, không thể hiện được
trên thị giác của ảnh" — at the doc's own rates a full +100 was worth 0.060 of
luma on BLACKS, 0.082 on HIGHLIGHTS and 0.042 on WHITES, and the probe that ran
the frame through the pass read BLACKS +100 moving its mean by 0.0001. Every
rate below is now the largest its own move allows, measured rather than
inherited: 0.058 -> 0.080 on BLACKS, 0.082 -> 0.113 on HIGHLIGHTS and 0.042 ->
0.080 on WHITES, with SHADOWS' 0.151 left where it was because it already bit.

  - `TONE_BLACK_LIFT` 0.7 -> 0.93. The ceiling is a FOLD, not a slope: past
    0.9387 the doc's own square root carries luma backwards inside its window
    (0.94 folds 3.4e-6, 0.95 folds 8.9e-5) and a gradient wears it as a band.
    0.93 is the last round rate under it — monotone on the check's 1/32768
    grid, and the 1e-4 of travel between it and 0.94 is not a code value.
  - `TONE_BLACK_CRUSH` 0.85 -> 8.0. The doc's own form — `L * (1 + amount * W
    * 0.85)` — is bounded by its own window, which is 1 only AT the floor, so
    its whole visible travel at full -100 is 0.019 of luma: five code values on
    a black patch, and a rate past 1 drives the product negative and clips the
    toe to a flat black instead of deepening it. The toe's own EXPONENT,
    `L -> W * (L/W)^(1 + rate * W)`, is monotone for ANY rate and worth 0.054,
    while x = 0 stays on 0 and the 0.18 edge stays on 1 — both anchors and the
    compact support kept.
  - `TONE_HIGH_GAIN` 2.5 -> 14.0, with the head term moved from `(1 - L)` to
    `(1 - L)^2`. The linear headroom dies too slowly to keep the rate's own
    ceiling off the clamp: above a gain worth 2.6 the move overshoots 1.0, the
    clamp draws a plateau and the ramp falls back over it by 0.018 — the fold
    the knee check now measures as a drawdown from the running maximum. Squared,
    the move has died out by the time the ramp reaches the clamp: 14.0 is
    fold-free at both signs and still lands 1.44x of the 1.5x the quarter it
    owns is allowed.
  - `TONE_WHITE_GAIN` 1.5 -> 3.0, which takes the top of the ramp TO the
    ceiling from 0.92 up and leaves the clamp to flatten what is left. That is
    the doc's own §2.4, where a WHITE is the frame's clipping point ("giới hạn
    cháy sáng") and not a Hermite that cannot move the head; it is felt only
    above the 0.80 shoulder and the head is still exactly 1.0 on 1.0.

`FILM_TONE` is re-solved for the squared head, which is worth less at the 0.75
knot for the same rate: monochrome -0.16 -> -0.1143 and mono-high-contrast
+0.83 -> +0.5929. The four knots the stocks are tuned to do not move — 0.22 and
0.7375 on Acros, 0.17 and 0.815 on Acros HC — and the check pins each of them.

The knee check's guard is REPLACED. The old one compared the first cell of the
sweep against the second (a slope at 1/512), which is blind to a fold that
starts later: it passed a ramp whose own drawdown was 0.018. The new one walks
1/32768 of the ramp and measures `running max - value` for each knob at both
signs, over the four moves alone and then over a 243-combination sweep, so what
is pinned is the fold itself and where it is.

Two folds are pinned rather than removed, both named in the check:

  - SHADOWS -100 dips 0.018 (4.6 code values) around 0.06..0.11 of its own
    accord. It is the doc's §2.2 formula — `L *= 1 + amount * W * (1 - L)^1.8`
    — where the window rises faster than the light, and it PREDATES this change.
    The monotone rewrite (`L' = 1 - (1 - L)^(1 - SH * |a| * W(L))`) is written
    out beside it and was NOT taken: it is exact but it costs the knob 30-50% of
    its crush.
  - WHITE +100 rests a plateau on the clamp from 0.92 up. That is what §2.4 asks
    of the knob and the ramp is non-decreasing through it, so it is not a fold.

`scripts/tone-base-check.mjs`'s mirror of the pass takes the same three moves
(its own `toneBlack` and the squared head) so the pixel it predicts is still the
pixel the pass draws.

Checked: node scripts/highlight-knee-check.mjs; node scripts/tone-base-check.mjs;
node scripts/auto-tone-check.mjs; node scripts/half-check.mjs; node
scripts/mask-wb-check.mjs; node scripts/preview-match-check.mjs; node
scripts/raw-develop-check.mjs; node scripts/white-level-check.mjs; node
scripts/wb-table-check.mjs; node scripts/sharpen-check.mjs; node
scripts/denoise-check.mjs; npx tsc --noEmit.
2026-10-02 10:54:54 +07:00
3dtours 76d84503c9 web: SHARPENING lifts only the edge it was pointed at, so a flat half of the frame keeps the grain it came with
SHARPENING was the doc's §4.2 kernel with the two parts of §4.2 missing from
it. `CLARITY_SKSL` evaluated the 3x3 unsharp mask with Mask = 1 on every pixel
and no coring at all, so a flat sky, a cheek and a noise speckle all took the
gain an eyelash took. That is `thay_doi_thong_so_giong_lightroom.md` §1 written
out as a bug: "khi Sharpen, ảnh nổi đầy sạn hạt cát" — the knob could not raise
the contrast of an edge without raising the noise of everything beside it, and
on a grainy frame the second effect won.

`SHARPEN_SKSL` is the doc's own line, `Image + Amount x HighPass x Mask`, with
the two terms it names:

  - EDGE DETECTION: the Sobel magnitude G = sqrt(Gx^2 + Gy^2) on luminance, put
    through the doc's soft threshold smoothstep(T, T + 0.1, G). Flat fields
    read G = 0 and get Mask = 0 — the pixel is handed back untouched.
  - DETAIL (halo coring): a high-pass under SHARPEN_CORE is a speckle, not a
    detail, and is suppressed. The coring is soft (a ramp across the threshold,
    not a cliff) so a detail sitting on it is not switched on and off from one
    pixel to the next.

The HIGH-PASS is the doc's Radius, held at one image pixel — 0.7-0.9px on a
Retina panel — and it is a LUMINANCE high-pass carried by all three channels.
A per-channel kernel sharpens a red edge against a green one and draws a colour
fringe down every contour; the file's own §3 rule is to keep R/L, G/L and B/L
where they were.

`scripts/sharpen-check.mjs` pins the three properties the old kernel could not
have: a flat field and a field of grain come back unchanged, a step below the
threshold comes back unchanged, and a hard step moves apart on both sides while
the flat halves beside it stay put. `CLARITY_SKSL` and `clarityUniforms` are
gone with it, and the header note that said CanvasKit had two convolution steps
to replace now says the one it has.

Checked: node scripts/sharpen-check.mjs; node scripts/denoise-check.mjs; node
scripts/tone-base-check.mjs; node scripts/highlight-knee-check.mjs; node
scripts/auto-tone-check.mjs; node scripts/half-check.mjs; node
scripts/mask-wb-check.mjs; node scripts/preview-match-check.mjs; node
scripts/raw-develop-check.mjs; node scripts/white-level-check.mjs; node
scripts/wb-table-check.mjs; npx tsc --noEmit.
2026-10-02 08:55:53 +07:00
3dtours 90a7ec9e46 web: NOISE REDUCTION takes the colour speckle out of the frame and leaves every strand of it where it was
The knob was a `MakeBlur` image filter on the draw of the graded photo — one
sigma over all three channels — so at NOISE REDUCTION 100 a 1024-pixel preview
lost every edge finer than 0.6 of a pixel of its own and nothing brought the
luminance detail back. That is thay_doi_thong_so_giong_lightroom.md §4's own
warning ("Noise Reduction sẽ làm nhòe toàn bộ chi tiết sợi tóc và vân da") written
into the engine, and the filter had a second cost: a paint filter is handed the
shader's INPUT, so the pass could never read the graded pixels it was supposed to
correct.

It is a two-child pass now, NR_SKSL, run after the draw: the frame, and a blurred
copy of it (blurredFrame — the snapshot read back through the same MakeBlur the
ramp's base and the negative sharpening use). The output takes its CHROMA from
the blurred child and its LUMA from the frame, the split the tone ramp's header
already describes (`rgb - luma`), so the output's brightness is the input's at
every amount by construction — the eye is nearly blind to a hue change at that
scale, which is the whole reason the colour half is free. The reference reaches
NR_CHROMA_SPAN = 0.4% of the frame's width, the doc's own 3..5 pixels of a
full-resolution frame, where the knob's blur was 0.6 of a pixel.

The luminance half of §4.1 (its bilateral filter) is deliberately not here: it is
the half that costs detail and no frame has shown grain the colour half left
behind. ponytail: add it as a second child of this same pass when one does.

Checked: `tsc --noEmit` clean; `denoise-check.mjs` (new) compiles NR_SKSL on
CanvasKit, asserts the engine still wires both children and no longer blurs the
draw, and renders the pass at five amounts against a flat pair — amount 0 is the
pixel exactly, amount 1 carries the neighbourhood's colour difference, and the
luma never moves at any of them; `highlight-knee-check.mjs`, `tone-base-check.mjs`
and `mask-wb-check.mjs` still green.
2026-10-02 08:14:25 +07:00
3dtours e4f5407c19 web: each of the four tonal knobs moves its own band of the ramp and not the guard the four used to share
BLACK, SHADOW, HIGHLIGHT and WHITE were four bumps summed into the identity,
and the sum carried a guard: the two bumps of a half shared a slope, so past a
total of 1 the curve folded backwards, and the ceiling that stopped it was
shared by the amplitudes of a half. A stock already sitting on SHADOW therefore
took BLACK's lift down with it — on the monochrome stock (sh = -0.24) BLACK at
-100 came back with 0.663 of the travel the knob has on its own, which is the
"kéo theo sự thay đổi của thông số khác" report exactly.

thay_doi_thong_so_giong_lightroom.md section 2 asks for four WINDOWS instead:
each knob owns a compact band of the ramp and is exactly zero outside it, and
the four moves are applied ONE AFTER ANOTHER rather than summed. A composition
of monotone maps is monotone by construction, so it needs no guard, and each
knob then measures 1.00 of its travel on every stock. BLACKS is the doc's toe —
u = clamp(1 - L/0.18, 0, 1) cubed, opened by sqrt(L) - L at 0.7 and deepened by
0.85, both of which are exactly zero at L = 0, so (0,0,0) stays (0,0,0): the
grey pedestal that BLACK +100 left on a black was the sum adding its bump's
height at the black point, which is the doc's own "Milky / Foggy". SHADOWS is
the doc's bell over the deep tones, HIGHLIGHTS the bell over the bright ones,
WHITES the doc's Hermite on the shoulder from 0.80. The ramp keeps its two
anchors — 0.00 and 1.00 — at every setting of the four knobs.

One deliberate departure from the doc: HIGHLIGHT carries a (1 - L) the doc's raw
knee does not, because pow(L - 0.5, 1.5) added to L overshoots the cube above
0.94 — 17% of the ramp driven to flat white at +100 before the clamp. Read
against the headroom that is left, the move is zero at L = 1 by construction and
the head rolls instead of clipping.

The windows are read in the sRGB-encoded luma this file already works in, not in
linear light as the doc's section 1 sets out: the doc's own boundaries (0.18,
0.05..0.45, 0.55..0.95, 0.80) land as perceptual positions there, and moving the
whole renderer to the linear domain is a bigger change than this pass. The
divergence is the one the scratchpad compat doc already warns the Android port
about, and it is noted at the windows themselves.

Checked: `tsc --noEmit` clean; `highlight-knee-check.mjs`, `tone-base-check.mjs`
and `mask-wb-check.mjs` updated to the four windows and passing; the twin ramp
over a 1/512 grid is monotone to -0.00119 (0.30 code values, at t = 0.098 with
every knob at full negative), both anchors hold for every combination, and a
knob outside its band is the exact identity.
2026-10-02 08:11:32 +07:00
3dtours bc550569ad web: BLACK keeps the picture's texture and not the base it was read off, clarity stops the blur at an edge instead of at a colour, and a backup folder that refuses is handed back to the picker
BLACK at full deflection returned a soft picture, and on a monochrome frame it
returned the blurred base outright. The tone pass reads the ramp at the
neighbourhood's base and then rebuilds the pixel, and it rebuilt it by the RATIO
the base had moved by — `Base' * (Input / Base)`, the other half of
fix_shadow.md's decomposition. A ratio is a gain, and that gain is a function of
the neighbourhood: the knob that takes the base toward zero scales every pixel's
detail by the same coefficient, so the knob that darkens the frame takes its
texture with it. Measured on lightroom_shadow.jpg at 1024px through tone-sim.mjs,
the pass in plain JS with the shader's own constants: at BLACK -100 the finest
gradient came back at 0.75 of the input under the ratio and at 0.98 under the
sum, while the frame darkened the same either way (mean 0.416 to 0.359 both). A
monochrome stock is the whole frame of that error, because all three channels ARE
the pixel's luma there, which is why the picture came back as the base — soft,
and short of every edge it had.

The reconstruction is `Base' + Detail`, ADDED and not scaled, and it costs
nothing where there is no move to make: with every knob on zero the ramp at the
base IS the base, so the difference is exactly zero and the pass is the identity
however coarse the base is. A caller that hands in no neighbourhood at all — a
mask — hands in the pixel's own image as its base and gets the global move back,
which is what the ratio gave it too. `o` is held inside the cube before the
detail is added, so a neighbourhood the ramp has pushed under the floor keeps the
structure around it instead of carrying its pedestal down onto every pixel in the
region. The bright side of the same move is untouched: the lift is still the
neighbourhood's, and at SHADOW +100 the band above the lifted knot still keeps
0.81 of its spread where the global move kept 0.42.

CLARITY drew a light stroke down every contour, and the reason was in what the
blur called a neighbour. The range weight was the colour difference,
`exp(-dot(d, d) * 24)`, which is loose on any coloured edge — two sides of a hair,
a branch or a rail can share a red and differ in green — so the reference reached
across the edge, and the reference is exactly what the blend subtracts. The wider
it reaches, the more a contour reads as detail. It reads LUMINANCE now, one
decision per tap at the doc's own scale (`CLARITY_RANGE_SIGMA` = 0.04), so an edge
of any hue stops the blur dead.

The blend moved the three channels by their own differences, which is what
coloured fringing along every contour was, and the amount it moved them by was
the MASK's `CLARITY_GAIN` borrowed for a different child. It moves LUMINANCE now
— one value carries the whole pixel back with it, so hue is untouchable and skin
does not go sallow at the top of the knob — under the doc's midtone weight
M(L) = 4L(1-L), which deepens the greys a picture is made of and leaves the burnt
ends and the deepest shadows where they are. The positive side's gain lives
inside the shader (4.5, raised from the doc's 1.8 because the range weight above
reaches less far and carries less detail): clarity-halo.mjs, which measures the
pass pushing a pixel outside the range of its own neighbourhood, reads a bright
stroke of 55.3/255 on lightroom_shadow.jpg and 54.3/255 on DSCF1701.JPG at +10,
against the old pass's 145 and 127 at the same knob — and 8.0 already reads 98, so
the knob does not need to go further to keep the flat areas moving.
`exportEngine` passes the knob's own units now, `[clarityKnob / 10]`, since the
gain and the sign are the shader's business and the mask's gain is not the
frame's.

And a backup folder the browser had stopped letting the page write to wrote
nothing and said so, once, with no way back: a permission outlives the tab only
while the tab does, so the row kept reading a permission of a moment ago while
the write went to the disk without one. A refusal that names the permission, or
the folder that is not there, now goes through the picker ONCE — the picker is
the only thing that hands a folder back — and the run is repeated; anything else
is the folder itself saying no, and is not asked twice. The sentence on the
screen is one line over a strip of photographs and cannot carry a reason, so the
reason goes to the console and a word of it into the note (`{why}`, the name the
browser gave the refusal and never a stack), which is the whole of what tells a
folder that was moved from a permission that lapsed. Both dictionaries learn the
word.

Checked: `tsc --noEmit` clean; `tone-base-check.mjs` and `highlight-knee-check.mjs`
updated to the new reconstruction and passing; `tone-sim.mjs` on
lightroom_shadow.jpg at 1024px for the numbers above; `clarity-halo.mjs` for the
stroke.
2026-10-02 07:20:31 +07:00
3dtours 31435aac77 web: the offer to install is made once, and the answer is kept 2026-10-01 07:46:10 +07:00
3dtours 166a677590 light: the tone ramp is four bumps on the identity, not straight segments between five knots — a knot is an angle, an angle in a tone curve is a Mach band, and the wedge reads the seams: BLACK +100 broke at 0.030 with 105 of second difference, HIGHLIGHT -100 at 0.747 with 72
The ramp was a0..a4 with the pixel's base interpolated straight between them.
A segment meets its neighbour at an ANGLE, and the second derivative of a tone
curve is what a gradient reads as a band — so a knob left a line across the
mid-tones, worst exactly where it was reported: BLACK +100 put its whole lift
inside 0.25 and the stretch from 0.25 up came back identical to the untouched
frame (the "transition stays grey" it was reported for), and HIGHLIGHT -100
folded a seam into 0.747, between the highlights it pulled and the shadow it
left under them.

Measured on a 1024-step luma wedge through the exported pass, second
difference through a ±1% box: BLACK +100 read 105 at 0.030 against 0.000 from
0.25 up, HIGHLIGHT -100 read 72 at 0.747. Step of the first derivative across
the knots: 0.955 at 0.25 and 1.146 at 0.75 — the curve arrived folded, and
1.146 is a sign flip, not a bend.

So each knob is now a BUMP on the identity, peaking on its own knot — BLACK on
0.00, SHADOW on 0.25, HIGHLIGHT on 0.75, WHITE on 1.00 — with the kernel
(1-u^2)^2 over a half-width (a half of the ramp for the two ends, whose knots
ARE the ends, a quarter for the two heads). Level at u = 0, so a knot moves
without a fold at its own top; level at u = 1, so a move lands on the identity
and on its neighbour without an angle; C1 everywhere between. The two bumps of
a half meet on 0.50 both on zero, which is the same fixed midpoint as before,
and DR still moves the same knots (0.12 on the toe, 0.18 on the head, half of
each on the heads beside them).

A sum of bumps can overshoot where two steep sides land on one stretch — past a
slope of 1 the curve runs BACKWARDS, a worse band than the seams this replaces,
and it is reachable: DR alone was under it, BLACK and SHADOW +100 together were
not (unguarded min slope -0.0141). The guard reads each pair at its own
steepest points, 8/(3*sqrt(3))/w per unit amplitude (TONE_BUMP_SLOPE_HALF 3.0792,
TONE_BUMP_SLOPE_QUARTER 6.1584), holds the two under one and gives them up
together past it. A single knob never reaches it (a full BLACK is 0.77, a full
SHADOW 0.77), so every slider keeps its whole travel; the worst case is DR at
full, which gives up a tenth of its head roll (0.18 -> 0.8376 on the head), and
BLACK with SHADOW both at +100, which arrive at 0.65 of their own lift instead
of folding. Guarded, the sweep over five levels of all four knobs and DR reads
a min slope of +0.0183 and a max of 1.9937, with the largest slope jump 0.00005.

After: the same wedge, the same pass. The knot steps are 0.096 at 0.25 and
0.478 at 0.75, with no sign flip — C1 across the knot instead of a fold. BLACK
+100 now carries the rework out of its own quarter: +0.139 at 0.25, +0.101 at
0.30, +0.033 at 0.40, 0.000 at 0.50, where it used to read 0.000 from 0.25 all
the way up. HIGHLIGHT -100 keeps its lift (-0.126 peak against -0.121 before) and
spends it over the quarter instead of into a line. Every knob on zero is the
identity to the last bit — the pass also runs for the stock split tones and for
DR alone — and 0.50 is still the one value no knob moves.

Checks: tone-base-check.mjs now runs the bump and the guard as arithmetic
beside the shader (with the negative control: the unguarded pair still folds,
the guard is what stops it). highlight-knee-check.mjs reads the kernel and the
two slope constants off the source, pins the four amplitudes and the guard, and
sweeps the travel of every knob as before. All ten checks that run without a
browser pass, build clean.

Skipped: the guard's ceiling is a constant, not a search for the widest travel
that still clears a band we cannot see. Add when a frame shows a band the
deflections in hand cannot explain.
2026-09-30 21:24:01 +07:00
3dtours 097e383b86 light: the tone base is a real blur, not nine point samples of one — the ring aliased the luma and the ramp painted the alias back as mottle
The base layer shipped last commit was nine POINT SAMPLES of the child, one
ring out at TONE_BASE_RADIUS. A ring is not an average, and on a frame with
texture at the ring's own scale it is worse than one: the nine lumas differ,
the sample pattern beats against the texture, and the base field comes out
aliased. The gain the pixel then rides, o(base)/base, is a function of the
base with a kink at every knot — so each alias of the base becomes an alias
of the gain, and the reconstruction paints it straight back over the detail
it was supposed to leave standing. Reported on a waterfall: grey patches
loose on a mountainside, a smear across the face of the falls, and plateaus
in cloud and sky (the pass runs whole-frame, so its base is read in the
bright end too).

Measured, 1160x774 at SHADOW +100, the high-frequency part of the gain field
(9px high-pass, where a real base can hold none): 0.0627 for the ring,
0.0048 for a blur of the same radius. Thirteen times. The fix is not more
taps — a 7x7 at a third of the step is still point samples, only smaller —
it is to stop sampling: the caller blurs.

So the base is now a second CHILD of the tone pass, the frame blurred by
Skia's own MakeBlur (blurredBase in exportEngine.ts, the draw drawBlurred
already made for the sharpening pass) at TONE_BASE_RADIUS of the frame width
and sigma TONE_BASE_SIGMA of that radius — a box's equivalent at the radius,
so the neighbourhood is the one the radius always named and the cuts are
smooth instead of hard. The shader reads it ONCE per pixel and baseLuma
loses its loop and its `bx`. That is also the cheaper pass: nine child evals
walked the exposure/matrix chain nine times, one eval does not.

A caller with no frame to blur hands in the image it is already shading as
the base. The tap then lands exactly on t and the ramp is the global move
again — a mask's degenerate bx of zero, spelled as a child that IS the
source, which is what gradientMask.ts's own call (base == t) already meant.

Checks: tone-base-check.mjs is new — SkSL is only compiled at runtime and
nothing here compiled TONE_SKSL whole, so the pass is compiled and rendered
for real, with the frame and the base held at two flat values a little
apart: the pixel has to land on the value the ramp over THAT base predicts
(171 for a 128 pixel over a 76 base), and a base equal to the pixel has to
be the identity. highlight-knee-check now pins the one tap, the absence of
`bx`, the blur, and the two-child wiring. 9/9 pass, build clean.

Skipped: no guided filter proper — the base is a plain blur, so a strong
edge is no longer held out of it the way the range weight held it (a dark
rock a blur's width from the water reads a lifted base and keeps its own
darkness). Add when a frame shows the halo; the doc asks for a plain blur
and this is one.
2026-09-30 19:03:55 +07:00
3dtours e61dccc784 light: the tone ramp is drawn through a base layer, not the pixel, so a SHADOW lift moves the region and leaves the texture in it standing — the quarter above the knot came back at 0.57x of its own spread, 0.78x now
The four knots were read at the pixel's own luma, which makes the ramp a
global curve: every pixel at luma t lands on the same o whatever surrounds
it. SHADOW's a1 is the head of the quarter above it, so lifting it squashed
that whole quarter to the half slope left over — measured on a real frame,
0.50 of its spread (shadow-band.py), 0.57 on the deployed bundle — the grey
sheet the knob was reported for. A curve drawn through the pixel cannot see
local contrast; that is what the eye was reading.

So the ramp is read at TONE_BASE_RADIUS (2.5% of the frame) of the luma
around the pixel — a 3x3 range-weighted blur, the fix_shadow.md Base x
Detail split — and the pixel then rides the neighbourhood's gain o/base,
keeping its own difference from it. Base moves, detail stays: the same lift
on the same pixels, with the texture inside the region left standing. Full
deflection keeps 0.78 of the band's spread now.

The base is the same maths in every caller: bx = 0 (a mask, which has no
neighbourhood) reads the pixel nine times and gets the old global move back,
and with every knob on zero the ramp at base IS base, so the ratio is 1 and
the pass is the identity however coarse the base is.

Skipped: no chroma compensation (Hunt). Measured, the ratio held saturation
(0.3184 -> 0.3169), so it is not earned yet. No linear-light ramp either:
the multiply is a uniform gain on encoded values, which is the same stop
exposureMove already argues for.
2026-09-30 18:46:36 +07:00