Commit Graph

554 Commits

Author SHA1 Message Date
3dtours 90ce9fdd8b chore(docker): rebuild the NAS image bundle on 74f438c 2026-10-10 20:37:20 +07:00
3dtours 74f438caa8 Revert "perf(library): a drive is walked with its position written every five seconds"
This reverts commit 2803f76aac.
2026-10-10 20:36:31 +07:00
3dtours 5ffa713f35 chore(docker): rebuild the NAS image bundle on 2803f76 2026-10-10 20:26:57 +07:00
3dtours 2803f76aac perf(library): a drive is walked with its position written every five seconds 2026-10-10 20:25:53 +07:00
locpham 8fccfdd001 chore(docker): rebuild the NAS image bundle on d36fa78 2026-10-10 20:03:05 +07:00
locpham d36fa78250 fix(library): a drive is read again from the catalogue it is filed under 2026-10-10 20:02:27 +07:00
locpham 99e54835ba chore(docker): rebuild the NAS image bundle on 3befdf5 2026-10-10 19:11:44 +07:00
3dtours 3befdf50eb 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 ed179fc200 chore(docker): rebuild the NAS image bundle on e32ab2f 2026-10-10 18:08:07 +07:00
3dtours e32ab2f3a3 chore(docker): rebuild the NAS image bundle on af5f94e 2026-10-10 18:00:41 +07:00
3dtours af5f94e94e fix(app): the hand-over is announced, so the window that held the pen lets go 2026-10-10 17:58:48 +07:00
3dtours cb9e96dd8d chore(docker): rebuild the NAS image bundle on bcc24a6 2026-10-10 17:52:20 +07:00
3dtours bcc24a62c3 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 c557108eee chore(immich): drop the IMMICH_URL hook
A deployment no longer suggests an address for the first server: every
address is each account's own and is typed in the app, so the field opens
blank with a placeholder. The env var, its compose passthrough, the example
line and the `defaultUrl` field that carried it are all gone; the backend's
config route now answers with the saved list alone.
2026-10-10 17:10:30 +07:00
3dtours 8901e0c378 feat(immich): ask nothing of a server at the door
Adding a server no longer opens with a tick list: a fresh node keeps every
album its key can see — the empty album list the backend stores for "all of
them" — and narrowing is a change made on the node that is already there.
The album-ID field carries the warnings that were silent before it: a line
with no id in it, and an id this key never named, both said where they are
typed. A node's key can now be replaced in the dialog that narrows its
albums; the backend probes it first, so a key that does not answer leaves
the node and the dialog as they were.
2026-10-10 16:38:28 +07:00
3dtours e27998d820 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 d2e2ebe108 feat(immich): read Immich through a per-user read-only proxy
The browser cannot talk to Immich directly: the key must stay out of it,
COEP blocks the origin, and the app has no place to keep a key per user.
So the backend keeps it. `src/immich.ts` holds the whole surface — the
user's servers live in a JSON column on `users` (additive migration), and
every route reads the key from there and never takes a URL from the
browser except when probing one.

Albums, a page of assets, a thumbnail and an original, all behind the
normal session check. `probe` is the only route that touches a URL the
client named, and it validates it first (http/https only, no credentials,
no path, no query, no hash) so the browser cannot turn the backend into a
proxy to an arbitrary host. The key is masked down to its last four
characters everywhere it comes back out, and no log line carries it.

The share-link path is the same routes with `type: 'share'`, whose key
travels as `?key=`, so there is one code path per call rather than two.

test/immich.mjs runs a fake Immich on loopback — two keys with different
albums, one of them without `asset.download` — and checks 59 things
including that neither the responses nor the log leak a key.
2026-10-10 15:52:42 +07:00
3dtours 7b52e3f3e3 chore(docker): rebuild the NAS image bundle on 1429802 2026-10-10 11:30:29 +07:00
3dtours 1429802b57 fix(studio): the EV knob steps by a tenth of a stop 2026-10-10 11:26:51 +07:00
3dtours bb09a9f555 chore(docker): rebuild the NAS image bundle on 1e26887 2026-10-10 10:09:54 +07:00
3dtours e21e7bae55 chore(docker): keep the local fixtures and the stale harnesses out of the tree 2026-10-10 10:08:48 +07:00
3dtours 1e26887a17 fix(library): open SINGLE on the card the reader pressed, stage the graded tile
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).
2026-10-10 09:23:33 +07:00
3dtours af1d047227 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
3dtours 549bfab310 test(library): check that the wall survives being fed its own window 2026-10-10 07:53:38 +07:00
locpham 32d1541af1 chore(docker): rebuild the NAS image bundle on 6fdd0f3 2026-10-10 07:47:15 +07:00
locpham 6fdd0f3240 fix(library): the wall counts its columns off the layout, not off the run it drew
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.
2026-10-10 07:43:55 +07:00
locpham c314d4608f chore(docker): rebuild the NAS image bundle on e03fed6 2026-10-09 22:56:10 +07:00
locpham e03fed60f9 fix(library): stop the shelf from hearing about the tiles it asked for itself 2026-10-09 22:54:58 +07:00
3dtours 102dc13351 chore(docker): rebuild the NAS image bundle on fc3f46c
- recipescam-web-frontend:latest and recipescam-web-api:latest, saved for the NAS.
- the frontend now carries the wall whose row step is taken once, so its window
  settles instead of chasing itself on a roll of unevenly named frames.
2026-10-09 22:19:35 +07:00
3dtours fc3f46c302 fix(library): keep the wall row step, so its window cannot chase its own tail
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).
2026-10-09 22:17:21 +07:00
3dtours 90e504e2a2 chore(docker): rebuild the NAS image bundle on 91d1bd7
- recipescam-web-frontend:latest and recipescam-web-api:latest, saved for the NAS.
- same app as 53e7606: every build step cached, only timestamps moved. Frontend
  keeps the shelf that fills in as it reads, the studio look on the frame tile,
  and the shelf return to the frame the studio holds.
2026-10-09 22:04:08 +07:00
3dtours 91d1bd7d9d chore(docker): rebuild the NAS image bundle on 53e7606
- recipescam-web-frontend:latest and recipescam-web-api:latest, saved for the NAS.
- the frontend now carries the shelf that fills in as it reads, the studio's look
  on the frame's own tile, and the shelf's return to the frame the studio holds.
2026-10-09 21:52:52 +07:00
3dtours 53e7606b37 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 6a052cb9d9 chore(docker): rebuild the NAS image bundle on 08b7b91 2026-10-09 21:19:09 +07:00
3dtours 08b7b91ef9 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 41f941dc59 chore(docker): rebuild the NAS image bundle on db983e5 2026-10-09 20:50:21 +07:00
3dtours db983e54d4 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 24121e259d chore(docker): rebuild the NAS image bundle on e1707bd
The bundle now carries a folder that can be given the thumbnails it is short
of, by hand or on the way in, a rescan row that reads RESCAN..., and a wall of
thumbnails that scores a frame where it stands.
2026-10-09 20:05:04 +07:00
3dtours e1707bd9b9 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 e520068c68 chore(docker): rebuild the NAS image bundle on d231d29
The bundle now carries the tone curve with its channel picks back on the
card, and the LIGHT strip that repeated the panel's rows gone from a
desktop.
2026-10-09 18:44:29 +07:00
3dtours d231d298a5 fix(studio): keep the tone curve's channels, and drop the strip that repeats them
Two halves of one report: the R/G/B channel picks belong on the graph, and
the chips down the sidebar were reading as a second copy of the sliders.

- ToneCurvePanel.tsx + app.css: the channel row (RGB/R/G/B and RESET) and
  the .curve-tabs rules come back, so the card picks a channel again and
  bends that one. Undoes the row's removal; the graph itself is unchanged.
- app.css: the strip a LIGHT panel opens is hidden on a desktop, with the
  row of panel chips it is opened from. Both name the seven rows the panel
  already stands in, so a desktop was drawing every knob twice — the strip
  beside the sliders, and the sliders. The phone keeps it: there the panels
  are the strip, not the column.

Verified in Chrome at 1440x900 and 820x700 with a photo loaded: on the
desktop the strip is gone and the card carries RGB/R/G/B/RESET, the R tab
bends the red curve while the composite stays drawn beneath it; on the
phone the strip still opens TONE -> EV..BLACK, TONE CURVE, and the card is
the same.
2026-10-09 18:44:00 +07:00
3dtours 1ea60a2ad9 chore(docker): rebuild the NAS image bundle on 05c2380
The bundle carries the two fixes now on recipes-web: the GPS stamp that
opens off on a new photo (2d2e662) and the tone-curve card left with its
graph alone (05c2380).

- recipescam-images.tar.gz: frontend + api, saved from the images the
  deployed compose project runs.
- recipescam-images.tar.gz.sha256: sha256sum of the bundle.

skipped: a per-architecture note, add when the NAS is not amd64.
2026-10-09 18:21:15 +07:00
3dtours 05c2380a8b fix(studio): leave the tone curve with the graph alone
The floating tone-curve card opened with its own row of channel tabs
(RGB / R / G / B) and a RESET button. The panel is meant to be a graph,
so that row is gone: the card is now the head and the curve, nothing
else. The curve edits the composite rgb channel, which is what the row
selected by default anyway.

- ToneCurvePanel.tsx: drop CURVE_CHANNELS, the channel state and the
  curve-tabs block; every read and write goes through 'rgb'.
- app.css: drop .curve-tabs / .curve-tab / .curve-dot / .curve-reset
  and the .curve-line.ghost path that only drew the other channels.

skipped: selecting a channel other than the composite one, and RESET,
add when a channel chooser comes back on purpose.
2026-10-09 18:20:07 +07:00
3dtours 2d2e662e1a fix(studio): open a new photo with the GPS stamp off
A photo carrying EXIF GPS arrived with GPS WATERMARK already ON: adoptPhoto
set useGeotag to the presence of the position, so the file's own EXIF decided
to print a location. The stamp is the visitor's to ask for.

A new photo now opens with it OFF whatever the file carries; the position is
still read off the photo and is there the moment the chip is switched on by
hand (and the device-locate try for an EXIF-less frame is unchanged).

Verified in a real Chrome against the built bundle: a GPS photo opens OFF
with no hint, switching the chip ON reads back 'GPS read from the photo.'; a
no-GPS photo opens OFF and offers the manual entry.
2026-10-09 18:12:12 +07:00
3dtours 6c468cd114 chore(docker): rebuild the NAS image bundle on fa2e9e1 2026-10-09 17:11:51 +07:00
3dtours fa2e9e133e fix(library): stop the wall's window loop that blanked the screen
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.
2026-10-09 17:10:44 +07:00
3dtours 3433c9da8a chore(docker): rebuild the NAS image bundle on 1647362 2026-10-09 16:22:44 +07:00
3dtours 1647362c68 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 259e5f3378 fix(library): draw a .tif tile on the wall's own route
Two things kept a scanned `.tif` off the wall, and both had to be fixed for
its tile to come out as a picture.

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

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

`scripts/tiff-decode-check.mjs` gains the fixture that was broken — a
JPEG-compressed strip — judged by how far a channel strays from the libvips
reference rather than by equality, since that strip is lossy. Its bundle now
re-exports the shim alongside the reader so the two are one module instance and
the fixture's JPEG has a decoder behind it.
2026-10-09 15:21:38 +07:00
3dtours c3d46b0d15 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