Commit Graph

41 Commits

Author SHA1 Message Date
3dtours cda08c3908 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 3242b3aa48 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 c80c8729a8 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 aa981e4e65 fix(library): fix photo count live update and optimize scan speed 2026-10-07 21:16:10 +07:00
3dtours 801877d763 fix(library): fix open location folder tree scroll and folder update path matching 2026-10-07 20:12:12 +07:00
3dtours 9bfb39be91 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 b835415c20 fix(library): support thumbnail generation and display for HEIC and TIF files 2026-10-07 18:43:11 +07:00
3dtours 1c7be6dfd4 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 3edafcfe1e 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 2e06e5ab5d 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 5db943ddd5 fix(library): fix RAW develop fallback & optimize STUDIO to LIBRARY transition speed 2026-10-06 13:24:20 +07:00
3dtours f98ce2c042 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 140b4f88b2 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 e63183b298 feat(library): smart subfolder auto-link and instant handle reconnection 2026-10-05 10:03:09 +07:00
3dtours c4d904a207 fix(library): instant Lightroom-style catalog restore with lazy tile fallback 2026-10-05 08:05:09 +07:00
3dtours b30c7403e3 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 77cf872b16 fix(library): optimize image scanning, fast raw preview extraction and lazy thumbnail loading 2026-10-05 06:34:37 +07:00
3dtours 79f0d77a8e 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
3dtours 3f5ef88de5 web: the mark of a reading follows the folder the walk stands in, and the head of the tree never wears it
The mark was pinned to the folder a reading was kept to, which is a fine thing
to say and no use at all to look at: a reading kept to the roll is kept to the
roll for the whole of an afternoon, so the one row that said anything said the
same word from the first frame to the last while the walk went through every
folder under it. The reader asking which folder is being read was told, in
answer, which folder they had asked about. A reading is where it is, not where
it was pointed, and where it is is a thing only the reading knows — so the walk
now tells it. The frame in hand names the folder it sits in, and the progress
carried to the screen says so with the rest, which is where the column reads it.

The mark is a way down and not a single row: the folder being read says so, and
so does every folder above it. That is what keeps a reading visible under a
branch folded shut — the row doing the work is the row the fold takes away, and
the rows above it are all a reader would have left. Two rows say the same thing
and no more, which is the point: the eye follows the marks down to the work.

The head of the tree is never one of them, and this is the rule that survives
from the last reading of the question. A roll is the head of everything under
it, and a head marked while one folder below it is read says the whole of the
tree is being read — an answer that is both loud and false, and worse than the
silence it replaced. So the mark stops one row short of the top. The one case
left bare is a reading whose frames sit in the roll's own folder and nowhere
else: there is no row between the work and the head, and a flat roll is a roll
in which the toolbar's line, which names the count, is the whole of what there
is to say.
2026-10-01 20:30:25 +07:00
3dtours 4f87f022da web: a reading of a hundred thousand frames says where it is once, counts what the catalogue holds, and keeps a quarter of the disk it kept
A roll of a hundred thousand frames is read by a walk that tells the screen
about every frame it lands. The column is redrawn out of that count, so the
telling was a whole tree rebuilt a frame at a time — on the large library that
is the reading spending its afternoon drawing itself, and to the reader it looks
like a scan that has started over from the top every time the column breathes.
Five tellings a second is a counter that still moves to the eye and a page that
is doing the reading instead; every frame still lands in the counts, and only
the copy the screen reads waits.

What the counter said was wrong twice over, and both were the same mistake told
two ways. The total was the frames already reached plus the frames still in the
queue, so a roll whose catalogue already holds most of it announced itself as
`29980/61211` — the numbers of the reading underneath, printed as if they were
the numbers of the roll. The reading now measures itself against the frames the
catalogue already holds of the folder it was pointed at: the roll's own size
while the walk is still up in its first folders, and the frames under a
subfolder alone when it was kept to one. And a row says at least what the
catalogue holds of it, never less — where the reading's number alone had the
head of the tree count 29980 while the line above it counted a hundred
thousand, one question with two answers. Nor is a scan of nothing announced as
`0/0` before its first pass has come back; the line waits until it knows what it
is reading.

The third thing is not a number but a weight. The tile kept for a frame is the
tile the grid cell and the strip both paint, and there is one of them per frame
of a roll that reaches six figures. At 512 on the long edge a tile weighed 42KB
and a hundred and sixty thousand of them weighed six and a half gigabytes of the
reader's disk — for a grid cell that is ~240px wide and a strip tile that is 132.
A quarter of the pixels at the quality Lightroom keeps its own grid previews at
puts the same picture in the same cell: the tile is now ~12KB and the roll a
quarter of the disk it was. The canvas a tile is drawn onto is told it is opaque,
so it no longer carries a channel of noughts through every draw and every encode.
The stage loses a little softness at 2.4x, which is the trade and is known.
2026-10-01 18:31:08 +07:00
3dtours c93f9fdd19 web: a scan right-clicked inside the tree reads that folder and what lies under it, and leaves the rest of the roll alone
A roll is dated folders inside dated folders, and the reader who wants April
re-read is not a reader who asked for the four years around it. Right-clicking a
subfolder row carried the roll's own menu, and RESCAN on it read the whole tree
from the top — every folder of every layer opened again, every frame in them
asked for its size and its time, to reach the one folder the pointer was on. On
a library of a hundred thousand that is a walk of minutes for a folder of
twenty.

The scan now takes the folder it was pointed at, spelled as the walk spells a
path: '' for the picked folder, `2026/04/` for a subfolder. The walk is handed
that path as its queue and reads down from there, so the tree is entered three
levels in rather than at its root, and the menu row passes the path of the row
under the pointer. A right click on the head of the tree still reads the whole
roll, which is what a right click on the picked folder has always meant.

What a reading kept to one folder must not do is speak for the roll. What it
names in the column is a branch of the tree, so the column keeps what it has and
the rows outside the folder being re-read stand where they are. What it counts
is a branch too, and its numbers drawn over the rows would count a roll down to
one folder of itself, so the column falls back on the catalogue until the
reading is through — the frame the reader is waiting for is the frame that was
re-read, and it is in the column the moment it lands.

Nor does it write a position down. `walk/ROLL.json` belongs to the reading that
walks the whole roll: a fraction of the tree written into it hands the next
visit a roll with the rest of itself missing from the walk, and it clears a file
another reading may be in the middle of. One folder is short enough to read
again, and the frames it does not re-read are skipped on their size and their
time anyway. A jump is refused for the same reason — the reading answers to the
folder it was kept to, and a click elsewhere is a different reading's business.
2026-10-01 17:29:47 +07:00
3dtours e2d468b77d web: a scan hands its frames to the strip as it reads them, and coming to the library no longer walks the roll behind the reader
LIBRARY drew the strip from the catalogue and the catalogue from a `getAll` of
the `photos` store: every row and every thumbnail in it, read back on a clock to
learn what a scan of its own had just stored. On a catalogue of 150,000 frames
one read measured 2036ms and sixty megabytes of rows; under a scan in the same
window, with four lanes of RAW bytes and a LibRaw of a quarter of a gigabyte
beside it, that read is the read that gives way — and a read that gave way
answered `[]`, so the screen threw away the frames it was showing. That is the
strip that loses its count and its thumbnails under a scan, and the frames the
reader cannot pick while the roll is being read.

The reading now hands its frames over as it stores them. `scanFolder` keeps the
rows it met for the first time in the batch they landed in, and `flush` gives
them to `watchRows` the moment the transaction closes; the screen appends them,
so the strip is the roll arriving and not the roll found again. A batch is fifty
frames, so this is fifty rows over a callback where it used to be a hundred and
fifty thousand rows over IndexedDB. A frame the catalogue already held is not
handed over — it is on the strip already, and it takes its place again when the
reading is through.

Which is why the read on the clock is now done for none of them. It stands for
the reading another window holds, whose frames never come through this one: a
reading of this window's hands over every frame it stores, and the counter that
says so is what decides. The scan's own tail read went with it — the screen
watching a reading reads the catalogue back once the reading ends, and that is
the whole catalogue read once per scan instead of twice at the end of every one.

`readPhotos` answers null where `listPhotos` answered an empty list. A read that
came back with nothing is a read that failed, not a catalogue that emptied, and
the frames it would have cleared are the frames the reader is working their way
through. The strip keeps them.

And the strip starts on what the last screen read. STUDIO and LIBRARY are two
screens of one page, not two pages: the reader who goes to develop a frame and
comes back was reading a hundred thousand rows again to see the strip they had
just left.

The folder is walked on the way in only when the last reading of it never
finished. A reading that reaches its end clears its position, so "is there a
position" is "was this roll cut off", answered by one small file opened and
shut. Before this, every visit to the library paid a walk of the folder the
reader was opening — a hundred thousand names off the disk, on the folder they
had just asked to browse — and the reader who came to choose a frame paid for a
scan they never asked for. A folder the reader wants looked at again says so
itself, from the menu on its row.

Checked on the running bundle: a 3000-frame scan reads the catalogue back twice,
one of the two the screen coming up on a catalogue that is still empty — against
three for the commit before this one and thirteen for the one before that, and
nothing read back for the whole of the scan. The peak heap is 116-138MB with no
long tasks, and 3001 rows went in. A reading stopped at 59 of 4000 still leaves
`walk/ROLL.json` at 113,626 characters with no local storage key beside it, and
the visit after a reload carries on with the strip filling under it — 162 rows,
312, 463, 612, 762, 913, 1062, 1212 as the scan went on. LIBRARY on 150,000
frames shows no "No folder yet" at any point and settles on "150000 photos" in
4.9s against the 8.1s it took. The wall, the strip, the grid, the deep link and
the phone's recipes all pass their checks.
2026-10-01 17:00:53 +07:00
3dtours 137d54d37d web: a scan watches the catalogue on a clock and keeps its place in a file, not in five megabytes of local storage
LIBRARY read the whole catalogue back on every batch the scan wrote: `reload()`
is a `getAll` of the `photos` store, every row and every thumbnail in it, and on
a catalogue of 150,000 frames one read was measured at 2036ms, twenty of them at
315s with the worst at 58s, the heap behind them going from 44MB to 89MB — beside
a LibRaw open of a quarter of a gigabyte and four lanes of RAW bytes. On a folder
of RAW that is the crash, and on any long roll it is the strip taking the roll's
own time to move.

The read-back now runs on a clock: at most once every five seconds of a scan, and
once more when the reading ends, which is when the last of it is due. A batch
lands every second or so, and reading it back costs every frame in the catalogue
— so a scan that read it back each time spent the roll doing nothing else. The
strip still fills while the scan runs; it fills in steps.

The walk's own position went to the origin private file system. It is one path
per frame the walk has found and not read — about twenty-eight characters each,
measured over a folder of three thousand, which is the 85,626-character write —
and local storage on this origin takes 5,000,000 characters and then throws,
measured the same way. That is a hundred and seventy-five thousand frames: past
it the first write of a long roll fails, the position is nowhere, and the next
visit walks the whole roll from the top. That is the reading that goes back to
the start, and OPFS takes the same text as a file. It goes down at most once
every two seconds, for the same reason the catalogue is read back on a clock:
the string is a few megabytes of paths.

The folder is walked again for being raised no more than once every thirty
seconds. A hundred thousand frames is a hundred thousand stats on the disk, and
this screen is raised every time the reader comes back from the studio.

And the column no longer says there is no folder before it has looked. The note
waited on `folders` alone, which is empty for as long as the screen is coming up:
on the catalogue of 150,000 it said so for the whole of the load and showed the
folder after. It now waits on `loaded`, and says nothing until there is something
to say.

Checked on the running bundle: a 3000-frame scan reads the catalogue back 13
times where the code read it back once per batch, writes the position 21 times,
and leaves the peak heap at 116MB with no long tasks. A reading stopped at 483 of
4000 leaves `walk/ROLL.json` at 101,107 characters naming 3,550 unread frames,
with no local storage key beside it, and the visit after a reload carries on at
585 rather than at the top. LIBRARY on 150,000 frames shows no "No folder yet" at
any point and settles on "150000 photos". The wall, the strip, the grid, the deep
link and the phone's recipes all pass their checks.
2026-10-01 16:34:46 +07:00
3dtours 3e3045d912 library: the reading is copied into a folder of the reader's own, so a cleared profile gets it back without a frame being read twice 2026-09-30 16:41:11 +07:00
3dtours 325d5c0470 library: the walk position belongs to the origin, so the app beside the browser carries a reading on instead of listing the roll again 2026-09-30 16:26:42 +07:00
3dtours 75ff444621 library: the screen reads the catalogue back when a batch lands, not on a 700ms timer, and a tile keeps the object URL it was given 2026-09-30 15:41:18 +07:00
3dtours c9e72595f7 library: the other window on the same origin draws the scan instead of running a second one
An installed app and a browser tab are one origin, one catalogue and one roll.
Without a word between them the second window opens, finds nothing in flight,
and reads the same frames again — two readers of one folder, two RAW decoders at
256MB apiece, for a progress the first window already has.

The window holding the reading now announces it on every frame and on a
heartbeat, and answers a window that has just opened and asks. A window that
does not hold the reading takes it as its own: the same progress line, the
folder marked as being read, the same STOP — which it relays rather than
redirects — and the same jump. What it does not do is read the disk.

A reading that is through leaves the catalogue behind, and a window watching it
has nothing left to be told, so the screen reads the catalogue back once more
when the session goes away: the frames that arrived with the last flush would
otherwise never be drawn in the window that was only watching.

Measured over one origin (two pages, 24 RAW frames): the second window read 25
frames before and 1 after (the open frame), the peak over an idle browser fell
from 1412MB to 1155MB against 944MB for a single reader.
2026-09-30 12:58:08 +07:00
3dtours 64d41f67e9 library: read one RAW at a time, and hand the decoder a size it already read
A folder of RAW took the page down. The catalogue reads four frames at once —
that is what makes a roll land quickly, and for a JPEG it is free — but a RAW is
not read by this page at all: it is handed to libraw-wasm, which opens it inside
a worker of its own, and that worker is built with a quarter of a gigabyte of
linear memory (emscripten's shared memory, 256MB, instantiated per instance) and
the whole frame is copied into it. Four of those at once, on frames of 22.6MB,
is past what a renderer is given before a single tile is drawn, and the page goes
with it — which is the crash this answers.

One open at a time now, whoever asks for it: the gate wraps the read of a RAW and
nothing else, so the frames around the one being opened still have their bytes
read off the disk, their decodes run and their tiles encoded side by side. Only
the open queues. A folder of JPEGs never touches the gate and keeps all four
lanes.

The tile also stops asking the decoder a question the bytes answer. A JPEG — and
the preview a camera writes inside a RAW is one — carries its frame size in its
own header, in the first few hundred bytes the read already holds for the date.
`jpegSize` reads it there, and `tile` is handed the size instead of decoding the
whole frame a second time only to learn which edge is long. `jpegSize` existed
for exactly this and had no caller; it has one now.

Verified:

- library-check.mjs, scan-nav-check.mjs, roll-walk-check.mjs all pass against the
  built bundle — the catalogue still reads a roll, a RAW still becomes a tile off
  its own preview, a frame still says where it was shot, an interrupted reading
  still goes on by itself.
- A stand-in folder answered `showDirectoryPicker`, 48 frames of 22.6MB, resident
  memory of the whole browser process tree sampled every 150ms:
  - before: 4 frames read at once, peak RSS 1335MB, 565MB before the roll was
    picked, 12s, 48 tiles, no error;
  - after: 1 frame read at once, peak RSS 1180MB, 32s, 48 tiles, no error.
  The 2.6× on the clock is mostly the harness: its `getFile` copies 22.6MB per
  frame, so serialising the open serialises that copy too. A real folder pays a
  disk read and an open in sequence instead.
- 200 frames of 8.5MB JPEG, same harness: peak 1713MB, 4 at a time, 200 tiles,
  no error — the JPEG path is untouched by this commit.

ponytail: reading a RAW could skip LibRaw entirely — the camera's preview is a
plain JPEG and could be cut out of the head of the file this page has already
read. Probed on four samples: RW2 carries a 1920×1280 preview at 54KB, NEF one at
6016×4016 of 1.08MB, but DNG has only 720×480 inside its first 4MB and RAF keeps
almost none, and picking the wrong segment risks a thumbnail for a tile. Not
worth it until a RAW that is neither DNG nor RAF is the common case. The JPEG
path still peaks high (1713MB over 200 frames) and that is `createImageBitmap`
decoding every 5472×3648 frame whole, at four lanes — an app that reads fewer at
once trades time for the same headroom, if a page that large is ever the crash
again.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 16:08:26 +07:00
3dtours 53cb1c97e9 web: a frame is scored under the picture, and the wall is read through the score
The catalogue could say what a frame was and when it was shot, and nothing about
whether it was any good. A reader who had been through a roll of two thousand
had no way to say "these four", and the thumbnail wall drew every frame the open
folder held in the one order it had: newest shutter first, always.

The frame that is up now carries its own score — five stars under the picture,
and the star the click lands on is the score it gets. The star it already has
takes the score back, so a score that was given by mistake is taken off without
a sixth control. It is the catalogue's own row and nothing on the disk is
touched: the file is not written and not read, the browser only stores one more
number against a frame it already holds, and the number is the reader's.

The wall is read through three of them. The score is the first — four stars and
up is the floor, any rating is the wall as it was — because that is what a
reader who has just been through a roll wants next. The year the shutter fired
in is the second, offered as the years the open folder actually holds and not a
century of empty ones. The hours it fired in are the third, as a from and a to:
16:00 to 18:00 is the afternoon the reader was out, and either end alone is
"from 16:00" or "up to 18:00". All three read the shutter time, on this
machine's own clock — the same reading the frame's date line prints, so the
afternoon is the afternoon and not an offset of it. Beside them, the order: the
newest shutter first, the oldest first, or the most stars first.

What the filters hold is the wall and only the wall. The strip under the picture
is the shelf the open folder is, and a shelf that quietly loses three quarters
of its frames is not a shelf any more; the frame that is up has to stay reachable
while the wall is narrowed around it. That is also why the filters are drawn in
the thumbnail view: they are a way of looking through the shelf, not a property
of the roll, and nothing about them outlives the visit.

Verified:
  library-check.mjs — 52 steps, all passed, five of them new. The frame that is
    up is scored from the row under it and the catalogue holds star 4 against it
    (aria-pressed on the fourth star true); the wall narrows to one tile at 4★,
    to none at 5★, and back to three with the rating cleared; the year 2016 holds
    the two JPEGs of three and 16:00–18:00 the same two, out of a roll whose
    frames are written years apart in different parts of the day (the JPEG in the
    afternoon of 15 Feb 2016, the RAW at seven in the morning of 1 Jan 2026);
    read by score, the scored frame comes first. Every earlier step still holds,
    including the two that count what a reading costs and the stand-in folder's
    two frames — the JPEG carrying the GPS tag and the RAW printing
    "26.4mm · f/2.8 · 1/320s".
  scan-nav-check.mjs, roll-walk-check.mjs — all passed. frontend tsc --noEmit
    clean, vite build clean.

ponytail: the score is one number on the frame's own row — no rating table, no
votes, no accounts; the catalogue is a per-browser shelf and so is the score. The
filters live in the thumbnail view's own state, so a reload starts with the whole
shelf again, which is what a filter is for. No text search: a roll of date folders
is three levels deep at most, and the three that a reader actually digs with are
the ones here — add the box when a folder of mixed names makes one worth typing
into.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 08:02:05 +07:00
3dtours 477c71d1f4 web: the frame under the preview says where it was shot and what it was shot with
A negative is opened to be looked at, and the two things a photographer reads
off it first are the numbers the camera wrote and where it stood when it wrote
them. The stage gave the name, the folder, the date and the weight of the file,
which is what the catalogue knows; the rest was a trip into the studio.

Both are now under the picture. The numbers are the camera's own — ISO, focal
length, aperture, shutter, frame size — printed in the order a photographer
says them, and every one the file does not carry is left out rather than stood
in for: a Fuji RAF gets no line at all, a Panasonic RW2 gets the glass and the
shutter and no ISO, and a JPEG gets the lot. Where the frame was shot is the
GPS it carries, named by the geocoder when one answers and left as the
coordinates it holds when none does — a naming is a network round trip on a PRO
account, and the numbers do not wait for it, so a refusal or a miss costs the
reader nothing.

What this costs is one read of the frame's first few hundred kilobytes, the
same head a scan hands the parser, and only the frame that is up pays it: the
strip walks past a hundred negatives without reading one of them. The read is
held by the read itself and not by the frame object under it, because the
catalogue is read back on a timer while a scan runs, which hands the screen a
fresh object every few hundred milliseconds — a screen that went by the object
would read the same file again and again for as long as the scan lasts.

The check's stand-in folder had no frame of its own with a GPS tag on it, and
none of the samples carries one, so the check writes one: an APP1 segment
holding a GPS IFD and nothing else, spliced in right after the frame's SOI. It
is deliberately the first APP1 — a JPEG carries one EXIF segment and the
catalogue reads the first, so a file that has a place is a file that spent its
EXIF on the coordinates. That is also what makes the RAW the frame that shows a
spec line, and the two frames now check the two halves of the same feature.

The run counts what a reading costs, and a head is not what it counted before:
it counts the whole file a lane develops apart from the head a parser is handed,
because "the RAW was read" is a claim about the scan and the preview legitimately
reads the same file's head.

Verified:
  library-check.mjs — 47 steps, all passed, two of them new. The JPEG off the
    check's server carries a GPS tag and the page keeps 16.0544, 108.2022 under
    the frame, with no geocoder behind it to name them; the RAW prints
    "26.4mm · f/2.8 · 1/320s" — the glass and the shutter it has, no ISO and no
    frame size it does not. Every earlier step still holds, including the one
    that says a scan does not read a RAW whose size and write time have not
    moved: whole reads {}, heads {"P1010256.RW2":1,"P1010256.JPG":2}, the one
    RAW head being the frame that is up.
  scan-nav-check.mjs, roll-walk-check.mjs — all passed. frontend tsc --noEmit
    clean, vite build clean.

ponytail: the place is named by the API's geocoder and nothing else — no map, no
picker, no place a reader can type. The coordinates are what the file carries,
and a file that carries none shows no line, which is the honest answer and the
common case for a phone frame with location off. The line is read for the raised
frame only; a grid of hundreds is a list of names, and a row of ISO numbers
under each tile is not what it is for.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 07:57:36 +07:00
3dtours 753eea0d7b web: a reload in the middle of a reading goes on, it does not start over
The reader opens a roll of a few thousand frames and holds Ctrl+Shift+R while
it is being read — over a reading that takes minutes, that is the one thing
they do. What came back was a reading that started again at the top: every
folder listed a second time, the toolbar counter back at zero, the frames that
were already in the catalogue walked over so that their size and their write
time could say they had not moved.

None of that was in the catalogue, because the catalogue only holds the frames
that were read. What was lost with the page was the reading's position: the
folders the walk had been through, the folders still in its queue, and the
frames it had found and not yet read. The catalogue alone can never answer
where a reading was.

The position is now written to session storage as the reading goes — walked,
pending and the frames in hand — and read back on the way in. Session storage
and not local storage, because a position belongs to the tab: the tab that
reloads carries on from the frame it stopped at, and a tab opened beside it
starts a reading of its own. The key goes when the reading finishes, and goes
with the folder when the folder is removed.

The frames in hand are the part worth spelling out. A frame a lane is in the
middle of, and a frame whose row is in the batch and not yet in the catalogue,
are on neither side of the line the position is written on: the catalogue does
not hold them and the walk will not find them again, so both are written into
the queue and read again. They also were counted when they left the queue, and
the count is written with them — else the reloaded reading would count them a
second time. The counter now carries on from where it was instead of restarting
from zero.

One thing was hidden behind the other: the stand-in folder the check hands the
page had no `getFileHandle` and no `getDirectoryHandle`, so the path that asks
the root for the frames a position names threw and answered nothing — and the
reading then walked the roll from the top, exactly the behaviour the check was
meant to catch. The check's folder answers both now, the way the browser's does.

Verified:
  library-check.mjs — 45 steps, all passed, two of them new. The reading is
    stopped on the frame it is reading (`check.hold`), the tab is reloaded, and
    it comes back at "Scanning 3/3 — 0 new…" — the same line it was stopped at,
    not a reading starting over — with the position still holding 4 folders
    walked, an empty queue and 3 frames in hand. Listing 3 tiles and letting the
    key go, the reading finishes the roll: 3 tiles in the strip, the catalogue
    written, the position cleared, and every folder listed once
    ({"2026":1,"CheckRoll":1,"Empty":1,"04":1}) where a reading that started
    over lists each of them twice.
  scan-nav-check.mjs, roll-walk-check.mjs — all passed. frontend tsc --noEmit
    clean, vite build clean.

ponytail: the position is the tab's, so a reload keeps it and a new tab does
not — the tab that reloaded is the one the reader is looking at, and a second
tab reading the same roll from the top costs a walk and no bytes, because a
frame that has not moved is dropped on its size and its time. The position is
written at the batch beat, so a reload reads at most one batch again, and the
frames in hand are read again on purpose: their rows were never stored.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 07:50:01 +07:00
3dtours fbe9a1bb5b web: a roll is read where it was left, and only for the frames that moved
A reader with a large roll ran into three things at once, and they were one
thing: a reading is dropped the moment the tab goes, and the second one over
the same folder took as long as the first.

The catalogue was never emptied — `scanFolder` has no delete anywhere in it —
but it read every frame again. The test that was meant to skip a frame that has
not moved compared the frame's *shutter* time with the file's write time
(`seen.taken === file.lastModified`), two numbers that are equal only by
accident: a still's EXIF date is when the picture was taken, not when the file
was written. So a rescan of any indexed roll went back to the disk for every
file, decoded every frame and wrote it back — which is what reads as "it threw
the index away and started over", and it cost the same minutes the first read
did. A frame that cannot say when it was taken was worse off: it falls back to
the file's own time, so it matched, was skipped forever, and never picked up an
edit.

A row now carries `mtime`, the write time the browser reports for the file, and
a frame is skipped on the same size and the same write time — which is what the
comment over that line always claimed. A row filed before the field existed has
no `mtime` and is read one last time. On the 36-frame roll the bench serves (24
JPEG 8.2MB + 12 RAW 22.6MB, two levels deep):

  first reading        5209ms
  the same roll again  2887ms   24/36 frames read again
  first reading        5320ms
  the same roll again   603ms    0/36 frames read again

And the screen starts that reading itself. Opening LIBRARY on a roll whose
reading ended when the app did now walks it again on the way in — and again
when the tab is raised — so the frames it never got to are read with no one
asking, and frames that landed in the folder since are picked up by the same
walk. The scan belongs to the tab and the walk skips what the catalogue already
holds, so a frame that has not moved is a name, a size and a time and nothing
else; the run that does it says nothing in the toolbar, the ring on the row and
the progress line are the report.

The folder menu's commands lead with a mark of their own — fold ▴, rename ✎,
scan ↻, forget ✕, reconnect ⚿, add + — the way the tool rail and the view
switch already do: a column of marks reads at a glance where a block of
uppercase does not.

Verified:
  library-check.mjs — 43 steps, all passed, six of them new: the folder menu's
    three marks, the row menu's four, the add-only menu's one, the refused
    folder's lone reconnect carrying its ⚿, a reading cut short that goes on by
    itself (three rows back, and the bytes read are the two frames the
    catalogue had lost, not the one it still held), and the folder read again
    from its own menu. The stand-in folder now carries `__fake` on both handle
    kinds — a folder handle that does not is one the screen cannot ask about
    after a reload, which is a folder it offers to reconnect — and the frames
    it hands out report one write time instead of `Date.now()` per call, which
    is what a real handle does and what a frame is skipped on.
  scan-nav-check.mjs — all passed, the row still counting the reading as it
    comes rather than the catalogue standing still. roll-walk-check.mjs — all
    passed. frontend tsc --noEmit clean.

ponytail: nothing watches the folder, so a roll that changes under a screen
left open is picked up on the next visit or the next raise, not on the change —
a FileSystemObserver when the browsers ship one. A frame is skipped on size and
time alone, so an edit that keeps both is invisible until that frame is read
again; the row's own rescan is the way to ask for exactly that.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 06:33:41 +07:00
3dtours 77729ad99a web: read the roll through four lanes and a JPEG's header
A scan of 36 real files off the local disk took 8957ms — 249ms a frame — and
the page was idle for nearly all of it: a CPU profile over the walk is 61.5%
idle, so what the catalogue was doing was waiting, not working. Of that time the
bytes and the LibRaw preview are 2656ms and the tile 5761ms, while the EXIF read
is 14ms of the lot. One frame at a time spends the disk's latency and the
decoder's thread on nothing, and a JPEG was read whole — 8MB through a JS array
to find a date in its first kilobyte — then decoded at 24MP to be drawn at 512px.

The walk now runs four frames at a time. A frame that is new is read as a 256KB
header slice unless it is a RAW, which LibRaw has to have whole to seek to the
preview inside it; the shutter time comes off that slice. The tile is asked of
the decoder at 512 on the long edge, so a quarter of the pixels of a 24MP frame
are ever allocated, and the aspect comes from the frame's own SOF header for a
JPEG and from an eight-pixel decode for anything else; the canvas is left to
re-encode what comes back. The column still counts a frame the moment the scan
reaches it, so which frames are up is unchanged, and a frame that has not moved
is still dropped on its size and its time before a byte of it is read.

Measured on the same roll (24 JPEG 8.2MB + 12 RAW 22.6MB, two levels deep,
served over loopback with no added delay):
  one frame at a time   8957ms   249ms/frame
  four lanes            5474ms   152ms/frame
  + the header slice    4793ms   133ms/frame
Six lanes came out worse than four (5259ms) and is not what this does: past a
few the disk and the decoder are the limit.

Verified:
  library-check.mjs — 35 steps, all passed. scan-nav-check.mjs and
    roll-walk-check.mjs — all passed.
  frontend tsc --noEmit clean. Live 8090 on index-CdakqRi8.js matching dist/:
  /, /library and /app 200 with 0 console errors.

ponytail: the lanes run on the main thread, and a RAW still reads whole per file
— a few LibRaw opens at once now — so a 5000-frame folder is still lumpy; move
the walk into a worker and a RAW's preview onto a sync handle when that is real.
A frame smaller than 512 is now scaled up to it rather than drawn at its own
size, which is invisible at the 132–220px a tile is painted at; keep the old fit
if a print is ever taken off a tile.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-28 22:01:03 +07:00
3dtours 1cb2618fd7 web: count a roll as it is read, and open a frame without losing the count
Two holes left by the last change. A frame is opened with `window.location.href`
rather than a link, so the studio was still handed a fresh page and the scan with
it — every route out of the catalogue now goes through `go()`, which pushes the
address instead of reloading while a scan is in flight. And a row counted what was
filed away rather than what had been read, so it sat at zero for the whole of a
first scan: the catalogue's frames only reach IndexedDB in one batch at the end.

`ScanProgress.counts` carries the frames the scan has reached, per row, off the
scan's own bookkeeping, and the column reads it while the scan runs — the row
under the reader's eye moves as the roll is read, and the toolbar's line stays the
whole picture. A re-read counts from the start, which is what it is doing.

The regression check grows the two: a frame opened mid-scan from the catalogue
(of a roll it already holds) has to stay in the page, and the row has to count.
2026-09-28 20:39:51 +07:00
3dtours 79b0d0db86 web: keep reading a roll while the studio is up
The catalogue hands the visitor to the studio with a plain `<a href>`, and the
app has no router: following it threw the page away, so a scan that was halfway
through a roll died with it. A scan belongs to the tab, not to the screen that
started it.

The scan now lives outside the component (`scanSession`/`startScan`/`stopScan`,
watched by whoever is up), so the catalogue can unmount and come back to a
reading that never stopped; a screen that returns joins it and sees the toolbar
line, the stop button and the rows filling in. The internal links are taken over
for a history push *only while a scan is in flight* — everywhere else the
browser navigates exactly as before, so the landing-to-studio flow is untouched.

`scripts/scan-nav-check.mjs` is the regression: a roll read one frame at a time
off a slow server, handed to the studio mid-scan, back to the catalogue, and the
whole roll read to the end with the studio up, counting documents along the way.
2026-09-28 20:28:00 +07:00
3dtours 3aedd67ca0 web: open the catalogue without a version another tab can block 2026-09-28 20:16:58 +07:00
3dtours 52732a17ee web: name a roll's folders before reading their frames 2026-09-28 20:06:10 +07:00
3dtours d0fddba1e8 web: walk a roll layer by layer, and let the strip drop the branch 2026-09-28 19:55:02 +07:00
3dtours 4c65923eff web: rename a folder, and add one from the empty column
A folder's own menu gains a rename, which paints a label over the folder
without moving it — the frame ids and the recipes hang off the directory's
name, not the label. The empty part of the column answers a right click
with ADD FOLDER, the FOLDERS heading goes (the count already rides on
each row), and the column narrows to 118px so the stage takes the room.
2026-09-28 18:38:02 +07:00
3dtours d1abda141a web: browse the catalogue as a tree of folders
The library page was a flat wall of thumbnails. It now reads like the admin
pictures tab: a folder tree on the left, the picked node's frame on the stage,
that node's frames in the strip below, and a chip pair to swap the stage for a
wall of every thumbnail in the node.

Folders are walked recursively (dot/@-prefixed names skipped, an unreadable
subfolder is dropped rather than failing the scan), so a roll with a dated
subfolder keeps both levels. Node counts include the subfolders underneath.
2026-09-28 18:15:14 +07:00
3dtours 81637b8502 web: a catalogue of the folders on the visitor's own disk
A RAW studio that cannot see a folder is one photo at a time. /library
now takes a folder through Chromium's directory picker, keeps the handle
in IndexedDB so the folder is there on the next visit, and walks it into
a grid: one thumbnail per frame, the frame's own date, and the recipe it
was last graded with. Nothing is uploaded and nothing is read twice —
the RAW itself is opened only when a tile is clicked, at which point the
studio develops it and the recipe comes back on top. The studio files
every change back against the frame, debounced, so reopening a RAW is
not doing the grade again.

Thumbnails come off LibRaw's unpack_thumb for a RAW, which is a seek and
a copy where a develop is a full decode of every pixel, and off
createImageBitmap for anything else. A RAW with no preview inside it
gets a placeholder tile rather than a minute of decoding per file.

  node scripts/library-check.mjs
  ok  both frames indexed as tiles — 2 tiles from P1010256.JPG + P1010256.RW2
  ok  thumbnail for P1010256.JPG — 40400 bytes, jpeg=true
  ok  thumbnail for P1010256.RW2 — 39895 bytes, jpeg=true      (LibRaw preview)
  ok  studio developed the frame from its handle
  ok  the address was handed back — url=/app (no ?lib= left behind)
  ok  the look was filed back against the frame — baseFilter=none, 19 knobs
  ok  the tile says the frame is edited
2026-09-28 17:46:12 +07:00