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.
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.
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.
- 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.
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.
- 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.
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.
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.
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.
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.
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.
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.
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.
- 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.
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.
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.
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.
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.