The column files a local folder as an absolute key — `/2012` — while a frame
carries its own `dir` as a path from the root, `2012`. `shown` set the two
against each other as they stood, so a frame's dir was never the node's dir, and
never under it either: every folder in the disk tree was counted in the row
above it and stood in no pane at all — the wall white, the frames nowhere.
The key sheds the slash it leads with before the two are set against each other,
the way `markOf` already cleans a reading's own path.
Measured on the live account's own catalogue — 112761 frames, 6 under `/2012`:
the node matched 0 frames before, 6 after; the root's own list is untouched
(927).
The shelf is laid out in the width the wall reports, and that width was read
once, by the effect that watches the wall's own element. The phone's layout
stands where the wall does, so a visit that opens on the phone mounts the wall
with nothing to measure: the effect returned with the width still nought and
never ran again, and every row of the shelf was laid out one pixel wide — a
library of blank boxes until another render made that effect run (SINGLE and
ALL THUMBS are two of them, which is why the toggle brought the pictures back).
It is read after every render now, the way the window of rows the wall draws
already is (see `useWallWindow`), and the effect keeps the resizing itself.
Measured on 8090, phone 390 handed to the desk: the tiles were 1x1 px and are
now 85x113 (and 120x160 at 1440), all loaded, nothing left in `.lib-noimg`.
Coming back from the studio on a phone left the grid where it was: the
frame the studio was holding was lit by nothing and looked for by hand.
The shelf already stamps the frame it was left on and the wide screen
already opens on it; the phone's grid never read it.
The stamp travels down as a landing, and the grid lights that frame,
walks the list out to it when it sits past the rows drawn so far, and
scrolls it under the eye. Each landing is spent once, so a list that
changes under the reader is not dragged back to it.
probe-mobile-return.mjs, on a phone: the frame the studio held is the one
lit and the one in view, both when it is at the top of the shelf and when
it is buried past the first window.
The rail glided to the frame in hand. A glide across a roll of six hundred
fires a scroll event a frame, and every one of them asked the thumbnail
cache for another window: the tiles of the frames being looked at queued
behind two screens of margin, so the strip sat blank exactly where it was
being read while it drew frames nobody had reached. The rail now lands
where it is put, one window is asked for, and the frames in view are the
first asked for a tile.
The strip also keeps its height: `overflow-x: auto` makes the other axis a
scroller too, so a strip squeezed by the column hid its own frames rather
than spilling them. The photo above gives way instead.
The studio is where a frame is graded, and the frame next to the one being
graded is the one the reader almost always wants next — a roll is shot in
order and looked through in order. Until now the only way to it was LIBRARY:
the stage had no way back to the folder the frame came from.
The strip stands between the photo and the row that names the file, on the
phone at the same place. It holds the folder the open frame was filed in —
the frames beside it, not every frame the folder was scanned for, which is
what the row's own `dir` is read for — in a file browser's order rather than
the catalogue's newest-shutter-first, because a roll off a camera is the
order it is looked through in. The frame on the stage is the lit one and the
one the rail is scrolled to, wherever the rail had been left; a click is the
same opening the catalogue's wall makes, so the frame arrives with the look
it was last left at.
The tiles are the shelf's own: `makeThumb` off the same store, and the URL
cache the wall keeps is lent to the strip through the hook that fills it.
docker/frontend/src/ui/Filmstrip.tsx the strip
docker/frontend/src/ui/ImageStage.tsx one optional slot for it
docker/frontend/src/App.tsx the folder, read off the catalogue
docker/frontend/src/Library.tsx the tile hook, exported
docker/frontend/src/styles/app.css the strip and its frames
The picker hands a whole drive over named `\`, which `normPath` folds to the
empty root, and a row of that roll was keyed `Roll` where the reading files its
counts, its walked folders and its frames under `/Roll` — one folder under two
names. Every number beside a subfolder of a picked drive read nought, and the
spinner of a reading stood on the roll's own row and nowhere it was reading.
The row key is the roll's own spelling again, and so is the path `relOf` hands
back for a row under it: a reading kept to one folder of a drive root asks for
`/Roll`, and `sub` counted from the old `Roll/` merged nothing. Both are the
spelling the check at the foot of `scripts/tree-scan-check.mjs` asks for, and it
is green again.
A bounded reading also measured itself against `\/Roll` spelled from the raw
name, which no id is filed under: `held` stood at nought and the line read the
catalogue's whole roll as the ground it was climbing from. It is built from
`normPath` of both, the way an id is.
A folder on an Immich server is named by an album's id and a folder on this
machine by a path, and both are drawn in one column, so a preview asked for
by one spelling of an address has to be found by the others. A frame's id
now answers to the spellings of the same path — drive-letter case, either
separator, a leading slash — and the thumbnail cache holds enough of them
that a roll is not read twice.
The cloud catalogue is read by what the folder says it is. Kind 'share', or
a folder with no album ticked, is every album the key can see: one reading,
kept to its own cursor, and the frames no album names found by the
whole-library read behind it. A folder with albums ticked is those albums,
read album by album, and it is complete when the last of them is. The album
rows are written before the node's own number, so the column never says a
number and then grows rows under it.
The inference session runs without a proxy: the worker script a proxy loads
is fetched by the session's own origin, and a cross-origin page is served
by the page's.
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 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.
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.
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.
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.
The library is the accounts' shelf, but the tab kept two copies of that fact: the
screen read `/me` once as it mounted and then stopped listening, so a visitor who
signed in at the studio came back to the panel asking for the login they had just
given. One record, read by the studio, the landing page and the shelf alike, is
now the only copy — a sign-in anywhere is a sign-in everywhere.
- auth.ts (new): the tab's one session record. `useAuth()` reads it through
`useSyncExternalStore`, a `/me` already in flight is joined rather than
repeated, and a call that fails leaves the record *unknown* rather than signed
out, so a cold API never reads as a guest.
- TopBar: LIBRARY is a link for a member and a dead button for everyone else. An
anchor wearing `aria-disabled` still navigates on a click, and the screen
behind it would only send the guest home again — so a guest gets the shape of
the button and nothing to press.
- Library: the gate is that shared record, not a copy taken at mount. A guest who
opens `/library` straight is turned home with `replace`, so a Back press does
not walk them into the same wall. The screen is mounted the whole time the tab
is open, so the turn is for the address the visitor actually opened: a guest on
the studio stays on the studio.
- engine/library.ts: the thumbs hand the thread back. Two lanes instead of four,
a frame's worth of idle between them, and one job per frame — a second caller
joins the decode already running instead of starting another.
Verified in Chrome (stubbed API, harness run against both the dev server and the
built bundle): a guest gets a dead LIBRARY button and stays on /app; signing in at
the studio turns it into the link and opens the shelf, with no second login and no
"log in to browse" note; the same after a sign-in on the landing page; /library
opened cold as a guest lands on / with Back going to /app; opened cold signed in,
the shelf is up. On a folder of 22 frames the open runs 2 decodes at a time (was
5) and repeats no frame (44 decodes, was 56 — 12 frames were read twice); the
tiles still come out 22 on the open, 24 by hand, 22 again after a reload.
Four asks, one screen: a folder can be given the tiles it is short of, by hand
or on the way in; the rescan row says RESCAN...; and the wall of thumbnails
scores a frame without the trip to the stage.
- Library.tsx + i18n: "RESCAN FROM THE TOP" is "RESCAN..." in both languages.
The reading is unchanged — a row's roll is still read from the top, its
subfolders included.
- engine/library.ts + Library.tsx: the tile of one frame is one function now
(`makeThumb`: the store, the backup behind it, then the file's own preview
for a RAW or a HEIC and a decode for anything else). The wall's lazy path
calls it instead of keeping its own copy of the same branches.
- engine/library.ts: `missingThumbs` asks which of a folder's rows no tile was
ever drawn for, in one transaction, and `makeThumbs` makes those — a few
frames at a time, and giving up when the caller has moved on.
- Library.tsx: the folder menu carries MAKE THUMBS, which reads the row's
folder and what lies under it; and opening a folder asks for its own frames'
tiles by itself, silently, in the background — the branch too when the
screen is drawing the branch. A frame that lands after the ask is asked for
again, which is why the frame count is a dependency of the effect.
- Library.tsx: a wall card carries the same five stars the stage does, under
the picture, and rates the frame on the click. The click stops at the star,
so scoring a frame is not picking it.
Verified in Chrome at 1440x900 against OPFS standing in for the disk, three
frames with one of them in a subfolder: opening the folder leaves two tiles
(the node's own frames) and MAKE THUMBS takes the roll to three, the frame in
the subfolder included; clearing the store and reloading brings the two back
on the open; the menu reads RESCAN... and MAKE THUMBS; and the wall's fourth
star lights four, writes star 4 to the catalogue row, and takes it back on the
second click. The same checks pass on the built bundle at :8090.
Selecting thumbs and flipping SINGLE <-> ALL THUMBS crashed the view with
React #185 ("Maximum update depth exceeded") and left the app white.
useWallWindow measures after every render through a layout effect with no
dependency array, and it ended by setting state with a value it had just
found. A set made from a layout effect renders again whether or not the value
moved, so an unchanged measurement re-rendered, which remeasured, which set
again: the loop. It is now guarded on the last window actually written (a
ref), and nothing is set that the wall already stands at.
Reproduced with the throwaway storm probe against a 3000-file roll (the tree
vanished at round 2, ~50 identical renders). After the fix: 12 rounds of hard
SINGLE/ALL THUMBS toggling on the production bundle, no error, no white
screen.
Three things, all from one look at a folder of TIFF scans.
A `.tif` is no longer a RAW. Nothing here develops one, and `tif`/`tiff` sat in
`RAW_EXT` for no decoder's sake: opening a TIFF asked for PRO and its tile wore
a RAW badge, for a file the engine has nothing to hand back. Both extensions
are dropped, and a TIFF keeps its own route instead — the tile reads the file's
own pixels (`useThumbUrls`, via `tiffDevelop`) and so does the studio — so a
`.tif` still opens and still draws, it just is not a RAW any more.
The wall's render loop. `painted` was a fresh `.slice()` on every render, so
`useThumbUrls` saw a new list each time round and its effect ran again, and
again: about twenty thousand renders a second while the page sat idle, and the
file reads frozen at the first screenful, so a tile scrolled to never came up
(7 of 24 cards drawn). `painted` is memoised on `listed, wall.from, wall.to`
now — no date formats in eight idle seconds, 28 of 28 cards after a scroll.
Scrolling between the two modes. SINGLE → ALL THUMBS lands the wall on the
frame the strip was showing, centred in the window; ALL THUMBS → SINGLE swipes
the strip to the frame nearest the wall's middle, centred in the row. Each way
the frame the other mode was standing on is the one you come back to.
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.
- 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.