Commit Graph

73 Commits

Author SHA1 Message Date
3dtours 976ec2cd37 fix(library): a server's frames stand at the shape the server stated, not at a guess 2026-10-13 18:50:47 +07:00
3dtours 6f2c9cc780 fix(library): draw one tile for a frame a catalogue holds under both spellings 2026-10-13 15:02:22 +07:00
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 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 0665796e37 improve: Immich cloud filter and optimize 2026-10-11 20:18:04 +07:00
3dtours 9517b53901 feat(library): RESET SETTING puts a frame back to the tile it came in with 2026-10-11 12:00:22 +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 1848f057fc fix(library): read the catalogue the disk holds, not the version this build asks for 2026-10-11 11:59:38 +07:00
3dtours 3f778a859e fix(library): split a stored path on the separator it was written with 2026-10-11 11:59:38 +07:00
3dtours b35f67f491 fix(library): a catalogue is read without the file handles it was keeping
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.
2026-10-10 21:15:03 +07:00
3dtours b76de03eee Revert "perf(library): a drive is walked with its position written every five seconds"
This reverts commit ced749d72c.
2026-10-10 20:36:31 +07:00
3dtours ced749d72c perf(library): a drive is walked with its position written every five seconds 2026-10-10 20:25:53 +07:00
locpham 27e68c0036 fix(library): a drive is read again from the catalogue it is filed under 2026-10-10 20:02:27 +07:00
3dtours b3b9ed0193 fix(library): a reading that never finished no longer says nought
`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.
2026-10-10 18:27:49 +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 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 f335ff605b fix(library): a folder opens on a screenful of tiles, not the whole roll
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.
2026-10-10 08:40:37 +07:00
locpham f099e9d6af fix(library): stop the shelf from hearing about the tiles it asked for itself 2026-10-09 22:54:58 +07:00
3dtours 7b3086b1db feat(library): a shelf that fills in as it reads, and one that carries the studio's look and frame back with it
- 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.
2026-10-09 21:52:38 +07:00
3dtours c2e27b8e9d feat(studio): edits follow the photo — reopening it from LIBRARY restores the whole look
Settings changed in the studio were remembered per photo, but only as a
film recipe, so everything outside that recipe (straighten, flips, crop,
watermark, geotag, frame, the picked sim) was gone as soon as the photo
was reopened from the library in a later session.

Keep the whole StudioState instead and restore it when a library photo is
opened; rows written by older builds — a flat recipe — are still readable,
so photos edited before this change open with their look intact.

SAVE PHOTO is untouched: that remains the step that publishes a photo to
the landing film strip.
2026-10-09 21:19:06 +07:00
3dtours 9ad61be26a fix(library): one session for the whole tab, and a shelf that does not take the machine
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.
2026-10-09 20:50:17 +07:00
3dtours 936414a3b6 feat(library): give a folder its missing thumbnails, and the wall its stars
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.
2026-10-09 20:04:24 +07:00
3dtours 88d97432bd fix(library): stop calling a .tif a RAW, and stop the wall's render loop
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.
2026-10-09 16:12:18 +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 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 db7ce04613 fix(library): sync React photos state after scan and support case-insensitive existing photo lookup 2026-10-08 20:50:18 +07:00
3dtours e1d6b90322 fix(library): update real-time folder tree photo counts and persist scan state across page reloads 2026-10-08 20:28:29 +07:00
3dtours d86b634404 fix: realtime folder photo counts during scan discovery and decode 2026-10-08 20:08:19 +07:00
3dtours 02d82eba2a fix: realtime subfolder discovery, count updates, and deep level scan indicator 2026-10-08 19:09:27 +07:00
3dtours f9461d1b60 feat: show scanning icon for parent and subfolder nodes along scan path 2026-10-08 19:02:06 +07:00
3dtours 2ea0475d24 fix(library): propagate active scanning spinner icon along full folder path hierarchy to root 2026-10-08 18:00:49 +07:00
3dtours f7ecc084d8 feat(library): read the walk without tiling, and scope the filters to the open folder
The scan made a JPEG tile for every frame as it walked, so a wall of 12MP
files spent its whole budget in the walk; the tile now waits until the card
is actually drawn (makeTile + putPhotoThumb), and a rawish frame whose date
is unreadable retries at 4MB instead of stopping at the head.

The year menu was built from the whole catalogue, so it offered values the
open folder could not produce; it now follows the rows on screen and clears
the choice when a move leaves it stale.
2026-10-08 11:28:14 +07:00
3dtours d2549cfde1 fix(library): keep the shell the deploy replaced out of a failed navigation, and say when another tab holds the library open 2026-10-08 09:23:23 +07:00
3dtours f828fefe13 fix(library): mark the roll's own row while it is read, and count rows from the catalogue up 2026-10-08 08:05:18 +07:00
3dtours 26dab9a92e fix(library): mark only the folder a reading stands in, and keep a one-folder reading out of the roll's count
The column's ring was drawn from the open node's roll: a row of another roll
had no path under it, so the empty prefix matched every row of the roll being
read and the whole column lit up. The roll's own row was marked too, at every
moment of a reading of it, and a reading kept to one folder counted its frames
onto the roll's row as well — a roll counted down to one branch of itself.
A queued request was drawn from the reading's roll instead of its own, so a
request on a second roll showed nothing at all.
2026-10-07 23:10:21 +07:00
3dtours 06a545493f fix(library): fix photo count live update and optimize scan speed 2026-10-07 21:16:10 +07:00
3dtours 66df58c7e4 fix(library): fix open location folder tree scroll and folder update path matching 2026-10-07 20:12:12 +07:00
3dtours ced1a1c4e1 fix(library): resolve root folder rename, open in explorer, open at location centering, UPDATE rescan bug, and optimize scan RAM memory usage 2026-10-07 19:16:03 +07:00
3dtours ab7c9f0248 fix(library): support thumbnail generation and display for HEIC and TIF files 2026-10-07 18:43:11 +07:00
3dtours bd45a6b45a perf(library): persistent SPA shell, in-memory folder tree caching, and zero-flicker LRU thumbnail caching 2026-10-07 12:05:58 +07:00
3dtours 2af000c1cb library: open a phone's HEIC, and lock the shelves to accounts
A HEIC is the one camera file no engine on this platform will paint:
Chromium answers createImageBitmap with InvalidStateError and its
WebCodecs take no hvc1 still, and unlike a RAW there is no JPEG preview
inside to lift. It is decoded in software instead, by libheif built to
wasm, in a worker of its own — so a folder of them reads without the tab
stopping for a second, and what leaves the worker is the same JPEG
developRaw hands the studio. Nothing downstream knows the difference.

Into the studio it goes on the same terms as every other frame: dropped,
picked, or opened from the catalogue, no tier asked and the engine's own
colour untouched. The frame's date, ISO and position come off the HEIC
itself, which is passed along as the bytes the stamps read.

The catalogue's tile does not pay the decode: a phone writes a small
HEVC copy of its frame as an item of its own (thmb, 320x240 on an
iPhone), reachable only by walking the container by hand, and it decodes
in milliseconds where the frame takes a second — from the same 256KB the
catalogue already reads for a header.

The shelves themselves now want an account: a guest gets the note and
the way in, and the scan never starts.
2026-10-06 20:59:40 +07:00
3dtours e2e0297f31 library: stand a frame up in the catalogue, wall, strip and studio
The library stage carried no way to correct a frame shot on its side, and
nothing kept the correction. A quarter turn now rides on the catalogue row
(`rot`, beside `star`, carried over by a rescan), the two chips under the
preview step it, and the tile, the strip thumbnail and the stage all read it
through one bake so a frame is never stood on its side in one place and
upright in another. The studio opens a frame at that angle and a turn made
there is filed straight back onto the row, so either end of the turn is
what the frame comes up at next time.
2026-10-06 17:22:56 +07:00
3dtours c64e2e42c0 fix(library): fix RAW develop fallback & optimize STUDIO to LIBRARY transition speed 2026-10-06 13:24:20 +07:00
3dtours d01c64736b fix(raw): fix RAW color matrix calculation, enable highlight recovery mode, and add dedicated tiffThumbnail decoder 2026-10-05 16:03:41 +07:00
3dtours 97f3bdebf7 fix(library): resolve main-thread UI freeze and process kill data loss via OPFS payload capping, yield throttling, and UI batching 2026-10-05 12:13:45 +07:00
3dtours a730441918 feat(library): smart subfolder auto-link and instant handle reconnection 2026-10-05 10:03:09 +07:00
3dtours c15c0f7f52 fix(library): instant Lightroom-style catalog restore with lazy tile fallback 2026-10-05 08:05:09 +07:00
3dtours b2fc0762ee fix(library): add one-time DB cursor migration to clean legacy Blobs and implement hierarchical Add Folder tree matching 2026-10-05 07:01:11 +07:00
3dtours 91722861f2 fix(library): optimize image scanning, fast raw preview extraction and lazy thumbnail loading 2026-10-05 06:34:37 +07:00
3dtours 3f6cb8ea1d feat(library): update, rescan and stop on the folder menu, queued per roll
A folder row now asks for the rest of a roll, the whole of it from the top,
or takes its request back. Requests queue behind the reading in flight and
run one at a time; a row waiting its turn draws a ring that does not turn.
2026-10-02 22:25:43 +07:00