The two rounds were meant to answer the stroke sooner, but a preview cannot be
half a repair: the blurred fill is what a machine without the runtime gets, and
with it on the stage a spot of dirt came back smeared rather than gone — worst of
all on a run of strokes, where each new one cancels the model's round that was
still on its way and leaves the blur standing longest. The repair that works is
the one that waits for the model, and the wait is worth it.
The worker stays available but is switched off with the rest: this reverts the
runtime's proxy along with the second round, so the stage is exactly what it was
before, lag and all.
Reverts 5489114.
SINGLE is where a roll is read one frame at a time, and the step from one frame
to the next was a trip to the strip below the picture and back. The stage now
carries the pair itself, at the edges the eye already reads as "there is more
this way", dimmed at the ends of the roll rather than taken away so the pair does
not jump about. The press stops at the arrow: the zoom, the pan and the open
under it are the picture's.
The phone's full-screen view has no room for a pair, so the same step is the
reader's own finger. A drag that travelled sideways is a step the way the list is
read; one that wandered up and down, and a tap in place, are left alone.
Both walk the list on screen — filters and sort included — so what is drawn after
the step is what stood beside it.
Verified against a local preview with a seeded shelf: the arrows appear, the left
one is dimmed on the newest frame and the right one on the oldest, and a left
swipe on the phone moves to the next frame and back.
The graph is seconds of solid arithmetic however large the crop is, and it ran
where the pointer lives: the brush lagged behind the hand for exactly as long as
the model took. The runtime's wasm provider now works in a worker of its own, so
the page keeps drawing while the repair is thought through.
The wait itself stays — it is the model's, not the page's — so a preview paints
the blurred fill first and lets the model's version of the same mask replace it.
Filing and export still wait for the model: the faster answer is for the eye
waiting on the brush and for nothing else.
Measured on the same photo and stroke, worst main-thread gap 3703 ms before and
487 ms after; previews land at 1.0 s and 5.3 s, and the model's render is the one
left on the stage.
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.
The background walk behind a cloud node read its pages after the answer the
screen had already been given, and no one told the screen when the last page
landed: the note went on saying a reading was running for the rest of the
visit. The walk now gives its own last word, with the count it read.
The strip and the wall windows were measured off the node the effect was set
up on. The stage is drawn while a node is open, so a visit that loses its
nodes and gets them back draws a new strip and a new wall, and a window
measured off the old node — a box of nothing, a scroll always nought — never
moved again for the rest of the visit. Both now read the element that is up.
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.
The dialog knew two shapes: an add form with the saved links above it, and an
album-only view that hid the form. So a saved server could be retick'd but never
renamed, and the duplicate-address warning was only readable from the add side.
One shape now. The list is always drawn, every row carries SỬA beside
REMOVE THE LINK, and SỬA puts that server's name and address in the fields below
— prefill, edit, save. A blank key is the one that server already answers by, so
a rename does not ask for it again, and the backend's single PUT probes before it
stores either way. The desktop bar's button says TÀI KHOẢN CLOUD once there is an
account to manage, and THÊM IMMICH while there is not.
- 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.
A row of `photos` kept the frame's own FileSystemFileHandle, and reading the
catalogue handed them all to the page at once. Measured on a catalogue of
504290 frames, 458103 of them with a handle, opening the library left the JS
heap at 133MB while the browser process held 5.7GB and the renderer 2.1GB:
handles are not the page's heap, so nothing ever asked the collector to run,
and the rescan's own reload of the catalogue every thirty seconds piled one
such reading on the next until the tab was out of memory.
No row keeps a handle now. The catalogue is read a few thousand rows at a time
— the four and a half gigabytes of live handles came from asking for all of
them at once — and a frame's file is asked of its folder's handle when it is
drawn or opened, `fileHandleFor`. `reconnectPhotosForFolders` no longer writes
handles back into the frames, which is what put them there.
504290 rows read in chunks peak at 1.4GB and settle at 1.1GB, against 8.0GB in
one read before.
`saveWalkedDirs` filled in a nought beside every folder a reading had listed but
never opened. A reading stopped by a closed tab, and one kept to a branch, list
the rest of the roll before they have been into any of it — 484 folders of one
real roll read empty over tens of thousands of frames — and the record is folded
back into itself, so the noughts stayed.
Only a reading that met the whole roll may say nought now (`WalkedDirs.full`), and
the screen drops a nought from a record that does not say so. An old record has no
word to give, so its rows fall back on the catalogue instead.
feat(library): search Immich from the filter row
One box, one query, one node: the backend reads what is typed as a tag name when
the key can see one and as a file name otherwise, and the answer is filed under the
node itself. A share link searches its one album in place. The box is not a filter —
it leaves the shelf, which is what the md's search permission is for.
feat(ui): a shorter status line, and a wall that keeps its own row height
The status line is one short 12px row at the foot of the library. A wall with fewer
frames than there are columns no longer stretches its one row — and the cards' own
borders with it — down to the stage.
- 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.
Switching from ALL THUMBS to SINGLE opened the frame nearest the middle of
the wall, not the one that was pressed, and the strip then centred that
stranger. `openFromWall` now prefers the last frame the reader chose when it
is still in the listed shelf, and falls back to the nearest card as before.
A frame the studio graded kept its look in the tile but the stage was read
from the file, so the preview showed the negative the tile was made from.
`stageSrc` now draws the graded tile, and the `onThumbWritten` subscription
marks the frame edited so the shelf is current when the reader comes back.
Verified: scripts/wall-check.mjs, and the D22 harness against the deployed
build on 8090 (all ok, stage vs tile diff 0.0).
Choosing another row in the column read the whole roll back out of the store and asked it for a tile of every frame in one transaction: 20 000 requests in flight at once at 40 000 frames, a 200 ms block of the main thread per switch, and a mint job over the whole folder. The rows are the ones the screen is holding already, the store is asked a chunk at a time, and a folder opening under the pointer is the head of the list — the wall mints the rest as it scrolls. MAKE THUMBS by hand still walks the whole folder.
A column count read back off the cards the window drew is an input the window
feeds itself with: the drawn run is a whole number of rows, and those rows were
read back into the count. A wall hidden behind the studio read nothing but zeroes
off its own cards and reset itself to the top on the way out. Either way a window
was written from a window, pass after pass, until React gave up with #185 and the
whole screen went white — there is nothing between that throw and the floor.
The count now comes off the grid, which is the same read the settle effect above
already scrolls the wall by; a wall with no box is not measured at all, and is
measured on the frame it comes back up; the one reset is written off the shelf's
own length rather than off the last window; the scroller is not a place the
browser may anchor; and a chain of writes past four is cut until something
outside moves — a scroll, a resize, or another shelf.
Selecting thumbs or flipping SINGLE <-> ALL THUMBS on a roll whose captions wrap
to different heights crashed the shelf with React #185 ("Maximum update depth
exceeded") and left the screen blank. useWallWindow read the step from one row to
the next off the rows the window itself had drawn, and the average of a run of
rows moves as the run grows: `to` traded places with a sum a row away — 35, 40,
35, 40, three milliseconds apart — every pass a render, until React gave up.
The step is taken once per column count and kept, re-read when the wall
re-columns, which is the only thing that changes a card own height (a caption
wraps at the width it is given). The window is a function of the scroll and
nothing else, so it settles.
Reproduced with the storm probe against a 3000-frame roll of unevenly named
frames: before, blank at round 1 with #185; after, 12 rounds of hard
SINGLE/ALL THUMBS toggling plus a round trip through the studio — no error, no
blank screen, and D18 unchanged (tile CHANGED, the shelf raising the studio
frame 4px off centre, the studio link opening the same frame).
- tiles land one at a time. The screenful a picked folder asks for was built all
at once, and a HEIC or a RAW in it is a decode of its own, so the strip stood
empty until every one of them was done: 2293ms on a roll of eighteen, with the
batch arriving in one go (0/18 then 18/18 in a single step). Tiles are built in
two lanes now and each one goes up the moment it is drawn — first picture at
36ms, then 1, 2, 4, 6, 8, 9, 11, 12, 14, 15, 16, 17, 18 of 18, done at 2496ms.
The threading is handed back between tiles (`scheduler.yield`), so a click
still answers while the strip fills: no long tasks either side, worst frame gap
169ms -> 164ms, a view toggle 49ms -> 41ms. The tiles are the same tiles; the
wall's is drawn from the same counter.
- the studio's look reaches the shelf. A frame graded in the studio writes the
tile the shelf draws, at neutral geometry — no turn, crop, watermark or frame,
since the shelf turns a tile itself — and every screen holding the tile is told
the moment it is written, so the row repaints without a reload.
- the shelf comes back on the frame the studio was holding. The frame a visit
opened is kept with the visit, and coming back from the studio raises it: the
strip centres it and the wall brings its card to the middle, even when the
frame sits in another folder of the same tree. A shelf that opened on whatever
it was left standing on sent the reader looking for the frame they had just
been working on.
- the frame's name stands under the picture, in the studio's own meta bar.