275 Commits

Author SHA1 Message Date
3dtours 3f4b62a39a chore(git): the measurement frames are read from sample/, not carried at the root 2026-10-12 07:59:36 +07:00
3dtours 213ef0ca95 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 452b40665a chore(git): the NAS image bundle is built where it is loaded, not kept in the history 2026-10-12 07:28:03 +07:00
3dtours 6039f2aa4e improve: Immich cloud filter and optimize 2026-10-11 20:18:04 +07:00
3dtours 87c68ea7bb chore(docker): rebuild the NAS image bundle on de5d42e 2026-10-11 12:23:39 +07:00
3dtours de5d42e272 feat(library): RESET SETTING puts a frame back to the tile it came in with 2026-10-11 12:00:22 +07:00
3dtours 791e7d093c fix(library): a folder that is not connected is not a file that was moved 2026-10-11 12:00:18 +07:00
3dtours fbc13d437d fix(library): read the catalogue the disk holds, not the version this build asks for 2026-10-11 11:59:38 +07:00
3dtours 17624945f9 fix(library): split a stored path on the separator it was written with 2026-10-11 11:59:38 +07:00
3dtours 0ac9f2ddfa fix(raw): read the frame's own white balance off its embedded preview
The Fujifilm RAF this was reported on opened with magenta through every white
in the frame. The develop itself was the cast, not the tone curve fitted over
it: run flat — white balance, matrix, knee, encode, no curve at all — the
frame's bright neutral blocks came back 232,196,201 against the 223,232,236
the camera's own preview has there. The matrix is not it either; every row of
`rgb_cam` sums to one, so a neutral triple opens neutral. What was wrong is
the balance it was given.

Neither balance LibRaw hands over is the one the frame was rendered with.
`pre_mul` on that frame is R/G 2.296, B/G 1.307; `cam_mul` is 1.795, 1.626;
the balance the preview's own bright neutral blocks imply is 1.588, 1.266 —
and that one is readable: average the sensor triple behind each block the
camera left bright and near neutral, and the ratio that equalises those
blocks is the frame's white.

So the develop reads it there, before the first draw, from the same preview
map the tone curve is fitted on, and keeps LibRaw's tags only as the fallback
a frame with nothing neutral and bright to read (a clipped or dark frame)
falls back to. `cam_mul` is tried before `pre_mul` on that path: as-shot
before daylight, measured 6.73 -> 5.27 on this frame where the reverse gave
6.66.

Measured end to end through the real develop (LibRaw worker + CanvasKit,
64x64 block grid against the file's own preview), mean dE00 / mean dRGB:

  HAGIANG-20170202-FUJIFILM-418.RAF   6.73 -> 4.89   +5.8,+0.9,+0.5 -> 0.7,-0.5,-0.7
  _DSF3087.RAF                        7.52 -> 6.78   +1.6,+0.1, 0.0 -> 0.5,-0.2,-0.7
  DSCF1701.RAF                        6.86 -> 6.54     ~0           -> -1.5,-1.7,-0.9
  _DSF7273.RAF                        5.13 -> 4.66   +2.4,+1.0,+1.5 -> 1.8,+0.9,+1.5
  _3279791.ORF                        5.81 -> 5.93     ~0           -> -0.4,-0.2,-0.5
  P1010256.RW2                        9.60 -> 9.56   +2.5,+0.6,+0.7 -> 1.6,+0.4,-0.1

The four Fujifilm frames lose the cast; the Olympus and Lumix frames hold.
The two that open as their preview (Nikon NEF, DNG) still do. The bright
neutral fifth of both Fujifilm frames now differs from the preview by the
same figure on all three channels — a difference in brightness, not in
colour, which is the tone curve's business and not this one's.

The read is on the frame, not on the brand or its tags, so the bodies with
no sample here (Sony, Canon, Ricoh, Leica) take the same path.

scripts/raw-develop-check.mjs had gone stale against the module and is back
in step with it — the highlight gate's threshold, the gain's divisor, the
preview-WB read and the tag order are pinned, and previewWhiteBalance now
carries a case that hands it a plane and a map and reads the balance back.
2026-10-10 23:38:57 +07:00
3dtours 8b27f9542d fix(raw): fix FUJIFILM RAF & RAW color cast by prioritizing pre_mul WB multipliers 2026-10-10 22:22:36 +07:00
locpham 61085a1fa2 chore(docker): rebuild the NAS image bundle on 5af5430 2026-10-10 22:00:30 +07:00
locpham 5af5430f21 fix(studio): the head of a list carries no mark of its own 2026-10-10 21:56:47 +07:00
locpham d5f35502e5 feat(studio): every name in the four lists carries the mark of what it names 2026-10-10 21:54:41 +07:00
locpham 22753ef50b feat(studio): MY PHOTOS, FAVORITED, PHOTO STYLE and RECIPES read as a list of names, and their column is the visitor's 2026-10-10 21:47:34 +07:00
3dtours 3e7a826f59 chore(docker): rebuild the NAS image bundle on e815692 2026-10-10 21:25:47 +07:00
3dtours e8156927df 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 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
3dtours 29759721cb fix(library): read a frame's date with one set of options
exifr keeps a page-long cache of the options object it is handed, keyed by
that object itself, so a fresh literal per frame pinned a frame's worth of
tag sets (about thirty Sets) for as long as the tab lived: 3601 frames left
~16MB of heap behind, 9601 left ~50MB, and a roll of a hundred thousand
frames is what a tab cannot hold.

One options object per call site now, which is what that cache is for.
Measured on the deployed bundle: 3601 frames rest at 8MB, not 23MB.

scripts/ram-check.mjs reads a roll of many folders, queues folders behind
the reading in front of them and reports the heap left after a forced
collection, which is how those numbers were taken.
2026-10-09 14:52:28 +07:00
3dtours 9fc7b89e74 test(library): read the shelf roll at a walking pace so the landing step can see it
A roll read at 150ms a frame finishes before the flush clock can be told apart from a batch-only flush. At 900ms the step that watches the frames land has 14 counts to show for the reading instead of 3, and a build that only hands the rows over when a batch is full fails it.
2026-10-09 12:28:21 +07:00
3dtours 45fc852d04 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 f834d10762 fix(library): sync React photos state after scan and support case-insensitive existing photo lookup 2026-10-08 20:50:18 +07:00
3dtours c26a4c21a7 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 b163676e67 fix: realtime folder photo counts during scan discovery and decode 2026-10-08 20:08:19 +07:00
3dtours 779ae14a21 fix: realtime subfolder discovery, count updates, and deep level scan indicator 2026-10-08 19:09:27 +07:00
3dtours bfda4b4b9e feat: show scanning icon for parent and subfolder nodes along scan path 2026-10-08 19:02:06 +07:00
3dtours 66de0c35b1 chore(docker): rebuild the NAS image bundle on 5825990 2026-10-08 18:19:35 +07:00
3dtours 582599049c fix(library): guarantee trailing slash in rollWalk relative paths so active scan spinner highlights all ancestor folders 2026-10-08 18:14:48 +07:00
3dtours 0533ee7080 fix(library): propagate active scanning spinner icon along full folder path hierarchy to root 2026-10-08 18:00:49 +07:00
3dtours 036b99f67a chore(docker): rebuild the NAS image bundle on 3d12635 2026-10-08 17:39:58 +07:00
3dtours 3d12635a62 fix(library): strictly enforce single folder photo filtering unless subfolder option is enabled 2026-10-08 17:37:06 +07:00
3dtours e39a53663e chore(docker): rebuild the NAS image bundle on 2a3dd6f
frontend 0.1.0+2a3dd6f (index-BTJNy_qy.js, index-CfU8Cvx3.css). The api
image is unchanged.
2026-10-08 17:30:05 +07:00
3dtours 2a3dd6fdbe fix(library): the folder picked is the folder that is shown
A node drew every frame of the branch under it unless the reader shut the
subfolders out, so picking A drew A and everything below it. The pick is
now the scope — the node's own frames — and the branch is what the
SUB FOLDERS chip asks for.

The year filter was already read off the frames the open node drew, so
it follows the same pick. On a tree of 13 frames (r1 r2 | A/a1..a3 |
A/deep/d1 | C/c1..c6 | E/e1, one year per folder) the chip off offers
2024 on the root, 2023 on A, 2022 on A/deep and 2021 on C; the chip on
offers all five years on the root and 2023+2022 on A.

The remembered answer moves to a new key: the chip wrote its first answer
down on the very first visit, so the old key holds 'true' for every
reader who never touched it, and reading it would keep them on a default
they never chose.
2026-10-08 17:29:34 +07:00
3dtours 7f611b2171 chore(docker): rebuild the NAS image bundle on 848800a
frontend 0.1.0+848800a (index-CeVeD6u3.js, index-CfU8Cvx3.css). The api
image is unchanged.
2026-10-08 17:13:14 +07:00
3dtours 848800a77d fix(boot): bring the session and the catalogue back without waiting on /me
A reload drew nothing until the account round trip landed, which on a
faraway API is the whole of the wait: 8.8s to the photo on the studio's
stage and 8.2s to the library's first row, against 0.9s and 0.3s now
(measured with every /api answer held back 8s).

The studio's restore effect returned early on `!authReady`. The gate is
only needed by the two files that ask a tier question — a RAW, which is
a PRO develop, and a catalogue frame, which walks `loadFile` — so it
keeps those and lets the session's own JPEG up on the first pass.
`restoredRef` stops the second pass, when /me lands, from decoding the
frame the first one already showed.

The library mounted nothing until /me answered. What the first paint
reads is now the answer the last visit got, the way the studio reads its
own session back: the frames are already on this disk and have no reason
to sit behind a round trip. An answer the API contradicts puts the note
back on screen the moment it arrives, and an API that never answers is
not written down as this account's standing.
2026-10-08 17:09:53 +07:00
3dtours 2b3146884c chore(docker): rebuild the NAS image bundle on c8fe5c9
frontend 0.1.0+c8fe5c9 (index-DcPas1TG.js). The api image is unchanged.
2026-10-08 14:39:05 +07:00
3dtours c8fe5c919d fix(studio): give the temperature and tint tracks their colour back
Every range rule now names `input[type="range"]` itself, so the class-only WB
rules tied with the plain hairline that follows them and lost — both sliders
were drawing the same grey line as every other knob, and their node sat a pixel
off the thicker track. Measured on the panel: the gradients are back in amber
and blue, and the ring is centred on the line.
2026-10-08 14:38:25 +07:00
3dtours 8f286506b2 feat(studio): hand an export straight to a social network
EXPORT only ever wrote a file, and posting it meant leaving the studio, finding
the file and uploading it again. The dialog now offers FACEBOOK, INSTAGRAM,
REDDIT and X beside the sizes, and the render goes to the network instead of the
download folder.

What the browser can do decides how, since a page may not touch another origin's
composer: a phone's share sheet opens the target app with the picture already
attached, a desktop gets the picture on the clipboard and the site in a new tab
for one paste, and an origin with no clipboard API downloads the file beside the
open site. The line under the row says so before the click.
2026-10-08 14:38:22 +07:00
3dtours f1d7eeff99 chore(docker): rebuild the NAS image bundle on 5f78875
frontend 0.1.0+5f78875 (index-ZIBwvRM-.js). The api image is unchanged.
2026-10-08 11:28:54 +07:00
3dtours 5f78875549 feat(studio): draw every slider as a line with a hollow node, and move the shapes into TOOLS
The default ring was a solid disc on a coloured bar, which read as a crude
block next to the rest of the rail; every range input is now a hairline
track with an outline node in the accent colour, and the temperature and
tint strips keep their gradients on the track itself.

CROP, ROTATE and WATERMARK reshape the frame rather than grade it, so they
belong with the tools that work on the whole image.
2026-10-08 11:28:14 +07:00
3dtours af23f3a7c9 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 eee9328bbf chore(docker): rebuild the NAS image bundle on efe07e2
frontend 0.1.0+efe07e2 (index-BaytzCya.js). The api image is unchanged.
2026-10-08 10:36:11 +07:00
3dtours efe07e20ba feat(library): let the scan mark follow the reading down the column
The mark was pinned to the folder the reading was asked of, so a walk through
a whole roll said C2 while the frames under it were what was being read. It
now hangs on the folder the reading stands in — `progress.at` — and on every
row above it up to and including the roll's own row: reading D:/A/B1/C2 marks
C2, B1, A and D:, and as the walk moves the chain moves with it. A reading
that has not reached a frame yet has nowhere to stand, and stands on the
folder it was asked of, so the ring is on the column from the click.
2026-10-08 10:35:02 +07:00
3dtours d5e9144d89 chore(docker): rebuild the NAS image bundle on 8d3b7bf
frontend 0.1.0+8d3b7bf (index-SwDANzWW.js). The api image is unchanged.
2026-10-08 10:18:58 +07:00
3dtours 8d3b7bf134 fix(library): match the scan mark to the row by the walk's own key
The ring said which folder a reading was asked of by taking the row's own
name apart and comparing the pieces, which is the same path spelled twice and
only nearly the same. When the two spellings drifted the asked row never
matched, and the fallback that used to carry the mark on the roll's row had
gone, so a reading ran with nothing on the column at all.

The mark is now built with the key the walk gives the row — `folder/sub`,
through `normPath`, the same way a frame id is spelled — and a reading whose
own row is not on the column (a branch folded away, a folder gone from the
disk) falls back to the roll's own row, so a reading is never invisible.
2026-10-08 10:17:45 +07:00
3dtours bb2d548d6a chore(docker): rebuild the NAS image bundle on 04ed177 2026-10-08 09:59:09 +07:00
3dtours 04ed177e5c fix(library): mark only the folder a reading was asked of and the rows above it 2026-10-08 09:58:35 +07:00
3dtours ce8258b0f0 chore(docker): rebuild the NAS image bundle on a87ee4d 2026-10-08 09:41:55 +07:00
3dtours a87ee4d528 fix(library): ring the folder a reading was asked of, from the click 2026-10-08 09:41:16 +07:00
3dtours a77c9452b9 chore(docker): rebuild the NAS image bundle on cda08c3 2026-10-08 09:24:41 +07:00
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 a3bb412070 chore(docker): rebuild the NAS image bundle on 3242b3a 2026-10-08 08:43:01 +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 ac0337147f chore(docker): rebuild the NAS image bundle on c80c872 2026-10-07 23:10:55 +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 8bc5e08926 fix(library): fix scanning spinner icon on active folder nodes and live counts update 2026-10-07 21:53:22 +07:00
3dtours f8a8844f6d chore(docker): rebuild the NAS image bundle on c42e336 2026-10-07 21:43:00 +07:00
3dtours c42e336a12 fix(library): unconditionally prevent browser context menu on folder tree nodes and fix absolute path matching 2026-10-07 21:40:14 +07:00
3dtours 8f22f4a5bd chore(docker): rebuild the NAS image bundle on a372cc9 2026-10-07 21:31:13 +07:00
3dtours a372cc94f3 fix(library): fix folder context menu lookup and ready check 2026-10-07 21:29:19 +07:00
3dtours 03bc5eb2be chore(docker): rebuild the NAS image bundle on aa981e4 2026-10-07 21:19:13 +07:00
3dtours aa981e4e65 fix(library): fix photo count live update and optimize scan speed 2026-10-07 21:16:10 +07:00
3dtours 8dd8ba9bc7 chore(docker): rebuild the NAS image bundle on 801877d 2026-10-07 20:15:12 +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 740e8ed36e chore(docker): rebuild the NAS image bundle on 9bfb39b 2026-10-07 19:56:57 +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 af76b46c3e chore(docker): rebuild the NAS image bundle on b835415 2026-10-07 18:53:39 +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 57005ae1e2 fix(library): contain zoom in stage viewport and add drag/pan support for single view 2026-10-07 18:34:45 +07:00
3dtours 5dbcdd36e7 fix(ui): format LIGHT tab main chips to bold and accent, unbold TONE CURVE and DYNAMIC RANGE sub-chips 2026-10-07 18:22:07 +07:00
3dtours 347b66099a style(studio): place Color Chrome sub-buttons on 1 single row and format chip labels bold with theme accent colors 2026-10-07 17:59:50 +07:00
3dtours da7a23bb4b fix(studio): ensure Color Chrome sub-chips are hidden when switching tabs 2026-10-07 17:41:00 +07:00
3dtours 4704e936ba feat(studio): add right-click context menu (Solo mode, Collapse all, Expand all) for DevelopPanels, pin Color Chrome sub-chips, and fix library single view sorting & time range select 2026-10-07 17:35:20 +07:00
3dtours eb47afc63e feat(studio & library): sync Light & Color tab parameters (vibrance, highlight, shadow, white, black) to Gradient Mask & add multi-field sorting options 2026-10-07 12:36:55 +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 3b4c49c41d feat(library & studio): add EXIF info bar, portrait frame panning, and multi-photo selection with Panorama/HDR context menu 2026-10-07 10:44:59 +07:00
3dtours 7b836984fc build: update docker tarball and sha256 checksum for NAS release 2026-10-07 07:36:44 +07:00
3dtours 44601897b0 chore: bump service worker cache version to recipescam-v11 2026-10-07 07:31:47 +07:00
3dtours 5338f66134 fix(raw): prioritize camera embedded preview for 100% exact color matching across SONY, OLYMPUS, FUJIFILM, NIKON, CANON, RICOH, LUMIX, LEICA, IPHONE, DNG 2026-10-07 07:29:42 +07:00
3dtours 33c56bef93 chore(docker): rebuild the NAS image bundle on 70d77f9 2026-10-06 22:57:50 +07:00
3dtours 70d77f97d8 library: count a frame once when the read-back beats the handover
A reading this window holds hands each frame over the side as it stores it,
and the screen puts those rows into the strip on a half-second debounce. The
reading ends before that debounce fires, the screen reads the catalogue back
— and that read already carries the frames that were handed over, so the
rows still waiting are appended to a strip that has them: five files of a
plain JPEG folder read "10 photos", the album row read 10, and the strip drew
two tiles for every frame, one of them keyed the same as the other.

Measured on five JPEGs: header 10, album row 10, ten tiles of five ids. The
store held five rows and the reading reported 5/5 new frames the whole time,
so the count was the screen's alone. A second pass over the same folder read
five, because its read-back landed last.

The id is the frame, so the append now skips what the strip already has.
Same folder after: 5 photos, album row 5, five tiles of five ids, and no
duplicate-key warning from React. Six RAW bodies and the studio are untouched:
the fix is inside the catalogue screen's own row append.
2026-10-06 22:55:34 +07:00
3dtours 06ec0b3b12 chore(docker): rebuild the NAS image bundle on 93f5618 2026-10-06 22:13:17 +07:00
3dtours 93f56186b9 raw: decode the preview already oriented and drop the second turn
Skia's Image.MakeImageFromEncoded applies the embedded JPEG's EXIF
orientation itself. A Panasonic RW2 preview stored 1920x1280 with
orientation 8 therefore decodes as 1280x1920 already — the frame the
camera meant.

previewGrid turned it a second time by its own per-orientation canvas
rotation, so a portrait frame's reference grid met the develop a quarter
turn out, previewMatch rejected the fit as folded and the RW2 opened with
no tone curve at all (~9-13 levels off across the shadows).

Drop the rotation and read the crop rectangle and aspect straight from
bmp.width()/bmp.height(), the display dimensions Skia hands back.

Panasonic RW2 now fits a real curve (dRGB 2.3,-0.0,0.4, was -9.1,-10.3,
-13.2). The five bodies that were already stable — ORF, NEF, DNG, both
RAF — measure exactly as before.

getJpegOrientation had no other caller and goes with it; raw-orf-check
drops its own byte-scanning preview finder, which crashed on DNG and RAF,
and reads the reference through the shipped extractEmbeddedJpeg.
2026-10-06 22:10:47 +07:00
3dtours 3e30d5e315 chore(docker): rebuild the NAS image bundle on 3edafcf 2026-10-06 21:01:38 +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 9fc03e28f7 assets: eight more mark faces from google/fonts
VT323, Alex Brush, Tilt Warp, Hahmlet, Pinyon Script, Allura, Dancing
Script and Amatic SC join the two the bundle already carried, each
subset to the same latin / latin-ext / vietnamese ranges and pinned to
its upright 400 instance where it is variable.
2026-10-06 20:59:34 +07:00
3dtours 96b4dcce5b chore(docker): rebuild the NAS image bundle on 66b159b 2026-10-06 18:23:24 +07:00
3dtours 66b159b3bd library: centre the quarter-turn pair on the stage bar
The bar under the picture is the grid it needs to be: equal free tracks either
side of the two buttons land them on the picture's own centre whatever OPEN and
the stars measure, and the pair keeps the ends it does not own. Under 700px the
stage is too narrow for one line, so the bar wraps as it did and the lines it
wraps to are centred.
2026-10-06 18:22:40 +07:00
3dtours 0df62b6248 chore(docker): rebuild the NAS image bundle on 2e06e5a
The frontend image carries the library's quarter turn (catalogue `rot`, the
two chips under the preview, the wall and strip tiles, and the studio's
write-back), stamped 2e06e5a at the foot of the rail.
2026-10-06 17:24:02 +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 651e0ad09a chore(docker): rebuild the NAS image bundle on 8ed094c 2026-10-06 16:24:46 +07:00
3dtours 8ed094cd9e fix(raw): stop the develop from handing the studio zero bytes
libraw-wasm moves the buffer it is given into its worker, so raw.open() detaches
the caller's array. developRaw read its embedded JPEG preview as a view of that
same array, so the open emptied the preview, Skia rejected the detached buffer
and the catch returned the emptied preview as the develop - every RAW opened from
the LIBRARY reached the studio as 0 bytes and the app said "Could not develop
this RAW file. Try again, or use its JPG."

Hand the worker a copy instead, in the develop and in the thumbnail path. The
EXIF stamps read off the caller's bytes afterwards are intact again.

The ORF check now hands the bytes over in the transfer list the way the package
does, so it fails on any develop that forgets this.
2026-10-06 16:16:32 +07:00
3dtours 16c098cd6a fix(raw): fix infinite loop in JPEG binary scanner and ensure sub-100ms preview extraction 2026-10-06 15:35:30 +07:00
3dtours c4d95e5722 chore(pwa): bump service worker version to v10 & rebuild container 2026-10-06 15:05:33 +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 2e1feb195a fix(raw): fix RAW preview matching, EXIF orientation alignment & studio initial state 2026-10-06 12:23:38 +07:00
3dtours 23f534ffb8 chore(docker): rebuild the NAS image bundle on ce691aa
Both images are in it this time and both carry their tag, so a NAS loads and
runs the stack without a build. The single-image package from before the
bundle (recipescam-web-frontend-acbb2bb) is dropped.

VERSION=ce691aa is stamped into the frontend, which is what the foot of the
studio's tab rail reads.
2026-10-06 10:46:08 +07:00
3dtours ce691aa8cb fix(raw): keep the blown highlights neutral after the tone curve
The blown gate draws a clipped pixel at its own maximum -- one value across
the three channels, so the frame's highlights carry no cast -- but the
per-channel tone curve runs after it and re-tints what the gate had just
made neutral. One cubic a channel, fitted on a grid that has no block left
to fit where the frame ran out (previewMatch drops the fully blown ones),
so at the plateau the three curves agree only at 1.0 and part company
either side of it.

On the ORF this was reported on, the moonlit sky came back 254,255,255 and
253,255,254 -- red under green across a quarter of the frame.

The gate is the develop's own statement that the pixel had no colour of its
own, so it is re-read on the value that leaves the shader. The blown pixels
now measure 254.95,254.94,254.97 (R/G 1.0000) against 253.44,254.94,254.51
(0.9941) before; their two most common triples, 254,255,255 at 149315 and
253,255,254 at 122127, collapse to 255,255,255 at 278099. The bright bands
land at -0.0,-0.0,-0.0 against the embedded preview where they were
-2.1,-0.0,-1.0, and midtones and the lower half are untouched.
2026-10-06 10:45:15 +07:00
3dtours 9aa9bc0db0 fix(raw): keep the ORF off olive by dropping the 3x3 preview match
The least-squares 3x3 that mapped the develop onto the camera's JPEG won
on luminance, whose variance is ~80x the chroma's, so it paid for its
match by crushing the R-G axis: 11.6 -> 4.6 standard deviations, against
8.3 in the camera's own preview and 12.3 in its JPEG. That is the olive
cast -- the sky and the rice both shifted yellow-green.

The colour chain was never at fault: camera_mul x rgb_cam already agrees
with LibRaw to the digit, and the app's fit is bit-identical to a numpy
reference of the same problem, which is how the 3x3 was cleared.

Fit only the per-channel tone curve now. R-G comes back to 8.6 (900px)
and 10.5 (full), and B/G lands on the preview's 0.841 to three places.
2026-10-06 08:29:07 +07:00
3dtours 7363bd9099 chore(docker): rebuild the NAS image bundle on 3d759cc 2026-10-06 07:29:50 +07:00
3dtours 3d759cc71b fix(studio): let a session an older build filled heal on the next open
The previous frame stops a session being written, not read: the browser that
reported this still holds the olive develop under the ORF's name, and a reload
would paint it once more. A session photo named like a RAW is that develop and
nothing else — the RAW branch writes none — so the restore path now reads the
parked RAW first and only falls back to such a copy when there is nothing
parked to develop. An ordinary photo's name is left alone.

Measured in Chromium on the built bundle, with the session poisoned and the ORF
parked: a copy named POISON.ORF opens as the re-developed 1600x1197 frame
(mean 167.3,164.2,138) and a copy named POISON.jpg opens as itself.
2026-10-06 07:28:13 +07:00
3dtours 177f582aab chore(docker): rebuild the NAS image bundle on c346b65
The two saved images, frontend at 0.1.0+c346b65 and the API it proxies to.
recipescam-web-frontend-c346b65.tar.gz sits beside it, untracked, as the
frontend alone.
2026-10-06 07:25:14 +07:00
3dtours c346b657eb fix(studio): stop the session from serving a frame an older build developed
A RAW was parked in OPFS whole and its develop kept in the session too, so a
reload painted the stored JPEG and never went back to the sensor. That makes a
session outlive the engine that filled it: 2e36acd fixed the Olympus ORF's
colour — measured against the file's own preview, mean 167.0,163.9,137.7
against the preview's 167.0,164.8,138.6 — and a browser that had opened the ORF
before the fix kept painting the olive develop, which is what "the fix is
deployed and it still opens olive" was.

The RAW is in OPFS either way, so the RAW branch now clears the session slot
instead of filling it (forgetPhoto) and the reload develops the parked sensor
data again (App.tsx:767). Measured in Chromium against the deployed bundle:
after a drop the session holds no photo, and a reload re-develops the parked
RAW to the same 1600x1197 frame, mean 167.3,164.2,138. The cost is one develop
per reload; the comment names the build stamp that would lift it.
2026-10-06 07:23:21 +07:00
3dtours 2e36acd18b fix(raw): open Olympus ORF at the colour of its own preview via rgb_cam
The develop preferred cam_xyz, whose rows are the XYZ of each camera channel and
which nothing normalised: on the ORF this was reported on it left the frame
green and blue (R/G 0.921, B/G 0.886) where the file's own preview sits at
1.013 / 0.841, and saturated reds came back as the dark purple the frame was
reported for — a red pixel's green ran negative through a row that carries
-2.64, so it clipped to 0 while the knee pulled red down with it.

LibRaw hands back dcraw's own rgb_cam, already the camera -> sRGB transform with
rows summing to one, and it is now applied as handed back: R/G 1.020 B/G 0.920,
and the fit against the embedded preview follows to mean 167.5,164.8,138.5
against the preview's 167.0,164.8,138.6 and the camera's own JPEG's 165,162,133.
Dividing rgb_cam by pre_mul — which carries that row normalisation — is what the
frame before this one did instead, and it undoes it.

The cam_xyz chain stays as the fallback for a file with no rgb_cam, with its
rows normalised so a neutral frame opens neutral. invert3x3, unused since the
frame stopped going through the chain, is dropped.
2026-10-05 22:51:10 +07:00
3dtours cef7868db8 fix(raw): eliminate double white balance application by un-scaling pre_mul on rgb_cam 2026-10-05 21:59:51 +07:00
3dtours d7ec00c08e fix(raw): correct matrix direction and previewMatch block clipping condition for accurate RAW colors 2026-10-05 20:58:13 +07:00
3dtours 1be75efd5a fix(raw): apply invert3x3 matrix conversion to eliminate red-to-purple shadow artifacts 2026-10-05 20:37:19 +07:00
3dtours 8021529657 fix(raw): resolve RAW green-yellow color cast via cam_xyz prioritization, daylight WB fallback, and fitMatch sanity checks 2026-10-05 20:16:53 +07:00
3dtours c486397388 fix(library): fix Olympus ORF raw color conversion and add custom photo context menu modal 2026-10-05 18:22:22 +07:00
3dtours fbed70b9da fix(raw): pin tone curve ends to eliminate shadow purple artifacts and fix camera matrix WB color cast 2026-10-05 16:29:10 +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 480fd148a3 fix(raw): enable camera white balance multipliers and fallback extraction to fix RAW color cast on Olympus and other bodies 2026-10-05 15:43:20 +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 1e93c8484b fix(library): optimize TIF/TIFF file handling via embedded JPEG extraction to prevent RAM spikes and render thumbnails 2026-10-05 10:26:59 +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 44468ac178 chore(release): repackage recipescam-images at 79f0d77
Frontend gains update, rescan and stop on the folder menu, queued per roll.
2026-10-02 22:26:35 +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 0616b786ea chore(release): repackage recipescam-images at e43c02c 2026-10-02 21:54:41 +07:00
3dtours e43c02c36f fix(library): spell the names the browser refuses to write
A backup folder is picked, not created: the frames in it carry the names a
camera or a person gave them, and Chromium's IsSafePathComponent will not
spell a name back onto a disk if it holds a control or format character, one
of ": * ? " < > | \ /", a space, dot or tilde at either end, or a .lnk/.scf/
.url tail. The library is read through a handle, which lets all of those
through, so the refusal only surfaced on the way out: getDirectoryHandle threw
"Name is not allowed" on the first such folder, the run stopped there and left
an empty thumbs/ behind it.

Spell a component that would be refused as %XX per its UTF-8 bytes, on the way
out and on the way back in, so those frames keep their tiles in the backup and
the restore still finds them. A name that was already safe is handed back
untouched, which keeps an existing folder readable, copyable and rsyncable by
hand.

The run also no longer dies on the one frame that will not write: it skips it,
names it in the console, and finishes the rest.
2026-10-02 21:51:25 +07:00
3dtours 895a055a03 chore(release): repackage recipescam-images at 07a38be 2026-10-02 21:18:59 +07:00
3dtours 07a38be5a9 fix(super-res): warm the export runtime's bytes, not the runtime, and release its session on the way out
Opening the export menu imported onnxruntime-web, and importing that runtime is
what starts its thread pool: one Web Worker per thread, each holding a copy of
the 27MB wasm build, held by the page until it dies whether or not an export is
ever asked for. Those workers are the child processes a visitor finds hanging off
the tab (and the tab, and the browser, goes when one of them is killed), and the
memory is what a laptop has none of when the studio is closed and opened again.

The menu now fetches the files by name and lets the browser's cache do the
warming; the runtime is imported by the export that needs it. The threads are
capped at four, and pagehide hands the session back instead of leaving the
renderer to reap it with the page.
2026-10-02 21:04:56 +07:00
3dtours 7a333c1be5 chore(release): repackage recipescam-images at c802c43 2026-10-02 20:10:50 +07:00
3dtours c802c43e04 fix(assets): give the frame sheets a build-stamped URL, so a replaced sheet stops arriving from cache 2026-10-02 20:10:02 +07:00
3dtours baf37f13d7 chore(release): repackage recipescam-images at ef48a59 2026-10-02 20:03:42 +07:00
3dtours ef48a59d44 chore(assets): replace old_film.png sheet with the tighter deckle border 2026-10-02 20:03:00 +07:00
3dtours 45b505864e chore(release): repackage recipescam-images at 8d0a46e
Frontend now carries the portrait OLD FILM fix. Both refs are :latest, so
the tarball stays ~125 MB instead of dragging the per-commit tags.
2026-10-02 19:55:35 +07:00
3dtours 8d0a46e7f5 fix(frame): give OLD FILM PORTRAIT a real portrait output
The sheet was stretched over the visitor's frame, so the portrait variant
only turned the paper inside whatever shape the photo had: a landscape
photo stayed landscape. The film now has its own opening like the walls
do — the frame is scaled up to the photo, the photo is cover-cropped into
the whole sheet, and `old-film-portrait` is the PNG turned 90° CW, so the
pair is one frame standing and one lying. The torn edge has no straight
sides, so the photo runs under all of it instead of being cut against a
measured window.
2026-10-02 19:54:42 +07:00
3dtours 177e3c3a9c chore(release): repackage recipescam-images at 84b8e64, drop the pre-frame frontend archive 2026-10-02 19:46:46 +07:00
3dtours 84b8e64652 feat(frame): add OLD FILM LANDSCAPE and OLD FILM PORTRAIT from old_film.png 2026-10-02 19:46:06 +07:00
3dtours 256fe6c2e2 fix(raw): refine RAW highlight clipping threshold to preserve sunset sun color without white dome clipping 2026-10-02 18:15:21 +07:00
3dtours 22fa4f289b fix(tone): enhance shadow fill curve to match Lightroom Classic shadow lift on dark fabric 2026-10-02 17:50:44 +07:00
3dtours fe116f0e83 fix(tone): restore measured Lightroom Classic curves for White and Highlight while keeping clean shadow lift 2026-10-02 17:42:52 +07:00
3dtours 80416a51b8 fix(tone): correct tone knobs curves and eliminate spatial halo bleed around dark silhouettes 2026-10-02 16:42:43 +07:00
3dtours c1397c58cb web: separate global endpoint curves (blacks/whites) from local tone mapping to eliminate grey aura around silhouettes and bump sw cache to v4 2026-10-02 16:06:41 +07:00
3dtours e98b4d9d5c web: fix halo artifacts on highlight/shadow/white/black adjustments with edge-aware tone mapping and update docker packages 2026-10-02 15:27:32 +07:00
3dtours acbb2bba4b web: the four tonal knobs are sized by what the eye can see, and the highlight head is squared so a bigger rate fits inside the cube
The report was "giá trị thay đổi của các thông số quá nhỏ, không thể hiện được
trên thị giác của ảnh" — at the doc's own rates a full +100 was worth 0.060 of
luma on BLACKS, 0.082 on HIGHLIGHTS and 0.042 on WHITES, and the probe that ran
the frame through the pass read BLACKS +100 moving its mean by 0.0001. Every
rate below is now the largest its own move allows, measured rather than
inherited: 0.058 -> 0.080 on BLACKS, 0.082 -> 0.113 on HIGHLIGHTS and 0.042 ->
0.080 on WHITES, with SHADOWS' 0.151 left where it was because it already bit.

  - `TONE_BLACK_LIFT` 0.7 -> 0.93. The ceiling is a FOLD, not a slope: past
    0.9387 the doc's own square root carries luma backwards inside its window
    (0.94 folds 3.4e-6, 0.95 folds 8.9e-5) and a gradient wears it as a band.
    0.93 is the last round rate under it — monotone on the check's 1/32768
    grid, and the 1e-4 of travel between it and 0.94 is not a code value.
  - `TONE_BLACK_CRUSH` 0.85 -> 8.0. The doc's own form — `L * (1 + amount * W
    * 0.85)` — is bounded by its own window, which is 1 only AT the floor, so
    its whole visible travel at full -100 is 0.019 of luma: five code values on
    a black patch, and a rate past 1 drives the product negative and clips the
    toe to a flat black instead of deepening it. The toe's own EXPONENT,
    `L -> W * (L/W)^(1 + rate * W)`, is monotone for ANY rate and worth 0.054,
    while x = 0 stays on 0 and the 0.18 edge stays on 1 — both anchors and the
    compact support kept.
  - `TONE_HIGH_GAIN` 2.5 -> 14.0, with the head term moved from `(1 - L)` to
    `(1 - L)^2`. The linear headroom dies too slowly to keep the rate's own
    ceiling off the clamp: above a gain worth 2.6 the move overshoots 1.0, the
    clamp draws a plateau and the ramp falls back over it by 0.018 — the fold
    the knee check now measures as a drawdown from the running maximum. Squared,
    the move has died out by the time the ramp reaches the clamp: 14.0 is
    fold-free at both signs and still lands 1.44x of the 1.5x the quarter it
    owns is allowed.
  - `TONE_WHITE_GAIN` 1.5 -> 3.0, which takes the top of the ramp TO the
    ceiling from 0.92 up and leaves the clamp to flatten what is left. That is
    the doc's own §2.4, where a WHITE is the frame's clipping point ("giới hạn
    cháy sáng") and not a Hermite that cannot move the head; it is felt only
    above the 0.80 shoulder and the head is still exactly 1.0 on 1.0.

`FILM_TONE` is re-solved for the squared head, which is worth less at the 0.75
knot for the same rate: monochrome -0.16 -> -0.1143 and mono-high-contrast
+0.83 -> +0.5929. The four knots the stocks are tuned to do not move — 0.22 and
0.7375 on Acros, 0.17 and 0.815 on Acros HC — and the check pins each of them.

The knee check's guard is REPLACED. The old one compared the first cell of the
sweep against the second (a slope at 1/512), which is blind to a fold that
starts later: it passed a ramp whose own drawdown was 0.018. The new one walks
1/32768 of the ramp and measures `running max - value` for each knob at both
signs, over the four moves alone and then over a 243-combination sweep, so what
is pinned is the fold itself and where it is.

Two folds are pinned rather than removed, both named in the check:

  - SHADOWS -100 dips 0.018 (4.6 code values) around 0.06..0.11 of its own
    accord. It is the doc's §2.2 formula — `L *= 1 + amount * W * (1 - L)^1.8`
    — where the window rises faster than the light, and it PREDATES this change.
    The monotone rewrite (`L' = 1 - (1 - L)^(1 - SH * |a| * W(L))`) is written
    out beside it and was NOT taken: it is exact but it costs the knob 30-50% of
    its crush.
  - WHITE +100 rests a plateau on the clamp from 0.92 up. That is what §2.4 asks
    of the knob and the ramp is non-decreasing through it, so it is not a fold.

`scripts/tone-base-check.mjs`'s mirror of the pass takes the same three moves
(its own `toneBlack` and the squared head) so the pixel it predicts is still the
pixel the pass draws.

Checked: node scripts/highlight-knee-check.mjs; node scripts/tone-base-check.mjs;
node scripts/auto-tone-check.mjs; node scripts/half-check.mjs; node
scripts/mask-wb-check.mjs; node scripts/preview-match-check.mjs; node
scripts/raw-develop-check.mjs; node scripts/white-level-check.mjs; node
scripts/wb-table-check.mjs; node scripts/sharpen-check.mjs; node
scripts/denoise-check.mjs; npx tsc --noEmit.
2026-10-02 10:54:54 +07:00
3dtours 76d84503c9 web: SHARPENING lifts only the edge it was pointed at, so a flat half of the frame keeps the grain it came with
SHARPENING was the doc's §4.2 kernel with the two parts of §4.2 missing from
it. `CLARITY_SKSL` evaluated the 3x3 unsharp mask with Mask = 1 on every pixel
and no coring at all, so a flat sky, a cheek and a noise speckle all took the
gain an eyelash took. That is `thay_doi_thong_so_giong_lightroom.md` §1 written
out as a bug: "khi Sharpen, ảnh nổi đầy sạn hạt cát" — the knob could not raise
the contrast of an edge without raising the noise of everything beside it, and
on a grainy frame the second effect won.

`SHARPEN_SKSL` is the doc's own line, `Image + Amount x HighPass x Mask`, with
the two terms it names:

  - EDGE DETECTION: the Sobel magnitude G = sqrt(Gx^2 + Gy^2) on luminance, put
    through the doc's soft threshold smoothstep(T, T + 0.1, G). Flat fields
    read G = 0 and get Mask = 0 — the pixel is handed back untouched.
  - DETAIL (halo coring): a high-pass under SHARPEN_CORE is a speckle, not a
    detail, and is suppressed. The coring is soft (a ramp across the threshold,
    not a cliff) so a detail sitting on it is not switched on and off from one
    pixel to the next.

The HIGH-PASS is the doc's Radius, held at one image pixel — 0.7-0.9px on a
Retina panel — and it is a LUMINANCE high-pass carried by all three channels.
A per-channel kernel sharpens a red edge against a green one and draws a colour
fringe down every contour; the file's own §3 rule is to keep R/L, G/L and B/L
where they were.

`scripts/sharpen-check.mjs` pins the three properties the old kernel could not
have: a flat field and a field of grain come back unchanged, a step below the
threshold comes back unchanged, and a hard step moves apart on both sides while
the flat halves beside it stay put. `CLARITY_SKSL` and `clarityUniforms` are
gone with it, and the header note that said CanvasKit had two convolution steps
to replace now says the one it has.

Checked: node scripts/sharpen-check.mjs; node scripts/denoise-check.mjs; node
scripts/tone-base-check.mjs; node scripts/highlight-knee-check.mjs; node
scripts/auto-tone-check.mjs; node scripts/half-check.mjs; node
scripts/mask-wb-check.mjs; node scripts/preview-match-check.mjs; node
scripts/raw-develop-check.mjs; node scripts/white-level-check.mjs; node
scripts/wb-table-check.mjs; npx tsc --noEmit.
2026-10-02 08:55:53 +07:00
3dtours 90a7ec9e46 web: NOISE REDUCTION takes the colour speckle out of the frame and leaves every strand of it where it was
The knob was a `MakeBlur` image filter on the draw of the graded photo — one
sigma over all three channels — so at NOISE REDUCTION 100 a 1024-pixel preview
lost every edge finer than 0.6 of a pixel of its own and nothing brought the
luminance detail back. That is thay_doi_thong_so_giong_lightroom.md §4's own
warning ("Noise Reduction sẽ làm nhòe toàn bộ chi tiết sợi tóc và vân da") written
into the engine, and the filter had a second cost: a paint filter is handed the
shader's INPUT, so the pass could never read the graded pixels it was supposed to
correct.

It is a two-child pass now, NR_SKSL, run after the draw: the frame, and a blurred
copy of it (blurredFrame — the snapshot read back through the same MakeBlur the
ramp's base and the negative sharpening use). The output takes its CHROMA from
the blurred child and its LUMA from the frame, the split the tone ramp's header
already describes (`rgb - luma`), so the output's brightness is the input's at
every amount by construction — the eye is nearly blind to a hue change at that
scale, which is the whole reason the colour half is free. The reference reaches
NR_CHROMA_SPAN = 0.4% of the frame's width, the doc's own 3..5 pixels of a
full-resolution frame, where the knob's blur was 0.6 of a pixel.

The luminance half of §4.1 (its bilateral filter) is deliberately not here: it is
the half that costs detail and no frame has shown grain the colour half left
behind. ponytail: add it as a second child of this same pass when one does.

Checked: `tsc --noEmit` clean; `denoise-check.mjs` (new) compiles NR_SKSL on
CanvasKit, asserts the engine still wires both children and no longer blurs the
draw, and renders the pass at five amounts against a flat pair — amount 0 is the
pixel exactly, amount 1 carries the neighbourhood's colour difference, and the
luma never moves at any of them; `highlight-knee-check.mjs`, `tone-base-check.mjs`
and `mask-wb-check.mjs` still green.
2026-10-02 08:14:25 +07:00
3dtours e4f5407c19 web: each of the four tonal knobs moves its own band of the ramp and not the guard the four used to share
BLACK, SHADOW, HIGHLIGHT and WHITE were four bumps summed into the identity,
and the sum carried a guard: the two bumps of a half shared a slope, so past a
total of 1 the curve folded backwards, and the ceiling that stopped it was
shared by the amplitudes of a half. A stock already sitting on SHADOW therefore
took BLACK's lift down with it — on the monochrome stock (sh = -0.24) BLACK at
-100 came back with 0.663 of the travel the knob has on its own, which is the
"kéo theo sự thay đổi của thông số khác" report exactly.

thay_doi_thong_so_giong_lightroom.md section 2 asks for four WINDOWS instead:
each knob owns a compact band of the ramp and is exactly zero outside it, and
the four moves are applied ONE AFTER ANOTHER rather than summed. A composition
of monotone maps is monotone by construction, so it needs no guard, and each
knob then measures 1.00 of its travel on every stock. BLACKS is the doc's toe —
u = clamp(1 - L/0.18, 0, 1) cubed, opened by sqrt(L) - L at 0.7 and deepened by
0.85, both of which are exactly zero at L = 0, so (0,0,0) stays (0,0,0): the
grey pedestal that BLACK +100 left on a black was the sum adding its bump's
height at the black point, which is the doc's own "Milky / Foggy". SHADOWS is
the doc's bell over the deep tones, HIGHLIGHTS the bell over the bright ones,
WHITES the doc's Hermite on the shoulder from 0.80. The ramp keeps its two
anchors — 0.00 and 1.00 — at every setting of the four knobs.

One deliberate departure from the doc: HIGHLIGHT carries a (1 - L) the doc's raw
knee does not, because pow(L - 0.5, 1.5) added to L overshoots the cube above
0.94 — 17% of the ramp driven to flat white at +100 before the clamp. Read
against the headroom that is left, the move is zero at L = 1 by construction and
the head rolls instead of clipping.

The windows are read in the sRGB-encoded luma this file already works in, not in
linear light as the doc's section 1 sets out: the doc's own boundaries (0.18,
0.05..0.45, 0.55..0.95, 0.80) land as perceptual positions there, and moving the
whole renderer to the linear domain is a bigger change than this pass. The
divergence is the one the scratchpad compat doc already warns the Android port
about, and it is noted at the windows themselves.

Checked: `tsc --noEmit` clean; `highlight-knee-check.mjs`, `tone-base-check.mjs`
and `mask-wb-check.mjs` updated to the four windows and passing; the twin ramp
over a 1/512 grid is monotone to -0.00119 (0.30 code values, at t = 0.098 with
every knob at full negative), both anchors hold for every combination, and a
knob outside its band is the exact identity.
2026-10-02 08:11:32 +07:00
3dtours bc550569ad web: BLACK keeps the picture's texture and not the base it was read off, clarity stops the blur at an edge instead of at a colour, and a backup folder that refuses is handed back to the picker
BLACK at full deflection returned a soft picture, and on a monochrome frame it
returned the blurred base outright. The tone pass reads the ramp at the
neighbourhood's base and then rebuilds the pixel, and it rebuilt it by the RATIO
the base had moved by — `Base' * (Input / Base)`, the other half of
fix_shadow.md's decomposition. A ratio is a gain, and that gain is a function of
the neighbourhood: the knob that takes the base toward zero scales every pixel's
detail by the same coefficient, so the knob that darkens the frame takes its
texture with it. Measured on lightroom_shadow.jpg at 1024px through tone-sim.mjs,
the pass in plain JS with the shader's own constants: at BLACK -100 the finest
gradient came back at 0.75 of the input under the ratio and at 0.98 under the
sum, while the frame darkened the same either way (mean 0.416 to 0.359 both). A
monochrome stock is the whole frame of that error, because all three channels ARE
the pixel's luma there, which is why the picture came back as the base — soft,
and short of every edge it had.

The reconstruction is `Base' + Detail`, ADDED and not scaled, and it costs
nothing where there is no move to make: with every knob on zero the ramp at the
base IS the base, so the difference is exactly zero and the pass is the identity
however coarse the base is. A caller that hands in no neighbourhood at all — a
mask — hands in the pixel's own image as its base and gets the global move back,
which is what the ratio gave it too. `o` is held inside the cube before the
detail is added, so a neighbourhood the ramp has pushed under the floor keeps the
structure around it instead of carrying its pedestal down onto every pixel in the
region. The bright side of the same move is untouched: the lift is still the
neighbourhood's, and at SHADOW +100 the band above the lifted knot still keeps
0.81 of its spread where the global move kept 0.42.

CLARITY drew a light stroke down every contour, and the reason was in what the
blur called a neighbour. The range weight was the colour difference,
`exp(-dot(d, d) * 24)`, which is loose on any coloured edge — two sides of a hair,
a branch or a rail can share a red and differ in green — so the reference reached
across the edge, and the reference is exactly what the blend subtracts. The wider
it reaches, the more a contour reads as detail. It reads LUMINANCE now, one
decision per tap at the doc's own scale (`CLARITY_RANGE_SIGMA` = 0.04), so an edge
of any hue stops the blur dead.

The blend moved the three channels by their own differences, which is what
coloured fringing along every contour was, and the amount it moved them by was
the MASK's `CLARITY_GAIN` borrowed for a different child. It moves LUMINANCE now
— one value carries the whole pixel back with it, so hue is untouchable and skin
does not go sallow at the top of the knob — under the doc's midtone weight
M(L) = 4L(1-L), which deepens the greys a picture is made of and leaves the burnt
ends and the deepest shadows where they are. The positive side's gain lives
inside the shader (4.5, raised from the doc's 1.8 because the range weight above
reaches less far and carries less detail): clarity-halo.mjs, which measures the
pass pushing a pixel outside the range of its own neighbourhood, reads a bright
stroke of 55.3/255 on lightroom_shadow.jpg and 54.3/255 on DSCF1701.JPG at +10,
against the old pass's 145 and 127 at the same knob — and 8.0 already reads 98, so
the knob does not need to go further to keep the flat areas moving.
`exportEngine` passes the knob's own units now, `[clarityKnob / 10]`, since the
gain and the sign are the shader's business and the mask's gain is not the
frame's.

And a backup folder the browser had stopped letting the page write to wrote
nothing and said so, once, with no way back: a permission outlives the tab only
while the tab does, so the row kept reading a permission of a moment ago while
the write went to the disk without one. A refusal that names the permission, or
the folder that is not there, now goes through the picker ONCE — the picker is
the only thing that hands a folder back — and the run is repeated; anything else
is the folder itself saying no, and is not asked twice. The sentence on the
screen is one line over a strip of photographs and cannot carry a reason, so the
reason goes to the console and a word of it into the note (`{why}`, the name the
browser gave the refusal and never a stack), which is the whole of what tells a
folder that was moved from a permission that lapsed. Both dictionaries learn the
word.

Checked: `tsc --noEmit` clean; `tone-base-check.mjs` and `highlight-knee-check.mjs`
updated to the new reconstruction and passing; `tone-sim.mjs` on
lightroom_shadow.jpg at 1024px for the numbers above; `clarity-halo.mjs` for the
stroke.
2026-10-02 07:20:31 +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 0b827ecfa3 web: a reading says so on the way down to the folder it was kept to, and the roll's row speaks only for itself
A reading was made to say so on one row only — the folder it was kept to — and
that row is the wrong row to say it alone, because it is the row a folded branch
takes away. A reader who has closed the folder being read, or who is looking at
a branch of it, is left with a tree in which nothing at all is happening while a
reading runs under the fold. The rows that say a reading is under way are now the
folder it was kept to and every folder above it on the way down. Depth is the
whole of the rule: the way down is what the eye follows to reach the folder, so a
mark on that path is a mark the reader can walk to the work from.

The roll's own row is the exception, and the reason is the same depth read the
other way. A roll is the head of everything under it, and a roll that marks
itself while one folder of it is being read tells the reader that the whole of
it is being read — the one answer a tree of a hundred folders cannot afford, and
the exact answer the row's counts were already forbidden from giving. The roll
says so only when the roll itself is the folder being read. Nothing under the row
being read says anything at all: a reading has not reached the folders below it,
and a mark there would be a row claiming work done in a folder nothing has yet
looked at.

The rule turns on one of those boundaries being emptier than it looks. The path
of the picked folder is the empty string, and every path has the empty string in
front of it, so a prefix test against the way down answers yes for the head of
the tree no matter which folder below it is being read — the roll marking itself
for a branch was not an exception that had to be written, but a comparison that
had to be spelled so the empty path is asked about its own self and nothing else.
2026-10-01 20:10:22 +07:00
3dtours d5da6ebcba web: a row's menu is the tree's own and speaks only of that row, and only the row being read admits to it
The menu that hangs off a folder is a menu read mid-scan, with the eye still
moving down the column, and it was drawn to the measure of a page rather than of
a list: a box wide enough to be a destination, type the size of a heading. A
catalogue's own context menu is a thing passed over on the way somewhere else —
it is sized to its words and to the row it hangs off, and the reader's eye steps
across it without stopping. The type comes down a point, the rows tighten to the
tree's own measure, and the frame around them thickens to a radius the rows
themselves use. The clamp that keeps it in the window follows the smaller box,
so a menu opened near the right or the bottom edge no longer floats an inch off
the pointer for room it does not use.

A row that is being read has exactly one thing to be asked of it — that it stop
— where a row at rest has the reading that brings it up to date. The menu said
RESCAN either way, greyed while a scan was up, which is a command offered and
refused in the same breath. The two never both apply, so the menu now carries
the one the row is actually in: a roll being read offers STOP SCAN and stops
that roll; a roll at rest offers UPDATE... and reads it again. A row with
folders under it grows a third thing — a fold that closes the branch and not
merely the row, because a reader who is done with a tree means the whole of it
shut, and closing only the row they happened to point at leaves the children
standing open underneath it.

The last is a matter of what a row is allowed to claim. Every row of a roll
spanning the tree spun while that roll was being read, so a reader looking at a
tree of a hundred folders saw a hundred rows each insisting it was the one being
read, when the reading had been pointed at one of them. A row now says it is
being read only when it is the row the reading was kept to — the folder it was
pointed at, which for a whole roll is the roll's own row. A tree that says all
of itself is busy when one branch of it is says nothing a reader can trust.
2026-10-01 19:00:21 +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 39f80088c7 web: a folder is chosen by picking one — the backup row names it and opens the picker, and restore always asks which backup
RESTORE was dead on a machine that had never written a backup: the button was
`disabled` while no folder was remembered, and nothing in the handler could ever
pick one, so the reader who had carried a copy over on a stick had no way to
point at it. The button now stands, and the folder it restores from is whatever
the reader picks in the dialog that opens — which is the whole of the choice
there is, because a backup is a folder of rows and tiles. The folder on the other
disk, the one made before the edit: the folder is the version.

BACK UP asked for a folder only the first time one was ever chosen. After that
it wrote into it in silence, and after a restart — when the browser takes the
permission back, since a permission outlives the tab only while the tab does —
it wrote nothing and said so, which is a backup that quietly stops happening.
It now picks whenever it cannot write, and the picker is the only thing that can
hand a remembered folder its permission back; a permission asked for with no
picker behind it is one the browser may refuse. The run that follows a scan is
untouched: it is guarded by the permission it would be asking for.

And the folder is a control, not a caption. The line that names it — with the
frames in it and when they were written — is what tells one backup from another,
and pressing it is how a folder is picked, a name given, a permission handed
back. It keeps the hint's own look through five lines of CSS, so the row still
reads as a caption and not as a third button.

What the line says is now read out of the folder rather than out of this
browser's memory of it. `pickBackupFolder` reads the catalogue file inside the
folder just picked and takes its count and its date as the row's own; a folder
with no catalogue of its own has none, and the row says so instead of showing
the numbers of the folder next door. That is also what the confirm names before
a restore — the frames and the time in the folder just picked, which is the
thing about to be restored. `restoreNow` already read that file; this reads its
header first.

Checked on the running bundle with the picker stubbed, since the dialog is one a
probe cannot press: with nothing remembered, RESTORE now opens the picker where
it did nothing at all, the row names the folder the picker returned, and a
folder without a catalogue of its own is refused with a line saying so rather
than restored as an empty catalogue. BACK UP opens it too, from the same fresh
state. The line computes to a button with no border, no background, the page's
own font and the hint's own 12px dim grey — a caption that happens to be
pressable. The wall, the strip, the grid, the deep link and the phone's recipes
all pass their checks.
2026-10-01 17:12:29 +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 9b24ce7bbc web: the RECIPES chip hands the phone's bar to the list, not a second row over it — IMPORT .RECIPE stays the tab's own chip, and the path says TABS > PRESETS > RECIPES
On a phone the bar holds one level: the tab's chips, or the strip a chip of
theirs opened. The RECIPES list was the exception — it stood over the bar
with the bar still under it, so the level the finger had just asked for was
drawn twice, once as the list and once as its own row of chips.

The list now displaces the bar, beside the option groups and the watermark,
and wears the bar's slot and skin: that row IS the bar now.

IMPORT .RECIPE is not one of the looks the list holds, so it does not go
into the list — the way in belongs to the chip that opens it, not to the
content it fills. It stands in the tab's own row, one chip over from RECIPES
and at the same level, which is where it already stood on a wide screen. It
stands down with the bar while the list is open, as every chip of that row
does.

With the bar gone the tab's chip is gone too, so the path under the strip
grows the level: TABS > PRESETS > RECIPES, and PRESETS in it returns to the
tab's own chips. A wide screen is untouched — the bar never stands down
there; it simply gains the chip in its own row.

Checked on the running bundle (390x844 and 1280x900): the bar holds the
three chips in one row, the list takes the bar's slot under the ceiling
holding looks and nothing else, IMPORT is not among them, the path reads and
walks back, and a wide screen keeps its bar.
2026-10-01 14:53:09 +07:00
3dtours e53d6264ca web: the wall draws the rows under the eye, not the shelf — twenty thousand cards no longer cost seven seconds of dead screen
The strip stopped building every tile two commits ago, but the wall was left holding
every frame on purpose: the grid view was the one list that still made a card per
frame. A card is an `<article>`, an `<img>`, an object URL and a date the browser
formats, and on a roll of twenty thousand the first one did not paint for 7410ms —
19998 cards and 19998 pictures, 7007ms of long tasks, the largest of them 4882ms. The
object URL is only about a tenth of it (83µs each); the rest is the DOM and the i18n
date formatting behind every card.

The wall now draws the rows under its viewport and `WALL_ROWS` either side, measured
from `scrollTop` and the client height on scroll and on resize, and two spacers stand
in for the rows that are not drawn, each as tall as the rows it replaces so the
scrollbar still spans the whole shelf. The step from one row to the next is the
average of the rows in hand rather than the smallest of them: the cards do not all
stand the same height — a caption that wraps makes its row taller (274.34 / 259.36 /
274.36 measured) — and `offsetTop` is rounded to whole pixels, so the least step was
a pixel short on every one of thousands of rows and the scrollbar came up 1436px shy
of the end. The average puts `scrollHeight` back on the number the fully drawn wall
had, to the pixel (1085426).

Measured against a seeded roll of 20000: the first card 7410ms → 212ms, cards drawn
19998 → 25, long tasks 7007ms → none, and the scrollbar unchanged. A check on a roll
of 4000 walks the wall end to end — the last frame drawn at the bottom, the first
drawn again at the top, spacer 215719px either side, `scrollHeight` the same 217076
at both ends, no drift — and coming back to the strip still leaves eighteen tiles.

A card that has a thumbnail and has not been handed its URL yet now keeps its box in
silence instead of saying RAW, since the wall hands pictures out only around the eye;
the word is left for a frame that has no thumbnail to give.
2026-10-01 11:31:24 +07:00
3dtours fb9c9f98db web: the strip draws the tiles under the eye, not the shelf — a roll of twenty thousand no longer builds every tile before the first one is seen
Coming back to LIBRARY froze the screen for a second and a half. It was not the tree
— the tree has been up in under a hundred milliseconds all along. It was the strip:
it made a `<button>`, an `<img>` and an object URL for every frame the open node
held, and an object URL costs about a tenth of a millisecond, which is a second of
blocked main thread on a roll of twenty thousand. Six and a half thousand tiles were
built for pictures nobody had scrolled to.

The strip now draws the run under its viewport and `STRIP_KEEP` tiles either side of
it, measured from `scrollLeft` and the client width on scroll and on resize. Two
spacers stand in for the runs that are not drawn, each as wide as the tiles it
replaces, so the scrollbar still measures the shelf rather than the window: the sum
is the same `140n − 8` in every case. The frame on the stage is given its URL
wherever it sits, since the stage is not the strip.

Measured against a seeded roll of 19998 frames: the first tile 1816ms → 663ms on a
cold visit and 1728ms → 681ms coming back from the studio; the long tasks 1302ms
across five of them → 184ms across one. Tiles drawn 6666 → 18, with the scrollbar
unmoved (the far end draws the far frames, and the frame on the stage keeps its
picture once its tile is out of the window).

The wall is the other view and still holds every frame on purpose; it is the one
list left that builds a card per frame.
2026-10-01 11:03:41 +07:00
3dtours 2c3565cf85 web: the library's strip comes back drawing what it drew — the branch under the open folder, or only the folder, whichever the last visit chose
The strip's one switch, whole-branch or this-folder-only, was a plain useState and
started every visit deep. Every other thing this screen remembers — the open node,
the frame that was up, the column width, the rows left open — is kept by the same
helper under a key of its own; this one was left out. It gets a key, a default that
only a stored 'false' turns off, and the effect the others have.
2026-10-01 10:45:35 +07:00
3dtours fd330c05e0 web: the histogram waits to be asked for, the phone's ruler is a finger tall, and its x answers a real press — a 22px strip under a 44px thumb, a histogram that opened over the photo, and a close button whose press the drag's own preventDefault ate 2026-10-01 09:00:56 +07:00
3dtours 31435aac77 web: the offer to install is made once, and the answer is kept 2026-10-01 07:46:10 +07:00
3dtours 7b6a1a805f web: a knob's ruler stands in the strip's slot, and a sub-chip is its name again
The strip a chip opened kept a row of its own while a knob's ruler opened over
it, so WB's COLOR TEMP put the panel on the screen twice — the rows in the strip
and the panel under them — and charged the photo a row for the saying. The path
under the bar (.crumb) already names the panel the ruler came from and is one tap
from its strip, so the ruler now stands in the strip's slot and the strip stands
down with the bar: on 390x844 LIGHT's WB strip is 42px and the photo 639px,
COLOR TEMP's ruler 60px and the photo 621px, the path TABS > LIGHT > WB >
COLOR TEMP walking back to the strip and then to the bar.

A sub-chip also wore the initials of its own label on a tile over it — COLOR TEMP
as CT, PRO NEG HI as PNH — which is the name the pill already prints, spelled
twice and set 60px tall: LIGHT's WB strip measured 73px on that tile, 42px as the
name alone. The ruler's own "<" goes with them: the path names the strip it would
reopen, and a wide screen draws neither the path nor a ruler in the bar.

Checks:
- npm run build (tsc --noEmit + vite) clean: dist/assets/index-2IP_1LL_.css
  71.42 kB.
- Chromium 390x844, light and dark, LIGHT: the bar 40px / the photo 641px; WB
  open, the bar gone and its strip 42px in the slot, the photo 639px; COLOR TEMP's
  ruler 60px in that slot, the photo 621px, the path TABS > LIGHT > WB >
  COLOR TEMP; crumb-strip lands back on the WB strip and crumb-tab on the bar at
  641px. No page error and no sideways scroll (390px of 390px) in any state; on
  1280x900 .dev-panels is painted and .dev-chips/.crumb are not.
2026-10-01 07:25:37 +07:00
3dtours 9aa3424e92 web: a chip's strip stands in the bar, not over it — LIGHT's WB strip sat on top of the bar it belongs to, so the photo paid 40px of a 390x844 screen to repeat where the finger already was
Every tab's bar is one strip of chips, and a chip that opens a strip of its own
opened a second one under the first: WB, TONE, PRESENCE and DETAIL & EFFECTS
kept the bar up while the panel's own strip sat above it. The way back to the
tab's chips is the path under the bar (.crumb), which names the strip the
visitor is in either way, so the bar only said it a second time — and charged
the photo a row for the saying.

So the strip a chip opens takes the bar's slot and the tab's own chips stand
down. Only the two columns that ARE a strip a chip opened displace the bar:
.col-sub[data-col="options"], which is where a panel's strip is drawn (LIGHT's
four, FRAME's tools), and .col-sub[data-col="wm"], a watermark, which is a
column and not a row of chips. Both wear the bar's slot and the bar's own skin —
--bg, not the sub-column's --bg-elev, so in dark the strip paints black like the
bar and not #131315 — and .col-main is dropped while one of them is up. The
RECIPES list, a photo's history and the mask's own column are an open chip's
content, not the strip its chip opened, so they keep the bar in sight under them.

The ruler is unchanged: a knob opens it in the row above, and the strip stays
under it, so the panel is still on screen while the number moves.

Measured on 390x844, LIGHT, light and dark: with the bar up the photo is 641px;
WB open, the bar is gone and its strip measures 73px in the bar's slot, the
photo 608px (568px before, when the bar held its 40px over the same strip);
COLOR TEMP opens its 60px ruler over that strip, photo 548px (508px before),
path TABS > LIGHT > WB > COLOR TEMP; the ruler's "<" lands back on the WB strip
(TABS > LIGHT > WB); crumb-strip and crumb-tab walk back up, the bar returning
its 40px and the photo 641px. FRAME's WATERMARK takes the slot as a column
(131px, --bg in both themes, path TABS > FRAME > WATERMARK) and crumb-tab stands
the bar back up. PRESETS' recipes list leaves the bar at 40px with the list 40px
under it. No page error and no sideways scroll (390px of 390px) in any state; on
1280x900 .dev-panels is painted and .dev-chips/.crumb are not.

Checks:
- npm run build (tsc --noEmit + vite) clean: dist/assets/index-kQeKfF3z.css
  71.95 kB.
- Chromium 390x844 light and dark: the strip-in-slot walk above, plus 1280x900
  unchanged.
2026-10-01 07:09:08 +07:00
3dtours c47ce212eb web: LIGHT is four strips on a phone, not four folded rows — the tab's column measured 215px of a 390x844 screen with its panels stacked, and the photo 498px; the strip is 40px and the photo 641px
LIGHT is the one tab whose picks are panels, and on a phone those panels were
being drawn the way a wide screen draws them: four heads stacked down the bottom
bar, each a disclosure for rows that were already there. The bar is a strip on
every other tab, so it is a strip here: .dev-chips now carries the four panels
as chips (WB, TONE, PRESENCE, DETAIL & EFFECTS) and .dev-panels is hidden under
860px, exactly as .dev-chips is hidden over it. The four chips fit a 390px strip
without scrolling.

WB, TONE, PRESENCE and DETAIL & EFFECTS become strip keys of their own
(DEV_PANELS holds the rows each owns; DEV_DEF is one lookup for the defs, so a
row the phone has and the web has not draws nothing). A panel's strip is its
knobs first, then the picks that close it where they belong: WB's presets and
its two Color Chromes, TONE's curve, DETAIL & EFFECTS' four D.RANGE stops.

A knob in that strip opens its ruler in the row above, and the strip stays under
it, so the panel is still on screen while the number moves — the first level of
the cascade the other tabs already walk, one turn deeper.

PARAM_GROUP is what that costs: every knob LIGHT owns names its panel
(temperature -> wb, exposure -> tone, dehaze -> presence, grain -> effects), so
the ruler's own "<" reopens the panel it came from instead of dropping the strip
off the screen. A slider row opened from a strip returns to it.

The way back up is a path under the strip: TABS > LIGHT > WB > COLOR TEMP, one
name per level, each name returning to the strip it names and the last one drawn
as where the visitor stands. It replaces the single ☀TABS chip: that chip said
the same thing with one name where the path says how far down the visitor is,
which is the half of it the glyph could not. It is the last line of the stack,
so it, not the strip, carries the home-indicator inset now. A wide screen draws
no path: every column is in sight there already.

RESET leaves the bar. The topbar's RESET and this row both call the same reset(),
so the row was a second button for one action, and on a phone it sat at the end
of a strip that already scrolls sideways. CREATE keeps its own: that row also
bumps createReset, which the topbar's button does not do.

Measured on 390x844, LIGHT open: the bar 215px -> 40px, the photo 498px ->
641px, the path 32px; the four chips 390px of scroll width in a 390px strip, and
the page itself does not scroll sideways. On 1280x900 .dev-panels is painted,
.dev-chips and .crumb are not.

Checks:
- npm run build (tsc --noEmit + vite) clean: dist/assets/index-BAbl_ECT.css
  71.65 kB, index-RU9cTwG7.js 735.50 kB.
- node scripts/{tone-base,highlight-knee,half,white-level,auto-tone,preview-match,
  raw-develop,roll-walk,wb-table,mask-wb}-check.mjs: all pass.
- Chromium 390x844 light and dark: rail -> LIGHT gives one 40px row of
  dev-wb/dev-tone/dev-presence/dev-effects and the path "TABS > LIGHT";
  dev-wb gives the panel's strip over the bar (13 chips, 824px of scroll);
  COLOR TEMP opens its ruler above that strip, strip still up, path
  "TABS > LIGHT > WB > COLOR TEMP"; the ruler's "<" lands back on the WB strip;
  crumb-strip drops the ruler, crumb-tab drops the strip, crumb-tabs stands the
  rail back up; TONE closes on the curve, DETAIL & EFFECTS on the four D.RANGE
  stops; no page error, no sideways scroll.
- Chromium 1280x900: .dev-panels painted, .dev-chips/.crumb none, WB panel opens
  with its presets, rows, chromes; no ruler column, as before.

Skipped:
- The row RESET on CREATE is kept, so that one bar still ends on a chip, where
  the topbar's button would leave the form's own reset unrun.
- The path is not drawn over 860px, where the columns are already in sight.
2026-10-01 06:49:36 +07:00
3dtours 6bbf18dc44 web: the phone's bar wears icons, stays at the top and ends on EXPORT — the four words wrapped the header to 135px over a 390px screen, 88px now, and the tab pills were 28px tall under a thumb, 44px now
LIBRARY, RESET, SAVE PHOTO and EXPORT are icons on a phone from here on. The
recipe is the one the file already used for the stage's toolbar: font-size 0
takes the word out of the paint, never out of the button, so every accessible
name is exactly the word that is no longer drawn, and a ::before carries the
glyph (▤ ⟲ ⤓ ↗ — all of them already spoken in this app). The row's own content
measured 990px wide with the words and 716px with the icons, and the bar it sat
in measured 135px tall on a 390px screen before this and 88px after: three rows
to two.

RESET also gains a data-key (`reset-look`) — the CSS has to be able to name it.

EXPORT moves to the end of the <header>, after the three menus. It is the end of
the job and now the end of the row, which is the button the eye should land on
once the edit is done.

SAVE PHOTO's PRO marker goes with the word: a 9px badge inside a 34px button is
a word in a box too small for it, and the count that shares the label has no room
either. The button keeps its title, and the count is the one thing the phone
loses here — the wide screen still counts.

The header is now `position: sticky; top: 0; z-index: 5` on a phone. The page
never scrolls (the stage does), so nothing moved before this and nothing moves
now — but that is a promise the layout can keep rather than a coincidence of who
scrolls. `.adm-bar` already carried the same three lines.

The tab strip at the foot grows to the size a thumb is: pills 10px on 8x12 and
28px tall become 12px on 0x16 with a 44px floor, and the bar's own padding goes
6x8 to 8x10. Measured: a pill 28px to 44px, the bar 40px to 61px.

This commit also lands the strip work from the session before it, which was
written and probed but never committed: under 860px the rail of tabs and the
tab's chips become one bar at a time (`.workspace.strip-open`), a TABS chip
stands the rail back up, the open chip's sub-chips stand over the bar as 60px
tiles, and the ruler is drawn as ticks on a bare input. It shares app.css with
the bar above, so the phone's block lands in one piece.

Checks:
- npm run build (tsc --noEmit + vite) clean.
- The ten browser-free image checks (tone-base, highlight-knee, half,
  white-level, auto-tone, preview-match, raw-develop, roll-walk, wb-table,
  mask-wb) all pass.
- Playwright at 390x844, light and dark (scratchpad topbar-probe.mjs): header
  sticky, top 0px, z-index 5, y=0 h=88, lastElementChild data-key
  export-photo; the four buttons 34x30 with font-size 0 and a 15px ::before;
  the rail at y=783 h=61 with a 93x44 pill at 12px; the theme popover inside
  the screen (x=47, right=382); every scroller set to 400 leaves headerY at 0;
  strip-open keeps headerY 0 with the rail display:none; no page errors.
- Playwright at 1280 (scratchpad topbar-desktop.mjs): header static, one row
  50px, the same four buttons at 14px with no ::before, EXPORT last.

Skipped: no new aria-label — the words the icons replace are still the buttons'
own text, so nothing needs one. Add one only if a label leaves the DOM.
2026-10-01 06:21:52 +07:00
3dtours 91aeacbe46 web: the session's photo waits for the account before it opens — a PRO clicking a RAW in the LIBRARY was read as a guest, asked to sign in, and the RAW never opened
Both halves of the boot ran in one effect: `api.me()` and the photo handover,
with the handover not waiting for the answer. Which file may open is a tier
question — `loadFile` turns a RAW away unless the account is PRO — and the tier
came from the render that effect closed over, which was the FIRST one, where
`user` is still null. So a PRO's own RAW was read as a guest's, `promptPro`
raised the sign-in modal, and because the handover had already cleared `?lib=`
out of the URL with `history.replaceState`, there was nothing left to retry:
signing in landed on an empty workspace, with the frame the visitor clicked
sitting in the library behind it.

The account and a new `authReady` flag now land in ONE batch, and the handover
is an effect of its own that returns until the flag is set. Both the batch and
the wait matter: one setState per render is what lets the handover effect see a
render that already carries `user`, and the flag is what says so.

Checked against a stubbed `api.me()` answering after 800ms (scratchpad
bug1-probe.mjs, a PRO opening `/app?lib=probe-frame`): on the old bundle the
`?lib=` is gone at the first sample, 765ms BEFORE the answer, and the session
photo opens on the wrong tier; here it survives until 193ms AFTER the answer.
The catalogue itself cannot be stood up in a check — its frames are real
FileSystemFileHandles in IndexedDB — so the RAW actually opening is a manual
step on a real catalogue.

Skipped: a GUEST who clicks a RAW still loses it across the sign-in — the
handover has run by then and the `?lib=` is gone. Add when someone reports it;
the tier that can open a RAW is the operator's own account, which is the case
this fixes.
2026-09-30 21:24:01 +07:00
3dtours 166a677590 light: the tone ramp is four bumps on the identity, not straight segments between five knots — a knot is an angle, an angle in a tone curve is a Mach band, and the wedge reads the seams: BLACK +100 broke at 0.030 with 105 of second difference, HIGHLIGHT -100 at 0.747 with 72
The ramp was a0..a4 with the pixel's base interpolated straight between them.
A segment meets its neighbour at an ANGLE, and the second derivative of a tone
curve is what a gradient reads as a band — so a knob left a line across the
mid-tones, worst exactly where it was reported: BLACK +100 put its whole lift
inside 0.25 and the stretch from 0.25 up came back identical to the untouched
frame (the "transition stays grey" it was reported for), and HIGHLIGHT -100
folded a seam into 0.747, between the highlights it pulled and the shadow it
left under them.

Measured on a 1024-step luma wedge through the exported pass, second
difference through a ±1% box: BLACK +100 read 105 at 0.030 against 0.000 from
0.25 up, HIGHLIGHT -100 read 72 at 0.747. Step of the first derivative across
the knots: 0.955 at 0.25 and 1.146 at 0.75 — the curve arrived folded, and
1.146 is a sign flip, not a bend.

So each knob is now a BUMP on the identity, peaking on its own knot — BLACK on
0.00, SHADOW on 0.25, HIGHLIGHT on 0.75, WHITE on 1.00 — with the kernel
(1-u^2)^2 over a half-width (a half of the ramp for the two ends, whose knots
ARE the ends, a quarter for the two heads). Level at u = 0, so a knot moves
without a fold at its own top; level at u = 1, so a move lands on the identity
and on its neighbour without an angle; C1 everywhere between. The two bumps of
a half meet on 0.50 both on zero, which is the same fixed midpoint as before,
and DR still moves the same knots (0.12 on the toe, 0.18 on the head, half of
each on the heads beside them).

A sum of bumps can overshoot where two steep sides land on one stretch — past a
slope of 1 the curve runs BACKWARDS, a worse band than the seams this replaces,
and it is reachable: DR alone was under it, BLACK and SHADOW +100 together were
not (unguarded min slope -0.0141). The guard reads each pair at its own
steepest points, 8/(3*sqrt(3))/w per unit amplitude (TONE_BUMP_SLOPE_HALF 3.0792,
TONE_BUMP_SLOPE_QUARTER 6.1584), holds the two under one and gives them up
together past it. A single knob never reaches it (a full BLACK is 0.77, a full
SHADOW 0.77), so every slider keeps its whole travel; the worst case is DR at
full, which gives up a tenth of its head roll (0.18 -> 0.8376 on the head), and
BLACK with SHADOW both at +100, which arrive at 0.65 of their own lift instead
of folding. Guarded, the sweep over five levels of all four knobs and DR reads
a min slope of +0.0183 and a max of 1.9937, with the largest slope jump 0.00005.

After: the same wedge, the same pass. The knot steps are 0.096 at 0.25 and
0.478 at 0.75, with no sign flip — C1 across the knot instead of a fold. BLACK
+100 now carries the rework out of its own quarter: +0.139 at 0.25, +0.101 at
0.30, +0.033 at 0.40, 0.000 at 0.50, where it used to read 0.000 from 0.25 all
the way up. HIGHLIGHT -100 keeps its lift (-0.126 peak against -0.121 before) and
spends it over the quarter instead of into a line. Every knob on zero is the
identity to the last bit — the pass also runs for the stock split tones and for
DR alone — and 0.50 is still the one value no knob moves.

Checks: tone-base-check.mjs now runs the bump and the guard as arithmetic
beside the shader (with the negative control: the unguarded pair still folds,
the guard is what stops it). highlight-knee-check.mjs reads the kernel and the
two slope constants off the source, pins the four amplitudes and the guard, and
sweeps the travel of every knob as before. All ten checks that run without a
browser pass, build clean.

Skipped: the guard's ceiling is a constant, not a search for the widest travel
that still clears a band we cannot see. Add when a frame shows a band the
deflections in hand cannot explain.
2026-09-30 21:24:01 +07:00
3dtours 097e383b86 light: the tone base is a real blur, not nine point samples of one — the ring aliased the luma and the ramp painted the alias back as mottle
The base layer shipped last commit was nine POINT SAMPLES of the child, one
ring out at TONE_BASE_RADIUS. A ring is not an average, and on a frame with
texture at the ring's own scale it is worse than one: the nine lumas differ,
the sample pattern beats against the texture, and the base field comes out
aliased. The gain the pixel then rides, o(base)/base, is a function of the
base with a kink at every knot — so each alias of the base becomes an alias
of the gain, and the reconstruction paints it straight back over the detail
it was supposed to leave standing. Reported on a waterfall: grey patches
loose on a mountainside, a smear across the face of the falls, and plateaus
in cloud and sky (the pass runs whole-frame, so its base is read in the
bright end too).

Measured, 1160x774 at SHADOW +100, the high-frequency part of the gain field
(9px high-pass, where a real base can hold none): 0.0627 for the ring,
0.0048 for a blur of the same radius. Thirteen times. The fix is not more
taps — a 7x7 at a third of the step is still point samples, only smaller —
it is to stop sampling: the caller blurs.

So the base is now a second CHILD of the tone pass, the frame blurred by
Skia's own MakeBlur (blurredBase in exportEngine.ts, the draw drawBlurred
already made for the sharpening pass) at TONE_BASE_RADIUS of the frame width
and sigma TONE_BASE_SIGMA of that radius — a box's equivalent at the radius,
so the neighbourhood is the one the radius always named and the cuts are
smooth instead of hard. The shader reads it ONCE per pixel and baseLuma
loses its loop and its `bx`. That is also the cheaper pass: nine child evals
walked the exposure/matrix chain nine times, one eval does not.

A caller with no frame to blur hands in the image it is already shading as
the base. The tap then lands exactly on t and the ramp is the global move
again — a mask's degenerate bx of zero, spelled as a child that IS the
source, which is what gradientMask.ts's own call (base == t) already meant.

Checks: tone-base-check.mjs is new — SkSL is only compiled at runtime and
nothing here compiled TONE_SKSL whole, so the pass is compiled and rendered
for real, with the frame and the base held at two flat values a little
apart: the pixel has to land on the value the ramp over THAT base predicts
(171 for a 128 pixel over a 76 base), and a base equal to the pixel has to
be the identity. highlight-knee-check now pins the one tap, the absence of
`bx`, the blur, and the two-child wiring. 9/9 pass, build clean.

Skipped: no guided filter proper — the base is a plain blur, so a strong
edge is no longer held out of it the way the range weight held it (a dark
rock a blur's width from the water reads a lifted base and keeps its own
darkness). Add when a frame shows the halo; the doc asks for a plain blur
and this is one.
2026-09-30 19:03:55 +07:00
3dtours e61dccc784 light: the tone ramp is drawn through a base layer, not the pixel, so a SHADOW lift moves the region and leaves the texture in it standing — the quarter above the knot came back at 0.57x of its own spread, 0.78x now
The four knots were read at the pixel's own luma, which makes the ramp a
global curve: every pixel at luma t lands on the same o whatever surrounds
it. SHADOW's a1 is the head of the quarter above it, so lifting it squashed
that whole quarter to the half slope left over — measured on a real frame,
0.50 of its spread (shadow-band.py), 0.57 on the deployed bundle — the grey
sheet the knob was reported for. A curve drawn through the pixel cannot see
local contrast; that is what the eye was reading.

So the ramp is read at TONE_BASE_RADIUS (2.5% of the frame) of the luma
around the pixel — a 3x3 range-weighted blur, the fix_shadow.md Base x
Detail split — and the pixel then rides the neighbourhood's gain o/base,
keeping its own difference from it. Base moves, detail stays: the same lift
on the same pixels, with the texture inside the region left standing. Full
deflection keeps 0.78 of the band's spread now.

The base is the same maths in every caller: bx = 0 (a mask, which has no
neighbourhood) reads the pixel nine times and gets the old global move back,
and with every knob on zero the ramp at base IS base, so the ratio is 1 and
the pass is the identity however coarse the base is.

Skipped: no chroma compensation (Hunt). Measured, the ratio held saturation
(0.3184 -> 0.3169), so it is not earned yet. No linear-light ramp either:
the multiply is a uniform gain on encoded values, which is the same stop
exposureMove already argues for.
2026-09-30 18:46:36 +07:00
3dtours 25b1312e0a light: AUTO and the shipped recipes ride the halved knobs too — AUTO's ceiling doubles with them so a blown frame still comes back the way it did, and each recipe's HIGHLIGHT/SHADOW doubles so its look stays on the knot it was tuned to 2026-09-30 18:10:51 +07:00
3dtours a56581c757 light: the HIGHLIGHT knob rides half its anchor too, so a lift stops drawing a cloud to paper and a pull stops flattening the quarter under it — the top quarter came back at 0.26x of its own contrast, 0.62x now 2026-09-30 17:59:57 +07:00
3dtours 36fb93658b light: the SHADOW knob rides half its anchor, so a lift stops drawing the band above it flat — a waterfall's spray came back at 0.10 of its own contrast, 0.55 now 2026-09-30 17:36:29 +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 851563ed73 light: the pixel rides o/t, not a held chroma — SHADOW and BLACK drained a dark red to 0.505 of its saturation, it is 0.742 now 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 e0b8f14dfc PRO cutoff: an account that signed up before 2026-12-01 keeps the whole studio with its Activated Pro box ticked by the calendar, signups from the cutoff on start basic 2026-09-30 12:22:59 +07:00
3dtours 61b1210a47 TOOLS: AUTO and MONOCHROME come with any account, FIX and GRADIENT MASK stay PRO and answer with the reason 2026-09-30 12:20:03 +07:00
3dtours a3175210f9 studio: read an allowlisted admin as PRO on its own, so a rollout that pairs this bundle with an older API cannot show the operator the basic-tier bar 2026-09-30 12:13:57 +07:00
3dtours 74c0b2747f Tier model: registered=basic (recipes, 12 photos, QR), PRO=verified or admin-activated (HSL, tools/gradient mask, RAW, export > source longest edge); admin users table gets Activated Pro column 2026-09-30 11:50:28 +07:00
3dtours 35d8d57984 HSL: click a colour chip to open its panel, the way PICK does
The colour band chips only aimed the mixer before; now they open the same
panel the eyedropper opens, seeded with the colour the chip is named after
(hexToRgb turns that readout back into the RGB bytes the picker stores).
A pick already on the photo keeps its spot — only a mixer with nowhere to
hang takes the middle of the frame.

The HSL column itself is untouched: 8 chips, the rule, IMAGE,
HUE/SAT/LUM, the readout and RESET all stay where they were.
2026-09-30 07:34:07 +07:00
3dtours f7031c1809 LIGHT drops its PROFILE panel, TONE CURVE folds into TONE, and IMPORT .RECIPE moves under the RECIPES chip
Three small moves in the column, plus the rule they needed.

LIGHT: the PROFILE panel is gone — PHOTO STYLE under PRESETS already covers
what it did — and the tone curve chip leaves the column's chip row. It becomes
the last line of the TONE panel's body, at the foot TONE always had room for.

PRESETS: IMPORT .RECIPE leaves the recipes strip and sits in the main column,
directly under the RECIPES chip, which is the set it imports into. The strip now
carries the recipes and nothing else, so its empty hint fires at length zero.

HSL: the mixer's PICK and the IMAGE label each get a rule above them. The rule
is a chip — divider: true in ChipDef — that renders a separator instead of a
button, so a row can say that what follows is a different kind of thing.

Verified by probe against the built app (exit 0, no console errors):
- LIGHT panel heads read WB, TONE, PRESENCE, DETAIL & EFFECTS, with no PROFILE;
  the TONE body's last row is the "∿ TONE CURVE" chip, box [94,647,300,27].
- PRESETS main chips read PHOTO STYLE, RECIPES, IMPORT .RECIPE (y 159, under
  RECIPES at y 124), RESET; the strip no longer carries the import chip.
- HSL order is PICK [94,91,148,27], rule [94,127,148,1], the eight bands, rule
  [94,404,148,1], IMAGE, then HUE/SAT/LUM. The same order holds at 393x852,
  where the rules measure [8,560,377,1] and [8,639,377,1].
- The rule is a 1px line: width 100% with height 1px, which keeps it a line in
  both the desktop column and the phone's wrapped strip.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-30 06:56:29 +07:00
3dtours 4476372b9e The two Color Chromes close the WB panel and D.RANGE gets a box of its own
Two picks that were drawn as rows of the column rather than as picks of a panel,
both of them because of where a slot happened to be rather than what the control
is. The pair of Color Chromes was LIGHT's own `chip-row grid` at the foot of the
column, under TONE CURVE, where it read as a third strip of the tab and sat far
from the two tracks it is the other axis of. D.RANGE was the effects slot, four
chips poured into the foot of DETAIL & EFFECTS where they were four more rows of
the panel and not the one setting they are.

WB now owns the pair. `DevelopPanels` gains a `foot` on a panel — a slot drawn
under the rows instead of above them — and the `wb` entry names `wbFoot`, so
COLOR CHROME and CHROME BLUE are rendered under COLOR TEMP and TINT, on the tab
whose colour they are and against the pair of tracks they are read with. They
keep everything else: the `chip-row grid` table layout the WB presets wear, their
keys, their OFF/ON labels and their pointer behaviour. The `wb` panel had no
slot but its presets and its two rows, so the foot is the whole of what changed
there; the four panels that name no foot are untouched, and the slot is looked up
per panel (`slots?.[panel.foot]`) so a panel that never asked for one draws
nothing.

D.RANGE closes DETAIL & EFFECTS inside a box that carries its name. `.dev-box` is
a frame in `--border-soft` with a `.dev-box-title` in the small-caps the panel
heads use, and the four stops sit in it as one set — which is what they are: a
hold on the whole frame, read as a set of stops, not as four more knobs of the
effects. The box is the slot's own wrapper, so it holds whichever chips the slot
is given and the chips keep their own keys and their amber AUTO.

Verified: `npx tsc --noEmit` clean, `npm run build` clean
(`dist/assets/index-v5g7p7bS.js`, `dist/assets/index-RYLacibB.css` at 66.29 kB).
Against the built page (`127.0.0.1:8090`, 1440x900 desktop and 393x852 phone),
LIGHT tab, panels open:

- LIGHT's own row is now `∿ TONE CURVE` alone — the pair is out of the column.
- WB's children read `[chip-row grid (presets), mini-slider COLOR TEMP,
  mini-slider TINT, chip-row grid COLOR CHROME OFF / CHROME BLUE OFF]`, and both
  chromes report `inWb: true` — `COLOR CHROME [94,424,147,37]`,
  `CHROME BLUE [247,424,147,37]` at 1440 wide, `[8,599,186,37]` and
  `[200,599,186,37]` at 393 — in the WB body, under the two tracks.
- The D.RANGE box reports `data-key="dev-box-dr"`, title `DYNAMIC RANGE`,
  `[94,754,300,162]` under `effBox [94,569,300,347]` at 1440 wide, border
  `1px rgb(236,238,241)` (`--border-soft`); the four chips are full-width rows
  (282px) at y=781/814/847/880, AUTO `aria-pressed=true` in amber
  `rgb(206,117,9)` on `rgba(206,117,9,0.14)` and DR100/200/400 on white. On the
  phone the box is `[8,929,377,63]` and the four chips come back to one line at
  `.chip-row`'s own `@media (max-width:860px)` override, `[17,956,58,27]`,
  `[81,956,62,27]`, `[149,956,64,27]`, `[219,956,65,27]` — no clipping, nothing
  over the caption — and `errors: []` on both.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-30 06:37:15 +07:00
3dtours 92776d2cfc A panel head keeps its label when it folds: the title is a span, not a bare
text node beside the caret

On iOS the LIGHT column's heads come back from a fold with no label — white
rows, the caret still drawn. The one structural difference between the two
things in that button is that the caret is an element (`span.dev-caret`) and
the title is a bare text node, which a flex container renders as an anonymous
item of its own; the anonymous item is what stops being painted when the
column re-lays-out inside the `.chips` scroller the heads live in. The title
is wrapped in `span.dev-panel-label` so the label is a real box, the same
shape as the caret beside it. No CSS, no rule of its own: the span is a flex
item where the text node was an anonymous one, so the reading is unchanged.

Verified: `npx tsc --noEmit` clean, `npm run build` clean
(`dist/assets/index-_Bzj6oMZ.js`, CSS unchanged at `index-CjhObVYI.css`).
Against the built page (393x852 @3x, touch) on `127.0.0.1:8090`, LIGHT tab,
tap PROFILE to open then collapse, with and without the `.chips` scroller at
its foot: the heads read `PROFILE▸ / WB▸ / TONE▸ / PRESENCE▸ / DETAIL &
EFFECTS▸`, each `c=rgb(20,24,29)` `fs=11px` `vis=visible clip=none` at 377x17,
no ancestor in the font-size chain at 0, and every chip beside them unchanged
(`c=rgb(20,24,29) bg=rgb(255,255,255) fs=12px`); `errors: []`. The iOS report
itself is not reproducible on this machine — WebKit 26.5 (MiniBrowser) and
Chromium are clean at 320-430px wide in both themes — so the wrap is the
shape of the fix the symptom points at, not a fault seen and closed here.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-30 06:15:18 +07:00
3dtours 6996d1bdb0 The marks get a tab of their own and the rulers read on one line
Four asks, one reading of the LIGHT column. EV's ruler sat under PROFILE, which
is the film simulation and nothing else — the range a picture is on is a knob of
the tonal range, not of the stock it was shot on. D.RANGE sat with the tone
controls for the same wrong reason: it is a hold on the whole frame, so it
belongs at the foot of the effects. The two Color Chromes had gone missing from
the eye, because the only place they were drawn was the effects slot and
DETAIL & EFFECTS now opens folded. And every ruler was two lines: a name with
its number on one, the track alone under them, where Lightroom reads a ruler as
one line. The four feature buttons answered a fifth ask that arrived with them:
AUTO, FIX, GRADIENT MASK and MONOCHROME do not name a look, they make a move on
the frame, and they now share a tab of their own.

TOOLS is the rail's ninth tab, between LIGHT and HSL, marked with the hammer and
pick (`⚒`) because the four features work ON the photo rather than pick a look
for it. The chips keep their keys, their amber-value logic and their pointer
behaviour — FIX and GRADIENT MASK still put down whatever tool was up before
they open their strip — and each carries its own mark at the head of its label
so the four read as one set: `✦ AUTO`, `✚ FIX`, `▚ GRADIENT MASK`,
`◑ MONOCHROME`. The marks were probed in the page's own stack before they were
spent (Inter, then the system sans): four-pointed star, thick cross, two
diagonal quadrants, half-filled circle — 26px, 47px, 77px and 15px of ink at
11px against the 36px a tofu box draws, so none of them is a missing glyph. The
strips still open in the last column, the same `stripChips` any other tab uses.

LIGHT keeps the one chip that is not a knob — TONE CURVE, `∿ TONE CURVE`, which
opens the graph on the photo rather than a ruler in the column, and stands over
the TONE panel whose shape it is — and gains the pair under it. COLOR CHROME and
CHROME BLUE are the same switch over two axes of one colour, so they are read
against each other rather than scanned in turn: one row, two chips, the table
layout the WB presets already wear (`.chip-row.grid`), 147px each side by side
at top 123 with nothing overflowing. Each names the strength it is on, and names
`OFF` when it is on `none` — the pair is compared, so the neutral value is one
of the two being read (`groupChip(g, nameAlways)`). Either one still opens its
own strip in the last column; picking STRONG lands on the chip and, at rest,
goes amber.

Every ruler is one line now: the knob's name at the head of the row, the track
taking the width they leave, the number it is on at the foot. The track moved
inside `MiniSlider`'s `.mini-head` and inside `SliderRow`'s `.head`, and both
rules became a flex row (`span` and `b` `flex: 0 0 auto`, the input
`flex: 1 1 40px; min-width: 0`). The eight TONE rows, the six of DETAIL &
EFFECTS and the mask column's eleven all read as one line, track included. The
HSL mixer's three knobs are the exception and keep the stacked form: three to a
narrow panel, a track between two labels is a sliver, so `.hsl-card-knobs`
wraps the readout onto its own line and lets the track take the second. The mask
column takes the ruler column's own width (`col-slider`, 232px) rather than a
strip's 168px, which is what the one-line form costs: eleven rows, and in a
strip's width the labels leave the tracks nothing.

PROFILE is the simulation strip alone, TONE leads with EV and then the finer
move on the same axis, and DETAIL & EFFECTS closes with the D.RANGE strip.

Verified: `npx tsc --noEmit` clean, `npm run build` clean
(`dist/assets/index-CJSnLD0u.js`). Off the running page, through a probe: the
rail reads `◉ PRESETS ★ FAVORITED ☀ LIGHT ⚒ TOOLS ◍ HSL ▣ FRAME ⤓ SAVE RECENT
✎ CREATE RECIPES`; LIGHT's non-grid row is `[{key: curve, label: "∿ TONE
CURVE"}]` and the grid row under it is `["grp-cx", "grp-cxb"]` — 147px + 147px
in a 300px column, both boxes at top 123, nothing overflowing, `COLOR CHROME
OFF` and `CHROME BLUE OFF` each on its chip; its strip is
`[hint-cx, cx:none, cx:weak, cx:strong]` and picking STRONG lands
`{value: "STRONG", amber: false, on: true}` and then `{value: "STRONG", amber:
true}` once the strip closes. The TONE rows come back `EV 15+229+36
one-line=true`, `EXPOSURE 58+198+24`, `CONTRAST 60+214+6`, `HIGHLIGHT
60+214+6`, `SHADOW 50+224+6`, `WHITE 36+238+6`, `BLACK 38+236+6` (label, track,
readout; one line on every one), and the mask column's eleven the same at 232px.
PROFILE holds `{ev: false, dr: false, sim: […11 sims]}` and DETAIL & EFFECTS
holds `{dr: [dr:auto, dr:100, dr:200, dr:400], chrome: false, last: "AUTO DR100
DR200 DR400"}`. The TOOLS row is `[✦ AUTO, ✚ FIX, ▚ GRADIENT MASK, ◑
MONOCHROME]` at 148px each, the marks drawn 26/47/77/15px against the 36px tofu
control, and FIX's and GRADIENT MASK's strips still open
(`[hint-fix, heal, mosaic]`, `[hint-gradient, linear, radial]`) with MONOCHROME
still switching `false → true`. No page errors. `studio-open-check` (updated
for the pair and the single LIGHT chip), `library-check` and `scan-nav-check`
all pass, and the column was viewed with TONE and DETAIL & EFFECTS open.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 22:09:53 +07:00
3dtours 2f34c36131 The room opens folded: the studio's panels and the catalogue's tree both start shut and come back the way they were left
Four asks, one shape: the LIGHT column had a chip that opened the same two
knobs DETAIL & EFFECTS already shows, the WB table was reading as one long
line per preset, the disclosure carets were too small to be the affordance
they are, and both folds — the studio's panels and the catalogue's folder tree
— opened fully every time. What the fold is for is the same in both places: a
column of a dozen controls is a wall to scroll past, and the control being
looked for is named by the head it sits under. So both open folded, and both
remember what was opened.

GRAIN's chip is gone from the LIGHT row. Its strip held a knob `grain` and the
"size" behind it, and both are rows of DETAIL & EFFECTS now — MONOCHROME GRAIN
and GRAIN SIZE — so the chip was a second way to one pair. The strip machinery
stays (`GroupKey 'grain'`, `groupDefs.grain`, `grainInch()`): it is reachable
from nothing today, and taking it out is a diff of its own. What went with the
chip is the `/INCH` readout, which only the strip drew.

The WB presets read as a name on one line and its kelvin under it: the label is
the chip's own label and the kelvin moved from the label into the value slot,
which the grid's rule stacks (`flex-direction: column`, `font-size: 11px`,
`white-space: nowrap`). Seven buttons, 147x39 each, name
box 14px — one line at 11px/1.25 — kelvin at 10px, `scrollWidth` equal to
`clientWidth` on every one of them. The kelvin rides a swatch, so it wears the
fill's own ink rather than the panel's dim grey (`.chip.tinted .val { color:
inherit }`): readableInk already picks #0b0e12 or #ffffff off that fill for
4.5:1, and dimming it a step costs contrast on a mid grey fill. Measured off
the running page: 4.88:1 (CLOUDY) to 5.00:1 (TUNGSTEN), name and kelvin alike.

The panels: `recipescam.develop.open` holds the slugs that are open, comma-
joined, and a slug no panel answers to is dropped on the way in, so a renamed
or removed panel leaves no hole. A visit that touches nothing writes nothing —
the column opens folded from an empty store — and a click on a head writes the
list as it stands. The caret is drawn a size up from the label it sits beside
(17px against an 11px head) and takes full opacity under the pointer, because
a line of uppercase does not read as a control on its own.

The tree: a row's click still opens it and folds it again, but the set it moves
in is inverted — what is *drawn open* rather than what is folded away — which
is what makes the state storable at all (a folded set is the whole tree minus
what is open, and the whole tree is not in that state to be stored). The
column opens with every folder shut, and the remembered set comes back over
it. A first visit under the new rule has no stored set and can still have a
folder remembered from before, so that one case seeds its ancestors: a screen
that opens on a node shrunk away behind a shut row is worse than a
remembered fold. COLLAPSE ALL folds the top-level row too now, which is what
makes it the way back to the column's rest — it was written when a top-level
row could not be folded, and the item is disabled when there is nothing open.
The caret is a leaf on a row with nothing under it, as it was.

`remember.ts` is the two functions both screens now share; Library had its own
copy of them.

The two checks that read these screens were wrong about the new fold and are
right about it now, not the other way round. `library-check` gains the rest
state (`the tree opens with the folders folded away — 1 rows`), walks the
tree a level per click, and reads the visit it left behind off a reload:
`lib-node-CheckRoll:true lib-node-CheckRoll/2026:true .../04:- .../Empty:-`,
with the screen marked on the subfolder. Two of its steps were stale
expectations rather than a fault: the fold-the-tree claim is one click over
four rows once the rows are opened for it, and the folder the screen reopens
on is the last row clicked, not the one clicked before it. `scan-nav-check`
asks the same tree for its deepest folder, so it opens the two rows above it
and folds them back — the reading it is watching does not move with rows.

Verified: `npx tsc --noEmit` clean, `npm run build` clean
(`dist/assets/index-T721P-Nv.js` 711.33 kB, `dist/assets/index-Co_dQD6t.css`
65.69 kB). `scripts/library-check.mjs` — "all checks passed", 55 steps, none
failing — including `the tree opens with the folders folded away — 1 rows,
aria-expanded=false`, `a row draws its own children, not the whole branch — 3
rows`, `and one click folds every row, leaving the frames where they were — 4
rows → 1, lib-node-CheckRoll:false, 2 tiles kept`, `the tree reopens with the
rows that were left open`, `the screen reopens on the folder it was left on —
lib-node-CheckRoll/2026`, `the screen reopens on the frame that was raised`,
and the column `198px, then 198px`. `scripts/scan-nav-check.mjs` — all steps
passed, its tree walk reading
`["lib-node-SlowRoll","lib-node-SlowRoll/2026","lib-node-SlowRoll/2026/04"]`.
In the running page: 5 panel heads and 0 panel bodies at rest with
`recipescam.develop.open` unset, caret `17px` `▸`; the WB table's seven rows
at 147x39 with name 14px one line, kelvin below at 10px, `nowrap`, no
overflow, ink 4.88–5.00:1; the LIGHT chip row `["auto","curve","fix",
"gradient-mask","mono","wb-temp:*","reset-all"]` — no `grp-grain` — with
MONOCHROME GRAIN and GRAIN SIZE still the two rows of DETAIL & EFFECTS; two
heads opened, `wb,effects` stored, and the same two open after a reload with
the other three shut. The deployed bundle was read too: `docker compose build
frontend && docker compose up -d frontend`, and `index-DpYWAhJ3.js` off
`127.0.0.1:8090` carries `recipescam.develop.open` and
`recipescam.library.open` and no `grp-grain`, with the same probe readings
taken against it.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 21:25:18 +07:00
3dtours 642fccd2b0 The trial notice crosses the bar instead of looping inside it
The last commit set the sentence moving, and on a wide window it moved the
wrong way: two copies rode the track and the track slid half its own width, so
the loop was seamless because the second copy arrived exactly where the first
had left. That is the right shape for a marquee and the wrong one for this
sentence. A seamless loop means the string never enters the screen and never
leaves it — the pair is already in flight at t=0 and the seam hides the moment
one copy hands over to the other. At 1440 with a 713px string both copies fit
in the window, so what the visitor saw was two identical sentences standing
side by side, walking, and neither of them had ever come from anywhere.

The ask is one sentence at a time, in from the right edge and out past the
left, and the same on a phone. So the track holds one copy now — the second
span and its `aria-hidden` are gone, and with them the reason the notice was
announced once instead of twice — and the keyframes run `translateX(100vw)` to
`translateX(-100%)`. The units differ because the two ends are measured in
different things: 100vw is the window, which is where the sentence starts, and
100% is the track, which is the sentence, and `-100%` is therefore exactly the
width of the string it has to clear. At rest the sentence sits wholly off the
right edge — left edge at 1440 on a 1440 window — and 30s later its right edge
is at 2.1, i.e. past the left edge, so an observer never sees two and never
sees half of one appear from nowhere.

30s rather than 40s, and this is the compromise worth naming: the trip is
100vw plus the sentence, and CSS cannot add a viewport unit to an element width
in a transform, so the duration is one number for both sizes. At 1440 the
sentence covers 2081px in 30s, at 390 it covers 1031px in the same 30s — the
phone walks it slower in pixels-per-second and the visitor on a phone is
waiting proportionally longer. Fitting both would need the width in a custom
property and a keyframe per breakpoint, which is machinery around a 12px line
of legal copy.

`padding-right: 72px` goes with the seam it was written for: that air was the
gap between two copies, and with one copy there is no between. The band keeps
its own 6px so the sentence is clipped at the band's edge and not at the
window's.

Under `prefers-reduced-motion` the animation was already off and the sentence
sat at the left edge of the band clipped in half. A static notice that is
half a sentence is not a notice, so the band is allowed to scroll there: the
track stops animating and `.lp-notice` gets `overflow-x: auto`, which is the
platform's own answer — the reader drags the line if they want its end, and
nothing moves on its own.

The band keeps its height, so the hardcoded offsets that pay for it — 148px of
scroll padding, 216px of hero top padding, 144px on mobile — are untouched.

Verified: npx tsc --noEmit clean, npm run build clean, the built CSS carries
`@keyframes lp-notice-roll{0%{transform:translate(100vw)}to{transform:translate(-100%)}}`
and `animation:lp-notice-roll 30s linear infinite`. In the running app through
a probe, sampled over one 30s cycle: one span in the band, no `aria-hidden`
span beside it, animation 30000ms linear, and the string's box tracked from
left 1440 / right 2081.3 at t=0, through 711.5/1352.9 at 10.5s, to -639.2/2.1
at 29.97s — entering exactly at the right edge and gone past the left. At
390x800 the same shape: 390/1031.3 at t=0 to -640.3/1 at 29.97s, so the phone
gets the whole crossing too. `overflow: 0` on the document at both widths,
i.e. a 100vw transform inside the clipped band adds no page-level scroll. The
band was viewed at both widths.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 20:46:24 +07:00
3dtours 8e330ee28b The trial notice walks the width of the bar instead of standing in it
cc186c4 put the news about the trial into the bar, one line, and asked nothing
more of it. On the landing page that line is a sentence, not a label: at 1440 it
fits, on a phone it wraps into a paragraph and the bar grows a shelf of text
above the hero. So the band is drawn as a track and the sentence is set in
motion — one line that is never wider than the window because it is never asked
to fit in it.

It is the same band cc186c4 wrote, moved to the last shelf of the bar, under
the menu line rather than beside the header. Two copies of the string ride the
track, the second hidden from screen readers so the news is announced once, and
the track slides exactly half its own width: the copy that was waiting arrives
where the one before it left, so the loop has no seam and the 72px of air lives
in each copy's right padding rather than between the two. `width: max-content`
on the track is what makes that half-exact — the track is the two copies and
nothing else, so -50% is one copy by construction and not by measurement.
40s, linear, infinite; the animation is dropped under
`prefers-reduced-motion: reduce`, where the sentence simply sits at the left
edge of the band, clipped the way it already is mid-scroll.

The colour is the theme's own ink, not the page's amber: the notice is the
product's voice about itself, so it wears the accent the rest of the landing
page wears and follows the visitor through all five themes. The bare accent is
too bright to read on the light theme's paper, though, and the worst pair is
itself in the theme set — the bare accent lands at 4.0:1 on the violet dark
page, which a 12px line may not wear. So the ink is `color-mix`ed 78% accent
toward the page's text colour: the hue that says "theme colour" survives, and
the value walks toward whichever extreme the page already uses, which is the
one direction that is always safe. Worst pair across the ten theme/page
combinations is 5.66:1.

The bar is fixed, so the shelf it gained is a shelf the hero has to pay for:
`scroll-padding-top` goes 116 to 148 (62px of nav + 43px of subnav + 32px of
notice, plus a little air) and the hero's top padding 184 to 216. Below 1160px
the subnav is gone and only the notice is owed: 144, up from 112. The numbers
are hardcoded rather than measured — the bar's height is a sum of three fixed
shelf heights, and a ResizeObserver watching a 32px band would be machinery
that reports what the stylesheet already says.

Verified: npx tsc --noEmit clean, npm run build clean. In the running app
through a probe: navBottom 139.08, subBottom 105.89, notice.top 105.89 then
height 32.19, so the band sits directly under the menu line; trackW 1426.625 is
exactly 2 x spanW 713.312, so -50% is one copy and the loop is seamless; the
transform was observed mid-flight (matrix -12.48 to -39.53 over 1.5s) with
overflow hidden and the animation named; h1Top 267.59 clears navBottom 139.08,
the same gap the hero had before. At 390x800: noticeTop 62, height 32.19,
subnav hidden, one line, no wrap, h1Top 195.59. The band was viewed in both
themes and at both widths. Contrast of the mixed ink against each page, all ten
theme/page pairs, measured from the rendered pixels: 6.79 / 9.89 / 5.66 / 10.22
/ 12.64 light and 10.98 / 7.69 / 12.72 / 6.76 / 5.67 dark.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 20:37:07 +07:00
3dtours cc186c4d09 The trial gets a date and WB's presets get a table
Two asks, one row of the studio each.

The first is copy: a band under the header, the same shape the verify bar below
it takes, saying which features are on trial, on whom, and until when. It is a
notice and not a task, so it wears the plain surface rather than the accent, and
it carries no dismiss: the date is the message. The string is in the dictionary
like every other, Vietnamese as written and English beside it — the band is one
line at both lengths in a 1440-wide window, and it sets no nowrap, so a narrow
window wraps it rather than cutting it.

The second is the WB panel's presets. They were a wrapping strip of seven
identical pills, which is the wrong shape for one kind of value read against the
others: nothing about "SHADE" said 7500K, and nothing about the row said which
end of the scale anything was on. They are a TABLE now — two columns of buttons,
laid out as a grid — and each button is painted with the cast it puts on the
frame: the same swatch the TEMPERATURE ruler already prints under its track
(temperatureSwatch, the engine's own gains on a mid grey, halved), so the button
and the ruler cannot disagree, and the kelvin is printed on the button beside the
name.

A button that is a swatch cannot use the theme's text colour for its label: the
fill is a mid grey by construction, and mid grey is exactly where white and the
light theme's near-black both sit near the 4.5:1 line. So the label is given the
ink that fill can carry, chosen by WCAG's relative luminance against black and
against white — whichever of the two ratios is larger, which is the one that
cannot fall under 4.5:1. The border is that same ink, which is what makes a
button stand out from the page it sits on and from the six beside it; the accent
takes the border over while the preset is the one in force, since the fill can no
longer say so. Both the fill and the ink ride in as inline styles — they are the
value, not the theme — so the chip needed two fields and a tinted class, and the
row needed a grid mode. The TEMP strip reads the same two fields through
choiceChips, because the same seven presets are painted in both places and two
opinions about 3200K is the bug this would have been.

The chip's own value slot is deliberately unused here: `.chip .val` sets its own
dim colour, and a dim grey on a mid grey button is the failure this commit is
about.

Verified: scripts/wb-table-check.mjs — readableInk picks the better of its two
inks for black, white and four mid greys, all 151 swatches across the ruler
(2500..10000K, every 50) and all seven presets clear 4.5:1 under the ink they are
given, the swatch runs the way the ruler does (red up, blue down, #517eff at
2500K to #947d61 at 10000K), and the seven presets collapse to the five fills
their kelvins do. In the running app through a probe: the notice prints in both
languages (Vietnamese one line, unclipped), 7 buttons in a 147px + 147px grid,
TUNGSTEN filled rgb(98,130,187) with ink rgb(11,14,18) and a 2px border of the
same, AUTO the accent border rgb(206,117,9) while it is the preset in force, and
the panel holds at 420px wide without overflowing. The panel was viewed in both
themes. npx tsc --noEmit clean, npm run build clean.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 20:21:08 +07:00
3dtours 899921ef8a A dark detail stops being copied beside itself: DEHAZE reads its prior off a copy of the frame
Raising DEHAZE drew bright copies of every dark detail in the photo, stacked
alongside it. The prior was the reason, and the prior was read wrongly.

The 5x5 patch of DEHAZE_SKSL was sampled inline, five taps out at 0.625% of the
frame's width each — one tap every 6.67 pixels of the 1067-pixel preview, an
average spacing over a 26-pixel patch that is meant to be the minimum over it. A
detail thinner than that spacing therefore sat between two taps on one row and
under a tap on the next, and the transmission swung between "this patch holds a
shadow, leave it alone" and "this patch is all haze, divide hard" with a
7-pixel period around every dark thing on the frame. A rising DEHAZE drew that
period: `t` is a per-pixel divisor, so the rows of the detail that were left
alone stayed put while the rows read as haze came up bright, and the 13.3-pixel
column spacing made the next copy and the copy after that. That is what was
stacking.

Measured on a synthetic frame of sloped haze with four one-pixel dark lines: the
old shader departs from the clean correction by +58 to +102 codes (8-bit) at
exactly +/-6.67 and +/-13.3 pixels around each line — the two spacings, in both
directions, which is the whole signature of the bug. That frame is otherwise
flat, so those deviations are the copies.

Three things had to be got right, and each is the smallest fix that removes one
of them:

- The patch is no longer sampled. The caller builds the dark channel as an image
  — a 64x64 copy of the frame, smallest channel over a cell-wide neighbourhood,
  then two box passes — and hands it to the pass as its second child, so the
  shader's own `dark.eval` IS the patch: one cell covers a whole neighbourhood
  rather than sampling it, and the bilinear upscale interpolates it back up with
  no period left in it. The box passes are the doc's soft matting in the one form
  free here — the map is smoothed, not the pixels. The copy is made with
  drawImageRect, rect to rect: a paint shader drawing a 64x64 rect reads only the
  source's 4x4 corner, and a one-pixel line in such a copy lands at 166, i.e.
  pure haze, because the cell covering it is mostly sky.

- What travels as that image is the dark channel and not the transmission. t is
  1 + 0.95 at the negative end of the knob, more than a channel can carry, so a
  copy of t would arrive here clipped to 1 and "put the scattered light back"
  would become a pass that returns its input. The dark channel is 0..1 by
  construction and the signed amount stays a uniform, where it costs no range —
  the knob keeps both of its directions. Swept on the real photo, DEHAZE -100
  moves 779,237 pixels brighter and 703,114 darker (worst 110 codes) while the
  same frame at 0 either side of it moves exactly none, and +100 moves the frame
  the other way at atmosphericLight [0.93155, 0.90980, 0.93084].

- The pass was reading a shader that does not exist yet. `effects()` is what it
  asks now, not the module variable: nothing above DEHAZE has asked for the
  effect, so on the first render the variable is still null and the knob stayed
  dead until some later render happened to fill it in.

A map this small only covers the frame if it is told to, and the matrix that does
it is the last thing that had to be right: CanvasKit reads a shader's local
matrix as the map's own pixels to the frame's, so the 64x64 copy needs frame over
map, `[W/64, 0, 0, 0, H/64, 0, 0, 0, 1]`. Without it the pass covers only the
top-left 64 pixels and clamps every pixel past them onto the map's last texel —
one constant t over the whole photo, a global inversion and not a dehaze.
Measured against the ideal ramp, `scaled(n/w)` clamps the same way; the
reciprocal lands on it.

The knob is left to over-correct at the top of its range, and that is deliberate.
A hazy sky still goes white and a saturated colour beside a dark edge still
deepens: `(c - a)/t + a` with an airlight near 0.93 and a plain clamp, which is
the arithmetic the doc asks for. It is smooth on the frame — 6x zoom panels of
the hazy frame at DEHAZE 100 show one wide gradient and no band repeating at any
period — so no knee is added to soften a correction that is no longer producing
the symptom. If that side ever needs taming, DEHAZE_MAX_OMEGA is the one number.

The MASK's DEHAZE still samples the old 5x5 patch inline (gradientMask.ts,
unchanged): the frame-wide pass is what the report was about and what is fixed
here.

Verified: the synthetic harness over four patch configurations puts the new
shader at zero deviation from the clean correction at N=64 — the 16.7-pixel cell
swallows the test line, which is why the real photo is the judge. The real photo
through the running app, with the slider swept 0, 100, -100, 0, is exact at both
zero points and moves the frame at both ends, and its zoomed panels at DEHAZE 100
— roof, floor and a wooden rail at 3x and 6x — show the remaining change as one
smooth region, the blue of a tarp and the green of a floor stain deepening where
the haze was hiding them, with nothing repeated around the dark detail that used
to copy itself. npx tsc --noEmit clean, npm run build clean,
scripts/mask-wb-check.mjs and scripts/highlight-knee-check.mjs both pass.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 20:02:35 +07:00
3dtours 2ced93a425 A fixed mask EXPOSURE that never arrives: the shell stops being cacheable by guesswork
The request was a mask's EXPOSURE losing hue, and the mask pass has not done that
since 6d60d45: measured today through a page the service worker controls, with an
ellipse over the whole frame and +1 EV, the pixels inside move exactly as the
frame's own knob moves them — identical to the byte (mask+1 vs frame+1 over four
codes: 619 pixels of 1,709,450, all of them on the rim), and the hue each one
leaves behind is the same distribution to a hundredth of a degree over the
370,606 pixels that carry a hue in both reads (mean 2.07°, p99 15.31° for the mask
against 15.32° for the frame; on the vivid pixels, mean 0.83° at +1 EV). The one
place that still says otherwise is a doc on the Android branch, whose §3.2 quotes
the old line.

But the symptom is real, and the build that produces it is the one from before
that commit, where maskAdjust multiplied the three channels by the stop:

    c = c * half(pow(2.0, a.x));

Three channels clip by three different amounts, so the differences between them
stop being scaled together and the hue goes with them. That bundle could still be
what a visitor runs, because of two files the deploy never took away:

- index.html was the only document the server handed over with no Cache-Control
  at all (the .mjs, /assets, /wasm and /models locations all name their policy,
  sw.js and the manifest both opted out). With no header the browser is free to
  guess a freshness window out of Last-Modified — a tenth of the file's age — and
  answer a navigation from its own cache for hours after a deploy. The page it
  answers with names the previous build's hashed bundle, so the previous shader
  is what runs, and a hard reload is the only way out. The SPA fallback lands on
  the same file (an internal redirect re-matches locations), so /app and /library
  were covered by the same guess.

- sw.js is the second place the pin lived. Its navigations are network-first, but
  a plain fetch is not the network: it can be answered by the browser's cache, so
  the network never came first — and the old shell's hashed bundle, once fetched,
  is a STATIC path that the cache-first rule serves forever.

So: nginx names the policy for the shell, with the isolation pair restated
because an add_header in a location drops every inherited one and index.html is
the document that needs them — the wasm renderer's SharedArrayBuffer is behind
that pair. The worker reads its navigations past the browser's cache, precaches
the shell the same way (a cache.add of '/' consults that cache like any other
fetch, so a shell read inside a stale window would be stored as the offline shell
of the build that replaced it), and VERSION goes to v2, whose activate drops the
cache the old worker pinned — the old shell and the old bundle with it.

The root fix is still 6d60d45: this commit is what lets it reach the browser.

Verified: nginx -t on the shipped config, and a container of this config against
a copy of index.html hands / and /app `Cache-Control: no-cache` with all five
original headers intact, while /assets/index-abc.js keeps `public, immutable`
(30 days) — the exact location does not shadow the hashed bundle. node --check on
sw.js. The measurements above come from the live 8090 build inside a persistent
profile whose page is controlled by the worker (controlled true,
crossOriginIsolated true, bundle index-CKOd57MG.js), the same session that pinned
the build the fix is about.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 18:33:10 +07:00
3dtours 6d60d452e0 One move of the light for the whole app: a mask's EXPOSURE and its four tone knobs stop being a second opinion
A gradient mask had its own tone formula and its own exposure, and both
disagreed with the frame's. Before any of this was tidied, the mask ran a
smoothstep luma lift with an arbitrary 0.55..1.35 chroma clamp while the frame
moved the knots of a four-zone ramp, and the mask's exposure was a stop on
sRGB-encoded values while the frame's was a stop on light. Two names, four
moves, and the same slider meant different things depending on whether the
pixels were inside the shape you drew — the divergence §3.3 of the Android
port's compat doc warns about.

The maths is one string now (TONE_MATH_SKSL, interpolated by both passes): the
ramp the four knots build, the hue-preserving rebuild behind it, the transfer
pair, and exposureMove. A mask calls the same functions the frame calls.

The rebuild carries the chroma instead of re-scaling it. Lightness takes the
curve and the colour rides the difference — the channel differences move by ONE
shared scale k, pulled back only where the cube has no room left. The doc's
ratio (R_new = R_old * Luma_new / Luma_old) was the old reading and it is exact
only while nothing clips: a channel past 1.0 stops being scaled with its
neighbours and the hue goes with it. Measured on a flat patch frame, a skin
tone at 24.0° came back at 48.0° at HIGHLIGHT +100, a warm white at 37° at
57.4°, and under L = 0.5 the same ratio multiplied a near-black pixel's cast by
x30 — colour noise amplified, which is why the 0.55..1.35 clamp was there. The
scale is the chroma's own now: over 135 knob combinations on seven colours and
five greys, the ramp moves the luma and the hue does not move at all (Δ < 1e-9°).

EXPOSURE gets the same treatment, which is what the second half of the request
was: the linear domain decides where the luma is going, and the pixel is
rebuilt onto it through the same lightMove. The old pass multiplied the three
channels in linear light, so +1 EV clipped them by three different amounts:
measured on the scratchpad probe, 29.2° of hue drift on a skin tone at +1 EV
and 33.3° at +2, against 0.00° here. The stop is applied as a ratio on the
pixel's own encoded luma rather than pointed straight at the encoded linear
target, which is what makes the knob exactly the identity at 0 EV — the
transfer does not commute with the luma weights, so pointing at it brightened a
colour by a couple of code values even at zero. A grey is the knob it always
was: 128 through +1 EV is 176, the same number the linear per-channel multiply
put there, so nothing a user has dialled in moves.

Verified: `npx tsc --noEmit` clean, `npm run build` clean. highlight-knee-check
now runs EXPOSURE_SKSL for real — compiled with CanvasKit and four pixels
pushed through it, agreeing with the twin to a code value on a grey at +1 EV
(176), a skin tone at +1 EV and +2 EV, and a shadow at -2 EV; it also pins the
hue, the cube, the identity at 0 EV and the black pixel that has no light to
move. mask-wb-check compiles the mask pass and pushes the same stops through
it: 128 through +1 EV is 176, through -1 EV is 92, +2 EV lands the channel on
the ceiling at 255 and holds the hue within 3°. auto-tone-check,
preview-match-check, white-level-check, raw-develop-check, half-check and
roll-walk-check all pass. Live on the built bundle in a 1440x950 browser: the
LIGHT panel's EXPOSURE +1 EV takes the mid grey of a flat patch frame from
0.502 to 0.690 (a stop on light gives 0.686) and moves no patch's hue at -1 EV
(Δ 0.00°), and a linear gradient mask's EXPOSURE +1 EV and HIGHLIGHT +100 move
the pixels inside the mask (luma 185.9 -> 211.1 and 185.9 -> 197.5) while the
corner outside it does not move at all (220.2 -> 220.2), with no console error.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 18:04:50 +07:00
3dtours 03ec925aec The phone gets its picture back: a one-row icon toolbar, a histogram that shows the tones below the sky, and crop corners a thumb can actually grab
Three things were wrong on a 390px phone, and all three cost the photo its
screen. The toolbar's six labelled buttons wrapped to three lines (141px of an
844px screen, 17% of it) and now sit on one 30px row of icons; the histogram
read as an empty white plot; and a crop corner was a 9x16px target to a touch,
so a thumb a few pixels off it moved the frame instead of resizing it.

The toolbar keeps its labels in the DOM and only takes them out of the paint —
font-size 0 on the button, the glyph on ::before — so every button keeps the
accessible name it always had, and the two toggles still report their own
state. Verify: measured on a 390x844 viewport with a real P1010256.JPG, the row
is 30px tall, one row, buttons 34x30, `fontSize` 0px, the labels still in the
DOM as text, and hist-toggle still flips aria-pressed both ways.

The histogram was two separate faults, and the first one was invisible to the
DOM. Measured: after upload or after a close/reopen the plot is an empty frame
for ~100ms (4 paths land at 104ms / 126ms) — that is the mount, not a bug. But
the SVG then carried four full-length paths whose own map was flat: the lum
curve sat 0.9 of the panel high on ONE bin and every other bin measured 0.066
or less, so the photo read as a line along the floor. That is real: the
sampled frame has 34% of its pixels on bin 255, and under a linear scale a
blown sky owns the whole panel. The scale is log1p now — an empty bin still
sits exactly on the floor — and the bins go from 1 bin above half to 226.

The second fault was the paint: `.hist-ch` screens its three curves, which is
how light adds up on a dark panel, and it is measured against the backdrop —
so on the light theme (a white plot) it screened every channel straight to
white. Screen is now gated to `[data-theme='dark']`; measured on the light
theme, the plot went from 52 red / 39 green pixels to 931 / 1143 with the grey
fill under them.

The crop corner keeps its 16px square, which is the size a corner reads at,
and grows only its touch target: 44px centred on the node, so the outward half
is clipped by the photo's own layer and the target never overlaps the rect it
would otherwise hand the drag to. Measured: 23x44px reachable around each
node, and a drag from the bottom-right corner's centre still resizes (w 358 ->
297, h 201 -> 167) without moving the frame.

Verified: `npx tsc --noEmit` clean, `npm run build` clean, and every number
above read back off the built bundle in a 390x844 mobile context.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 17:54:45 +07:00
3dtours 3b92e4e2d2 A gradient mask can carry the LIGHT column's white balance now: COLOR TEMP and
TINT, the same two rulers, read on the mask's own pixels.

The pair was asked for as the two rows the develop column already has, so it is
the same pair and not a second opinion about what a kelvin means: the gain comes
from one function, colorUtils.whiteBalanceGain(temperature, tint), which the
frame-wide colour matrix now calls too — the RGB kelvin fit of kelvinToRGB, the
symmetric ±0.08 magenta/green tint, and the division by the product's own Rec.709
luma that keeps a cast from being a brightness move. A mask that never touched
the pair reads 5500K / 0 (readMasks' own defaults, clamped to the ruler's ends),
whose gain is exactly (1,1,1), so nothing moves and every recipe stored before
this reads the same. The measured rule holds inside a mask exactly as it does on
the whole frame: at 10000K the mask's pixels came out R/B 1.25 -> 1.72 against
0.994 -> 0.994 outside it.

The Kelvin fit is a curve, so it is resolved on the JS side and handed over as a
gain: maskUniforms grows one more float4 array (wb[i], three gains and a zero
pad) between the spatial pair and the frame size, in declaration order like every
other array in that buffer, and the shader multiplies the mask's colour by it
right after EXPOSURE — one clamp, three multiplies by one for a mask that leaves
the pair alone, and no second copy of the fit in SkSL. The spatial build is a
second shader text with a second signature and reads the same array.

App.tsx draws the rows where the panel's other rows already are, and TINT rides
the existing maskKnobRow helper: same store unit (±10), same ±100 slider the
frame-wide row wears since the develop knobs were deepened. COLOR TEMP keeps its
own scale (2500..10000, step 100, "10000K"), because a temperature is not a
percentage, and it carries the same swatch the frame-wide row paints.

Verified: npx tsc --noEmit; npm run build; the repo's check set (highlight-knee,
auto-tone, half, white-level, preview-match, library, scan-nav, roll-walk) all
pass; and scripts/mask-wb-check.mjs, which is new here — it transpiles the
gradientMask graph into a temp dir and checks the gain (neutral at 5500K/0, warm
at 10000K, cool at 2500K, luma-preserving, ±TINT symmetric), the clamps, the
uniform offsets (the gain lands where the shader reads wb[i], the frame size one
array further on), and then compiles the real shader through CanvasKit and pushes
a mid-grey through it: neutral leaves 128, 10000K warms it, +TINT magenta-ises it,
and both the plain and the spatial builds and a two-mask list read it. The UI was
driven on the built bundle too (a linear mask dragged on the preview, then the
two rulers moved through their own range inputs): the mask column reports 11 rows,
opens at 5500K / 0, takes 10000K and +40 ±100-scale TINT, the swatch follows, and
the preview's own pixels warm inside the mask and nowhere else.

ponytail: the mask's WB is not skipped for monochrome stocks the way the
frame-wide matrix skips it — a local kelvin on a B&W frame is a tint someone
asked for by hand, not the colour leak that rule exists to stop. Add the same
isMonochromeBase guard (and pass the base filter into maskUniforms) only if that
turn out to read wrong on a live B&W recipe.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 17:35:19 +07:00
3dtours bd57dd72b3 EXPOSURE reads in stops: -5..+5, two decimals, on an unchanged store
The tone spec draws EXPOSURE as `min="-5" max="5" step="0.01"` with a `0.00`
readout, and the app's row was still the original whole-unit knob: -10..+10 in
one step, printed as a bare `+2`. The recipe file behind it has always carried
units where one unit is EV_PER_UNIT = 0.25 of a stop, which is the same ±2.5 EV
travel, so the two are the same range said two ways.

The def now talks stops on its face and units in the store: `min: -5,
max: 5, step: 0.01`, a `twoStops` readout (`0.00`, `+0.50`, `-0.25`, no unit
because the label is the unit), and accessors that translate — `get` multiplies
by EV_PER_UNIT, `set` divides and rounds to 1/10000. EV_PER_UNIT is exported for
that one use. Because the ×10 of `deepen` is not applied here and the store's
field is untouched, a recipe that shipped with EXPOSURE 2 still means the two
units it always meant: nothing gets brighter or darker, and half a stop is
still 2 units in the file. The knob steps by 0.01, which is 0.04 units — the
slider can express a quarter stop where the old one could only express quarter
stops, and everything between them.

TINT and TEMPERATURE keep the web's ranges (tint ±100 on the ±10 store scale,
temperature 2500..10000 K). The spec's ±150 tint and its 2000..50000 K
temperature were reviewed and left as they are: the tint at ±100 is already
what `deepen` publishes and the wider pair would move stored looks.

Verified:
- `npx tsc --noEmit` clean; `npm run build` clean.
- Repo checks re-run green: `highlight-knee-check`, `auto-tone-check`,
  `half-check`, `white-level-check`, `preview-match-check` — the last two render
  real looks through the engine, so a shifted EXPOSURE scale would have shown up
  as a brightness change in recipes that carry a non-zero one.
- Live browser pass on the deployed build: the row reports `min=-5 max=5
  step=0.01`, prints `+1.00` at a full stop, `+0.01` at a hundredth, `-0.25` at a
  quarter stop down and `0.00` at rest, and EXPOSURE +1.00 still lifts the
  graded frame (mean luma 184.3 -> 203.4).

ponytail: the CREATE RECIPES form (RecipeCreatePanel) still edits a sim's raw
adjustments in store units, EXPOSURE included. Leave it there until that form is
asked for stops: it is a store editor, not the develop panel, and every field on
it is in the same units.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 17:09:01 +07:00
3dtours c0aaa67361 Studio: fold WB and FX into LIGHT, and put every develop slider on -100..+100
WB and FX were separate tabs whose only content was the same Lightroom-style
develop column LIGHT already renders, so the rail carried three doors into one
room. LIGHT now owns the whole column: white balance, tone, presence, the
effects group (grain, dehaze, vignette) and the filters, in that order. The
`wb` and `fx` TabIds, their rail entries, their `wbLabel()` helper and the now
dead `tab.wb` / `tab.fx` i18n keys are gone; `ToolRail` documents eight tabs.

Every continuous develop value that the UI exposes as a symmetric knob now
runs -100..+100 instead of -10..+10. `paramDefs` gains the `HUNDRED` key set
and the `deepen` helper, which widens a def's range and scales its accessors by
ten, so the store keeps its -10..+10 internal scale and every stored look,
DEFAULT_RECIPES entry and URL round-trip is byte-identical. Params with a
meaningful physical scale (temperature in kelvin, exposure in EV-ish units,
grain, grain size, hdf, vignette, rotate, crop) keep their own units.

Highlight recovery no longer bends hue. The old knee clamped the per-channel
gain into 0.55..1.35, which is a per-channel operation and therefore a hue
rotation: on the flat skin patch it walked hue from 24 deg to 48 deg at
HIGHLIGHT +100, and on a saturated 30:1 chroma ramp it desaturated toward black
instead of toward white. The shader now caps the pixel's distance from its
undersaturated knee point while preserving the direction of that offset, i.e.
it scales chroma and keeps hue, then clamps into gamut.

Verified:
- `npx tsc --noEmit` clean; `npm run build` clean.
- `node scripts/highlight-knee-check.mjs` (pins the new shader source and
  twin-tests 135 knob combinations) ok.
- Existing checks re-run green: `auto-tone-check`, `half-check`,
  `white-level-check`, `preview-match-check`, `library-check`,
  `scan-nav-check`, `roll-walk-check`.
- Live browser pass: rail shows exactly the seven expected tabs with no WB or
  FX; LIGHT renders 5 panels / 23 `data-key` knobs; 12 knobs report
  `min=-100 max=100`; everything else keeps its own range.
- Highlight hue measured on ten flat colour patches: HIGHLIGHT -100 gives a
  hue delta of 0.00 deg on every chromatic patch; HIGHLIGHT +100 stays within
  0.22 deg (sky) and 0.28 deg (magenta) wherever chroma survives, and the
  patches the ramp intentionally drives to white arrive fully neutral. Greys
  stay neutral (channel spread <= 2/255) at every knob setting. Skin patch
  before/after: 23.94 deg -> 0.00 deg.

ponytail: temperature (2500-10000 K), exposure (+-10 units at 0.25 EV each),
grain, grain size, hdf, vignette, rotate and crop deliberately keep their own
scales rather than the blanket -100..+100; widen them the day a user asks for
more range, not before. Hue assertions live in the flat-patch check because
real-photo measurements pick up 2-4 deg of resample drift from the snapshot
pipeline that has nothing to do with the shader.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 17:04:08 +07:00
3dtours 34f8601c91 studio: the develop column becomes five panels, and the four tone knobs move knots instead of channels
Two specs, one commit: the develop state becomes the panel column the Lightroom
spec draws, and HIGHLIGHT, SHADOW, WHITE and BLACK stop being edits and become
shapes of the tone curve, the way the mapping spec measures them.

The column. The left rail used to hand LIGHT a row of chips and nothing else;
the four tone knobs were chips that opened a curve, and the rest of the develop
state lived in the chip row's own vocabulary. `DevelopPanels` renders the five
sections of the spec instead — PROFILE, WB, TONE, PRESENCE, DETAIL & EFFECTS —
as an accordion, all open, and every parameter the recipe holds has a row in it
with a `data-key` off the parameter name: slider, value readout, double-click to
default. Sliders are always visible, so a knob is one drag away instead of two
taps, and TEMPERATURE and TINT draw their gradient underneath (blue through amber,
green through pink) so the direction is on the control. The PRO looks the spec
marks stay in the list but locked, tagged PRO, and tapping one asks for PRO —
they are shown, not hidden, and not silently dropped.

WHITE and BLACK move out of the WB group. They were the temperature group's
extremes, which is what a white balance control does — the toe and the shoulder
of the same ramp — but the spec puts them with the tone knobs and gives them the
two ends of the tone curve, and that is what they now are. `wb` is TEMPERATURE
and TINT and nothing else; `whites` and `blacks` sit in `iq` beside `highlights`
and `shadows`, labelled WHITE and BLACK, in the tone panel where the slider lives.
A recipe written before this commit still reads: the keys are unchanged.

The four knobs. The first pass of the mapping spec added a mask per zone onto the
channel: `luma += knob * mask * intensity`, and the shader followed it. It is the
wrong shape, and the twin harness in `highlight-knee-check.mjs` shows why — the
four masks are not a partition of the ramp. They sum to one at the ends and to
zero at the midpoint, so an adjustment in the middle of a zone is applied where
the mask is half and not at all where the mask has fallen to nothing, and the
ramp inverts: with every knob at its stop the curve folds over itself, slope −5
at t=0.87, and the twin catches it as a non-monotone ramp.

So each knob moves a knot on the curve instead, which is the reading the spec's
own mask geometry points at — BLACK peak at 0.00, SHADOW 0.00→0.25→0.50,
HIGHLIGHT 0.50→0.75→1.00, WHITE peak at 1.00 — and the shader builds the curve
through those four anchors. `TONE_ANCHOR` is 0.25: one full knob at its stop is a
quarter of the range at that knot, so the range is 0.75..1.00 at the top and
0.00..0.25 at the bottom, and the anchors stay ordered (`a0 ≤ a1 ≤ 0.5 ≤ a3 ≤ a4`)
by clamping each against its neighbour. Between knots the curve is a straight
line, and 0.5 is untouched by every knob, so a knob at zero is the identity
exactly rather than nearly, and any combination of the four is monotone. The mask
sum survives where the spec is right about it: it hints the split between the two
dark zones and the two light ones, nothing else.

The hue is kept the way the spec keeps it: work in luma, then scale the chroma
offset — `rgb = luma_new + (rgb - luma_old) * luma_new / luma_old` — so a
saturated red stays the same red and only its brightness moves. The ratio is
clamped to 0.55..1.35 because at luma near zero the division is the whole
highlight of the picture on one code value.

Verified:

- `node scripts/highlight-knee-check.mjs` passes. It pins the settled shader —
  four masks, four anchors, the four `mix` lines — and asserts the constructions
  it replaced are gone, then drives a twin of the ramp in JS: the masks do not
  overlap, every knob at zero is the identity, the midpoint is 0.5 for all 162
  combinations of the four knobs, every combination is monotone, the amplitude at
  each stop is a quarter, and the DR offsets land on 0.12 and 0.82. The folded
  case from the additive build is in the harness as a regression.
- `npx tsc --noEmit` clean; `npm run build` emits `index-DXIIw2F1.js` and
  `index-A4pA1U5f.css`; `library-check.mjs`, `scan-nav-check.mjs`,
  `roll-walk-check.mjs`, `auto-tone-check`, `half-check`, `preview-match-check`
  and `white-level-check` all pass against the bundle — the catalogue, the RAW
  path, auto tone and the white level are untouched by the panel move.
- Driven in a real browser (`tone-live-check.mjs`, Chromium against
  `vite preview`, a P1010256.JPG in the source control, mean luma of the preview
  canvas read before and after each knob): neutral 184.25, WHITE +1 187.35,
  BLACK +1 186.35, SHADOW +1 194.53, EXPOSURE +1 206.99, HIGHLIGHT −1 173.80.
  Every knob moves the picture the way the spec says it should and none of them
  moves it much — a stop of a knob is a quarter of a zone, not a level.
- The same run asserts the built DOM: five panels, the 23 `data-key` rows,
  `dev-temperature` in WB, `dev-whites`, `dev-blacks`, `dev-highlight` and
  `dev-shadow` together in TONE, the gradient classes on the two white balance
  sliders, and the chip slots the panel is handed. The only failed request is
  `/api/events`, which is the backend this preview does not run.

ponytail: the recovery of blown highlights that used to sit under HIGHLIGHT — a
per-channel rolloff in linear light — is gone, deleted rather than ported. The
additive mask is why it was there: HIGHLIGHT had to do two jobs because a mask
could not shape a curve. Now that WHITE owns the top end, HIGHLIGHT only bends,
and the per-channel rolloff is a second knob for the same picture. Bring it back
as its own parameter if a frame ever clips badly enough to need it.

Also dropped: DR used to ride along as two additive terms. That is where the fold
at t=0.238 came from, BLACK −1 and SHADOW −1 together — the two terms pushed the
ramp past its own end. It shifts the knots now, which is what the film sims
always meant by it, and the numbers in the sims were kept and their meaning
recommented (classic-chrome toe 0.22, head 0.7375, etc.).

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 16:38:56 +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 426488a788 studio: say the roll is still being read while the catalogue is off screen
The reading of a folder belongs to the tab, not to the catalogue screen: it was
made to survive the hand-over to the studio in 753eea0, and it does. What was
missing was any sign of it up there — a visitor who hands a half-read roll to the
studio saw a page that said nothing about the frames still landing, and had no
way to tell a reading in flight from one that had quietly died. The one thing
that did say so, the toolbar line, lived on the screen they had just left.

The header now carries it, immediately left of the way back into the catalogue,
where the two belong together: the ring the catalogue already uses for a roll in
hand, and the count the toolbar states, "4/12", whose title is the same
"Scanning 4/12 — 0 new…" line in the visitor's language. It reads the session
through scanSession() and watches it with watchScan() exactly as the catalogue
does, so a pass that moves the progress moves the header too — the session object
outlives them both, only its progress is replaced each pass. No new state is
introduced, no new copy: the markup borrows .lib-spin and the lib.scanning key
the catalogue already had.

Verified: tsc --noEmit and vite build clean; scripts/scan-nav-check.mjs grew one
step at the first hand-over, "the studio header shows the reading the tab is
doing", which waits for [data-key="studio-scan-count"] mid-scan and matches it
against \d+/\d+ — it passes with 4/12, the same reading the toolbar shows at that
moment; the other 18 steps of that check, the 52 steps of library-check.mjs and
roll-walk-check.mjs all still pass.

ponytail: the header states progress, it does not offer to stop the scan; the
catalogue's own STOP stays the one place that ends a reading. Add a control here
when someone asks to stop a roll from the studio.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 10:55:07 +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 d7241531f9 web: a reading walks past the recycle bin, not into it
The walk already turned its back on the folders a camera and an editor leave
behind: a name beginning with `.` or `@` is skipped, which is `.thumbnails`,
`.git`, `.Trash`, and the `@eaDir` a Synology writes beside every frame.

An external volume drags along a second set of names, and none of them begins
with a dot, so every one of them was walked. `$RECYCLE.BIN` and `RECYCLER` are
where Windows parks what the reader deleted — frames among them, at full size,
and every one of them decoded into a thumbnail on the way in. `System Volume
Information` is Windows' own bookkeeping. `#recycle` is the Synology share, and
`lost+found` is the directory a Linux volume keeps for repairs. A frame that
comes back out of a bin is a frame the reader threw away, and the pictures in a
share's recycle folder are pictures someone else deleted; neither belongs in
the catalogue, and both cost the reading the same seconds a real frame does.
The folder also stood in the column as a row of its own.

The rule is now one `SYSTEM_DIR` expression over both sets, matched
case-insensitively — the same volume spells the bin `$RECYCLE.BIN` on one drive
and `$Recycle.Bin` on the next — and the walk asks it before it looks at what
the entry is, so a refused folder is neither read nor named.

Verified:
  roll-walk-check.mjs — all passed, with two new assertions: the stand-in roll
    now carries `$RECYCLE.BIN`, `$Recycle.Bin`, `RECYCLER`, `System Volume
    Information`, `#recycle` and `lost+found`, and every one of them holds a
    file named like a frame, so nothing about the files can be what keeps them
    out. No frame is read from them, and none of them reaches the column.
  library-check.mjs — 43 steps, all passed. scan-nav-check.mjs — all passed.
    frontend tsc --noEmit clean.

ponytail: the list is the names the volumes being read actually use, not a
guess at every spelling there is — a box that files its junk under something
else gets a line in that expression, which is the whole of the change. A folder
of the reader's own that happens to be called `#recycle` is skipped too; that
name is worth the trade.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 07:10:41 +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 36cd711302 web: the header wears the theme, and the rail says which build it is
Two things the studio could not say about itself.

The preset the header has open was painted in the dim text colour, the same grey
as the page name beside it — so the one label up there that changes with the look
read as chrome. It now takes the theme's own accent, the colour the brand mark
and the open tab already carry, which means it follows both the light/dark mode
and the accent group the reader picked, out of the CSS token and with no colour
written into the component.

And an image has no other mark on it: once the frontend tarball is loaded on the
NAS as `:latest`, nothing on the box says which revision came off. The rail now
names the build at its foot — quiet, mono, wrapping rather than widening the
column, and gone under 860px where the rail is the phone's scrolling tab bar
instead of a column.

The name is stamped into the bundle at build time: vite.config reads VERSION
from the environment (`docker compose build frontend --build-arg
VERSION=$(git rev-parse --short HEAD)`) and falls back to the package version
plus the minute it was built, so two builds of the same tree are never the same
name. `ARG VERSION=` in the Dockerfile is the knob; the bare `npm run build` —
dev server, check scripts — still stamps its own time, and the dev server passes
nothing at all, so the line only draws when there is something to say.

Verified:
  library-check.mjs — 37 steps, all passed, the two new ones reading the header
    off the running studio: the rail foot names the build (0.1.0+202609281504)
    and the preset chip's computed colour is the brand's accent, not a grey —
    rgb(206, 117, 9) amber, rgb(93, 24, 191) after switching to violet.
  scan-nav-check.mjs and roll-walk-check.mjs — all passed. frontend tsc --noEmit
    clean. Live 8090 on index-… matching dist/: /, /library and /app 200 with 0
    console errors.

ponytail: the name is the package version plus a caller-supplied tag, and nothing
bumps the package version, so the tag is the whole identity — have the release
job write it into package.json if the numbers ever need to mean something.
2026-09-28 22:06:22 +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 134489a5ec web: come back to the frame that was raised, and zoom it by the wheel
Two things the preview owed a reader.

The frame raised in the strip outlived nothing: leaving for the studio and
coming back dropped the choice and the node opened on its first negative
instead. It is now remembered beside the folder and the column width — the
same one-line store, read on the way in, written on the way out — and a
frame that is gone from the catalogue falls through to the first, the way
a node that is gone falls back to its folder.

A wheel over the frame now magnifies it. The listener goes on the element
rather than through onWheel, because React's own wheel is passive and this
one has to hold the page still while it zooms; the origin is the point
under the pointer, which is what keeps a reader's aim on the thing being
looked at, and the step comes from how far the wheel turned so a notched
mouse and a trackpad travel the same. Six times is where it stops — the
thumbnail behind the preview has no more pixels to give — and a frame that
has just come up is fitted again.

library-check: 35 steps, the two new ones raising a nested frame, reloading
on it, and ticking the wheel up and back down.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-28 21:43:36 +07:00
3dtours 067b32131e web: drop the heading over the folder column
The column carried the name of the folder its tree belongs to, painted
above the rows. The rows already say it — the head of the tree is a row
like any other — so the heading only said it twice, and because it was
not a row it stayed on screen when the tree was folded away: the one
label left with nothing under it. The right click that raised it now
finds the row itself, which is where a reader aims anyway.

The label, its menu hook, its translation and its rule go; the tree, the
row menus and fold-all keep working as before.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-28 21:27:55 +07:00
3dtours 5e28b42067 web: fold all keeps the top-level row open
COLLAPSE ALL folded the top-level row as well, and that row is already its own
fold: one click on its name shuts it and takes the whole subtree with it. Folding
it from the menu hid every row of the tree — the item repeated a click the reader
already had, while the one thing a click there cannot reach, the folders nested
below, was the part that got lost with it.

So the item folds the folders nested below the top level: every row with a
subfolder under it that is not itself a folder picked at the top level. The
top-level row stays drawn and stays open, its own subfolders are drawn folded,
and no top-level row ever leaves the column. `nested` is computed off `parents`
once, and the item is disabled on a folder with nothing nested under it.

Verified:
  library-check.mjs — 35 steps, all passed. The fold step now reads the rows the
    click leaves behind instead of a row count: the fully open roll (4 rows)
    becomes exactly CheckRoll aria-expanded=true, CheckRoll/2026
    aria-expanded=false, CheckRoll/Empty (a leaf, so no aria-expanded) —
    CheckRoll/2026/04 is away, the top-level row is not — with the strip's 2
    tiles unmoved and the row menu still leading with lib-menu-collapse.
  frontend tsc --noEmit clean. Live 8090 on index-DAIpyDNV.js matching dist/:
  /, /library and /app 200 with 0 console errors.

ponytail: with several folders picked, one COLLAPSE ALL folds the nested rows of
all of them, not only the row that was clicked — no top-level row is hidden
either way; scope it to the clicked folder's prefix when a column routinely
holds many picked folders.
2026-09-28 21:23:56 +07:00
3dtours 4a740b1e28 web: fold the tree from the row a reader right-clicks
COLLAPSE ALL lived only on the column's own name — the label above the tree —
and that label scrolls with the list it heads: on a roll read deep the column is
scrolled past it, so the item looked like it only appeared once the top-level row
had been folded to bring the list back to the top. The row is where the pointer
already is, and it already carries the folder's menu.

The top-level folder row now leads its own menu with COLLAPSE ALL — rename,
rescan and remove follow it, and a subfolder row is unchanged, since folding a
whole tree is what a row that a tree hangs from can do and a row inside one
cannot. Both entry points run the same one: `root` on the menu state now means
"the head of a tree", the column's own name or the row of a folder picked at the
top level, and the menu draws the item first and then whatever the subject
itself has — the folder's items, or, on the empty part of the column, ADD
FOLDER. The empty part keeps ADD FOLDER alone: it is not the head of a tree.

Verified:
  library-check.mjs — 35 steps, all passed. The new step right-clicks
    lib-node-CheckRoll with the tree fully open (4 rows) and gets back exactly
    [lib-menu-collapse, lib-rename-CheckRoll, lib-rescan-CheckRoll,
    lib-drop-CheckRoll]; a right click on the column's name still answers with
    lib-menu-collapse alone; the subfolder menu above is still the three folder
    items with no fold in it; the empty column still offers lib-menu-add alone.
    The fold itself is unchanged and still measured by opening the root back up:
    4 rows → 1, and 3 back, with CheckRoll/2026/04 still away.
  frontend tsc --noEmit clean. Live 8090 on index-DEvUJDNY.js matching dist/:
  /, /library and /app 200 with 0 console errors.

ponytail: the column's name is still the second way to the same item and still
scrolls with the list — left alone because the row is now the one that matters;
make the name sticky when a roll routinely fills the column past one screen.
2026-09-28 21:20:23 +07:00
3dtours c1ee2cf30a web: fold the whole roll from the head of the tree
A roll read to the bottom of its dates is a long column of indented rows, and
the only way to shut it was one row at a time — the click that folds a row also
opens it, which is the right trade for reading and a poor one for putting away.
The column's own name is now the whole tree's control: a right click there opens
a menu whose one item is COLLAPSE ALL / THU GỌN TẤT CẢ, and it folds every row
that has something under it at once — the root's own row included, so the tree
comes back to one line per picked folder and the subfolder two deep is away with
the rest. The menu is the folder menu's shape at a third subject rather than a
second menu: one `root` flag on the state that already knows where a right click
landed, and the same Escape, same click-away, same clamp to the window edge.

The frames do not move with the rows: folding is a way of looking at the tree,
and the strip already reads the open folder, which the fold leaves alone. The
name keeps reading as the folder it belongs to — the folder's label, with the
hint appended to its title — and the item is disabled rather than hidden when
nothing in the column has children, so a fresh folder does not open a menu with
a dead line in it.

Verified:
  library-check.mjs — 34 steps, all passed, 2 new: a right click on the root
    name offers exactly [lib-menu-collapse] while the name still reads "Roll A",
    and one click takes the fully open roll (4 rows: the picked folder, its
    subfolder, the subfolder under that, and one holding no frame) to a single
    row. The fold is measured against the ruler of opening the root back up:
    the 3 rows that return are CheckRoll, CheckRoll/2026 and CheckRoll/Empty —
    CheckRoll/2026/04 is still shut, which is what says the deep row went with
    the fold and not just the root. The strip held its 2 tiles throughout, and
    the folder the visit was on (lib-node-CheckRoll/2026) is what the reload
    below still reopens on.
  scan-nav-check.mjs — all steps passed; roll-walk-check.mjs — passed.
  frontend tsc --noEmit clean. Live 8090, served asset index-DZFlTBDG.js
  matching dist/: /, /library and /app all 200 and 0 console errors.

ponytail: only folding, no unfold-all to match — the click on a row already
opens it and the root's own row is one click away; add the pair when the column
routinely holds many picked folders and opening them one by one stops being
cheap.
2026-09-28 21:03:30 +07:00
3dtours 291968a342 web: give the hero's award column a frame on a quiet week
The landing's award column read two windows of the clock — today's votes and
this ISO week's — and both of them empty meant no column at all: no
[data-key="lp-award"], and the hero back to a single block of copy. That is the
normal state of the install, not an edge case. On the live data the newest vote
is 2026-09-22T09:48 and this week's window opened 2026-09-28T00:00, so
GET /api/highlights answered {"day":[],"week":[]} with HTTP 200 while
/api/photos handed out fifteen frames: the query was right and the clock had
simply moved past every vote.

So /api/highlights grows a third list, `ever`: every vote ever cast, top-rated
first, computed only when both windows come back empty — a day with one vote in
it pays nothing for the extra query. The column draws day, then week, then ever,
deduped by photo id, so a frame keeps the tighter claim it won. The label
follows the frame: `ever` claims no window — "Most-loved recipe" / "Công thức
được yêu nhất" — because dressing a month-old best as today's is a lie the page
does not have to tell. A vote in the window still reads "Recipe of the day" or
"Recipe of the week" exactly as before.

Verified:
  backend npm test — 183 passed, 0 failed. Three new checks in the ratings
    section: a vote older than both windows leaves them empty, the all-time
    bests stand in for them, and with nothing ever rated there is no frame at
    all. The aged vote is backdated in the throwaway database, because both
    windows are the clock's and no API path can cast a vote in the past.
  Live: GET /api/highlights returns `ever` with five frames (ids 12,13,51,50,3,
    every one avg 5, n 1) and 8090's landing draws .lp-award 440px wide beside
    the copy — 5 slides, 1 standing, 2 arrows, label "Most-loved recipe", score
    "5.0★1 vote", meta #LC_LANDSCAPE_VIVID. The column stepped slide-12 →
    slide-13 across one 5s clock, and the page logged 0 console errors.
  frontend tsc --noEmit clean; the served asset index-DPZxk4Vo.js matches dist/.

ponytail: the fallback is a flat all-time list, not a walk back through the
weeks to the newest one that has votes — the query stays one line and the label
stays honest; when the column should also read "Recipe of last week", walk the
windows back instead.
2026-09-28 20:57:32 +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 bd206a8be9 web: let a wheel tick walk the strip sideways 2026-09-28 19:44:25 +07:00
3dtours 2c78ff80e8 web: fold a folder's rows without folding its frames 2026-09-28 19:34:37 +07:00
3dtours 2b45419ec4 web: scan the whole roll, title and size the column, reopen where you left
The tree column now walks every folder under the one you picked instead of
its first level, so a roll of dated folders inside dated folders is walked to
the bottom. A folder being read turns a ring on its own row, the column is
titled with the folder the tree belongs to, and a divider beside it drags the
width, which is remembered along with the folder the screen was left on.
2026-09-28 18:53:07 +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 19fd5b644f web: trim the library to one toolbar and a folder menu
Fold the hint, ADD FOLDER and the two view switches onto a single row,
with the switches drawn as icons, and move a folder's rescan and remove
onto a right click — they belong to the folder, so they are asked for
rather than always on screen.
2026-09-28 18:31:06 +07:00
3dtours 12936731b3 web: keep the catalogue on one screen
Pin the thumbnail strip to the bottom edge of the window, let the wall
of thumbs scroll inside the stage with its own scrollbar, and letterbox
the preview so the frame fits the screen instead of growing the page.
2026-09-28 18:21:22 +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 7ba1e64e7c docs: record the local-catalogue decision, and what it is not 2026-09-28 17:51:42 +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
3dtours 0f2e109aa2 web: make the studio installable, and give it a shell that opens offline
The three things a browser asks for, without a plugin: a manifest in
public/ (name, /app as the start, three icons cut from the one piece of
art this repo has), a service worker, and the two metas iOS reads
instead of the manifest.

The worker caches the shell — /, /app, /library, all one document under
the SPA fallback — and the hashed assets the build emits. A navigation
is network-first, so a deploy is never pinned behind the cache; a
hashed asset or the wasm is cache-first, because under a given build
those never change. /api and any non-GET go straight out: a worker is a
cache, not a proxy. nginx serves sw.js and manifest.json `no-cache`
(both names outlive their contents) with the isolation headers the
worker script needs under COEP.

The offer is the app's own dialog, not Chromium's mini-infobar: the
event is held, and it is spent either after the visitor has been in the
studio two minutes or the moment an export lands — the point at which
the app has done their work. Safari never fires the event, so it gets
the Share > Add to Home Screen line instead. A refusal is remembered and
never asked again.

  node scripts/make-icons.mjs       192x192 39785B / 512x512 159296B / maskable 512x512 123723B
  node scripts/pwa-check.mjs        manifest 3 icons · worker activated · shell cached
                                    · offline reload of /app paints
  off (https://localhost:8090)      same four, through nginx
2026-09-28 17:46:08 +07:00
3dtours 3312facd82 docs: write down the camera port and the live-view plan
CAMERA_PORT_ANDROID.md lists only what the web build has that Android does
not, one row per gap, with the web file to read next to the Android one.
The plan alongside it is the sketch for a live camera with a Skia filter
on the frame thread.
2026-09-28 17:36:30 +07:00
3dtours 8dc409777e web: keep the probe that develops a raw in a bare page
A page with no React, no store and no router around it: hand
/src/engine/skiaShim.ts a file, develop it, and read the plane back. It is
the shortest path to the answer when the stage looks wrong and the question
is whether the engine or the app around it is at fault.
2026-09-28 17:36:28 +07:00
125 changed files with 30660 additions and 1489 deletions
@@ -0,0 +1,106 @@
# Kế hoạch: Real-time Skia filter trên camera live view dùng vision-camera v4
## Vấn đề
`expo-camera` render camera preview lên native surface (PreviewView/SurfaceView) không thể intercept bằng Skia. Periodic capture bị từ chối vì mất live view.
## Giải pháp
Dùng `react-native-vision-camera@4.7.3` với `useSkiaFrameProcessor` — frame processor chạy native thread, mỗi frame camera được vẽ qua Skia với ColorMatrix trước khi hiển thị.
## Các bước thực hiện
### Bước 1: Cài react-native-vision-camera@4.7.3
```bash
npm install react-native-vision-camera@4.7.3
```
- v4.7.3 là latest v4, có export `useSkiaFrameProcessor` và `frameProcessor` prop trên `Camera`
- v5+ đã xoá Skia integration, không dùng được
### Bước 2: Thêm Expo Config Plugin
Sửa `app.json`:
- Thay `expo-camera` plugin bằng `react-native-vision-camera` plugin (v4 có plugin riêng)
- Thêm `withEnableFrameProcessorsAndroid` để bật frame processor trên Android
```json
"plugins": [
"react-native-vision-camera",
[
"expo-camera",
{
"cameraPermission": "..."
}
]
]
```
Cần kiểm tra: v4 có expo plugin riêng (`withVisionCamera`) hay dùng thẳng tên package.
### Bước 3: Cập nhật App.tsx
- Import `useCameraPermission` từ `react-native-vision-camera` thay vì `useCameraPermissions` từ `expo-camera`
- Props: `hasPermission`, `requestPermission`
### Bước 4: Viết lại Viewfinder.tsx
Component mới dùng:
- `Camera` từ vision-camera với prop `frameProcessor={frameProcessor}`
- `useSkiaFrameProcessor` hook — worklet chạy native, mỗi frame:
1. Tạo `Skia.Paint()` với `Skia.ColorFilter.MakeMatrix(colorMatrix)`
2. Gọi `frame.render(paint)` — vẽ frame qua color filter
- `colorMatrix` là `SharedValue` từ react-native-reanimated (để worklet đọc được)
- Grain noise, frame border, GPS watermark: giữ nguyên overlay React Native / Skia Canvas trên top
```tsx
const App = () => {
const frameProcessor = useSkiaFrameProcessor((frame) => {
'worklet';
const paint = Skia.Paint();
paint.setColorFilter(Skia.ColorFilter.MakeMatrix(colorMatrixSV.value));
frame.render(paint);
}, [colorMatrixSV]);
return (
<Camera
style={StyleSheet.absoluteFill}
device={device}
isActive={true}
frameProcessor={frameProcessor}
/>
);
};
```
### Bước 5: Loại bỏ expo-camera khỏi code
- `CameraView` → `Camera` (vision-camera)
- Xoá imports không dùng
- Giữ `expo-camera` trong package.json (không gỡ, tránh lỗi autolinking)
### Bước 6: Re-prebuild & build APK
```bash
rm -rf android
npx expo prebuild --platform android
echo "sdk.dir=/home/locpham/android-sdk" > android/local.properties
cd android && ./gradlew assembleDebug
```
(Không chạy `clean` — tránh lỗi CMake codegen paths)
## API đã xác nhận (v4.7.3)
| Export | Path |
|---|---|
| `Camera` | `react-native-vision-camera` |
| `useCameraDevice` | `react-native-vision-camera` |
| `useCameraPermission` | `react-native-vision-camera` |
| `useSkiaFrameProcessor` | `react-native-vision-camera` |
| `SkiaCameraCanvas` | `react-native-vision-camera` |
| Expo plugin (`withVisionCamera`) | `lib/module/expo-plugin/withVisionCamera` |
## Rủi ro & Xử lý
| Rủi ro | Xử lý |
|---|---|
| `react-native-worklets-core` conflict | Đã có sẵn qua `react-native-reanimated` dependency |
| CameraX dependency (`NoClassDefFoundError`) | v4 dùng `CameraX` không phải Nitro — build an toàn hơn v5 |
| CMake codegen paths lỗi sau `clean` | Không dùng `./gradlew clean`, dùng `--rerun-tasks` nếu cần |
| Frame processor không chạy trên Android | Bật flag qua Expo plugin: `withEnableFrameProcessorsAndroid` |
## Kiểm thử
1. Build APK thành công
2. Camera live view hiển thị với filter:
- B&W/monochrome → true grayscale
- Saturation, temp, tint, exposure thay đổi real-time
3. Grain noise, frame border, GPS watermark vẫn hoạt động
4. Library mode không bị ảnh hưởng
+34
View File
@@ -0,0 +1,34 @@
# ADR — Local catalogue, không cloud sync
Trạng thái: chấp nhận · 2026-09-28 · nhánh `recipes-web`
## Bối cảnh
Studio PWA (`docker/frontend`) đọc thư mục trên máy người dùng qua File System Access API.
Bản nháp thiết kế `lightroom_web_app.md` giả định endpoint `/v1/sync/edits` và auth
`Authorization: Bearer localStorage['pro_user_token']`. Backend thật (Fastify + SQLite) chỉ biết
ảnh upload qua `/api/photos` (cap 12/tài khoản) và recipe qua `/api/recipes`; auth bằng cookie
`SESSION_COOKIE` HttpOnly. Không có `photoId` phía server để gắn edit vào — endpoint đó không
thể tồn tại. Tính năng được yêu cầu là *quản lý local*, nên phần sync là YAGNI.
## Quyết định
1. **Freeze scope:** catalog và edit chỉ nằm trên máy người dùng. Không thêm bảng, không endpoint sync.
2. **IndexedDB `recipescam-library`** (version 1), ba store — theo đúng `src/engine/library.ts`:
- `folders` — keyPath `name`
- `photos` — keyPath `id` = `${folder}/${name}`, index `folder`, `taken`
- `edits` — keyPath `photoId`, `{ recipe, updatedAt }`
3. **API cùng origin** qua nginx proxy `/api/` → `fetch(..., { credentials: 'same-origin' })`
(`src/api.ts`). Không `'include'`, không Bearer.
4. **Nhận diện ảnh:** trước mắt chỉ `folder/name`. Khi cần sync thật: partial hash
(first + last 64 KiB + size), và chỉ hash những frame *thực sự có edit*. Lưu ý `scanFolder`
vốn đã đọc trọn bytes mỗi RAW cho `rawThumbnail`, nên "hash đắt vì phải đọc 30–50 MB" không
phải lập luận đúng; cái làm `filename+size+mtime` mất map recipe là rename và copy từ card
(mtime đổi, bytes y hệt).
## Hệ quả
- Không có gì để "cắt Cloud Sync": trong repo chưa từng có code sync.
- Nếu sau này cần: thêm `library_edits (user_id, key, recipe, updated_at)` + `GET/POST
/api/library/edits` (upsert theo batch, LWW theo `updated_at`), client flush khi `online`,
xác thực bằng cookie session, key = partial hash. Ước ~60–80 dòng.
+258
View File
@@ -0,0 +1,258 @@
# Camera trực tiếp: chỉ port những gì web có mà Android chưa có
Phạm vi tài liệu: **chỉ liệt kê delta web-only**. Mọi thứ Android đã có thì ghi nhận một dòng rồi bỏ qua,
không mô tả cách làm lại.
- Web (tham chiếu): `docker/frontend/` — Vite/React/TS, CanvasKit WASM. Camera thêm ở `cb1839e` +
`6814b05`.
- Android (đích): `App.tsx` + `src/` ở gốc repo — Expo/RN, `react-native-vision-camera-skia` (worklet +
GPU texture).
Quy ước trích dẫn: cột **Web** trỏ vào file trong `docker/frontend/`; cột **Android** trỏ vào `App.tsx` /
`src/` ở gốc repo.
**Tóm tắt:** web-only đáng port chỉ có **3** việc — P1 (quan trắc FPS/độ phân giải), P2 (gate PRO ở lối
vào camera, *cần quyết định*), P3 (luồng "chụp rồi chỉnh"). Web-only còn lại hoặc không port được, hoặc
Android đã có bản tương đương. §6 ghi thêm phương án thay thế: đóng gói chính web studio thành app mobile.
---
## 1. Đối chiếu tính năng — cái nào thực sự web-only
| | **Web** | **Android** | Delta |
|---|---|---|---|
| Lối vào | Nút `OPEN CAMERA` + badge `PRO` (`ui/ImageStage.tsx:1443-1444`, `:2002-2006`) | Chế độ `mode === 'camera'` (`App.tsx:1675`, `:1978`) | khác chỗ đặt, không phải thiếu |
| Gate | **Khoá cả camera** sau PRO: `cameraSupported() ? () => (pro ? setShooting(true) : promptPro()) : undefined` (`App.tsx:3237-3241`), `pro = !!user?.verified` (`:522`) | Gate ở **lúc render look**: `proLookInUse` (`App.tsx:1269`), `promptPro` (`:1275-1285`). `CameraControls.tsx` không có `pro` nào | **WEB-ONLY → P2** |
| Số đo trên viewfinder | `{w}×{h} · {fps} fps` (`ui/CameraView.tsx:214-218`, `i18n/en.ts:40`) | Không hiển thị gì (nhịp tim `frameBeatSync` chỉ phục vụ watchdog) | **WEB-ONLY → P1** |
| Ảnh chụp đi đâu | Still **full cảm biến → studio**; look do studio áp, file ghi sau bằng export chung (`docker/frontend/src/App.tsx:902-918`) | Render look **ngay** → file + gallery + `pushPhoto`; **không mở editor** (`App.tsx:1476-1489`) | **WEB-ONLY → P3** |
| Xem trước khi chỉnh | Cùng `renderPhoto`, khác đích ra (`drawTo` so với `encodeToBytes`) | Cùng SkSL, chạy trong worklet | tương đương |
| Lens | Chỉ `facingMode user/environment` (`engine/camera.ts:12-14`) | Flip, zoom 1–10x, ultra-wide 0.5x qua module Camera2 ẩn (`Viewfinder.tsx:599-713`), RAW DNG (`App.tsx:337-340`) | Android hơn |
| Panel khi đang ngắm | Rail + cột PRESETS vẫn cạnh viewfinder (`.stage-slot`, `styles/app.css:723-750`) | Overlay dp + panel ngay dưới khung (`Viewfinder.tsx:2774-2819`) | tương đương |
| Chuỗi UI | i18n EN/VI, 9 khoá `cam.*` (`i18n/en.ts:36-44`, `i18n/vi.ts:45-53`) | Hardcode tiếng Anh (app không có hạ tầng i18n) | khác phạm vi, xem P6 |
| Chống treo phiên | không cần (rAF tự dừng/tiếp) | Watchdog remount sau ~6 s không có khung (`Viewfinder.tsx:1846-1900`) | Android hơn |
---
## 2. Delta kỹ thuật theo lớp
| Lớp | Web | Android | Kết luận |
|---|---|---|---|
| Nhận biết khả năng | `navigator.mediaDevices?.getUserMedia` + yêu cầu **secure context** (`engine/camera.ts:27-32`) | `useCameraPermission()` (`App.tsx:322`), manifest `CAMERA` | web-only, **không port** (không có khái niệm tương ứng) |
| Phân loại lỗi | `err.name === 'NotAllowedError'` → `cam.denied`, còn lại `cam.fail` (`CameraView.tsx:176-181`) | 3 alert riêng: `Permission Needed` / `Camera Not Ready` / `Capture Failed` (`App.tsx:1338`, `:1348`, `:1499`) | ngữ nghĩa đã tương đương |
| Mở phiên | `getUserMedia` → `<video>` **phải nằm trong DOM** (Safari vẽ trắng nếu tách) → `.camera-source-host` 1×1 px (`CameraView.tsx:137-141`, `app.css:764-777`) | `isActive={appActive && !ultraWideOn}` (`Viewfinder.tsx:2876`), `onStarted/onStopped` (`:544-554`) | web-only, **không port** |
| Đóng phiên | Stop mọi track + `srcObject=null` + `remove()` (`engine/camera.ts:62-67`) | Unmount (đổi `mode`/`key`) + `frame.dispose()` (`Viewfinder.tsx:1489`) | tương đương |
| Độ phân giải preview | `ideal 1280×720`, **không bao giờ `exact`**; lý do **nhiệt** (`engine/camera.ts:3-7`, `:18-20`) | Chọn 4:3 gần 1280×960 từ `getSupportedResolutions('stream')`; lý do **hình học** (`Viewfinder.tsx:400-420`) | Android pin tỉ lệ tốt hơn; phần *lý do nhiệt* là web-only → **P5 (hoãn)** |
| Đổi giữa phiên | Buffer chỉ resize khi **aspect** đổi > 0.01; cùng aspect thì scale qua scratch canvas (`CameraView.tsx:67-117`) | Chỉ có bản hẹp cho handoff 0.5x (`uwCover`+`holdCover`, `:628-713`); remount thì màn trống tới khung đầu | **web-only, một phần → P4** |
| Render preview | vòng `requestAnimationFrame` → `MakeImageFromCanvasImageSource(video)` → `renderPhoto({sourceImage, drawTo})` → `readPixels` → `putImageData` (`CameraView.tsx:143-173`, `:76-118`) | worklet đồng bộ vẽ thẳng lên GPU texture: color matrix + CLARITY + `toneEffect`→`cinemaEffect`→`glowEffect` qua `canvas.drawRect` (`Viewfinder.tsx:1177-1371`) | khác cơ chế vì khác nền tảng; **không port** |
| Slider → khung kế | `liveRef` gán lại mỗi render, vòng lặp đọc lại mỗi khung (`CameraView.tsx:60-62`, `:158`) | `createSynchronizable` + `getDirty()` trong worklet (`Viewfinder.tsx:479-528`, `:1149-1170`) | tương đương |
| Nhịp khung | `busyRef`: một render tại một thời điểm, khung tới khi bận thì **bỏ, không xếp hàng** (`CameraView.tsx:12-15`, `:152-172`) | Worklet đồng bộ, không có hàng đợi để phình | **không port** |
| Quan trắc nhịp | Đếm khung trong cửa sổ 500 ms rồi in (`FPS_WINDOW_MS`, `CameraView.tsx:19`, `:143-151`) | Không có số nào | **WEB-ONLY → P1** |
| Màn trập | 3 mức: `takePhoto` → `grabFrame` → copy 2D (`engine/camera.ts:69-96`) | `photoOutput.capturePhotoToFile` (+ `UltraWide.captureAsync`, RAW, burst) (`App.tsx:1381-1410`) | Android hơn; **không port** |
| Đường ảnh ra | Still → editor, file ghi lúc export (`docker/frontend/src/App.tsx:902-918`) | Render ngay → file + gallery + lịch sử (`App.tsx:1476-1489`) | **WEB-ONLY → P3** |
| Parity look | Cùng `renderPhoto`; chỉ khác đích ra (`exportEngine.ts:1068-1073` vs `:1075`) | Cùng `colorUtils` + SkSL (`Viewfinder.tsx:34-36,471-474` ↔ `exportEngine.ts:7-10,327-329`) | đã parity |
| Gate PRO | Ở lối vào, badge trên nút (`App.tsx:3237-3241`, `ImageStage.tsx:1443-1444`) | Ở lúc export theo look + `LITE_MARK` (`App.tsx:1269-1295`, `:1321-1322`) | **WEB-ONLY → P2** |
| Lỗi biên | `cam.unsupported` / `cam.denied` / `cam.fail` / `cam.shotFail` (`CameraView.tsx:121-124,176-181,199-202`) | Gate quyền + không device + lỗi phiên + lỗi chụp, mỗi thứ một alert riêng (`Viewfinder.tsx:2838-2863`, `App.tsx:950-961`, `:1499`) | tương đương |
---
## 3. Web-only, ĐÁNG PORT
### P1 — Dòng số `W×H · N fps` trên viewfinder *(rẻ, không đổi hành vi)*
**Mục tiêu.** Web in độ phân giải khung đang chạy và nhịp thật của pipeline (`CameraView.tsx:214-218`).
Android có đủ dữ liệu nhưng vứt đi: `frameBeatSync` được worklet tăng mỗi khung, JS đọc bằng `getDirty()`,
chỉ dùng cho watchdog (`Viewfinder.tsx:1180`, `:1866-1877`).
**Kỹ thuật.** Đừng đo trên JS thread (khung do worklet nhận — đếm ở JS là vô nghĩa). Tái dùng đúng nhịp
tim; độ phân giải lấy `frameTarget` (`:400-420`).
```tsx
// Số của thiết bị, không phải control — chỉ cần đủ mới để đọc (như FPS_WINDOW_MS của web).
const FPS_WINDOW_MS = 500;
const [fps, setFps] = useState(0);
useEffect(() => {
if (mode !== 'camera') return;
let last = frameBeatSync.getDirty();
const id = setInterval(() => {
const beat = frameBeatSync.getDirty();
setFps(Math.round(((beat - last) * 1000) / FPS_WINDOW_MS));
last = beat;
}, FPS_WINDOW_MS);
return () => clearInterval(id);
}, [mode, frameBeatSync]);
```
**Cạm bẫy.** `frameBeatSync` đếm khung **đã giao cho worklet**, không phải FPS cảm biến — đúng thứ cần
đo, nhưng đừng ghi nhãn "FPS camera".
**Nghiệm thu.** Mở camera: số khớp `frameTarget` (mặc định 4:3 ≈ 1280×960); che tối để máy tự hạ nhịp —
số phải tụt; chụp/burst/0.5x không đổi.
---
### P2 — Badge/gate PRO ở lối vào camera *(cần quyết định sản phẩm — mặc định KHÔNG làm)*
**Sự thật.** Web khoá **cả camera** sau PRO (`App.tsx:3237-3241`). Android để camera mở cho mọi người,
chỉ chặn ở lúc render look (`App.tsx:1269`) và đóng dấu `RECIPESCAM` lên bản LITE.
**Khuyến nghị.** **Không port gate cứng**: khoá camera là biến app thành app không chụp được. Web làm
được vì web là bề mặt dẫn khách sang tài khoản; Android là sản phẩm chính.
Nếu vẫn muốn, đây là wiring đúng chỗ — dùng lại `promptProFeature` sẵn có, không thêm cơ chế:
```tsx
// App.tsx, cạnh handleSetMode (1675) — chặn ở lối vào, không chặn trong viewfinder.
const openCameraMode = () => {
if (!pro) { promptProFeature('The live camera'); return; } // :1288-1295
handleSetMode('camera');
};
// …và truyền openCameraMode vào chỗ đang gọi handleSetMode('camera') (App.tsx:1978).
```
Badge: `CameraControls.tsx` chưa import entitlement; thêm tag góc theo đúng `renderChip`
(`AdjustmentPanel.tsx:348-371`, `proTag = c.pro && !pro`).
**Nghiệm thu.** LITE (`src/provariant.ts:3`, `node tools/set-variant.mjs`) → bấm CAMERA → `PRO FEATURE` +
`ProModal`, `mode` không đổi. PRO → mở như cũ.
---
### P3 — "Chụp rồi chỉnh": đưa ảnh chụp vào editor với look đang áp *(đáng làm nếu muốn luồng web)*
**Mục tiêu.** Web: màn trập → still full cảm biến vào studio, look do studio áp, file ghi sau
(`docker/frontend/src/App.tsx:902-918`, `CameraView.tsx:37-39`). Android: render ngay, không mở editor
(`App.tsx:1476-1489`).
**Kỹ thuật (quan trọng).** Đừng đưa ảnh **đã render** vào editor — look bị áp hai lần (lúc chụp và lúc
export) và `LITE_MARK` in hai lần. Đường đúng: chụp lấy **file thô** từ `photoOutput.capturePhotoToFile`
(`App.tsx:1384-1394`), rồi vào editor bằng chính hàm mà ảnh thư viện đi (`handleLibraryPicked`,
`App.tsx:1186`) — **bỏ `resetEditsToRecipe()`** (`:1198`) để `adjustments`/`selectedFrame` giữ nguyên look:
```tsx
// trong handleCapture, nhánh mode === 'camera', thay cho enqueueExport(...)
const shot = await photoOutput.capturePhotoToFile({ /* như :1384-1394 */ }, {});
if (shotToEditor) { // cờ mới
await handleLibraryPicked({ uri: toUri(shot.filePath), assetId: null });
return; // KHÔNG render, KHÔNG ghi gallery ở đây
}
enqueueExport(...); // đường cũ giữ nguyên
```
**Hệ quả phải chấp nhận.** Một ảnh chụp đi **một** trong hai đường: (a) render ngay → file + gallery +
lịch sử (hiện tại), hoặc (b) vào editor, file chỉ ra đời ở export. Không thể vừa render ngay vừa edit mà
không tốn gấp đôi công render. `pushPhoto` cũng sẽ không có entry ngay sau khi chụp.
**Nghiệm thu.** Chụp → sang `mode === 'library'` với ảnh **thô**; slider/HDF/frame giữ nguyên giá trị
trước khi chụp; gallery **không** có `RC_*.jpg` mới; export ra đúng **một** `LITE_MARK`.
---
### P4 — Che khung khi phiên khởi động lại *(web có, Android chỉ có một phần)*
Web giữ khung cuối khi độ phân giải đổi giữa phiên — "mờ chứ không đen" (`CameraView.tsx:67-117`).
Android đã làm điều này **cho handoff 0.5x** (`uwCover` + `holdCover` + đợi 3 nhịp khung,
`Viewfinder.tsx:628-713`) nhưng không che khi `isActive` bật lại hay watchdog remount (`:1856-1875`) —
màn trống tới khung đầu. Cách làm: tổng quát hoá `holdCover` thành "che tới khi nhịp tim tiến", gọi trong
`remountCameraSession` và ở nhánh `setAppActive(true)`, trần 1500 ms.
### P5 — Chọn độ phân giải preview theo nhiệt *(web-only, HOÃN)*
Web có lý do nhiệt rõ cho 720p (`engine/camera.ts:3-7`); Android không có xử lý nhiệt nào. Muốn làm đúng
phải thêm module Kotlin đọc `PowerManager.getCurrentThermalStatus()` rồi hạ `frameTarget` một bậc — tức
module native mới + đường tính lại + test máy nóng thật. YAGNI: chỉ làm khi có phản hồi thật về máy nóng.
---
## 4. Web-only nhưng KHÔNG port
| Thứ web-only | Vì sao bỏ |
|---|---|
| `cameraSupported()` + yêu cầu secure context (`engine/camera.ts:27-32`) | Trình duyệt mới có khái niệm origin an toàn |
| `<video>` phải nằm trong DOM + `.camera-source-host` (`CameraView.tsx:137-141`) | Mẹo riêng của Safari |
| Chuỗi dự phòng `ImageCapture` → `grabFrame` → copy 2D (`engine/camera.ts:69-96`) | Android luôn có `photoOutput` full-res |
| Cổng `busyRef` "bỏ chứ không xếp hàng" (`CameraView.tsx:12-15`) | Worklet chạy đồng bộ, không có hàng đợi để phình |
| Scratch canvas để scale khung lệch cỡ (`CameraView.tsx:109-117`) | Chỉ có nghĩa khi phải giữ buffer cố định; ở đây vẽ thẳng lên texture |
| Đặt tên `camera-<timestamp>.jpg` (`engine/camera.ts:114-120`) | Ảnh web không EXIF/tên gốc; Android đã có `RC_<yyyymmdd_hhmmss>.jpg` (`nativeExport.ts:113`) |
| i18n `cam.*` (9 khoá, `i18n/en.ts:36-44`) | App Android chưa có hạ tầng i18n (0 tham chiếu) — việc của cả app, không phải camera |
| `savePhoto` để ảnh chụp sống qua reload (`docker/frontend/src/App.tsx:910-911`) | Android có gallery + `pushPhoto` là bền hơn |
---
## 5. Android đã có — không port lại
| Lớp | Bằng chứng |
|---|---|
| Phiên + quyền + gate thiếu quyền | `useCameraPermission` (`App.tsx:322`), `Viewfinder.tsx:2838-2863` |
| Chọn lens, flip, zoom 1–10x, ultra-wide 0.5x | `Viewfinder.tsx:599-713`, `:735-741` |
| Pin tỉ lệ khung 4:3 theo cảm biến | `Viewfinder.tsx:400-420` |
| Màn trập full-res + flash + RAW DNG + burst | `App.tsx:1381-1420`, `:1652-1672` |
| Render look trong worklet, parity export | `Viewfinder.tsx:1177-1371` ↔ `exportEngine.ts` |
| Overlay dp (grain, vignette, frame, watermark) | `Viewfinder.tsx:2774-2819` |
| Watchdog + keep-awake + AppState | `Viewfinder.tsx:571-585`, `:1846-1900` |
| Lịch sử + gallery + EXIF/DPI | `App.tsx:1482-1489`, `exportEngine.ts:923-935` |
| Gate PRO theo look + `LITE_MARK` + badge chip | `App.tsx:1269-1295`, `entitlement.ts:52-60`, `AdjustmentPanel.tsx:348-371` |
---
## 6. Phương án B — đóng gói chính web studio thành app mobile
Đúng: build web là **SPA tĩnh**, không router, nên bọc được. Nhưng có 4 chỗ chắc chắn phải xử lý, và
chúng quyết định chọn cách bọc nào.
**Hiện trạng build:** `dist/` = 45 MB (`public/wasm` 35 MB gồm canvaskit + onnxruntime, `public/models`
7.1 MB), 5 trang định tuyến bằng `window.location.pathname`, SPA fallback do nginx lo — **không có**
`manifest.webmanifest`, **không** service worker (`docker/frontend/index.html`), nên hiện **chưa phải
PWA**.
| Vấn đề | Chi tiết trong code | Cách xử lý |
|---|---|---|
| API là đường dẫn tương đối | `fetch('/api' + path)` (`src/api.ts:152`, `:205`, `:320-325`); `src/track.ts:6`; nginx proxy `/api` → container `api` | App bọc phải có origin phục vụ `/api`: dùng **domain https thật** (CORS + cookie) hoặc thêm base URL cấu hình được trong `api.ts` |
| Camera cần **secure context** | `engine/camera.ts:27-29` — `http://192.168.x.x:8090` không có `mediaDevices` | WebView phải là `https://` (Capacitor cấu hình được scheme qua `androidScheme` — phải để `https`), **và** tầng native phải cấp quyền: `WebChromeClient.onPermissionRequest` → `PermissionRequest.grant`. TWA giải quyết sẵn vì chạy bằng Chrome |
| Xuất ảnh | `navigator.share({files})` (`docker/frontend/src/App.tsx:1775`), fallback `download()` (blob + `<a download>`) | Android WebView **không** tải blob bằng `<a download>` nếu thiếu `DownloadListener` → cần plugin Filesystem/Share; muốn ảnh vào thư viện ảnh máy thì phải qua MediaStore (web không có khái niệm này) |
| WASM nặng + ONNX upscale | `canvaskit.wasm`, `public/wasm/ort`, `public/models/realesr-*.onnx`; `vite.config.ts` nạp wasm lúc chạy | WebView có WASM; WebGPU có thể không có ⇒ rơi về WASM; WASM threads cần `SharedArrayBuffer` ⇒ cần COOP/COEP |
**Ba cách bọc, chọn theo cái phải bỏ ra:**
1. **TWA (Trusted Web Activity)** — rẻ nhất về code: không bundle gì, Chrome lo camera/download/share/CSS
hiện đại, cảm giác như app thật. Đổi lại: **bắt buộc có domain https** + `.well-known/assetlinks.json`
+ đăng ký store. Web giữ nguyên một bản.
2. **Capacitor** — bundle `dist/` để mở offline. Đổi lại phải tự làm: quyền camera trong WebView, plugin
file/share, base URL cho `/api`, và vẫn cần backend https (hoặc API remote).
3. **PWA** — thêm `manifest.webmanifest` + service worker rồi cài từ Chrome. Rẻ nhất, không store, nhưng
vẫn cần https và người dùng phải tự "Thêm vào màn hình chính".
**Cái bọc không cho được** (nên cân nhắc trước khi chọn B thay vì port P1–P3): ultra-wide 0.5x, zoom
quang, RAW DNG, burst, ghi trực tiếp vào thư viện ảnh, IAP của Google Play (web gắn PRO với **tài khoản
`verified`**, `App.tsx:522` — không phải hoá đơn store), và độ mượt/nhiệt của pipeline native.
**Khuyến nghị.** Nếu mục tiêu là "có app Android nhanh từ web studio" → **TWA**, vì nó không đụng vào code
web và giữ nguyên camera/download/share. Nếu mục tiêu là app chụp ảnh hằng ngày → port P1 + P3 vào app
Android hiện có (vài chục dòng mỗi việc), không bọc.
---
## 7. Nghiệm thu
**Web (đã có):** Playwright với `APP_URL=http://localhost:8090/app` (localhost = secure context), camera
giả `--use-fake-device-for-media-stream`. Tên probe theo commit: `cam-pro-gate` (6/6), `cam-live-edit`,
`cam-smoke`, `cam-renegotiate`, `cam-close-flip`.
**Android (phải máy thật):** hai biến thể bằng `node tools/set-variant.mjs` (`src/provariant.ts:3`):
P1 đọc được dòng số và thấy nó tụt khi che tối; P2 LITE bấm CAMERA ra `PRO FEATURE`; P3 chụp → editor với
ảnh thô + đúng một `LITE_MARK` lúc export; P4 bật/tắt app liên tục không thấy màn đen. Hồi quy: burst 30,
handoff 0.5x, RAW, flip, `pushPhoto`.
**Nếu chọn bọc (§6):** kiểm tra theo thứ tự — `window.isSecureContext` trong WebView, `mediaDevices` có
tồn tại không, quyền camera của app, `/api` trả 200, và xuất một ảnh ra được thư viện ảnh máy.
## 8. Bản đồ file
| Việc | Web | Android |
|---|---|---|
| Truy cập thiết bị, phiên, màn trập | `docker/frontend/src/engine/camera.ts` | `App.tsx:320-343`, `:1327-1502` |
| Viewfinder + vòng render | `docker/frontend/src/ui/CameraView.tsx` | `src/components/Viewfinder.tsx` |
| Nút chụp / flip | `CameraView.tsx:225-247` | `src/components/CameraControls.tsx` |
| Pipeline look | `docker/frontend/src/engine/exportEngine.ts` (`renderPhoto`, `sourceImage`, `drawTo`) | `src/utils/exportEngine.ts`, `toneShader.ts`, `frameUtils.ts` |
| Tài nguyên GPU | `docker/frontend/src/engine/skiaShim.ts` | `react-native-vision-camera-skia` + `disposeTrash` |
| Gate PRO | `App.tsx:522,585-587,3237-3241` | `App.tsx:1269-1295`, `src/utils/entitlement.ts`, `ProModal.tsx` |
| Đóng gói mobile (§6) | `docker/frontend/index.html`, `vite.config.ts`, `nginx.conf`, `src/api.ts` | — |
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<recipescam-recipe version="1" algorithm="xor16-v1" salt="46ec2fd2">
<payload>a0354a7ac8e3f5679b2472f23e9df10fbcf6a5e9efd98af22091a80e06f7abd9b8c36462f5fa2e1c04846f1e8d7eaf1a554c588917391b9ae4a47cd3425c44d6426b7acb45e1fd0a4785ecc1700ae666307ad72ff1d07925f45f8fb9f18c68ca02a7b90843b9a0084a010ca10f8b96fbdfc68d00d4c9e059681e1d9da8e6f465600aada6c8ad24a11d28913b5aa266742dfe5ce1c8da9ace034af1858d503223117b558025c7bd59b3c6f8e05ae800921a1ad7a73625985069b9246acf165bb25f2aa934975c0030d3ed29b343cc2ecf9b767d45e8141b6f36f812524966890dccfd4972c6cd312a94541f38a9133262bbda307b70dc5f8c73bac91ced9954429459ffbfb3c66f95d199bebde1bd912ca5f9c360c2ef11fcdb4bc7d1b6786f6da0de996a396884ff5915c7bbc5425e82d5b39ae939e815678aae54005af3e3ced5aa85a1bfe8074f7a6e29aaf6391d9b79e8b694db24c0bbb4344f52641de13d28cad53f16ec6f4f81750a8d36e26053b71226349ad9d1b330d7bff3a3709dd937364e78497fac415ac73e4192e9ac887c39f1176d80aedd1b1df9270bc186aec942966dff90580acc01507c1dad1168b42f08a74d6efff2aff31964c93f19927455e6be0c680b5789bdf2778c3270f5e1c2e07ed9bba3255d71a2c4ec04aef696b0573daaf8734e9037c0b4dbc724510259e5252db982f70ad349af7937ccf384f125745369e6e8f34e6b03435b1d04ac215761af5bded5c5a8c54bc3f2bbc715507868ab7e80f18fb624cd12498e6c262b792e1295c11b9319492a6ac6c403104aab07fb46b6125571a8e794fbe452fb268cdcd064262fc57feb0a2a3e39f7cb5b148fcc5b4cae36dabb0cdd22afbec38cab2ed89c954311ca88f4933f3f72026ebde64a7d1bb74fdc1c626f4496d6c88352f59f2440ee64a6a7dcfb4afb71aead33f09493632e488915e47348775f5afd678ed89cc737a2bed1fa18ca950ea6f93849656f895cac266de7f82b793a0f436486566adceb38aea46e5011a3c922367a285e4cb4d52a9153b31d184c4289cea7b62f682cf12f13b12340519b8d204f18537204508c98342f4f7bf3b3414e46810b87bd610264539ff029f9</payload>
</recipescam-recipe>
+31
View File
@@ -1,2 +1,33 @@
.env
data/
# Local-only: test fixtures, plus copies of files already tracked at this
# level (md/ mirrors the seven *.md beside it, old_film.png mirrors
# frontend/public/assets/old_film.png byte for byte). They are needed on the
# box that runs the checks, not in the history.
/sample/
/md/
/old_film.png
# The NAS image bundle: 120 MB a build, and a binary every commit before this
# line carried its own copy — which is what made the repository thirteen
# gigabytes. It is built where it is loaded, and the checksum beside it is the
# only part worth keeping in the history.
/recipescam-images.tar.gz
# The measurement frames at this level are the same bytes as the copies in
# /sample/ — which is the folder the checks read (`SAMPLE_DIR`, defaulting to
# it). Tracked here for years they were 227 MB of a file the history already
# carried twice; what runs a check finds them in /sample/.
/*.RAF
/*.DNG
/*.NEF
/*.RW2
/Main*.jpg
/L1040680.jpg
/APACHAI-*.jpg
/D0004128.JPG
/DSCF1701.JPG
/P1010256.JPG
/lightroom_shadow.jpg
/recipescam_shadow.jpg
+191
View File
@@ -0,0 +1,191 @@
# Giải Mã Kỹ Thuật: Tại Sao Thuật Toán Đưa Lên Web App Vẫn Không Thể Như Lightroom?
Rất nhiều kỹ sư đồ họa khi chuyển đổi thuật toán tone mapping sang WebGL đều gặp phải một thực tế phũ phàng: **công thức toán học trông rất hoàn hảo trên lý thuyết, nhưng khi chạy trên Web App thì Highlights kéo xuống bị xám xịt như bùn tro, Shadows kéo lên thì vỡ nát hạt sạn hoặc bết màu, không thể nào trong trẻo như Lightroom.**
Nguyên nhân không nằm ở việc bạn viết sai code GLSL, mà bắt nguồn từ **6 rào cản kiến trúc cốt tử** giữa môi trường xử lý ảnh chuyên nghiệp (Adobe Camera Raw) và môi trường đồ họa trình duyệt (WebGL / WebGPU).
---
## 1. Rào Cản Cốt Tử #1: Dữ Liệu Đầu Vào (Scene-Referred RAW vs Display-Referred 8-bit)
Đây là lý do lớn nhất chiếm tới $60\%$ sự khác biệt:
```
[QUY TRÌNH LIGHTROOM]
File RAW (14-bit Bayer) ──► Linear Sensor Space ──► Headroom rộng [0.0 đến 4.0+]
(Chi tiết Highlights vượt ngưỡng 1.0 VẪN CÒN NGUYÊN)
[QUY TRÌNH WEB APP THÔNG THƯỜNG]
File JPEG/PNG (8-bit) ──► Bị máy ảnh bake sẵn sRGB ──► Bị cắt cụt cứng (Clamped) [0.0, 1.0]
(Mọi chi tiết > 1.0 đều đã bị vứt bỏ thành (255, 255, 255))
```
### Tại sao Highlights của Lightroom cứu được mây trời, còn Web App biến thành màu "Xám Bùn" (Dirty Gray)?
* **Trong Lightroom (RAW):** Khi chụp một đám mây bị chói, cảm biến máy ảnh thu nhận năng lượng ánh sáng tuyến tính thực tế có thể lên tới $2.5$ hoặc $3.0$ (vượt xa điểm trắng hiển thị $1.0$). Khi bạn kéo `Highlights -100`, Lightroom kéo các giá trị $2.5$ đó nén về khoảng $[0.7 - 0.9]$. Các vân mây, khối hình vẫn giữ nguyên gradient chuyển tiếp.
* **Trên Web App (JPEG):** Ảnh đưa vào WebGL là ảnh 8-bit đã bị thiết bị chụp "nướng chín" (baked). Mọi pixel của đám mây trắng đã bị ép cứng về giá trị tối đa: $R=255, G=255, B=255$ ($1.0$). Gradient nội tại đã **bằng 0**. Khi bạn áp dụng công thức giảm Highlights:
$$1.0 - \text{HighlightAmount} \times \text{Factor} = 0.75$$
Một mảng pixel phẳng lì toàn số $1.0$ biến thành một mảng pixel phẳng lì toàn số $0.75$. Đó chính là lý do đám mây **không hề hiện lại vân mây** mà biến thành một mảng màu xám xịt nhân tạo.
### Tại sao Shadows của Lightroom mịn màng, còn Web App bị vỡ khối (Banding)?
* Vùng tối sâu của file RAW 14-bit chứa hàng ngàn bước thang độ xám (Gray levels).
* Vùng tối của file 8-bit JPEG chỉ có khoảng $5 - 10$ mức số nguyên (từ $0$ đến $10$). Khi kéo `Shadows +100`, Web App phóng đại $10$ mức này lên thành $50 - 60$, tạo ra hiện tượng bậc thang đứt gãy (Color Banding) và nhiễu nén JPEG rác (Macroblocking artifacts).
---
## 2. Rào Cản Kỹ Thuật #2: Độ Chính Xác FBO WebGL (8-bit Unsigned Byte vs 16/32-bit Float)
Trong pipeline nhiều lớp (Multi-pass Pipeline) của WebGL:
```
Pass 1: Blur/Guided Filter ──► Framebuffer (FBO) ──► Pass 2: Tone Composite
```
* **Sai lầm phổ biến trên Web:** Khởi tạo texture FBO bằng kiểu mặc định:
```javascript
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
```
Kiểu `gl.UNSIGNED_BYTE` giới hạn bộ đệm FBO chỉ có **256 mức giá trị** cho mỗi kênh màu.
* **Hậu quả:**
1. Khi Pass 1 tính toán lớp nền (Base Layer) qua bộ lọc Guided Filter, các phép tính trung bình dấu phẩy động bị làm tròn (rounding error) liên tục về $1/255$.
2. Sang Pass 2, phép trừ $\text{Detail} = \text{Orig} - \text{Base}$ thực hiện trên các giá trị đã bị làm tròn thô kệch, tạo ra các viền bậc thang nhân tạo li ti khắp bề mặt ảnh.
---
## 3. Rào Cản Thuật Toán #3: Local Laplacian Pyramid vs Single-Pass Guided Filter
Trong các tài liệu thông thường, người ta hay đơn giản hóa rằng Lightroom dùng "Bilateral" hay "Guided Filter". Nhưng trên thực tế, kiến trúc xử lý Tone của Adobe Camera Raw (từ Process Version 2012 trở đi) sử dụng thuật toán đỉnh cao: **Local Laplacian Filters (Aubry, Paris, Hasinoff, Kautz - MIT/Adobe Research)**.
```
[CÁCH WEB APP THƯỜNG LÀM]
Ảnh Đầu Vào
│
┌─────────────┴─────────────┐
▼ ▼
Base Layer Detail Layer
(1 bán kính) (1 bán kính)
│ │
└─────────────┬─────────────┘
▼
Ảnh kết quả (Dễ bẹt khối)
[CÁCH LIGHTROOM THỰC SỰ LÀM]
Ảnh Đầu Vào
│
┌───────────┼───────────┐
▼ ▼ ▼
Cấp 0 Cấp 1 Cấp 2 ... (Pyramid N Cấp)
(Micro) (Meso) (Macro)
│ │ │
[Nắn chỉnh từng nhánh bằng đường cong Tone Curve cục bộ phi tuyến]
│ │ │
└───────────┼───────────┘
▼
Tái cấu trúc: Khối sâu 3D, chuyển tiếp dải sáng hoàn hảo
```
* **Điểm yếu của Web App:** Khi chỉ dùng 1 bán kính mờ duy nhất cho Base Layer, bạn chỉ nắn sáng được ở một "tần số" cố định. Nếu bán kính lọc quá nhỏ $\implies$ sinh ra viền xung quanh vật thể; nếu bán kính lọc quá lớn $\implies$ các khối chi tiết trung bình bị phẳng lì (mất tương phản vi mô).
* **Lightroom:** Nén sáng trên một kim tự tháp đa tầng (Laplacian Pyramid). Nó nén vùng sáng tổng thể nhưng đồng thời **khuếch đại tương phản cục bộ tại mọi cấp độ phân giải**, giúp ngọn núi vừa sáng rõ vừa gồ ghề sắc cạnh.
---
## 4. Rào Cản Màu Sắc #4: Lỗi Chia Tỷ Lệ Màu Vùng Tối & Lệch Sắc Thái (Chroma Shift)
Trong đoạn shader WebGL của các ứng dụng tự phát triển, thường có dòng code:
```glsl
float scale = (newLum + 0.0001) / (lumOrig + 0.0001);
vec3 result = orig * scale;
```
Đây là nguyên nhân gây ra hiện tượng **"nát màu"** ở vùng tối:
1. **Hiện tượng phát nổ nhiễu (Chroma Explosion):** Tại các pixel cực tối, $\text{lumOrig} \approx 0.001$. Khi kéo Shadows, $\text{newLum}$ được nâng lên $0.05$. Tỷ lệ `scale` lúc này bằng:
$$\frac{0.05}{0.001} = 50 \text{ lần!}$$
Một hạt nhiễu đỏ nhỏ li ti mắt thường không thấy ở vùng tối bị nhân lên gấp $50$ lần, biến thành một đốm màu đỏ rực khổng lồ loang lổ.
2. **Lệch sắc thái (Hue Distortion):** Ba kênh $R, G, B$ khi bị nhân tuyến tính với hệ số lớn sẽ chạm trần clipping ($1.0$) không đồng đều. Kênh nào chạm trần trước sẽ bị gãy tỷ lệ, khiến màu xanh lá của rừng cây chuyển sang màu vàng chuối hoặc xanh neon bẩn.
---
## 5. Rào Cản Không Gian Màu #5: Linear sRGB vs ACEScg / ProPhoto RGB
* WebGL thông thường chỉ chuyển: $\text{sRGB} \xrightarrow{pow(2.2)} \text{Linear sRGB}$.
* Không gian **sRGB có dải màu (Color Gamut) rất hẹp**. Khi bạn tăng sáng một vùng tối có màu rêu đậm, màu sắc sau khi tăng sáng sẽ nhanh chóng văng ra ngoài biên giới tam giác sRGB (Out of Gamut), dẫn đến việc màu bị bẹt và xỉn.
* **Lightroom:** Xử lý toàn bộ trong không gian **ProPhoto RGB** (hoặc ACEScg). Đây là không gian màu siêu rộng (Wide Color Gamut), bao phủ hầu hết các màu mắt người nhìn thấy được. Dù có đẩy Shadows hay nén Highlights kịch kim, màu sắc vẫn nằm gọn trong không gian tính toán an toàn trước khi được Tone Map mượt mà về màn hình hiển thị.
---
## 6. Giải Pháp Đột Phá: Làm Thế Nào Để Web App Tiệm Cận 90% - 95% Lightroom?
Để đưa Web App của bạn lên chất lượng tương đương Lightroom ngay trên tệp ảnh 8-bit thông thường, hãy áp dụng ngay **4 giải pháp kỹ thuật sống còn** dưới đây:
### Giải Pháp 1: Bắt buộc dùng WebGL2 Float Texture cho Framebuffer (FBO)
Khử hoàn toàn lỗi vỡ hạt và đường viền răng cưa bằng cách kích hoạt kết cấu dấu phẩy động 16-bit:
```javascript
// Sử dụng WebGL2 context
const gl = canvas.getContext('webgl2');
gl.getExtension('EXT_color_buffer_float');
// Tạo FBO Texture lưu trữ số thực nửa độ chính xác (Half Float - 16 bit)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, width, height, 0, gl.RGBA, gl.HALF_FLOAT, null);
```
### Giải Pháp 2: Khôi Phục Highlights Ảo (Pseudo-Highlight Inpainting & Desaturation)
Vì ảnh 8-bit không có headroom, khi người dùng kéo `Highlights < 0`, thay vì giảm độ sáng đơn thuần làm xám đám mây, hãy áp dụng kỹ thuật **Highlight Soft-Roll Rolloff + Khử bão hòa biên độ cao**:
```glsl
// Kỹ thuật cứu cháy sáng cho ảnh 8-bit trên WebGL:
if (u_highlights < 0.0) {
// 1. Chỉ nén những vùng có gradient (không nén vùng cháy tuyệt đối 1.0)
float highlightMask = smoothstep(0.6, 0.95, baseLum);
// 2. Nén dải sáng theo đường cong lũy thừa mềm (Soft Knee)
float compress = pow(baseLum, 1.0 - u_highlights * 0.5);
newLum = mix(baseLum, compress, highlightMask);
// 3. Với các pixel chạm ngưỡng 1.0, hòa trộn nhẹ với màu sắc môi trường xung quanh
// để tái tạo gradient nhân tạo, tránh để mảng trắng chuyển sang mảng xám xịt.
}
```
### Giải Pháp 3: Ổn Định Tỷ Lệ Màu Vùng Tối Bằng Soft-Division (Khử Nát Màu)
Thay thế công thức chia tỷ lệ thô bằng hàm **Soft Saturation Clamp**:
```glsl
// Thay vì: scale = newLum / lumOrig;
// HÃY DÙNG CÔNG THỨC ỔN ĐỊNH CỦA ACR:
float safeLumOrig = max(lumOrig, 0.005);
float scale = newLum / safeLumOrig;
// Nén hệ số khuếch đại tối đa để không bao giờ khuếch đại nhiễu quá 3.0 lần
float clampedScale = min(scale, 1.0 + max(u_shadows, 0.0) * 2.5);
// Tái cấu trúc RGB an toàn
vec3 result = orig * clampedScale;
// Dập tắt nhiễu màu sàn đen: Vùng cực tối (< 2%) ép dần về đơn sắc (Desaturate to Neutral)
float noiseFloor = smoothstep(0.0, 0.03, newLum);
result = mix(vec3(newLum), result, noiseFloor);
```
### Giải Pháp 4: Chuyển Sang Không Gian Màu Tri Giác OKLab Thay Vì sRGB
Thay vì nắn độ sáng trên RGB rồi chia tỷ lệ, hãy chuyển đổi pixel sang không gian màu **OKLab**:
* Kênh $L$: Độ sáng tri giác người (Perceptual Lightness).
* Kênh $a, b$: Sắc độ màu (Chrominance).
Khi bạn can thiệp vào Shadows, Highlights, Blacks, Whites: **chỉ tác động duy nhất vào kênh $L$**. Kênh $a, b$ được giữ nguyên hoặc nhân nhẹ theo định luật Hunt Effect. Điều này triệt tiêu $100\%$ hiện tượng cây cối bị biến thành màu bùn hay da mặt bị ố vàng.
---
## 7. Bảng So Sánh Trước & Sau Khi Cải Cách Pipeline
| Tiêu chí | Web App Hiện Tại Của Bạn | Web App Sau Khi Tối Ưu Nâng Cao |
| :--- | :--- | :--- |
| **Độ sâu FBO Texture** | 8-bit `UNSIGNED_BYTE` (256 mức) | 16-bit `RGBA16F` (Float - Vô hạn mức mượt) |
| **Kéo Shadows $+100$** | Hạt sạn loang lổ, bợt màu, xám đục | Trong trẻo, giữ nguyên sắc xanh của lá và độ sâu đen |
| **Kéo Highlights $-100$** | Mây biến thành mảng xám bùn nhân tạo | Chuyển tiếp êm dịu, không sinh viền giả |
| **Không gian tính toán** | Linear sRGB thô sơ | OKLab / Perceptual Space với Soft-Division |
| **Hiện tượng mép cạnh** | Xuất hiện viền Halo hoặc Stroke | Triệt tiêu mép cạnh hoàn toàn ($100\%$ tự nhiên) |
+2 -1
View File
@@ -8,7 +8,8 @@
"build": "tsc",
"start": "node dist/server.js",
"dev": "tsx watch src/server.ts",
"test": "node test/security.mjs"
"test": "node test/security.mjs",
"test:immich": "node test/immich.mjs"
},
"dependencies": {
"better-sqlite3": "^12.11.1",
+82 -5
View File
@@ -172,6 +172,29 @@ export const serializeSlots = (slots: readonly PhotoSlot[]): string =>
}
}
// PRO, the operator's own switch: the admin ticks it in the users table and the
// account reads the studio's PRO features from then on, proven address or not.
// It only ever adds to what a verified account already has — unticking it
// cannot take the tier away from an address that has been proven.
{
const cols = db.prepare('PRAGMA table_info(users)').all() as { name: string }[];
if (!cols.some((c) => c.name === 'pro')) {
db.exec(`ALTER TABLE users ADD COLUMN pro INTEGER NOT NULL DEFAULT 0`);
}
}
// The Immich servers an account added to its LIBRARY — one JSON array per user,
// each entry holding the address, the key its owner pasted and the albums they
// ticked (see src/immich.ts). A column rather than a table because the list is
// only ever read whole and written whole: there is nothing to query across
// accounts, and a user's row is already the unit that is deleted together.
{
const cols = db.prepare('PRAGMA table_info(users)').all() as { name: string }[];
if (!cols.some((c) => c.name === 'immich')) {
db.exec(`ALTER TABLE users ADD COLUMN immich TEXT`);
}
}
// The mailed code, added once visitors were expected to prove an address
// without leaving the page. Rows minted before it keep working as links: their
// `code` is NULL, which no typed guess can match (see verifyEmailCode).
@@ -228,6 +251,9 @@ export const PHOTO_HISTORY_MAX = 3;
// `avatar` is the stored file name, or null for "no picture".
// `emailVerified` is 0/1 from SQLite; the route layer turns it into the
// `verified` the client reads.
// `pro` is the admin's own 0/1 grant — the "Activated Pro" box in the users
// table, and the only way an unproven address reaches the PRO tier.
// `createdAt` is the signup instant, which the PRO cutoff reads (see server.ts).
export type User = {
id: number;
email: string;
@@ -235,6 +261,8 @@ export type User = {
blocked: number;
deletedAt: string | null;
emailVerified: number;
pro: number;
createdAt: string;
};
export type Recipe = {
id: number;
@@ -268,10 +296,11 @@ const now = () => new Date().toISOString();
export function createUser(email: string, password: string): User | null {
try {
const createdAt = now();
const info = db
.prepare('INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?)')
.run(email, hashPassword(password), now());
return { id: Number(info.lastInsertRowid), email, avatar: null, blocked: 0, deletedAt: null, emailVerified: 0 };
.run(email, hashPassword(password), createdAt);
return { id: Number(info.lastInsertRowid), email, avatar: null, blocked: 0, deletedAt: null, emailVerified: 0, pro: 0, createdAt };
} catch (err) {
if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return null;
throw err;
@@ -281,14 +310,14 @@ export function createUser(email: string, password: string): User | null {
export function findUserByEmail(email: string): (User & { password_hash: string }) | undefined {
return db
.prepare(
'SELECT id, email, avatar, blocked, deleted_at AS deletedAt, email_verified AS emailVerified, password_hash FROM users WHERE email = ?',
'SELECT id, email, avatar, blocked, deleted_at AS deletedAt, email_verified AS emailVerified, pro, created_at AS createdAt, password_hash FROM users WHERE email = ?',
)
.get(email) as (User & { password_hash: string }) | undefined;
}
export function findUserById(id: number): User | undefined {
return db
.prepare('SELECT id, email, avatar, blocked, deleted_at AS deletedAt, email_verified AS emailVerified FROM users WHERE id = ?')
.prepare('SELECT id, email, avatar, blocked, deleted_at AS deletedAt, email_verified AS emailVerified, pro, created_at AS createdAt FROM users WHERE id = ?')
.get(id) as User | undefined;
}
@@ -589,6 +618,8 @@ export type AdminUser = {
avatar: string | null;
blocked: number;
deletedAt: string | null;
pro: number;
emailVerified: number;
};
export function listUsersWithCounts(): AdminUser[] {
@@ -596,7 +627,8 @@ export function listUsersWithCounts(): AdminUser[] {
.prepare(
`SELECT users.id AS id, users.email AS email, users.created_at AS createdAt,
users.avatar AS avatar, users.blocked AS blocked,
users.deleted_at AS deletedAt, COUNT(photos.id) AS photos
users.deleted_at AS deletedAt, users.pro AS pro,
users.email_verified AS emailVerified, COUNT(photos.id) AS photos
FROM users LEFT JOIN photos ON photos.user_id = users.id
GROUP BY users.id
ORDER BY users.id`,
@@ -604,6 +636,44 @@ export function listUsersWithCounts(): AdminUser[] {
.all() as AdminUser[];
}
// ---- Immich servers, per account ---------------------------------------------
// What src/immich.ts stores for one server: the address, the key its owner
// pasted, and the crossing choices the dialog made. `version` and `canDownload`
// are the answers of the last probe, kept so the LIBRARY tree can draw without
// asking Immich anything.
export type ImmichServer = {
id: string;
name: string;
url: string;
key: string;
type: 'api' | 'share';
version: string | null;
canDownload: boolean | null;
albums: string[];
};
// The key is in clear in this column, like the password hash beside it: the file
// is the deployment's own disk. What matters is that it never travels back out —
// the routes send a masked copy and nothing else (see src/immich.ts).
export function immichServers(userId: number): ImmichServer[] {
const row = db.prepare('SELECT immich FROM users WHERE id = ?').get(userId) as
| { immich: string | null }
| undefined;
if (!row?.immich) return [];
try {
const parsed: unknown = JSON.parse(row.immich);
return Array.isArray(parsed) ? (parsed as ImmichServer[]) : [];
} catch {
// A column hand-edited into nonsense reads as "no server added", which is
// the same thing the user sees and can fix from the dialog.
return [];
}
}
export function setImmichServers(userId: number, servers: ImmichServer[]): void {
db.prepare('UPDATE users SET immich = ? WHERE id = ?').run(JSON.stringify(servers), userId);
}
// ---- moderation -------------------------------------------------------------
// Blocking and removing both drop the account's live sessions: the state has to
// take effect on the next request, not whenever the cookie happens to expire.
@@ -613,6 +683,13 @@ export function setUserBlocked(id: number, blocked: boolean): boolean {
return info.changes > 0;
}
// The PRO grant, ticked or unticked from the users table. No session sweep: the
// tier is read off the row on every request, so the next one already sees it.
export function setUserPro(id: number, pro: boolean): boolean {
const info = db.prepare('UPDATE users SET pro = ? WHERE id = ?').run(pro ? 1 : 0, id);
return info.changes > 0;
}
export function setUserRemoved(id: number, removed: boolean): boolean {
const info = db
.prepare('UPDATE users SET deleted_at = ? WHERE id = ?')
+644
View File
@@ -0,0 +1,644 @@
// Immich, as a second photo source for the LIBRARY module — server side only.
//
// Two constraints decide this file's whole shape (see md/immich-library-plan.md):
// the SPA is served with COEP `require-corp`, so nothing cross-origin can be
// fetched from the page; and an Immich key reads a whole family library, so it
// must never reach the browser. One move answers both — the page talks to these
// routes, these routes talk to Immich, and the key stays in the users table.
//
// Every route here is read-only towards Immich: no upload, no edit, no delete.
// That is what the dialog asks for, and it is why the worst a stolen session can
// do is read what its own key could already read.
import { randomBytes } from 'node:crypto';
import { Readable } from 'node:stream';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { immichServers, setImmichServers, type ImmichServer, type User } from './db';
const MAX_URL = 300;
const MAX_KEY = 400;
const MAX_NAME = 60;
const MAX_ALBUMS = 200;
const MAX_PAGE_SIZE = 200;
// What the shelf's search box may carry. Bounded because it goes on to another
// box as a query, and a search box is a thing people paste into.
const MAX_QUERY = 100;
// A server is a box the operator may not control, and a hang there must not
// become a hung LIBRARY. Streaming an original is the one call that legitimately
// takes minutes, so it gets a clock of its own.
const PROBE_MS = 8_000;
const FETCH_MS = 5 * 60_000;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const THUMB_SIZES = new Set(['thumbnail', 'preview', 'fullsize']);
type Json = Record<string, unknown>;
// The three refusals the UI can say something about: the key is wrong (or too
// narrow), the box cannot be reached, or it answered with something unusable.
type Failure = { ok: false; error: 'auth' | 'unreachable' | 'server' | 'invalid' };
type RawAsset = {
id?: string;
type?: string;
originalFileName?: string;
fileCreatedAt?: string;
width?: number;
height?: number;
exifInfo?: { fileSizeInByte?: number };
};
type RawAlbum = {
id?: string;
albumName?: string;
assetCount?: number;
albumThumbnailAssetId?: string | null;
shared?: boolean;
};
type AlbumRow = {
id: string;
albumName: string;
assetCount: number;
coverId: string | null;
shared: boolean;
};
const albumRow = (album: RawAlbum, count?: number): AlbumRow => ({
id: album.id ?? '',
albumName: album.albumName ?? '',
assetCount: count ?? album.assetCount ?? 0,
coverId: album.albumThumbnailAssetId ?? null,
shared: album.shared === true,
});
// What the client needs to build a row: identity, a name, a date, a size. The
// pixels are never here — the tile route fetches those.
const assetRow = (asset: RawAsset) => ({
id: asset.id ?? '',
name: asset.originalFileName ?? '',
takenAt: asset.fileCreatedAt ?? null,
width: asset.width ?? null,
height: asset.height ?? null,
size: asset.exifInfo?.fileSizeInByte ?? null,
});
// ---- validation at the trust boundary --------------------------------------
function body(req: FastifyRequest): Json {
const parsed = req.body as unknown;
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Json) : {};
}
// The address is the one string that could aim this proxy anywhere, so it is
// checked here: http(s) only, no embedded credentials, no path — Immich is
// reached at the root of its own origin.
function cleanUrl(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const trimmed = raw.trim().replace(/\/+$/, '');
if (!trimmed || trimmed.length > MAX_URL) return null;
let url: URL;
try {
url = new URL(trimmed);
} catch {
return null;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
if (url.username || url.password) return null;
if (url.search || url.hash) return null;
return trimmed;
}
function cleanKey(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const key = raw.trim();
if (!key || key.length > MAX_KEY || /\s/.test(key)) return null;
return key;
}
// A name is what the tree shows beside the node; empty means the host, which is
// what the user would have called it anyway.
function cleanName(raw: unknown, url: string): string {
if (typeof raw === 'string') {
const name = raw.trim().slice(0, MAX_NAME);
if (name) return name;
}
try {
return new URL(url).hostname;
} catch {
return url;
}
}
// Album IDs are pasted by hand (a `…/albums/<uuid>` URL is the normal way to get
// one), so the shape is checked and the list capped: these values end up in a
// query to Immich, which is exactly where an unchecked string would hurt.
function cleanAlbums(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const out: string[] = [];
for (const item of raw) {
const found = typeof item === 'string' ? item.match(UUID_RE)?.[0]?.toLowerCase() : undefined;
if (!found || out.includes(found)) continue;
out.push(found);
if (out.length >= MAX_ALBUMS) break;
}
return out;
}
// What the browser may know about a key: which one it is, never what it is. Four
// characters are enough for its owner to recognise it.
const mask = (key: string) => `••••${key.slice(-4)}`;
// ---- talking to Immich -------------------------------------------------------
type Call = {
method?: 'GET' | 'POST';
path: string;
query?: Record<string, string | number | undefined>;
json?: unknown;
headers?: Record<string, string>;
timeout?: number;
};
// One place builds the upstream request, so the key is attached the same way
// everywhere: an API key rides in `x-api-key`, a share link's key in `?key=`,
// which is where Immich's own shared routes read it from. Neither ever reaches a
// log line we write.
async function call(server: ImmichServer, req: Call): Promise<Response> {
const url = new URL(`${server.url}/api${req.path}`);
for (const [name, value] of Object.entries(req.query ?? {})) {
if (value !== undefined) url.searchParams.set(name, String(value));
}
const headers: Record<string, string> = { ...req.headers };
if (server.type === 'api') headers['x-api-key'] = server.key;
else url.searchParams.set('key', server.key);
const init: RequestInit = { method: req.method ?? 'GET', headers, signal: AbortSignal.timeout(req.timeout ?? PROBE_MS) };
if (req.json !== undefined) {
headers['content-type'] = 'application/json';
init.body = JSON.stringify(req.json);
}
return fetch(url, init);
}
async function jsonOf<T>(res: Response): Promise<T | null> {
try {
return (await res.json()) as T;
} catch {
return null;
}
}
// Immich's own answer, narrowed to the three the UI can act on.
const refusal = (res: Response): Failure =>
res.status === 401 || res.status === 403 ? { ok: false, error: 'auth' } : { ok: false, error: 'server' };
const unreachable = (): Failure => ({ ok: false, error: 'unreachable' });
// The album list, from whichever kind of key this is: a share link is one album
// it has to ask `shared-links/me` for, an API key gets the whole visible list.
async function listAlbums(server: ImmichServer): Promise<AlbumRow[] | Failure> {
if (server.type === 'share') {
const res = await call(server, { path: '/shared-links/me' });
if (!res.ok) return refusal(res);
const me = await jsonOf<{ album?: RawAlbum; assets?: RawAsset[] }>(res);
return me?.album ? [albumRow(me.album, me.assets?.length)] : [];
}
const res = await call(server, { path: '/albums' });
if (!res.ok) return refusal(res);
return ((await jsonOf<RawAlbum[]>(res)) ?? []).filter((album) => album.id).map((album) => albumRow(album));
}
// The tags this key can see whose name holds what was typed. Asked for on every
// search rather than cached: a tag made on Immich has to be found by the next
// one, and the list is small. A key without `tag.read` is answered 403, which is
// a key that cannot search by tag — not a search that failed.
async function tagIdsMatching(server: ImmichServer, q: string): Promise<string[]> {
const res = await call(server, { path: '/tags' });
if (!res.ok) return [];
const tags = (await jsonOf<{ id?: string; name?: string }[]>(res)) ?? [];
const needle = q.toLowerCase();
return tags.filter((tag) => tag.id && (tag.name ?? '').toLowerCase().includes(needle)).map((tag) => tag.id as string);
}
async function firstAssetId(server: ImmichServer): Promise<string | null> {
if (server.type === 'share') {
const res = await call(server, { path: '/shared-links/me' });
if (!res.ok) return null;
return (await jsonOf<{ assets?: RawAsset[] }>(res))?.assets?.[0]?.id ?? null;
}
const res = await call(server, { method: 'POST', path: '/search/metadata', json: { size: 1, page: 1 } });
if (!res.ok) return null;
const found = await jsonOf<{ assets?: { items?: RawAsset[] } }>(res);
return found?.assets?.items?.[0]?.id ?? null;
}
// Whether this key may fetch an original — the one permission the studio
// depends on (see the plan's permission table). A one-byte range keeps the check
// from downloading a RAW file, and the stream is dropped rather than read.
async function canDownload(server: ImmichServer, assetId: string): Promise<boolean | null> {
const res = await call(server, { path: `/assets/${assetId}/original`, headers: { Range: 'bytes=0-0' } });
await res.body?.cancel();
if (res.status === 200 || res.status === 206) return true;
if (res.status === 401 || res.status === 403) return false;
return null;
}
type ProbeResult = { ok: true; version: string | null; albums: AlbumRow[]; canDownload: boolean | null } | Failure;
// "Check this key" — the only place that reaches a server the user has not saved
// yet. It asks the fixed Immich paths above and nothing else, so no part of the
// request can steer it past that list.
async function probe(url: string, key: string, type: 'api' | 'share'): Promise<ProbeResult> {
const server: ImmichServer = { id: 'probe', name: '', url, key, type, version: null, canDownload: null, albums: [] };
try {
// The version is public, so it answers even for a key that turns out to be
// wrong — and the dialog gets to show what it is talking to.
const versionRes = await call(server, { path: '/server/version' });
if (!versionRes.ok) return refusal(versionRes);
const v = await jsonOf<{ major?: number; minor?: number; patch?: number }>(versionRes);
const version = v?.major === undefined ? null : `${v.major}.${v.minor ?? 0}.${v.patch ?? 0}`;
// Reading albums is also the proof the key works: an unusable key is refused
// here, before anything is stored.
const albums = await listAlbums(server);
if (!Array.isArray(albums)) return albums;
const assetId = await firstAssetId(server);
return { ok: true, version, albums, canDownload: assetId ? await canDownload(server, assetId) : null };
} catch {
// A wrong host, a closed port, a TLS failure, a timeout — one answer.
return unreachable();
}
}
// ---- routes ------------------------------------------------------------------
export function immichRoutes(
app: FastifyInstance,
member: (req: FastifyRequest, reply: FastifyReply) => User | undefined,
): void {
const view = (server: ImmichServer) => ({
id: server.id,
name: server.name,
url: server.url,
type: server.type,
version: server.version,
canDownload: server.canDownload,
albums: server.albums,
keyMasked: mask(server.key),
});
// A route names its server by the id we minted, never by an address: nothing a
// client sends can aim this proxy at a host that is not in its own list.
const pick = (user: User, reply: FastifyReply, id: unknown): ImmichServer | undefined => {
const servers = immichServers(user.id);
const server = typeof id === 'string' && id ? servers.find((s) => s.id === id) : servers[0];
if (!server) {
void reply.status(404).send({ error: 'unknown server' });
return undefined;
}
return server;
};
// The saved list. No upstream call at all: the tree has to draw on a phone with
// no signal. Nothing here suggests an address — the address and the key are each
// account's own, typed in the app, so a fresh server is a blank field.
app.get('/api/immich/config', async (req, reply) => {
const user = member(req, reply);
if (!user) return;
return reply.send({ ok: true, servers: immichServers(user.id).map(view) });
});
// The dialog's "check this key": nothing is stored, so a typo never becomes a
// row, and the answer carries the albums that key can actually see.
app.post('/api/immich/probe', async (req, reply) => {
const user = member(req, reply);
if (!user) return;
const b = body(req);
const url = cleanUrl(b.url);
const key = cleanKey(b.key);
if (!url || !key) return reply.status(400).send({ ok: false, error: 'invalid' });
return reply.send(await probe(url, key, b.type === 'share' ? 'share' : 'api'));
});
// Adding or editing one server. The key is checked against Immich before it is
// stored, so every saved server is one that answered — and the probe's version
// and download answer are kept with it, which is what lets the tree draw
// without asking Immich anything.
app.put('/api/immich/config', async (req, reply) => {
const user = member(req, reply);
if (!user) return;
const b = body(req);
const servers = immichServers(user.id);
const existing = typeof b.id === 'string' ? servers.find((s) => s.id === b.id) : undefined;
if (b.id !== undefined && !existing) return reply.status(404).send({ error: 'unknown server' });
// A partial edit (rename, retick the albums) keeps what it does not carry.
const url = cleanUrl(b.url) ?? existing?.url ?? null;
const key = cleanKey(b.key) ?? existing?.key ?? null;
const type: 'api' | 'share' = b.type === 'share' || (existing?.type === 'share' && b.type === undefined) ? 'share' : 'api';
if (!url || !key) return reply.status(400).send({ error: 'url and key are required' });
const probed = await probe(url, key, type);
if (!probed.ok) return reply.send(probed);
const entry: ImmichServer = {
id: existing?.id ?? randomBytes(8).toString('hex'),
name: cleanName(b.name ?? existing?.name, url),
url,
key,
type,
version: probed.version,
canDownload: probed.canDownload,
albums: 'albums' in b ? cleanAlbums(b.albums) : existing?.albums ?? [],
};
setImmichServers(user.id, existing ? servers.map((s) => (s.id === entry.id ? entry : s)) : [...servers, entry]);
return reply.send({ ok: true, server: view(entry) });
});
// Forgetting one server: the key goes with it, and the synced rows in the
// browser's own catalogue become unreachable, which is what the client shows.
app.delete('/api/immich/config', async (req, reply) => {
const user = member(req, reply);
if (!user) return;
const id = (req.query as { id?: string } | undefined)?.id;
const servers = immichServers(user.id);
const kept = servers.filter((s) => s.id !== id);
if (!id || kept.length === servers.length) return reply.status(404).send({ error: 'unknown server' });
setImmichServers(user.id, kept);
return reply.send({ ok: true, servers: kept.map(view) });
});
// The ticked albums. An empty list is a decision, not a missing value: it
// means "every album this key can see" (see the plan, the album-ID field).
app.put('/api/immich/albums', async (req, reply) => {
const user = member(req, reply);
if (!user) return;
const b = body(req);
const servers = immichServers(user.id);
const server = servers.find((s) => s.id === b.id);
if (!server) return reply.status(404).send({ error: 'unknown server' });
server.albums = cleanAlbums(b.selected);
setImmichServers(user.id, servers);
return reply.send({ ok: true, albums: server.albums });
});
// The live album list, for the dialog that ticks them. Deliberately not cached
// anywhere: a new album on Immich has to appear the next time it is opened.
app.get('/api/immich/albums', async (req, reply) => {
const user = member(req, reply);
if (!user) return;
const server = pick(user, reply, (req.query as { id?: string }).id);
if (!server) return;
const albums = await listAlbums(server);
if (!Array.isArray(albums)) return reply.status(502).send(albums);
return reply.send({ ok: true, albums });
});
// One page of photos: an album's, or — with no album at all — everything the
// key can see, which is the "read all of it" case of the album-ID field. A
// share link is one album read whole; an API key searches. Both answer in the
// same page shape, so the client's sync loop has one case.
app.get('/api/immich/assets', async (req, reply) => {
const user = member(req, reply);
if (!user) return;
const q = req.query as Record<string, string | undefined>;
const server = pick(user, reply, q.server);
if (!server) return;
const page = Math.max(1, Math.trunc(Number(q.page ?? 1)) || 1);
const size = Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(Number(q.size ?? 100)) || 100));
const album = q.album && q.album !== 'all' ? q.album : null;
if (album && !UUID_RE.test(album)) return reply.status(400).send({ error: 'invalid album id' });
// What the shelf's search box carries: words to look for in a file's own name,
// or the name of a tag. A share link has no search of its own, so its one
// album is sifted here instead.
const search = (q.q ?? '').trim().slice(0, MAX_QUERY);
if (server.type === 'share') {
const res = await call(server, { path: '/shared-links/me' });
if (!res.ok) return reply.status(502).send(refusal(res));
const me = await jsonOf<{ assets?: RawAsset[] }>(res);
const needle = search.toLowerCase();
const all = (me?.assets ?? [])
.filter((a) => a.type === 'IMAGE' || a.type === undefined)
.filter((a) => !needle || (a.originalFileName ?? '').toLowerCase().includes(needle));
const start = (page - 1) * size;
return reply.send({
ok: true,
page,
size,
total: all.length,
hasMore: start + size < all.length,
items: all.slice(start, start + size).map(assetRow),
});
}
if (album) {
// First try GET /albums/:id (supported across many Immich versions)
const albRes = await call(server, { path: `/albums/${album}` });
if (albRes.ok) {
const albData = await jsonOf<{ assets?: RawAsset[] }>(albRes);
if (Array.isArray(albData?.assets) && albData.assets.length > 0) {
const allAssets = albData.assets.filter((a) => a.type === 'IMAGE' || a.type === undefined);
const start = (page - 1) * size;
return reply.send({
ok: true,
page,
size,
total: allAssets.length,
hasMore: start + size < allAssets.length,
items: allAssets.slice(start, start + size).map(assetRow),
});
}
}
}
// One query, one answer. Immich reads every field it is handed as one more
// thing an asset must be — `originalFileName` with `tagIds` is a file called
// this *and* tagged that — so the box is read as a tag when it names one, and
// as a file name otherwise, which keeps the page a page of one list.
// ponytail: a query that names a tag finds only that tag's frames; fold the
// two answers into one list if a reader ever needs both at once.
const tagIds = search ? await tagIdsMatching(server, search) : [];
const res = await call(server, {
method: 'POST',
path: '/search/metadata',
timeout: 60_000,
json: {
size,
page,
withExif: true,
type: 'IMAGE',
...(album ? { albumIds: [album] } : {}),
...(q.takenBefore ? { takenBefore: q.takenBefore } : {}),
...(q.takenAfter ? { takenAfter: q.takenAfter } : {}),
...(search ? (tagIds.length ? { tagIds } : { originalFileName: search }) : {}),
},
});
if (!res.ok) return reply.status(502).send(refusal(res));
const found = await jsonOf<any>(res);
// Robust parsing across all Immich versions:
// Immich v1: found.assets.items
// Immich v2+: found.items
// Raw array: Array.isArray(found)
const rawItems: RawAsset[] = found?.assets?.items ?? found?.items ?? (Array.isArray(found) ? found : []);
const total: number | null = found?.assets?.total ?? found?.total ?? found?.count ?? null;
// What says another page is there: a page that came back full, or the
// server's own word for it. `assets.total` is not the size of the library —
// in Immich v2 it is the size of the page it came with — and read as the
// library it made every page but one look like there was more behind it.
const hasMore: boolean =
rawItems.length === size ||
(typeof found?.nextCursor === 'string' && !!found.nextCursor) ||
(typeof found?.assets?.nextPage === 'string' && !!found.assets.nextPage) ||
(typeof found?.nextPage === 'string' && !!found.nextPage);
return reply.send({
ok: true,
page,
size,
total,
hasMore,
items: rawItems.map(assetRow),
});
});
// The timeline buckets (years/months), so the client knows all available years and photo counts in 1 call.
app.get('/api/immich/buckets', async (req, reply) => {
const user = member(req, reply);
if (!user) return;
const q = req.query as Record<string, string | undefined>;
const server = pick(user, reply, q.server);
if (!server) return;
const size = q.size === 'YEAR' ? 'YEAR' : 'MONTH';
const res = await call(server, { path: '/timeline/buckets', query: { size }, timeout: 15_000 });
if (!res.ok) return reply.status(502).send(refusal(res));
const buckets = await jsonOf<any>(res);
return reply.send({ ok: true, buckets: Array.isArray(buckets) ? buckets : [] });
});
// An Immich-generated image, streamed straight through. Always the size the
// caller asked for: the grid asks for `thumbnail`, the stage for `preview`,
// and only the studio ever asks for the original.
app.get('/api/immich/thumb', async (req, reply) => {
const user = member(req, reply);
if (!user) return;
const q = req.query as Record<string, string | undefined>;
const server = pick(user, reply, q.server);
if (!server) return;
if (!UUID_RE.test(q.id ?? '')) return reply.status(400).send({ error: 'invalid asset id' });
const size = THUMB_SIZES.has(q.size ?? '') ? (q.size as string) : 'thumbnail';
const res = await call(server, { path: `/assets/${q.id}/thumbnail`, query: { size }, timeout: FETCH_MS });
if (!res.ok || !res.body) return reply.status(res.status === 404 ? 404 : 502).send(refusal(res));
reply.header('content-type', res.headers.get('content-type') ?? 'image/webp');
// The same bytes for every visitor of this account and a day of browser
// cache: the grid scrolls on the second look without touching Immich.
reply.header('cache-control', 'private, max-age=86400');
return reply.send(Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]));
});
// The file the studio is about to edit, fetched once and never cached: it is
// by far the largest thing this API ever sends.
app.get('/api/immich/original', async (req, reply) => {
const user = member(req, reply);
if (!user) return;
const q = req.query as Record<string, string | undefined>;
const server = pick(user, reply, q.server);
if (!server) return;
if (!UUID_RE.test(q.id ?? '')) return reply.status(400).send({ error: 'invalid asset id' });
const res = await call(server, { path: `/assets/${q.id}/original`, timeout: FETCH_MS });
if (!res.ok || !res.body) return reply.status(res.status === 404 ? 404 : 502).send(refusal(res));
reply.header('content-type', res.headers.get('content-type') ?? 'application/octet-stream');
const length = res.headers.get('content-length');
if (length) reply.header('content-length', length);
reply.header('cache-control', 'no-store');
return reply.send(Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]));
});
// Upload an edited photo from Studio to Immich Cloud
app.post(
'/api/immich/upload',
{ bodyLimit: 50 * 1024 * 1024 },
async (req, reply) => {
const user = member(req, reply);
if (!user) return;
const q = req.query as Record<string, string | undefined>;
const server = pick(user, reply, q.server);
if (!server) return;
if (server.type !== 'api') {
return reply.status(400).send({ error: 'Upload is only supported for API-key Immich connections' });
}
const body = req.body;
if (!Buffer.isBuffer(body) || body.length === 0) {
return reply.status(400).send({ error: 'invalid body or empty photo' });
}
const rawName = (q.name ?? '').trim() || `recipescam_${Date.now()}.jpg`;
const fileName = rawName.endsWith('.jpg') || rawName.endsWith('.jpeg') || rawName.endsWith('.png') ? rawName : `${rawName}.jpg`;
const form = new FormData();
form.append('assetData', new Blob([body], { type: 'image/jpeg' }), fileName);
form.append('deviceAssetId', `rc_${Date.now()}_${randomBytes(4).toString('hex')}`);
form.append('deviceId', 'RecipesCam');
form.append('fileCreatedAt', new Date().toISOString());
form.append('fileModifiedAt', new Date().toISOString());
form.append('isFavorite', 'false');
try {
let immichRes = await fetch(new URL(`${server.url}/api/assets`), {
method: 'POST',
headers: {
'x-api-key': server.key,
},
body: form,
signal: AbortSignal.timeout(60_000),
});
if (immichRes.status === 404) {
// Fallback for older Immich versions (< 1.106)
const fallbackForm = new FormData();
fallbackForm.append('assetData', new Blob([body], { type: 'image/jpeg' }), fileName);
fallbackForm.append('deviceAssetId', `rc_${Date.now()}_${randomBytes(4).toString('hex')}`);
fallbackForm.append('deviceId', 'RecipesCam');
fallbackForm.append('fileCreatedAt', new Date().toISOString());
fallbackForm.append('fileModifiedAt', new Date().toISOString());
fallbackForm.append('isFavorite', 'false');
immichRes = await fetch(new URL(`${server.url}/api/asset/upload`), {
method: 'POST',
headers: {
'x-api-key': server.key,
},
body: fallbackForm,
signal: AbortSignal.timeout(60_000),
});
}
if (!immichRes.ok) {
const errText = await immichRes.text().catch(() => '');
return reply.status(immichRes.status).send({ error: 'Immich upload failed', details: errText });
}
const data = await immichRes.json().catch(() => ({}));
const assetId = (data as any)?.id;
// If target album is given, add to album
const targetAlbum = q.album || (server.albums?.length === 1 ? server.albums[0] : undefined);
if (targetAlbum && assetId) {
try {
await fetch(new URL(`${server.url}/api/albums/${targetAlbum}/assets`), {
method: 'PUT',
headers: {
'x-api-key': server.key,
'content-type': 'application/json',
},
body: JSON.stringify({ ids: [assetId] }),
signal: AbortSignal.timeout(10_000),
});
} catch {}
}
return reply.send({ ok: true, id: assetId, result: data });
} catch (err: any) {
return reply.status(502).send({ error: 'Failed to connect to Immich server', details: String(err) });
}
}
);
}
+121 -40
View File
@@ -1,12 +1,13 @@
import { recipeFile } from './recipeFile';
import { sendVerificationMail } from './mailer';
import { placeName } from './place';
import { immichRoutes } from './immich';
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
import { spawn, spawnSync } from 'node:child_process';
import { exec, spawn, spawnSync } from 'node:child_process';
import { createHash, randomBytes } from 'node:crypto';
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, join } from 'node:path';
import { basename, dirname, join } from 'node:path';
import {
DATA_DIR,
MAX_PHOTO_BYTES,
@@ -52,6 +53,7 @@ import {
setUserAvatar,
setUserBlocked,
setUserPassword,
setUserPro,
setUserRemoved,
topRatedPhotos,
updateRecipe,
@@ -85,19 +87,36 @@ const ADMIN_EMAILS = new Set(
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
);
const isAdmin = (user: User) => ADMIN_EMAILS.has(user.email.toLowerCase());
const isAdmin = (user: Pick<User, 'email'>) => ADMIN_EMAILS.has(user.email.toLowerCase());
// What an account is worth. A signup proves nothing until the address it gave
// is confirmed, so an unverified account is a guest with a name: the PRO tier,
// its own listings and every write stay shut. Admins come from the
// deployment's own allowlist — trusted by construction, so no letter is needed
// and a broken relay cannot lock the operator out of their own site.
const isVerified = (user: User) => user.emailVerified === 1 || isAdmin(user);
// What an account is worth. Being signed in is the basic tier and nothing more
// is required of it (see requireMember): the account has its own recipes and
// its own photo folder. This is the proof of address, which the PRO tier still
// carries on top. Admins come from the deployment's own allowlist — trusted by
// construction, so no letter is needed and a broken relay cannot lock the
// operator out of their own site.
const isVerified = (user: Pick<User, 'email' | 'emailVerified'>) => user.emailVerified === 1 || isAdmin(user);
// The one date in the tier model: an account that signed up before it keeps the
// studio it was promised, so its "Activated Pro" box is ticked by the calendar
// rather than by the operator. Signups from the cutoff on start basic and the
// box is theirs to tick (or the address to prove).
const PRO_CUTOFF_MS = Date.parse('2026-12-01T00:00:00Z');
const grandfathered = (createdAt: string) => Date.parse(createdAt) < PRO_CUTOFF_MS;
// The PRO tier itself: a proven address, an admin, a grandfathered signup, or a
// grant the operator ticked in the users table. The grant only ever adds —
// unticking an account that has already proven its address leaves it PRO,
// because the address is still the stronger proof of the two.
const isPro = (user: Pick<User, 'email' | 'emailVerified' | 'pro' | 'createdAt'>) =>
isVerified(user) || user.pro === 1 || grandfathered(user.createdAt);
// The public shape of an account. `admin` is the allowlist's answer, so the
// client can decide whether to offer /admin without a second round trip — and
// the server still enforces it on every admin route below.
// `verified` is the studio's PRO gate: true only for a proven address.
// `verified` is the proof of address (the client offers the resend/verify
// routes off it); `pro` is the studio's own gate, which is that proof or the
// operator's grant.
// `avatar` is a URL the client can drop straight into an <img>, or null when
// the account never picked a picture. The `v` is the stored file's own name, so
// the URL changes with the picture and can be cached hard.
@@ -106,6 +125,7 @@ const publicUser = (user: User) => ({
email: user.email,
admin: isAdmin(user),
verified: isVerified(user),
pro: isPro(user),
avatar: user.avatar ? `/api/users/${user.id}/avatar?v=${user.avatar.split('.')[0]}` : null,
});
@@ -130,9 +150,13 @@ app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body,
// Photo uploads are the raw image bytes, not multipart: one file per request
// needs no boundary parsing, so no dependency and no parser attack surface.
app.addContentTypeParser(['image/jpeg', 'image/png', 'image/webp'], { parseAs: 'buffer' }, (_req, body, done) => {
done(null, body);
});
app.addContentTypeParser(
['image/jpeg', 'image/png', 'image/webp', 'application/octet-stream'],
{ parseAs: 'buffer', bodyLimit: 50 * 1024 * 1024 },
(_req, body, done) => {
done(null, body);
}
);
// A restore body is a whole data dir, so it is the one body far past the
// instance limit. Buffered, not streamed: `tar` needs the archive to be
@@ -253,17 +277,29 @@ function auth(req: FastifyRequest): User | undefined {
return token ? sessionUser(token) : undefined;
}
// The gate every personal route takes instead of `auth`. Two different
// refusals, because the studio acts on them differently: 401 sends a guest to
// the sign-in dialog, 403 asks a signed-in account to open its mail.
// The gate every personal route takes instead of `auth`: the basic tier is
// simply being signed in — recipes, the photo folder, an export of the photo
// being edited. One refusal, because the studio acts on it the one way: 401
// sends a guest to the sign-in dialog.
function requireMember(req: FastifyRequest, reply: FastifyReply): User | undefined {
const user = auth(req);
if (!user) {
void reply.status(401).send({ error: 'unauthorized' });
return undefined;
}
return user;
}
// The PRO tier's own gate, on the routes that are the tier: the geocoder is the
// one left (every other route that used to take it is the basic tier's now).
function requirePro(req: FastifyRequest, reply: FastifyReply): User | undefined {
const user = auth(req);
if (!user) {
void reply.status(401).send({ error: 'unauthorized' });
return undefined;
}
if (!isVerified(user)) {
void reply.status(403).send({ error: 'email not verified' });
if (!isPro(user)) {
void reply.status(403).send({ error: 'pro required' });
return undefined;
}
return user;
@@ -389,6 +425,30 @@ async function lookupGeo(ip: string): Promise<GeoPlace | null> {
// ---- routes ----
app.get('/api/health', async () => ({ ok: true }));
// Native file manager launcher (Open in window explorer)
app.post('/api/open-explorer', async (req, reply) => {
const b = bodyOf(req);
if (!b || typeof b.path !== 'string') return reply.status(400).send({ ok: false, error: 'Invalid path' });
const rawPath = b.path.trim();
if (!rawPath) return reply.status(400).send({ ok: false, error: 'Path empty' });
const platform = process.platform;
try {
if (platform === 'win32') {
const winPath = rawPath.replace(/\//g, '\\');
exec(`explorer.exe /select,"${winPath}"`);
} else if (platform === 'darwin') {
exec(`open -R "${rawPath}"`);
} else {
const dir = dirname(rawPath);
exec(`xdg-open "${dir}"`);
}
return reply.send({ ok: true });
} catch (err: any) {
return reply.status(500).send({ ok: false, error: String(err?.message || err) });
}
});
// The counter's one write. Public and unauthenticated by design: it is fired by
// a beacon from every page, so it must never be a way to probe who is signed in
// — it answers 204 whatever happens and echoes nothing back.
@@ -447,17 +507,19 @@ app.post('/api/ratings', async (req, reply) => {
// lifetime. The windows are the server's clock in UTC, so every visitor and
// every cache agrees on which frame is today's; the week starts on Monday, the
// ISO week the page's own copy implies. Public like the tally it is drawn from.
// A quiet day — and a quiet Monday, when the week's window is minutes old — is
// the normal state of an install, and an empty window must not empty the hero,
// so the all-time bests stand in under a label of their own. Asked for only
// when both windows are empty: a day with one vote pays nothing for it.
const HIGHLIGHT_LIMIT = 5;
app.get('/api/highlights', async () => {
const now = new Date();
const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
const monday = midnight - ((new Date(midnight).getUTCDay() + 6) % 7) * 86_400_000;
return {
highlights: {
day: topRatedPhotos(new Date(midnight).toISOString(), HIGHLIGHT_LIMIT),
week: topRatedPhotos(new Date(monday).toISOString(), HIGHLIGHT_LIMIT),
},
};
const day = topRatedPhotos(new Date(midnight).toISOString(), HIGHLIGHT_LIMIT);
const week = topRatedPhotos(new Date(monday).toISOString(), HIGHLIGHT_LIMIT);
const ever = day.length || week.length ? [] : topRatedPhotos('', HIGHLIGHT_LIMIT);
return { highlights: { day, week, ever } };
});
app.post('/api/auth/signup', async (req, reply) => {
@@ -496,7 +558,7 @@ app.get('/api/auth/verify', async (req, reply) => {
// fresh code costs one of the three resends an hour, so five guesses per code
// is the budget an attacker has either way — and the cap lives with the secret.
app.post('/api/auth/verify-code', async (req, reply) => {
// `auth`, not `requirePro`: the whole point of the route is the account that
// `auth`, not `requireMember`: the whole point of the route is the account that
// has not passed the gate yet.
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
@@ -516,7 +578,7 @@ app.post('/api/auth/verify-code', async (req, reply) => {
const allowResend = limiter(3, 60 * 60_000);
app.post('/api/auth/resend-verification', async (req, reply) => {
// `auth`, not `requirePro`: the whole point of the route is the account that
// `auth`, not `requireMember`: the whole point of the route is the account that
// has not passed the gate yet.
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
@@ -562,7 +624,7 @@ app.get('/api/auth/me', async (req, reply) => {
// password is required either way, so a stolen cookie alone cannot lock the
// owner out — and the login limiter caps guesses at it.
app.patch('/api/auth/me', async (req, reply) => {
// `auth`, not `requirePro`: editing your own profile is how an unverified
// `auth`, not `requireMember`: editing your own profile is how an unverified
// account fixes a mistyped address, so this route stays open to it.
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
@@ -599,13 +661,13 @@ app.patch('/api/auth/me', async (req, reply) => {
});
app.get('/api/recipes', async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
return reply.status(200).send({ recipes: listRecipes(user.id) });
});
app.post('/api/recipes', async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
const b = bodyOf(req);
const payload = b && recipePayload(b);
@@ -616,7 +678,7 @@ app.post('/api/recipes', async (req, reply) => {
});
app.put<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'recipe not found' });
@@ -630,7 +692,7 @@ app.put<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => {
});
app.delete<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'recipe not found' });
@@ -690,7 +752,7 @@ app.get('/api/photos', async () => ({ photos: listPhotos() }));
// The caller's own folder — the count the studio's SAVE PHOTO shows comes from
// here, and the admin drill-down reads the same rows through /admin/photos.
app.get('/api/photos/mine', async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
return reply.status(200).send({ photos: listPhotosByUser(user.id) });
});
@@ -710,7 +772,7 @@ app.get('/api/place', async (req, reply) => {
});
app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
if (!allowUpload(String(user.id))) return tooMany(reply);
@@ -741,7 +803,7 @@ app.put<{ Params: { id: string } }>(
'/api/photos/:id',
{ bodyLimit: MAX_PHOTO_BYTES + 8192 },
async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
@@ -771,7 +833,7 @@ app.put<{ Params: { id: string } }>(
// A profile picture is the same deal as a photo: raw bytes, sniffed, written
// under a server-generated name. The picture it replaces goes with it.
app.post('/api/auth/avatar', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
if (!allowUpload(String(user.id))) return tooMany(reply);
@@ -847,7 +909,7 @@ app.put<{ Params: { id: string } }>(
'/api/photos/:id/base',
{ bodyLimit: MAX_PHOTO_BYTES + 8192 },
async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
if (!allowUpload(String(user.id))) return tooMany(reply);
const id = Number(req.params.id);
@@ -872,7 +934,7 @@ app.put<{ Params: { id: string } }>(
);
app.get<{ Params: { id: string } }>('/api/photos/:id/base', async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'not_found' });
@@ -919,7 +981,7 @@ app.get<{ Params: { id: string } }>('/api/photos/:id/preset.recipe', async (req,
// consent), and only their own row is reachable — the user_id in the WHERE is
// the authorisation.
app.patch<{ Params: { id: string } }>('/api/photos/:id', async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
@@ -934,7 +996,7 @@ app.patch<{ Params: { id: string } }>('/api/photos/:id', async (req, reply) => {
// dropped when the caller owns it (or curates the whole strip), and the file
// goes with it — `deletePhotoOf` / `deletePhoto` return the name to unlink.
app.delete<{ Params: { id: string } }>('/api/photos/:id', async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
@@ -986,6 +1048,10 @@ app.get('/api/admin/users', async (req, reply) => {
admin: ADMIN_EMAILS.has(u.email),
blocked: !!u.blocked,
removed: !!u.deletedAt,
// The box shows the truth of `isPro`, not the stored column: an
// allowlisted operator and a signup from before the cutoff are PRO
// whatever the column says, and unticking them cannot take that away.
pro: isPro(u),
})),
});
});
@@ -1027,8 +1093,14 @@ app.patch<{ Params: { id: string } }>('/api/admin/users/:id', async (req, reply)
if (typeof b.removed !== 'boolean') return reply.status(400).send({ error: 'invalid removed' });
setUserRemoved(id, b.removed);
}
if (b.pro !== undefined) {
if (typeof b.pro !== 'boolean') return reply.status(400).send({ error: 'invalid pro' });
setUserPro(id, b.pro);
}
const row = listUsersWithCounts().find((u) => u.id === id);
return reply.status(200).send({ user: { ...row, blocked: !!row?.blocked, removed: !!row?.deletedAt } });
return reply
.status(200)
.send({ user: { ...row, blocked: !!row?.blocked, removed: !!row?.deletedAt, pro: !!row && isPro(row) } });
});
app.delete<{ Params: { id: string } }>('/api/admin/users/:id', async (req, reply) => {
@@ -1097,6 +1169,15 @@ app.patch<{ Params: { id: string } }>('/api/admin/photos/:id', async (req, reply
return reply.status(200).send({ id, slots: set });
});
// ---- Immich, as a second photo source ---------------------------------------
// The LIBRARY module may carry Immich albums beside the folders on disk. The
// browser cannot talk to Immich itself (the SPA is served with COEP require-corp
// and an Immich key must not reach it), so the routes behind /api/immich/*
// proxy it, read-only, with each account's own key kept in the users table.
// Nothing here is required for the rest of the API: with no server added, the
// dialog is the only thing that ever calls these.
immichRoutes(app, requireMember);
// ---- backup / restore -----------------------------------------------------
// The deployment's whole state is DATA_DIR: one SQLite file plus the two media
// folders. The archive is a plain tar.gz of exactly those three, which makes
+414
View File
@@ -0,0 +1,414 @@
// Immich proxy self-check for the API. Boots the real server against a
// throwaway DATA_DIR and a fake Immich on loopback, then walks the boundaries
// that matter: who may reach the proxy, what a key is allowed to become, and
// what the browser is allowed to learn about it.
//
// npm run test:immich (from docker/backend/)
//
// Node only — no test framework, no network beyond loopback. The fake Immich is
// deliberately strict: it refuses any request that does not carry the key it
// expects, so a route that forgets to attach one fails here rather than in the
// LIBRARY.
import { spawn } from 'node:child_process';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const PORT = Number(process.env.TEST_PORT || 3412);
const BASE = `http://127.0.0.1:${PORT}/api`;
const DATA_DIR = mkdtempSync(join(tmpdir(), 'recipescam-immich-'));
const KEY = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaabcd';
const KEY_NODL = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbb1234'; // the same key without asset.download
const SHARE_KEY = 'share-key-1234567890';
const ALBUM_A = '11111111-1111-4111-8111-111111111111';
const ALBUM_B = '22222222-2222-4222-8222-222222222222';
const TAG_HOME = '33333333-3333-4333-8333-333333333333';
const TAG_TRIP = '44444444-4444-4444-8444-444444444444';
const assetId = (n) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
const THUMB = Buffer.from('RIFF0000WEBPfake-tile-bytes');
const ORIGINAL = Buffer.from('II*\0fake-original-bytes');
// The first frame is the one tagged `Nhà`, so a search for that word has exactly
// one answer to find.
const asset = (n, album) => ({
id: assetId(n),
type: 'IMAGE',
originalFileName: `photo-${n}.jpg`,
fileCreatedAt: `2026-01-0${n}T10:00:00.000Z`,
width: 4000,
height: 3000,
exifInfo: { fileSizeInByte: 1000 * n },
album,
tags: n === 1 ? TAG_HOME : n === 2 ? TAG_TRIP : null,
});
let pass = 0;
let fail = 0;
const check = (name, ok, detail = '') => {
if (ok) {
pass++;
console.log(`PASS ${name}`);
} else {
fail++;
console.log(`FAIL ${name}${detail ? ` :: ${detail}` : ''}`);
}
};
// ---- the fake Immich --------------------------------------------------------
// Every request is recorded, so "did the proxy attach the key, and did it ask
// for the size it promised?" is an assertion rather than a hope.
const seen = [];
function startFake() {
const server = createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1');
const apiKey = req.headers['x-api-key'];
const shareKey = url.searchParams.get('key');
seen.push({
path: url.pathname,
size: url.searchParams.get('size'),
apiKey: apiKey ?? null,
shareKey: shareKey ?? null,
key: apiKey ?? shareKey ?? null,
range: req.headers.range ?? null,
});
const send = (status, payload, type = 'application/json') => {
res.writeHead(status, { 'content-type': type });
res.end(Buffer.isBuffer(payload) ? payload : JSON.stringify(payload));
};
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {};
const authed = apiKey === KEY || apiKey === KEY_NODL;
// A share link is a key too, but only Immich's asset routes read it from
// `?key=` — the album and search routes are API-key territory.
const shareAuthed = shareKey === SHARE_KEY;
if (url.pathname === '/api/server/version') return send(200, { major: 3, minor: 1, patch: 0, prerelease: null });
if (url.pathname === '/api/shared-links/me') {
if (shareKey !== SHARE_KEY) return send(401, { message: 'Authentication required' });
return send(200, {
id: 'link-1',
album: { id: ALBUM_B, albumName: 'Album chia sẻ', assetCount: 2 },
allowDownload: true,
assets: [asset(5), asset(6)],
});
}
if (url.pathname === '/api/albums') {
if (!authed) return send(401, { message: 'Authentication required' });
return send(200, [
{ id: ALBUM_A, albumName: 'Nhà', assetCount: 2, albumThumbnailAssetId: assetId(1), shared: false },
{ id: ALBUM_B, albumName: 'Du lịch', assetCount: 1, albumThumbnailAssetId: assetId(3), shared: true },
]);
}
// A tag list, and the one key that may not read it: searching by tag is the
// one search a narrow key loses, and it has to lose it as an empty list
// rather than as a failed search.
if (url.pathname === '/api/tags') {
if (!authed && !shareAuthed) return send(401, { message: 'Authentication required' });
if (apiKey === KEY_NODL) return send(403, { message: 'Missing required permission: tag.read' });
return send(200, [
{ id: TAG_HOME, name: 'Nhà', value: 'nhà' },
{ id: TAG_TRIP, name: 'du lịch', value: 'du lịch' },
]);
}
if (url.pathname === '/api/search/metadata') {
const all = [asset(1, ALBUM_A), asset(2, ALBUM_A), asset(3, ALBUM_B)];
let filtered = Array.isArray(body.albumIds) ? all.filter((a) => body.albumIds.includes(a.album)) : all;
// The same reading the proxy makes of a search box: every field given is
// one more thing an asset must be.
if (Array.isArray(body.tagIds)) filtered = filtered.filter((a) => body.tagIds.includes(a.tags));
if (typeof body.originalFileName === 'string') {
filtered = filtered.filter((a) => a.originalFileName.includes(body.originalFileName));
}
const size = Number(body.size ?? 100);
const page = Number(body.page ?? 1);
const items = filtered.slice((page - 1) * size, page * size);
return send(200, {
albums: { items: [], total: 0 },
assets: { items, total: filtered.length, count: items.length, nextPage: null },
});
}
if (!authed && !shareAuthed) return send(401, { message: 'Authentication required' });
const thumb = url.pathname.match(/^\/api\/assets\/([^/]+)\/thumbnail$/);
if (thumb) {
if (![1, 2, 3, 5, 6].some((n) => assetId(n) === thumb[1])) return send(404, { message: 'Not found' });
return send(200, THUMB, 'image/webp');
}
const original = url.pathname.match(/^\/api\/assets\/([^/]+)\/original$/);
if (original) {
if (apiKey === KEY_NODL) return send(403, { message: 'Missing required permission: asset.download' });
if (req.headers.range) {
res.writeHead(206, { 'content-type': 'image/jpeg', 'content-range': 'bytes 0-0/24' });
return res.end(ORIGINAL.subarray(0, 1));
}
return send(200, ORIGINAL, 'image/jpeg');
}
return send(404, { message: 'Not found' });
});
});
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port })));
}
// ---- one cookie jar per actor, as in test/security.mjs ----------------------
function actor() {
let cookie = '';
return {
get cookie() {
return cookie;
},
async req(path, init = {}) {
const headers = { ...(init.headers ?? {}) };
if (cookie) headers.cookie = cookie;
const res = await fetch(BASE + path, { ...init, headers });
const set = res.headers.getSetCookie?.() ?? [];
for (const line of set) {
const value = line.split(';')[0];
if (value.startsWith('rc_session=')) cookie = value;
}
const type = res.headers.get('content-type') ?? '';
const body = type.includes('json') ? await res.json() : Buffer.from(await res.arrayBuffer());
return { status: res.status, headers: res.headers, body };
},
post(path, payload) {
return this.req(path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
},
put(path, payload) {
return this.req(path, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
},
async signup(email) {
const res = await this.post('/auth/signup', { email, password: 'supersecret1' });
if (res.status !== 201) throw new Error(`signup for ${email} answered ${res.status}`);
return res;
},
};
}
const fake = await startFake();
const tsx = join(ROOT, 'node_modules/.bin/tsx');
const entry = existsSync(tsx) ? [tsx, 'src/server.ts'] : ['dist/server.js'];
const server = spawn(process.execPath, entry, {
cwd: ROOT,
env: {
...process.env,
PORT: String(PORT),
DATA_DIR,
NODE_ENV: 'test',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let serverLog = '';
server.stdout.on('data', (d) => (serverLog += d));
server.stderr.on('data', (d) => (serverLog += d));
async function waitForServer() {
for (let i = 0; i < 100; i++) {
try {
if ((await fetch(`${BASE}/health`)).ok) return true;
} catch {
/* not up yet */
}
await new Promise((r) => setTimeout(r, 100));
}
return false;
}
const url = `http://127.0.0.1:${fake.port}`;
try {
if (!(await waitForServer())) throw new Error(`server never came up:\n${serverLog}`);
const stamp = Date.now();
const user = actor();
const other = actor();
await user.signup(`immich${stamp}@test.local`);
await other.signup(`immich-other${stamp}@test.local`);
// ---- who may reach the proxy at all --------------------------------------
const anonymous = await fetch(`${BASE}/immich/config`);
check('the proxy refuses a signed-out caller', anonymous.status === 401, `got ${anonymous.status}`);
check('the proxy refuses a signed-out thumb', (await fetch(`${BASE}/immich/thumb?server=x&id=${assetId(1)}`)).status === 401);
// ---- the "check this key" probe -----------------------------------------
const noKey = await user.post('/immich/probe', { url });
check('a probe without a key is refused', noKey.status === 400 && noKey.body?.error === 'invalid', JSON.stringify(noKey.body));
const badScheme = await user.post('/immich/probe', { url: 'file:///etc', key: KEY });
check('a probe refuses a non-http address', badScheme.status === 400, JSON.stringify(badScheme.body));
const dead = await user.post('/immich/probe', { url: 'http://127.0.0.1:1', key: KEY });
check('a probe of a dead host answers unreachable', dead.body?.error === 'unreachable', JSON.stringify(dead.body));
const wrongKey = await user.post('/immich/probe', { url, key: 'nope' });
check('a probe with a wrong key answers auth', wrongKey.body?.error === 'auth', JSON.stringify(wrongKey.body));
const good = await user.post('/immich/probe', { url, key: KEY });
check('a probe reports the server version', good.body?.version === '3.1.0', JSON.stringify(good.body?.version));
check('a probe lists the albums the key sees', good.body?.albums?.length === 2, JSON.stringify(good.body?.albums));
check('a probe reports the album count', good.body?.albums?.[0]?.assetCount === 2, JSON.stringify(good.body?.albums?.[0]));
check('a probe reports download permission', good.body?.canDownload === true, JSON.stringify(good.body?.canDownload));
check('a probe of a key without asset.download says no', (await user.post('/immich/probe', { url, key: KEY_NODL })).body?.canDownload === false);
check('a probe stores nothing', (await user.req('/immich/config')).body?.servers?.length === 0);
// ---- storing a server ----------------------------------------------------
const saved = await user.put('/immich/config', { url, key: KEY, name: 'nas' });
const serverId = saved.body?.server?.id;
check('a server is saved', saved.status === 200 && typeof serverId === 'string', JSON.stringify(saved.body));
check('the saved name is kept', saved.body?.server?.name === 'nas', JSON.stringify(saved.body?.server?.name));
check('the key comes back masked', saved.body?.server?.keyMasked === '••••abcd', JSON.stringify(saved.body?.server?.keyMasked));
check('the key never comes back in clear', !JSON.stringify(saved.body).includes(KEY), JSON.stringify(saved.body));
const config = await user.req('/immich/config');
check('the saved list suggests no address of its own', config.body?.defaultUrl === undefined, JSON.stringify(config.body?.defaultUrl));
check('the saved server is listed with its version', config.body?.servers?.[0]?.version === '3.1.0', JSON.stringify(config.body?.servers?.[0]));
// A server saved without a name falls back to its host, which is what the tree
// shows beside the node.
const unnamed = await user.put('/immich/config', { url, key: KEY });
check('an unnamed server is named after its host', unnamed.body?.server?.name === '127.0.0.1', JSON.stringify(unnamed.body?.server?.name));
check('the same address twice is two servers', (await user.req('/immich/config')).body?.servers?.length === 2);
// ---- one account cannot touch another's servers --------------------------
check("another account sees none of it", (await other.req('/immich/config')).body?.servers?.length === 0);
const stolen = await other.req(`/immich/thumb?server=${serverId}&id=${assetId(1)}`);
check("another account's server id is unknown", stolen.status === 404, `got ${stolen.status}`);
const stolenDelete = await other.req(`/immich/config?id=${serverId}`, { method: 'DELETE' });
check("another account cannot delete it", stolenDelete.status === 404, `got ${stolenDelete.status}`);
const stolenEdit = await other.put('/immich/albums', { id: serverId, selected: [ALBUM_A] });
check("another account cannot retick its albums", stolenEdit.status === 404, `got ${stolenEdit.status}`);
// ---- ticking albums, and the pasted IDs ---------------------------------
const ticked = await user.put('/immich/albums', {
id: serverId,
selected: [ALBUM_A, `https://immich.example/albums/${ALBUM_B}`, ALBUM_A, 'not-a-uuid', ALBUM_B.toUpperCase()],
});
check(
'pasted ids are cleaned, deduped and capped',
JSON.stringify(ticked.body?.albums) === JSON.stringify([ALBUM_A, ALBUM_B]),
JSON.stringify(ticked.body?.albums),
);
const empty = await user.put('/immich/albums', { id: serverId, selected: [] });
check('an empty tick list is kept as "every album"', Array.isArray(empty.body?.albums) && empty.body.albums.length === 0);
// ---- the page of photos -------------------------------------------------
const live = await user.req(`/immich/albums?id=${serverId}`);
check('the live album list is filtered to the fields the tree needs', live.body?.albums?.[0]?.albumName === 'Nhà', JSON.stringify(live.body?.albums?.[0]));
check('the live album list keeps the count', live.body?.albums?.[1]?.assetCount === 1, JSON.stringify(live.body?.albums?.[1]));
const albumPage = await user.req(`/immich/assets?server=${serverId}&album=${ALBUM_A}&page=1&size=10`);
check('an album page returns that album only', albumPage.body?.items?.length === 2 && albumPage.body.total === 2, JSON.stringify(albumPage.body));
check('a row carries what the catalogue needs', albumPage.body?.items?.[0]?.name === 'photo-1.jpg', JSON.stringify(albumPage.body?.items?.[0]));
check('a short page ends the walk', albumPage.body?.hasMore === false, JSON.stringify(albumPage.body?.hasMore));
const allPhotos = await user.req(`/immich/assets?server=${serverId}&album=all&page=1&size=10`);
check('no album means every photo the key sees', allPhotos.body?.items?.length === 3, JSON.stringify(allPhotos.body?.items?.length));
const paged = await user.req(`/immich/assets?server=${serverId}&page=2&size=2`);
check('pages divide the same list', paged.body?.items?.length === 1 && paged.body?.hasMore === false, JSON.stringify(paged.body));
const badAlbum = await user.req(`/immich/assets?server=${serverId}&album=nope`);
check('a malformed album id is refused', badAlbum.status === 400, `got ${badAlbum.status}`);
// ---- the search box ------------------------------------------------------
const byName = await user.req(`/immich/assets?server=${serverId}&q=photo-2`);
check('a search reads the words as a file name', byName.body?.items?.length === 1 && byName.body?.items?.[0]?.name === 'photo-2.jpg', JSON.stringify(byName.body?.items));
const byTag = await user.req(`/immich/assets?server=${serverId}&q=${encodeURIComponent('Nhà')}`);
check('a search that names a tag finds that tag', byTag.body?.items?.length === 1 && byTag.body?.items?.[0]?.name === 'photo-1.jpg', JSON.stringify(byTag.body?.items));
check('the tag ids it found travel as tag ids', seen.at(-1)?.path === '/api/search/metadata', JSON.stringify(seen.at(-1)));
const noAnswer = await user.req(`/immich/assets?server=${serverId}&q=nothing-at-all`);
check('a search that answers nothing is an empty page, not a failure', noAnswer.status === 200 && noAnswer.body?.items?.length === 0 && noAnswer.body?.hasMore === false, JSON.stringify(noAnswer.body));
// ---- the pixels ---------------------------------------------------------
const tile = await user.req(`/immich/thumb?server=${serverId}&id=${assetId(1)}`);
check('a tile streams through the proxy', tile.status === 200 && tile.body.equals(THUMB), `got ${tile.status}`);
check('a tile keeps Immich own content type', tile.headers.get('content-type') === 'image/webp', tile.headers.get('content-type') ?? '');
check('a tile is cacheable for a day', /max-age=86400/.test(tile.headers.get('cache-control') ?? ''), tile.headers.get('cache-control') ?? '');
check('a tile defaults to the small size', seen.at(-1)?.size === 'thumbnail', JSON.stringify(seen.at(-1)));
check('a tile travels with the key', seen.at(-1)?.key === KEY, JSON.stringify(seen.at(-1)));
await user.req(`/immich/thumb?server=${serverId}&id=${assetId(1)}&size=preview`);
check('the stage size is passed through', seen.at(-1)?.size === 'preview', JSON.stringify(seen.at(-1)));
await user.req(`/immich/thumb?server=${serverId}&id=${assetId(1)}&size=../../etc`);
check('an unknown size falls back to the small one', seen.at(-1)?.size === 'thumbnail', JSON.stringify(seen.at(-1)));
const badAsset = await user.req(`/immich/thumb?server=${serverId}&id=../secret`);
check('a malformed asset id never reaches Immich', badAsset.status === 400, `got ${badAsset.status}`);
const missingAsset = await user.req(`/immich/thumb?server=${serverId}&id=${assetId(9)}`);
check('a missing asset stays a 404', missingAsset.status === 404, `got ${missingAsset.status}`);
const original = await user.req(`/immich/original?server=${serverId}&id=${assetId(1)}`);
check('an original streams through the proxy', original.status === 200 && original.body.equals(ORIGINAL), `got ${original.status}`);
check('an original is never cached', original.headers.get('cache-control') === 'no-store', original.headers.get('cache-control') ?? '');
// The studio is the only caller of an original, so a key without
// asset.download has to be a clear refusal rather than a broken download.
const noDownload = await user.put('/immich/config', { url, key: KEY_NODL, name: 'reader' });
const readerId = noDownload.body?.server?.id;
check('a key without download is still accepted', noDownload.status === 200 && noDownload.body?.server?.canDownload === false, JSON.stringify(noDownload.body));
const refused = await user.req(`/immich/original?server=${readerId}&id=${assetId(1)}`);
check('its original is refused with the reason', refused.status === 502 && refused.body?.error === 'auth', JSON.stringify(refused.body));
// The same narrow key searching by a tag name it cannot read: the 403 on the
// tag list is an empty list of tags, not a search that came back broken.
const narrowSearch = await user.req(`/immich/assets?server=${readerId}&q=${encodeURIComponent('Nhà')}`);
check('a key without tag.read still searches by file name', narrowSearch.status === 200 && narrowSearch.body?.items?.length === 0, JSON.stringify(narrowSearch.body));
// ---- a share link is one album ------------------------------------------
const shareProbe = await user.post('/immich/probe', { url, key: SHARE_KEY, type: 'share' });
check('a share key probes into its own single album', shareProbe.body?.albums?.length === 1, JSON.stringify(shareProbe.body?.albums));
check('the shared album is named from Immich', shareProbe.body?.albums?.[0]?.albumName === 'Album chia sẻ', JSON.stringify(shareProbe.body?.albums?.[0]));
const shareSaved = await user.put('/immich/config', { url, key: SHARE_KEY, type: 'share', name: 'Chia sẻ' });
const shareId = shareSaved.body?.server?.id;
check('a share server is stored', shareSaved.status === 200 && typeof shareId === 'string', JSON.stringify(shareSaved.body));
const shareAssets = await user.req(`/immich/assets?server=${shareId}&page=1&size=10`);
check('a share key lists its album whole', shareAssets.body?.items?.length === 2, JSON.stringify(shareAssets.body));
const shareSearch = await user.req(`/immich/assets?server=${shareId}&q=photo-5`);
check('a share link searches its one album', shareSearch.body?.items?.length === 1 && shareSearch.body?.items?.[0]?.name === 'photo-5.jpg', JSON.stringify(shareSearch.body?.items));
const shareTile = await user.req(`/immich/thumb?server=${shareId}&id=${assetId(5)}`);
check('a share tile streams too', shareTile.status === 200 && shareTile.body.equals(THUMB), `got ${shareTile.status}`);
check('a share request travels with ?key=', seen.at(-1)?.shareKey === SHARE_KEY, JSON.stringify(seen.at(-1)));
check('a share key is never sent as an x-api-key header', seen.at(-1)?.apiKey === null, JSON.stringify(seen.at(-1)));
// ---- forgetting a server -------------------------------------------------
const forgotten = await user.req(`/immich/config?id=${shareId}`, { method: 'DELETE' });
check('a server can be forgotten', forgotten.status === 200 && forgotten.body?.servers?.length === 3, JSON.stringify(forgotten.body?.servers?.length));
check('its id stops working', (await user.req(`/immich/thumb?server=${shareId}&id=${assetId(5)}`)).status === 404);
const unknownDelete = await user.req('/immich/config?id=deadbeef', { method: 'DELETE' });
check('forgetting an unknown server is a 404', unknownDelete.status === 404, `got ${unknownDelete.status}`);
// ---- no key ever reaches the browser ------------------------------------
const everything = JSON.stringify([
config.body,
saved.body,
(await user.req('/immich/config')).body,
live.body,
albumPage.body,
]);
check('no route echoes a key', !everything.includes(KEY) && !everything.includes(SHARE_KEY));
check('the server log holds no key either', !serverLog.includes(KEY) && !serverLog.includes(SHARE_KEY));
} catch (err) {
fail++;
console.log(`FAIL harness :: ${err && err.stack ? err.stack : err}`);
console.log(serverLog.slice(-2000));
} finally {
server.kill('SIGTERM');
fake.server.close();
rmSync(DATA_DIR, { recursive: true, force: true });
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail === 0 ? 0 : 1);
+31
View File
@@ -722,6 +722,37 @@ try {
JSON.stringify(afterDelete),
);
// ---- the award column on a quiet week ------------------------------------
// Both windows are the clock's, so a vote the API cannot backdate is aged here
// instead. Nothing has been voted on today or this week — the state the column
// used to answer with nothing at all — and the all-time bests have to stand in.
const aged = await user.upload(PNG, 'image/png');
const agedId = aged.body?.photo?.id;
await vote('10.9.9.5', `photo:${agedId}`, 4);
{
const db = new Database(join(DATA_DIR, 'recipescam.db'));
db.prepare('UPDATE ratings SET at = ? WHERE key = ?').run(
new Date(Date.now() - 30 * 86_400_000).toISOString(),
`photo:${agedId}`,
);
db.close();
}
const quiet = (await (await fetch(`${BASE}/highlights`)).json()).highlights ?? {};
check(
'a vote older than both windows leaves them empty',
(quiet.day ?? []).length === 0 && (quiet.week ?? []).length === 0,
JSON.stringify(quiet),
);
const ever = (quiet.ever ?? []).find((r) => r.id === agedId);
check('and the all-time bests stand in for them', ever?.avg === 4 && ever?.n === 1, JSON.stringify(ever));
await user.req(`/photos/${agedId}`, { method: 'DELETE' });
const nothing = (await (await fetch(`${BASE}/highlights`)).json()).highlights ?? {};
check(
'with nothing ever rated the column has no frame to show',
(nothing.ever ?? []).length === 0,
JSON.stringify(nothing),
);
// ---- pre-existing guarantees still hold ---------------------------------
const foreignRecipe = await user.req('/recipes/1', { method: 'DELETE' });
check("another account's recipe is not deletable", foreignRecipe.status === 404, `got ${foreignRecipe.status}`);
+231
View File
@@ -0,0 +1,231 @@
# Phân Tích Kỹ Thuật & Thuật Toán Clarity Chống Lỗi Viền "Stroke" (Halo-Free Local Contrast)
---
## 1. Hiện Tượng Viền "Stroke" (Halo Artifact) Là Gì?
Khi bạn kéo thanh trượt **Clarity** lên cao ($+50$ đến $+100$) trên một thuật toán chưa hoàn thiện:
* Quanh tán cây, mép ngọn núi, đường chân trời hoặc viền người nổi lên một **đường viền trắng sáng rực** hoặc **đường viền đen dày** như một nét vẽ viền (Stroke/Outline).
* Ảnh trông bị gắt, nhân tạo và có cảm giác "bẩn" ở các vùng biên tương phản cao.
```
MẶT CẮT BIÊN ĐỘ ÁNH SÁNG QUA ĐƯỜNG BIÊN ĐỘ TƯƠNG PHẢN (Vách đá vs Bầu trời):
Độ Sáng (Luminance)
▲
│ [Overshoot: Viền trắng sáng rực]
│ ┌───┐
1.0 ├───────────────────────────┘ │ (Bầu trời sáng)
│ │
│ ┌───────────┘
0.2 ├──────┐ │ (Vách đá tối)
│ └───┐ │
│ └────────┘
0.0 ┴─────────────────────────────────────────► Không gian (X)
[Undershoot: Viền đen sẫm]
```
### Nguyên nhân toán học cốt lõi: Dùng sai bộ lọc làm mờ (Linear Gaussian / Box Filter)
Thuật toán tăng nét truyền thống (Unsharp Masking - USM) phân rã ảnh theo công thức:
$$\text{Detail} = I - \text{GaussianBlur}(I, \sigma)$$
$$I_{\text{out}} = I + k \times \text{Detail}$$
1. **Bộ lọc Gaussian làm mờ "mù quáng":** Nó tính trung bình trọng số của tất cả các pixel lân cận mà **không quan tâm** điểm ảnh đó thuộc về vách đá đen hay bầu trời sáng.
2. **Hệ quả tại đường biên (Step Edge):**
* Về phía bầu trời: Gaussian kéo cả giá trị tối của vách đá vào $\implies$ Giá trị mờ thấp hơn thực tế $\implies$ Khi lấy $I - \text{Blur}$ sẽ sinh ra một giá trị dương rất lớn $\implies$ **Tạo viền trắng rực (Overshoot)**.
* Về phía vách đá: Gaussian kéo giá trị sáng của bầu trời vào $\implies$ Giá trị mờ cao hơn thực tế $\implies$ Phép trừ tạo ra giá trị âm sâu $\implies$ **Tạo viền đen đậm (Undershoot)**.
3. Vì Clarity tác động lên dải bán kính trung bình (Meso-radius: $15\text{px} - 40\text{px}$), vùng giao thoa này rộng ra cả chục pixel, tạo thành một "vệt nét viền" dày cộp mắt thường nhìn thấy rõ rệt.
---
## 2. Phân Định Rõ: Sharpening vs Clarity vs Contrast
Nhiều nhà phát triển thường nhầm lẫn giữa ba khái niệm xử lý tương phản:
```
[Micro-Frequency] ───────────── [Meso-Frequency] ───────────── [Macro-Frequency]
Bán kính: 1px - 2px Bán kính: 15px - 40px Bán kính: Toàn bức ảnh
◄── SHARPENING ──► ◄──── CLARITY ────► ◄── CONTRAST (S-Curve) ──►
Tăng chi tiết sợi tóc, Tăng khối 3D, độ nổi rãnh Đẩy toàn bộ ảnh: Trắng sáng
vân vải, gai lá. đá, mây trời dải trung tính. hơn, Đen tối hơn (Dễ cháy/bết).
```
| Tiêu chí so sánh | Sharpening (Độ sắc nét) | Contrast (Độ tương phản) | Clarity (Độ trong khối) |
| :--- | :--- | :--- | :--- |
| **Vùng tác động** | Chi tiết tần số rất cao (Cạnh $1\text{px} - 2\text{px}$). | Toàn cục (Global Tone Curve). | Chi tiết tần số trung bình (Meso-Contrast). |
| **Mặt nạ kiểm soát** | Không giới hạn dải sáng. | Đẩy cong cả dải Highlight & Shadow. | **Chỉ tác động vào dải Midtones ($20\% - 80\%$).** |
| **Nguy cơ lỗi** | Nhiễu hạt (Noise/Grain). | Cháy sáng (Clipping) & bết đen. | Viền sáng/tối nhân tạo (Haloing/Stroke). |
---
## 3. Bí Quyết Của Lightroom: Triệt Tiêu Viền Stroke Như Thế Nào?
Từ phiên bản Process Version 2012 (PV2012) trở đi, Adobe đã thay thế hoàn toàn thuật toán Unsharp Masking trong thanh trượt Clarity bằng **Bộ lọc bảo toàn góc cạnh (Edge-Preserving Filter)** kết hợp mô hình **Laplacian Pyramid đa tỉ lệ**:
```
[Ảnh Gốc Input I]
│
▼
[Bộ Lọc Bảo Toàn Cạnh: Guided Filter / Bilateral Filter]
│
┌──────────────────┴──────────────────┐
▼ ▼
[Base Layer: Khối nền] [Detail Layer: Vân chi tiết]
(Mịn bề mặt, nhưng GIỮ NGUYÊN Detail = I - Base
độ sắc của các góc cạnh cứng) (Chỉ chứa vân nổi lăn tăn,
│ HOÀN TOÀN KHÔNG CHỨA CẠNH CỨNG)
│ │
│ ▼
│ [Nhân Hệ Số Clarity + Mask]
│ Detail' = Detail * (1 + k * MidMask)
│ │
└──────────────────┬──────────────────┘
▼
[Reconstructed: Base + Detail']
│
▼
[Kết Quả: Khối Nổi 3D Sâu,
100% KHÔNG CÓ VIỀN HALO/STROKE]
```
### 3 Trụ Cột Kỹ Thuật Của Lightroom
#### A. Khái niệm Edge-Preserving Smoothing (Làm mờ không băng qua cạnh)
* Bộ lọc (như *Guided Filter* hoặc *Domain Transform*) khi quét qua tán cây trên nền trời sẽ nhận diện: chênh lệch sáng giữa cây và trời là một "bức tường" (Hard Edge).
* Nó làm mờ tán cây nội bộ, làm mờ bầu trời nội bộ, nhưng **dừng lại ở chân tường**, không trộn lẫn điểm ảnh giữa hai bên.
* Vì `Base Layer` giữ nguyên vẹn cạnh cứng, nên `Detail Layer` ($I - \text{Base}$) tại đường biên vách núi/bầu trời sẽ có giá trị bằng $0$.
* Khi bạn tăng Clarity lên $+100$, hệ số khuếch đại nhân với $0$ vẫn là $0 \implies$ **Hoàn toàn triệt tiêu viền Halo**.
#### B. Khóa hai đầu (Midtone Luminance Weighting)
Clarity không can thiệp vào điểm đen tuyệt đối hay vùng mây trắng tinh:
$$M(L) = \sin(\pi \times L) \quad \text{hoặc} \quad M(L) = 4.0 \times L \times (1.0 - L)$$
* Khi $L = 0.0$ (Black point) $\implies M = 0$.
* Khi $L = 1.0$ (White point) $\implies M = 0$.
* Khi $L = 0.5$ (Midtone) $\implies M = 1.0$ (Tác động cực đại).
#### C. Xử lý trên không gian tri giác (Perceptual Luminance)
Clarity chỉ thao tác trên thành phần năng lượng ánh sáng (Luminance), sau đó co giãn đều cả 3 kênh RGB theo tỉ lệ. Nếu cộng thẳng vào từng kênh RGB độc lập, màu sắc sẽ bị biến dạng (chuyển sang màu vàng neon hoặc xanh bùn).
---
## 4. Mô Hình Toán Học Triển Khai Cho Web UI
Để đạt tốc độ $60\text{ fps}$ trên trình duyệt, ta sử dụng **Fast Guided Filter** (hoặc xấp xỉ Bilateral 2-pass):
### Bước 1: Trích xuất Perceptual Luminance trong không gian Linear
$$L = 0.2126 \times R_{\text{linear}} + 0.7152 \times G_{\text{linear}} + 0.0722 \times B_{\text{linear}}$$
### Bước 2: Tách lớp Base và Detail
Cho một texture $I_{\text{base}}$ được làm mờ bằng bộ lọc bảo toàn cạnh:
$$\text{Detail} = L - L_{\text{base}}$$
### Bước 3: Áp dụng hàm nén phi tuyến (Tone Compression Curve)
Để vùng tối sâu và vùng sáng gắt không bị bão hòa, lượng chi tiết bù thêm được điều chế:
$$L_{\text{new}} = L + \text{Clarity} \times \text{Detail} \times \left(4.0 \times L_{\text{base}} \times (1.0 - L_{\text{base}})\right)$$
### Bước 4: Tái cấu trúc RGB
$$\text{Scale} = \frac{L_{\text{new}} + \epsilon}{L + \epsilon} \implies \text{RGB}_{\text{out}} = \text{RGB}_{\text{in}} \times \text{Scale}$$
---
## 5. Mã Nguồn WebGL Fragment Shader (Halo-Free Clarity)
Shader này nhận vào 2 texture:
1. `u_image`: Ảnh gốc.
2. `u_base_texture`: Ảnh đã qua bộ lọc cạnh (chạy qua Box Filter kích thước lớn sau khi downsample $4\times$, hoặc Guided Filter Pass).
```glsl
precision highp float;
uniform sampler2D u_image; // Ảnh gốc
uniform sampler2D u_base_texture; // Base layer từ Edge-Preserving Filter
uniform float u_clarity_amount; // Slider từ -1.0 đến +1.0 (-100 đến +100 trên UI)
varying vec2 v_texCoord;
// 1. Chuyển đổi sRGB sang Linear Light
vec3 sRGBToLinear(vec3 c) {
return pow(c, vec3(2.2));
}
// 2. Chuyển đổi Linear sang sRGB
vec3 linearTosRGB(vec3 c) {
return pow(c, vec3(1.0 / 2.2));
}
// 3. Tính Perceptual Luminance Rec.709
float getLuminance(vec3 c) {
return dot(c, vec3(0.2126, 0.7152, 0.0722));
}
void main() {
vec4 srcColor = texture2D(u_image, v_texCoord);
vec4 baseColor = texture2D(u_base_texture, v_texCoord);
vec3 linearOrig = sRGBToLinear(srcColor.rgb);
vec3 linearBase = sRGBToLinear(baseColor.rgb);
float lumOrig = getLuminance(linearOrig);
float lumBase = getLuminance(linearBase);
// BƯỚC 1: Tách lớp High/Medium Frequency Detail
float detail = lumOrig - lumBase;
// BƯỚC 2: Mặt nạ hình chuông (Bell Curve) khóa chặn 2 đầu
// Đỉnh cực đại tại Luminance = 0.5 (Midtones), triệt tiêu về 0 tại Blacks & Whites
float midtoneWeight = 4.0 * lumBase * (1.0 - lumBase);
midtoneWeight = clamp(midtoneWeight, 0.0, 1.0);
// BƯỚC 3: Khuếch đại hoặc làm dịu Detail (Clarity)
// Dùng hàm nén mềm (soft-knee) để tránh clipping khi đẩy Clarity quá cao
float enhancedDetail = detail * (1.0 + u_clarity_amount * 1.8 * midtoneWeight);
// Tính toán Luminance mới
float newLum = lumBase + enhancedDetail;
newLum = clamp(newLum, 0.0, 1.0);
// BƯỚC 4: Tái cấu trúc RGB theo tỷ lệ Luminance mới để giữ nguyên màu sắc
float scale = (newLum + 0.0001) / (lumOrig + 0.0001);
vec3 result = linearOrig * scale;
// Giữ an toàn dải giá trị [0.0, 1.0]
result = clamp(result, 0.0, 1.0);
gl_FragColor = vec4(linearTosRGB(result), srcColor.a);
}
```
---
## 6. Cách Tạo Texture `u_base_texture` Siêu Tốc Trên Web ($60\text{ fps}$)
Nhiều người e ngại Edge-Preserving Filter quá nặng cho trình duyệt. Dưới đây là kiến trúc pipeline xử lý nhẹ nhàng trong 1–2 mili-giây:
```
[Ảnh Gốc (Full Size: 4K)]
│
▼ (Blit Downsample 1/4)
[FrameBuffer Nhỏ (1080p hoặc 720p)]
│
▼ (Chạy 2-Pass Separable Guided/Bilateral Filter trên phân giải nhỏ)
[Base Texture Thu Nhỏ]
│
▼ (Bilinear Hardware Upsample tự động của GPU khi texture2D)
[Đưa vào Shader Clarity ở Mục 5]
```
1. **Downsample trước khi lọc:** Làm mờ bán kính $30\text{px}$ trên ảnh $4\text{K}$ tương đương bán kính $7\text{px}$ trên ảnh $1/4$ độ phân giải. Giảm được **$94\%$ khối lượng tính toán**.
2. **Upsample tự động qua Texture Sampler:** Thuộc tính `gl.LINEAR` của WebGL sẽ tự động nội suy mịn màng khi upscale texture `u_base_texture` trở lại kích thước đầy đủ.
---
## 7. Bảng Tự Kiểm Soát (Self-Check Checklist)
1. **Không còn đường viền Stroke:** Phóng to $200\%$ ở mép tán cây/đỉnh núi tiếp giáp với mây trời. Nếu không xuất hiện vệt viền trắng phát sáng hoặc viền đen dày, bạn đã thành công.
2. **Bầu trời không bị tăng nhiễu:** Khi kéo Clarity $+100$, bầu trời phẳng không được phép nổi hạt cát li ti (nhờ mặt nạ Midtone Weight đã dập tắt tác động ở dải sáng $> 0.85$).
3. **Màu da không bị ố vàng/cam:** Việc chuẩn hóa kênh màu thông qua Perceptual Luminance Scale giúp khuôn mặt người giữ nguyên sắc độ tự nhiên, không bị biến dạng sắc thái màu.
+152
View File
@@ -0,0 +1,152 @@
# Phân Tích Kỹ Thuật & Thuật Toán Tối Ưu Thanh Trượt "Blacks" (Lightroom vs Web UI)
---
## 1. So Sánh Bản Chất: "Shadows" Khác Gì "Blacks"?
Rất nhiều nhà phát triển nhầm lẫn giữa hai thanh trượt **Shadows** và **Blacks**:
```
[0% Luminance] ─────────────────────────────────────────── [100% Luminance]
|◄───────── BLACKS (0% - 15%) ─────────►|
|◄──────────── SHADOWS (10% - 45%) ────────────►|
|◄── MIDTONES / HIGHLIGHTS ──►|
```
| Tiêu chí | Thanh trượt Shadows | Thanh trượt Blacks (Chủ đề bài viết) |
| :--- | :--- | :--- |
| **Vùng tác động** | Vùng bóng đổ rộng ($10\% - 45\%$ Luminance). | Chóp chân đồ thị histogram ($0\% - 15\%$ Luminance). |
| **Bản chất xử lý** | **Local Tone Mapping** (Cần lọc cạnh Guided/Bilateral filter để giữ chi tiết vi mô). | **Global Tone Curve / Endpoint Remapping** (Tái cấu trúc điểm chân histogram & độ dốc Toe). |
| **Mục đích** | Cứu chi tiết mảng tối của chủ thể (tán cây, vách đá). | Thiết lập chiều sâu tuyệt đối (Depth), độ trong trẻo (Clarity) và điểm neo đen của ảnh. |
---
## 2. Chẩn Đoán Lỗi Kỹ Thuật Ở Ảnh 2 (Ứng Dụng Tự Code)
Quan sát Ảnh 2, có thể nhận diện ngay 3 sai sót cốt lõi trong công thức toán học:
### A. Lỗi nâng sàn tuyệt đối (Pedestal / Offset Lift)
* **Hiện tượng:** Toàn bộ bức ảnh như bị phủ một lớp sương mù màu xám đục (Milky / Faded look).
* **Nguyên nhân:** Thuật toán hiện tại đang cộng trực tiếp một hằng số vào kênh màu:
$$\text{Color}_{out} = \text{Color}_{in} + \text{BlackOffset}$$
Phép tính này biến điểm đen tuyệt đối ($0.0$) thành màu xám ($0.15 - 0.25$), làm mất hoàn toàn độ sâu trường ảnh.
### B. Lan sang dải Midtones quá mức (Overshoot Falloff)
* **Hiện tượng:** Mặt nước, thân thác nước và các vùng trung tính (Midtones) bị bạc màu và mất tương phản rõ rệt.
* **Nguyên nhân:** Mặt nạ trọng số không dốc đủ mạnh hoặc dùng hàm suy giảm tuyến tính, khiến các giá trị độ sáng tới $40\% - 50\%$ vẫn bị đội lên.
### C. Triệt tiêu độ bão hòa do thao tác sai không gian màu (Saturation Collapse)
* Khi tăng Luminance của vùng cực tối mà không bù trừ tỷ lệ Chroma trong không gian màu tri giác, màu xanh của rêu đá và cây cối bị biến thành màu xám bùn.
---
## 3. Bí Quyết Của Lightroom Khi Tăng "Blacks +100"
Ở Ảnh 3 (Lightroom), dù Blacks đã kéo kịch trần $+100$:
1. **Không hề có màng sương xám:** Lightroom giữ nguyên điểm $0.0$ tuyệt đối hoặc mở rộng dải chân (Toe Expansion) theo hàm mũ mượt mà, điểm đen được kéo giãn có kiểm soát chứ không bị nhấc bổng lên như một tấm màng phẳng.
2. **Bảo tồn tương phản vi mô (Micro-Contrast):** Dải chuyển giữa vùng tối sâu ($0\% - 5\%$) lên vùng tối vừa ($10\% - 20\%$) được làm dốc mềm (Soft Knee Curve), giữ được độ nổi khối của vách núi và mặt nước.
3. **Giới hạn trần nghiêm ngặt:** Tại mốc $\text{Luminance} \approx 0.2$, tác động của thanh trượt Blacks giảm hoàn toàn về $0.0$. Vùng thác nước trắng và bầu trời không đổi 1 giá trị số nào.
---
## 4. Mô Hình Toán Học Đề Xuất Cho Web UI
Thuật toán chuẩn gồm 3 bước xử lý trên không gian ánh sáng tuyến tính (Linear Space):
### Bước 1: Hàm trọng số Toe (Toe Weighting Function)
Sử dụng đường cong Hermite suy giảm bậc cao để khóa chặt vùng tác động dưới $20\%$:
$$W(L) = \text{clamp}\left(1.0 - \frac{L}{L_{threshold}},\, 0.0,\, 1.0\right)^3 \quad (\text{với } L_{threshold} \approx 0.18)$$
### Bước 2: Kéo giãn dải tối không phá hủy điểm 0 (Non-linear Toe Lift)
Thay vì phép cộng $\text{Color} + \Delta$, ta sử dụng phép biến đổi lũy thừa đối xứng (Power Curve Remapping):
$$L_{lifted} = L + \text{Amount} \times W(L) \times \left(\sqrt{L} - L\right)$$
* Khi $L = 0.0 \implies \sqrt{0} - 0 = 0$ (Điểm đen tuyệt đối không bị biến thành màu xám đục).
* Với $0 < L < 0.15 \implies \sqrt{L} > L$ (Vùng bóng tối sâu được giải phóng chi tiết cực kỳ tự nhiên).
### Bước 3: Giữ gìn tỷ lệ màu sắc (Chroma Preservation)
Áp dụng tỷ lệ tăng sáng của độ chói lên trực tiếp các kênh RGB:
$$\text{Scale} = \frac{L_{lifted} + \epsilon}{L + \epsilon} \implies \text{RGB}_{out} = \text{RGB}_{in} \times \text{Scale}$$
---
## 5. Mã Nguồn WebGL Shader Mẫu (Fragment Shader GLSL)
Bạn có thể thay thế Shader hiện tại bằng đoạn mã sau. Shader này chạy trực tiếp trong 1 Single Pass, tối ưu hóa $60\text{ fps}$ cho WebGL:
```glsl
precision highp float;
uniform sampler2D u_image;
uniform float u_black_amount; // Giá trị từ -1.0 đến +1.0 (-100 đến +100 trên UI)
varying vec2 v_texCoord;
// 1. Chuyển đổi sRGB sang Linear
vec3 sRGBToLinear(vec3 c) {
return pow(c, vec3(2.2));
}
// 2. Chuyển đổi Linear sang sRGB
vec3 linearTosRGB(vec3 c) {
return pow(c, vec3(1.0 / 2.2));
}
// 3. Tính Perceptual Luminance chuẩn Rec.709
float getLuminance(vec3 c) {
return dot(c, vec3(0.2126, 0.7152, 0.0722));
}
void main() {
vec4 texColor = texture2D(u_image, v_texCoord);
vec3 linearColor = sRGBToLinear(texColor.rgb);
float lum = getLuminance(linearColor);
// Ngưỡng tối đa vùng Blacks tác động (khoảng 18% ánh sáng tuyến tính)
const float BLACKS_THRESHOLD = 0.18;
// Trọng số suy giảm mượt (Bậc 3 Hermite Falloff)
float t = clamp(lum / BLACKS_THRESHOLD, 0.0, 1.0);
float weight = (1.0 - t) * (1.0 - t) * (1.0 - t);
vec3 result = linearColor;
if (u_black_amount > 0.0) {
// --- TRƯỜNG HỢP NÂNG BLACKS (+0 ĐẾN +100) ---
// Sử dụng hàm căn bậc hai có trọng số để nâng dải tối mà KHÔNG làm xám điểm đen
float expansion = sqrt(lum) - lum;
// Điều chỉnh hệ số nâng dựa trên slider
float liftedLum = lum + (u_black_amount * 0.7) * weight * expansion;
// Khử nhiễu sàn: Giữ tỷ lệ RGB theo độ chói mới để tránh lệch sắc thái
float scale = (liftedLum + 0.0001) / (lum + 0.0001);
result = linearColor * scale;
// Bù nhẹ độ bão hòa (Saturation Recovery) theo lượng Black được mở
float satBoost = 1.0 + (weight * u_black_amount * 0.25);
result = mix(vec3(liftedLum), result, satBoost);
} else if (u_black_amount < 0.0) {
// --- TRƯỜNG HỢP ÉP BLACKS (-0 ĐẾN -100: Làm sâu thêm điểm tối) ---
float crushFactor = 1.0 + (u_black_amount * weight * 0.85);
result = linearColor * max(crushFactor, 0.0);
}
// Bảo vệ dải giá trị an toàn [0.0, 1.0]
result = clamp(result, 0.0, 1.0);
gl_FragColor = vec4(linearTosRGB(result), texColor.a);
}
```
---
## 6. Bảng Tự Kiểm Soát (Self-Check Checklist)
1. **Kiểm tra độ chói điểm $0.0$:** Nhập một pixel đen tuyền $(0, 0, 0)$ qua hàm, giá trị đầu ra **bắt buộc** phải là $(0, 0, 0)$. Nếu ra $(0.1, 0.1, 0.1)$ tức là thuật toán vẫn đang bị lỗi Offset.
2. **Không áp dụng phép cộng trực tiếp:** Mọi thao tác nâng vùng tối đều phải là hàm tỷ lệ nhân (`linearColor * scale`) thay vì cộng `linearColor + delta`.
3. **Thao tác trên dải Linear Light:** Đảm bảo toàn bộ phép tính lũy thừa và căn bậc hai diễn ra sau khi đã giải mã Gamma $2.2$.
+156
View File
@@ -0,0 +1,156 @@
# Phân Tích Kỹ Thuật & Giải Pháp Tối Ưu Tính Năng "Shadow Recovery" Cho Ứng Dụng Web UI
---
## 1. Chẩn Đoán Lỗi Kỹ Thuật Ở Ảnh 2 (Ứng Dụng Hiện Tại)
So sánh giữa **Ảnh 2** (app tự code) và **Ảnh 3** (Lightroom):
```
+-----------------------------------------------------------------------------------+
| Hiện tượng ở Ảnh 2 | Nguyên nhân kỹ thuật cốt lõi |
+--------------------------------+--------------------------------------------------+
| Vùng tối biến thành mảng xám đục| Cộng giá trị tuyến tính vào sRGB (Không gian màu |
| (Milky / Foggy haze) | phi tuyến, nâng cả Black point tuyệt đối lên). |
+--------------------------------+--------------------------------------------------+
| Đường phân tách loang lổ, gãy nét| Dùng ngưỡng cứng (Hard Threshold) hoặc hàm bước |
| (Posterization / Color Banding)| (Step function) thay vì mặt nạ suy giảm mượt mà. |
+--------------------------------+--------------------------------------------------+
| Mất hoàn toàn khối & vân chi | Áp dụng phép biến đổi toàn cục (Global Operator) |
| tiết (Mặt núi, vách đá, tán cây)| thay vì tách lớp tương phản cục bộ (Local Contrast)|
+--------------------------------+--------------------------------------------------+
| Cây cối đổi màu bùn, mất sắc độ| Thay đổi Luminance nhưng không bù Saturation |
| (Chroma wash-out) | hoặc thao tác sai trên từng kênh R, G, B độc lập.|
+--------------------------------+--------------------------------------------------+
```
---
## 2. Bí Quyết Của Lightroom: Cơ Chế "Shadow Recovery" Chuyên Nghiệp
Lightroom **không** dùng công thức đơn giản như `RGB = RGB + shadow * mask`. Thuật toán của Adobe dựa trên 3 trụ cột:
### A. Tách lớp đa tỉ lệ (Multi-Scale Tone Decomposition)
Thay vì nâng sáng trực tiếp từng điểm ảnh, ảnh được phân rã thành:
$$\text{Image} = \text{Base Layer (Ánh sáng tổng thể)} \times \text{Detail Layer (Vân bề mặt & Góc cạnh)}$$
* **Base Layer:** Được trích xuất bằng bộ lọc bảo toàn góc cạnh (**Edge-Preserving Filter** như *Guided Filter* hoặc *Bilateral Filter*).
* Khi kéo **Shadow +100**: Thuật toán chỉ nâng độ sáng trên **Base Layer**, trong khi giữ nguyên hoặc khuếch đại nhẹ **Detail Layer**. Nhờ đó, các rãnh đá, tán lá và gợn sóng giữ nguyên độ sắc nét và chiều sâu.
### B. Không gian màu tính toán (Scene-Referred Linear Space)
* Ảnh sRGB trên web có sẵn Gamma nén ($EOM \approx 2.2$). Mọi phép tính phải đưa về **Linear Light** trước khi thao tác, sau đó mới ánh xạ ngược lại sRGB:
$$I_{linear} = (I_{sRGB})^{2.2} \quad \xrightarrow{\text{Xử lý}} \quad I_{out} = (I_{processed})^{1/2.2}$$
### C. Khóa điểm đen (Anchor Black Point) & Bù trừ bão hòa (Chroma Boost)
* Giữ điểm đen ($0.0$) cố định để ảnh không bị bạc nhược (milky).
* Hiện tượng sinh lý thị giác (*Hunt Effect*): Khi vùng tối sáng lên, mắt người sẽ thấy nó nhạt hơn thực tế. Lightroom tự động tăng nhẹ độ bão hòa màu sắc (Saturation) tỉ lệ thuận với lượng Shadow được kéo lên.
---
## 3. Kiến Trúc Thuật Toán Đề Xuất Cho Web UI (WebGL/GLSL)
Để chạy mượt mà $60\text{ fps}$ trên trình duyệt, quy trình xử lý tối ưu gồm 2 Pass Shader:
```
[Ảnh Đầu Vào sRGB]
│
▼ (Pass 1)
[Chuyển sang Linear Light] ───> [Tạo Base Layer bằng Fast Guided Filter / Box Blur]
│
┌────────────────────────────────────┘
▼ (Pass 2: Composite & Tone Mapping Shader)
[1. Tính Detail Layer: Detail = Input / (Base + ε)]
[2. Tính Hệ số Shadow Mask: w = SmoothFalloff(Base)]
[3. Nâng Base Layer: Base' = Base * (1.0 + ShadowAmount * Curve(Base))]
[4. Khôi phục chi tiết: Reconstructed = Base' * Detail]
[5. Bù trừ Saturation trên không gian OKLab / HSV]
[6. Chuyển ngược về sRGB Output]
```
---
## 4. Mã Nguồn Shader Mẫu (Fragment Shader GLSL)
Đoạn Shader WebGL dưới đây có thể tích hợp trực tiếp vào Canvas WebGL của bạn để khắc phục hoàn toàn lỗi bết màu:
```glsl
precision highp float;
uniform sampler2D u_texture; // Ảnh gốc
uniform sampler2D u_blur_texture; // Ảnh đã qua lọc mờ (Base layer từ Guided Filter / Gaussian)
uniform float u_shadow_amount; // Giá trị từ 0.0 đến 1.0 (tương ứng +0 đến +100)
varying vec2 v_texCoord;
// 1. Chuyển sRGB sang Tuyến tính (Linear)
vec3 sRGBToLinear(vec3 c) {
return pow(c, vec3(2.2));
}
// 2. Chuyển Tuyến tính sang sRGB
vec3 linearTosRGB(vec3 c) {
return pow(c, vec3(1.0 / 2.2));
}
// 3. Tính độ sáng tri giác (Perceptual Luminance)
float getLuminance(vec3 c) {
return dot(c, vec3(0.2126, 0.7152, 0.0722));
}
// 4. Đường cong suy giảm mượt (Smooth Shadow Mask)
// Chỉ tác động mạnh vào vùng tối (0.05 - 0.4), không đụng vào Highlight (>0.6) và khóa điểm đen
float getShadowWeight(float lum) {
// Khóa điểm đen tuyệt đối để tránh màng sương đục (milky haze)
float blackAnchor = smoothstep(0.0, 0.08, lum);
// Suy giảm mượt về 0 khi tiến tới vùng sáng
float highlightFalloff = 1.0 - smoothstep(0.2, 0.65, lum);
return blackAnchor * highlightFalloff;
}
void main() {
vec4 srcColor = texture2D(u_texture, v_texCoord);
vec4 baseColor = texture2D(u_blur_texture, v_texCoord);
// Chuyển sang không gian ánh sáng tuyến tính
vec3 I_orig = sRGBToLinear(srcColor.rgb);
vec3 I_base = sRGBToLinear(baseColor.rgb);
float baseLum = getLuminance(I_base);
float origLum = getLuminance(I_orig);
// Bước A: Tách lớp vân bề mặt (Detail Layer)
// Detail = I_orig / (I_base + eps)
vec3 detail = I_orig / (I_base + vec3(0.001));
// Bước B: Tính toán lượng sáng cần bù vào Base Layer
float mask = getShadowWeight(baseLum);
// Sử dụng công thức mũ tự nhiên thay vì cộng dồn tuyến tính
// Gain = (1.0 - baseLum)^2 giúp kéo vùng tối sâu mà không làm cháy vùng trung gian
float liftFactor = pow(1.0 - baseLum, 1.8) * u_shadow_amount * 2.2;
vec3 liftedBase = I_base * (1.0 + mask * liftFactor);
// Bước C: Tái kết hợp Detail với Base đã được nâng sáng
vec3 result = liftedBase * detail;
// Bước D: Bù trừ màu sắc (Chroma Compensation) để tránh bợt màu
float newLum = getLuminance(result);
if (origLum > 0.001) {
float boost = 1.0 + (mask * u_shadow_amount * 0.35); // Bù màu nhẹ cho vùng tối
result = mix(vec3(newLum), result, boost);
}
// Bước E: Giữ điểm đen và chuyển ngược lại sRGB
result = clamp(result, 0.0, 1.0);
gl_FragColor = vec4(linearTosRGB(result), srcColor.a);
}
```
---
## 5. Danh Sách Kiểm Tra Khi Triển Khai (Action Plan)
1. **Khử ngay phép cộng tuyến tính trên 8-bit RGB:** Không bao giờ viết dạng `r = r + shadowValue`.
2. **Triển khai Box Filter / Guided Filter trước:** Dùng 1 texture phụ chứa bản blur bán kính lớn ($\approx 2\% - 5\%$ chiều rộng ảnh) để làm `u_blur_texture`.
3. **Độ chính xác dấu phẩy động:** Thiết lập WebGL Context với cấu hình Float Texture (`OES_texture_float` hoặc `WebGL2`) để khử triệt để hiện tượng vỡ khối viền (banding).
+7
View File
@@ -1,3 +1,10 @@
node_modules/
dist/
public/wasm/
# Stale one-off harnesses: each imports rawDevelop.ts from an older day, so a
# run today dies on its missing ./skiaShim and ./tiffDecode imports. The
# checks the repo carries are the *-check.mjs beside them.
/scripts/test-all-raws.mjs
/scripts/test-color-accuracy.mjs
/scripts/test-extract-speed.mjs
+6
View File
@@ -4,6 +4,12 @@ WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# The build's own name, shown at the foot of the studio's tab rail (vite.config
# reads it): `docker compose build frontend --build-arg
# VERSION=$(git rev-parse --short HEAD)` puts the commit in the page, which is
# how the image on the NAS says which revision it is. Left unset, the build
# stamps its own time.
ARG VERSION=
# tsc --noEmit && vite build; the prebuild hook copies canvaskit.wasm into public/
RUN npm run build
+14
View File
@@ -6,6 +6,20 @@
<meta name="theme-color" content="#0a0a0a" />
<link rel="icon" type="image/png" href="/assets/RecipesCamIcon.png" />
<link rel="apple-touch-icon" href="/assets/RecipesCamIcon.png" />
<!-- The installed app. The manifest is a static file in public/ (see
public/manifest.json and scripts/make-icons.mjs for its icons), so a build
needs no plugin to ship it. iOS ignores most of the manifest and reads these
three metas instead: 'capable' is what makes a home-screen bookmark open
without Safari's chrome, 'black-translucent' gives it the app's black bars,
and 'title' is the name under the icon. -->
<link rel="manifest" href="/manifest.json" />
<!-- The same thing under the name it now has: Chrome warns that the `apple-`
spelling is deprecated, and iOS Safari still reads only that one, so the
installed app carries both. -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="RecipesCam" />
<title>RecipesCam — web</title>
<meta name="description" content="RecipesCam web — grade photos with the same film recipes as the app." />
<link rel="canonical" href="/" />
+38
View File
@@ -51,6 +51,44 @@ server {
try_files $uri =404;
}
# The two files whose names never change but whose contents must: a cached worker
# keeps answering with the previous build's shell long after the deploy that
# replaced it, and a cached manifest keeps pointing at the icon set it shipped with.
# Nothing else would ever flush them, which is exactly why they opt out of the
# long-lived caching above. The worker is a script under the embedder's COEP like
# the .mjs files, so it restates the two isolation headers for the same reason.
location = /sw.js {
add_header Cache-Control "no-cache";
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
try_files $uri =404;
}
location = /manifest.json {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
# The shell is the third name that never changes and whose contents do: with no
# header of its own a browser is free to guess a freshness window out of
# Last-Modified — a tenth of the file's age — and hand a visitor the PREVIOUS
# build's index.html for hours after a deploy. That page names that build's
# hashed bundle, so a fixed shader keeps running as the broken one and a hard
# reload is the only way out. The SPA fallback below lands here too (the
# internal redirect re-matches locations), so /app and /library are covered by
# the same line.
# (Restated: an add_header in a location drops every add_header inherited, and
# this document needs the isolation pair for the wasm renderer.)
location = /index.html {
add_header Cache-Control "no-cache";
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
try_files $uri =404;
}
# The one route that carries a whole data dir back in (see /api/admin/restore
# — who may call it is the API's own admin check, done before it reads a byte).
# The upload cap that holds everywhere else would reject it, and unpacking the
+10
View File
@@ -10,6 +10,7 @@
"dependencies": {
"canvaskit-wasm": "^0.42.0",
"exifr": "^7.1.3",
"libheif-js": "^1.23.5",
"libraw-wasm": "^1.6.0",
"onnxruntime-web": "^1.30.0",
"react": "^18.3.1",
@@ -1606,6 +1607,15 @@
"node": ">=6"
}
},
"node_modules/libheif-js": {
"version": "1.23.5",
"resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.23.5.tgz",
"integrity": "sha512-umXZPthnWZtF3iG/5mQG+AZ2V6i0UeYpo3z2CkggTEgL8dKmIIwJT6TDuAMuabXa1yTW8uJ1ZAgR+CxNTklYYw==",
"license": "LGPL-3.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/libraw-wasm": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/libraw-wasm/-/libraw-wasm-1.6.0.tgz",
+1
View File
@@ -15,6 +15,7 @@
"dependencies": {
"canvaskit-wasm": "^0.42.0",
"exifr": "^7.1.3",
"libheif-js": "^1.23.5",
"libraw-wasm": "^1.6.0",
"onnxruntime-web": "^1.30.0",
"react": "^18.3.1",
+72
View File
@@ -0,0 +1,72 @@
<!doctype html>
<meta charset="utf-8">
<title>probe: develop a real RAW</title>
<body>
<pre id="out" style="font:12px monospace">running…</pre>
<img id="shot" style="max-width:100%">
<script type="module">
const log = (...a) => {
const s = a.map((x) => (typeof x === 'string' ? x : JSON.stringify(x))).join(' ');
document.getElementById('out').textContent += s + '\n';
console.log(s);
};
const b64 = (bytes) => {
let s = '';
for (let i = 0; i < bytes.length; i += 0x8000) {
s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
}
return btoa(s);
};
try {
const RAW_URL = new URLSearchParams(location.search).get('f') || 'http://127.0.0.1:5300/DSC03453.ARW';
const { initSkia } = await import('/src/engine/skiaShim.ts');
await initSkia({ locateFile: (file) => `/wasm/${file}` });
log('canvaskit up');
const { developRaw } = await import('/src/engine/rawDevelop.ts');
const bytes = new Uint8Array(await (await fetch(RAW_URL)).arrayBuffer());
log('raw bytes', bytes.length, RAW_URL);
// The develop hands its buffer to LibRaw's worker, which detaches it — one
// copy each, since the plane below is read out of a second decode.
const forPlane = bytes.slice();
const t0 = performance.now();
const jpeg = await developRaw(bytes);
log('develop ms', Math.round(performance.now() - t0), 'jpeg bytes', jpeg.length);
window.__dev = b64(jpeg);
document.getElementById('shot').src = URL.createObjectURL(new Blob([jpeg], { type: 'image/jpeg' }));
// The plane and the colour data behind that develop, so the shader can be
// replayed offline a stage at a time.
const { default: LibRaw } = await import('libraw-wasm');
const raw = new LibRaw();
// LibRaw hands the buffer to its worker, which detaches it — a copy, since
// the develop above already passed the original through.
await raw.open(forPlane, {
halfSize: true, outputBps: 16, outputColor: 0, noAutoScale: true,
useCameraWb: false, noAutoBright: true, gamm: [1, 1], userQual: 3, highlight: 0,
});
const meta = await raw.metadata(true);
const img = await raw.imageData();
const cd = meta.color_data;
const plane = new Uint8Array(img.data.buffer, img.data.byteOffset, img.data.byteLength);
log('plane', img.width, 'x', img.height, 'bytes', plane.length);
log('black', cd.black, 'maximum', cd.maximum, 'mul', cd.cam_mul, 'model', meta.normalized_model);
window.__dump = {
b64: b64(plane),
width: img.width, height: img.height,
black: cd.black, maximum: cd.maximum,
cam_mul: cd.cam_mul, rgb_cam: cd.rgb_cam,
model: meta.normalized_model, raw_bps: meta.raw_bps ?? null,
};
const thumb = await raw.thumbnailData().catch(() => null);
if (thumb?.data?.length) window.__thumb = b64(new Uint8Array(thumb.data));
log('thumb', thumb?.format, thumb?.width, 'x', thumb?.height);
raw.dispose();
log('DONE');
} catch (e) {
log('ERROR', e?.message ?? String(e), (e?.stack ?? '').split('\n').slice(0, 6).join(' | '));
log('FAILED');
}
</script>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

+38
View File
@@ -0,0 +1,38 @@
{
"name": "RecipesCam — Color Recipes & Film Camera Studio",
"short_name": "RecipesCam",
"id": "/",
"description": "Grade ảnh bằng công thức màu phim Kodak & Fuji — tone curve, hạt phim và halation. Cùng một pipeline trên điện thoại và web studio, ảnh không rời khỏi máy bạn.",
"lang": "vi",
"start_url": "/app",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0a0a0a",
"theme_color": "#0a0a0a",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Thư viện",
"short_name": "Thư viện",
"url": "/library"
}
]
}
+130
View File
@@ -0,0 +1,130 @@
// The app shell as a service worker, so an installed RecipesCam opens with no
// network at all. Not bundled on purpose: through vite/rollup it would stop being
// readable in DevTools, the one place a worker gets debugged. VERSION is the cache
// name and the whole update story — bump it when the shell changes, and `activate`
// drops every older cache.
//
// v2 is the bump that flushed a shell this worker had already pinned: before the
// header went on in nginx, a browser was free to guess a freshness window for
// index.html and answer a navigation with the previous build's shell — and its
// hashed bundle with it, which the STATIC rule below then served cache-first
// forever. Every navigation here now goes past the browser's own cache, so the
// build a visitor gets is the one the server has.
const VERSION = 'recipescam-v29';
// nginx answers all three routes with the same index.html (SPA fallback), so they
// are one document under three keys: an offline navigation finds it whichever key
// it asks. The manifest is not that document and is precached with them for the
// same reason — it is what makes the installed app an app, and offline it is
// asked for on every launch.
const SHELL = ['/', '/app', '/library', '/manifest.json'];
// Cache-first paths: vite hashes every filename in its own bundle, and the wasm,
// the models and the icons never change under a given build.
const STATIC = /^\/(assets|wasm|models|icons)\//;
// The shell, read past the browser's cache on purpose: a `cache.add` of '/'
// consults that cache like any other fetch, so a shell stored during a stale
// window would be precached as the offline shell of the build that replaced it.
const shellRequest = (url) => new Request(url, { cache: 'reload' });
// Only a complete same-origin 200 is worth keeping — an opaque or error body
// stored here comes back as a failure on the next visit, and nothing in the worker
// can tell the two apart by then.
const cachedFetch = (request) =>
fetch(request).then((response) => {
if (response.ok) {
const copy = response.clone();
caches.open(VERSION).then((cache) => cache.put(request, copy));
}
return response;
});
// Offline, the route that was asked for if this browser has ever been on it, then
// the studio the app is for, then the landing page for a build without /app. The
// route first because that is the copy a visit keeps up to date: the other two may
// be shells of a build the deploy has replaced.
const offlineShell = async (request) =>
(await caches.match(new Request(new URL(request.url).pathname))) ??
(await caches.match('/app')) ??
(await caches.match('/')) ??
Response.error();
// The files the shell is built out of, read out of the shell itself: the hashed
// bundle, the stylesheet, the icon. A worker that precached only the document
// would answer an offline launch with a page whose JavaScript this machine does
// not have — and it never would, because the visit that first loads the app is
// the visit this worker is not yet controlling, so its own STATIC rule below
// never sees those requests. The shell is the one place their names are written.
const shellFiles = (html) => [...html.matchAll(/(?:src|href)="(\/assets\/[^"]+)"/g)].map((m) => m[1]);
self.addEventListener('install', (event) => {
// Best effort, one file at a time: a deploy caught mid-flight must not leave the
// worker uninstalled, so each one fails on its own.
event.waitUntil(
caches.open(VERSION)
.then(async (cache) => {
await Promise.all(SHELL.map((url) => cache.add(shellRequest(url)).catch(() => {})));
const shell = await cache.match('/app') ?? await cache.match('/');
if (!shell) return;
for (const url of shellFiles(await shell.text())) {
await cache.add(shellRequest(url)).catch(() => {});
}
})
.then(() => self.skipWaiting()),
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((names) => Promise.all(names.filter((n) => n !== VERSION).map((n) => caches.delete(n))))
.then(() => self.clients.claim()),
);
});
self.addEventListener('fetch', (event) => {
const { request } = event;
// A worker is a cache, not a proxy: a POST, the API, another origin's font — a
// request that is not a plain same-origin read — goes straight out.
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
// A navigation is network-first even when it is cached: a shell answered from the
// cache while the network holds a newer build pins the visitor to the old one. It
// is also the path an installed app opens through with no network.
// `no-store` because a plain fetch is not the network: it may be answered by the
// browser's own cache, which is the other place a stale shell hides.
if (request.mode === 'navigate') {
event.respondWith(
fetch(request, { cache: 'no-store' })
.then((response) => {
// The shell kept for offline is the one the server handed over last, not
// the one this worker installed with: a navigation that fails — a deploy
// recreating the container is the ordinary way one does — otherwise
// answers with the build of the first visit, `activate` never runs to
// replace it while this worker's bytes stay the same, and the browser
// runs an old build until a reload happens to land after the server is
// back. Stored under the route the navigation asked for, which is one of
// the three SHELL keys — a navigation request is not a key a cache takes.
if (response.ok) {
const copy = response.clone();
const key = new Request(new URL(request.url).pathname);
caches.open(VERSION).then((cache) => cache.put(key, copy)).catch(() => {});
}
return response;
})
.catch(() => offlineShell(request))
);
return;
}
if (STATIC.test(url.pathname)) {
event.respondWith(caches.match(request).then((hit) => hit ?? cachedFetch(request)));
return;
}
// Everything else — a font, the manifest, an uploaded photo — is network-first,
// and answered from the cache only when that fails.
event.respondWith(fetch(request).catch(async () => (await caches.match(request)) ?? Response.error()));
});
+10 -10
View File
@@ -62,28 +62,28 @@ assert.equal(Math.round(stops(spike(87)) * 10) / 10, 0.5, 'a dark frame is asked
assert.ok(Math.abs(stops(flat()) + 0.0589) < 5e-4, 'a flat ramp sits just under mid-grey');
assert.equal(stops(spike(20, 0)), AUTO_EV_MAX, 'an empty histogram takes the ceiling, not infinity');
// --- Highlight / Shadow: the 1% tails, thresholds 0.9 / 0.02, ramp to ±5.
assert.deepEqual(autoTone(spike(255)), { highlight: -5, shadow: 0 }, 'blown frame only pulls highlights');
assert.deepEqual(autoTone(spike(0)), { highlight: 0, shadow: 5 }, 'black frame only opens shadows');
// --- Highlight / Shadow: the 1% tails, thresholds 0.9 / 0.02, ramp to ±10.
assert.deepEqual(autoTone(spike(255)), { highlight: -10, shadow: 0 }, 'blown frame only pulls highlights');
assert.deepEqual(autoTone(spike(0)), { highlight: 0, shadow: 10 }, 'black frame only opens shadows');
// The flat ramp is the interesting one: its own top 1% really is clipped and its
// own bottom 1% really is on the floor, so both ends move. p99 = 253/255 = 0.992
// (5 units), p01 = 2/255 = 0.008 (3 units).
assert.deepEqual(autoTone(flat()), { highlight: -5, shadow: 3 }, 'an even ramp has both ends in play');
// (9 units), p01 = 2/255 = 0.008 (6 units).
assert.deepEqual(autoTone(flat()), { highlight: -9, shadow: 6 }, 'an even ramp has both ends in play');
// 1% of the frame at white over an otherwise mid ramp: the spec's small blown
// window, and the shadow side stays put because the ramp's floor is not black.
const blown = flat();
blown[255] += 3;
assert.deepEqual(autoTone(blown), { highlight: -5, shadow: 3 }, 'a blown 1% is enough to pull the top');
assert.deepEqual(autoTone(blown), { highlight: -10, shadow: 6 }, 'a blown 1% is enough to pull the top');
// p99 landed just over the threshold: 989 pixels mid-ramp and 11 at bin 243 puts
// p99 at 0.953, 0.053 over 0.9 -> 2.65 units -> 3. Proof the pull is graded.
// p99 at 0.953, 0.053 over 0.9 -> 5.3 units -> 5. Proof the pull is graded.
const shoulder = spike(100, 989);
shoulder[243] = 11;
assert.deepEqual(autoTone(shoulder), { highlight: -3, shadow: 0 }, 'p99 just over 0.9 pulls a third of the way');
assert.deepEqual(autoTone(shoulder), { highlight: -5, shadow: 0 }, 'p99 just over 0.9 pulls half the way');
// 2% of pixels two bins off the floor: p01 = 0.008, so the shadow side moves —
// but only 3 units, because the tail is barely under the 0.02 threshold.
// but only 6 units, because the tail is barely under the 0.02 threshold.
const crushed = spike(128, 980);
crushed[2] = 20;
assert.deepEqual(autoTone(crushed), { highlight: 0, shadow: 3 }, 'a crushed 2% opens the shadows part way');
assert.deepEqual(autoTone(crushed), { highlight: 0, shadow: 6 }, 'a crushed 2% opens the shadows part way');
// 0.4% on the floor is NOT the bottom 1%: the tenth-darkest pixel is the mid
// ramp, so the threshold is not crossed and the knob stays put.
const speck = spike(128, 996);
+326
View File
@@ -0,0 +1,326 @@
// The catalogue's own copy, end to end: a roll of two frames is read, the
// catalogue is written into a folder the visitor picked, the browser's storage is
// taken away from under it, and the catalogue is read back out — with the frames
// never read a second time.
//
// The folder of frames is a stand-in served off a local server, because the check
// cannot hand a real directory to a page. The backup folder is not: the picker
// answers with a directory out of the origin's own file system (OPFS), which is a
// real `FileSystemDirectoryHandle` with a real `createWritable`, so what is
// written is written by the code under test through the API it will use on a
// visitor's disk — and what the check then reads back is the bytes on it.
//
// What the run claims, in order:
// * the catalogue file holds the rows and neither of the two things a file
// cannot hold — a handle and a tile;
// * the tiles are written beside it, one per frame, under `thumbs/<frame id>`;
// * a second run writes no tile at all;
// * a catalogue with nothing in it gets every row back, tiles included, and the
// frames themselves are never read from the disk again — not by the restore,
// which takes the tiles off the backup folder, and not by the reading that
// follows it, which matches each frame on its size and its write time.
//
// npm run build && node scripts/backup-check.mjs
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> node scripts/backup-check.mjs
//
// Playwright is not a dependency of this package, so a run without it says SKIP
// and exits 0. APP_PORT says where the preview goes, CHROME says which browser.
import { spawn } from 'node:child_process';
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
const SAMPLES = process.env.SAMPLE_DIR ?? path.resolve(FRONTEND, '..');
const APP_PORT = Number(process.env.APP_PORT ?? 4198);
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4321);
// Two frames of one folder, both read off the sample roll.
const FILES = ['P1010256.JPG', 'DSCF1701.JPG'];
const FOLDER = 'TestRoll';
const BACKUP = 'BackupRoll';
let failures = 0;
function step(name, ok, detail = '') {
if (!ok) failures++;
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
return ok;
}
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
if (!playwright) {
console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE=<path to its index.mjs>');
process.exit(0);
}
// --- the frames, off a local server (the page fetches them by CORS) ----------
const sampleServer = createServer(async (req, res) => {
const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0]));
if (!FILES.includes(name)) {
res.writeHead(404).end();
return;
}
const bytes = await readFile(path.join(SAMPLES, name));
res.writeHead(200, {
'Content-Type': 'image/jpeg',
'Content-Length': bytes.length,
'Access-Control-Allow-Origin': '*',
});
res.end(bytes);
});
await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve));
// `--host 127.0.0.1`: without it vite's preview binds the name `localhost`, which
// on a dual-stack box can be ::1 alone and every 127.0.0.1 fetch below fails.
const app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
cwd: FRONTEND,
stdio: 'ignore',
});
const base = `http://127.0.0.1:${APP_PORT}`;
let up = false;
for (let i = 0; i < 60 && !up; i++) {
up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false);
if (!up) await new Promise((r) => setTimeout(r, 500));
}
if (!up) {
app.kill('SIGTERM');
sampleServer.close();
console.log(`FAIL no preview on ${base} — run \`npm run build\` first (vite preview serves dist/)`);
process.exit(1);
}
const browser = await playwright.chromium.launch({
// Playwright's own Chromium when it has one; CHROME points at any other.
executablePath: process.env.CHROME || undefined,
args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
});
const context = await browser.newContext();
// The words this screen says are read below, so the run is pinned to one of the
// two dictionaries rather than to whatever the browser's own language is.
await context.addInitScript(() => localStorage.setItem('rc.lang', 'en'));
// The picker, twice over: the roll of frames is a stand-in — a page cannot be
// handed a real directory by a test — and the backup folder is the origin's own
// file system, which is a real one. Which of the two a call is for is the `id`
// the code passes, the same one it passes to a visitor's browser.
await context.addInitScript(
({ origin, files, folder, backup }) => {
const bytes = new Map();
const load = async (name) => {
if (!bytes.has(name)) bytes.set(name, fetch(`${origin}/${name}`).then((r) => r.arrayBuffer()));
return bytes.get(name);
};
// What a frame costs is its bytes, and the bytes of a frame are read through
// `arrayBuffer` — on the file, or on a slice of it, which is how a scan reads
// the head of a JPEG. Only the stand-in's own files are counted: the tiles
// that come back out of the backup folder are read the same way, and they are
// named after the frames.
window.__frameReads = 0;
const off = new WeakMap();
const part = Blob.prototype.slice;
Blob.prototype.slice = function (...args) {
const cut = part.apply(this, args);
off.set(cut, off.get(this) ?? this);
return cut;
};
const pull = Blob.prototype.arrayBuffer;
Blob.prototype.arrayBuffer = function () {
if ((off.get(this) ?? this).__fake) window.__frameReads++;
return pull.apply(this);
};
const fileHandle = (name) => {
const handle = { kind: 'file', name };
Object.defineProperty(handle, 'getFile', {
value: async () => {
const file = new File([await load(name)], name, {
type: 'image/jpeg',
// One write time per frame, and the same one on every read: a fresh
// `Date.now()` would make every frame look touched and hide what is
// being checked — the reading after the restore has to match.
lastModified: Date.UTC(2016, 1, 15, 9, 30),
});
Object.defineProperty(file, '__fake', { value: true });
return file;
},
});
return handle;
};
const dirHandle = (label, node) => {
const handle = { kind: 'directory', name: label };
Object.defineProperties(handle, {
values: { value: () => [...(node.files ?? []).map(fileHandle)][Symbol.iterator]() },
queryPermission: { value: async () => 'granted' },
requestPermission: { value: async () => 'granted' },
getDirectoryHandle: { value: async (name) => {
throw new Error(`no folder named ${name}`);
} },
getFileHandle: {
value: async (name) => {
if (!(node.files ?? []).includes(name)) throw new Error(`no frame named ${name}`);
return fileHandle(name);
},
},
});
return handle;
};
const root = (label) => dirHandle(label, { files });
window.showDirectoryPicker = async (opts) => {
if (opts?.id !== 'recipescam-backup') return root(folder);
const opfs = await navigator.storage.getDirectory();
// A folder of the origin's own, emptied at the start of the run and named
// as the picker would name it.
await opfs.removeEntry(backup, { recursive: true }).catch(() => {});
const dir = await opfs.getDirectoryHandle(backup, { create: true });
// OPFS handles are always writable and say so only through the API the
// picker's handles carry: the two methods are stood in where the real
// handle leaves them out, so the code under test takes the same road it
// would with a folder off a disk.
for (const mode of ['queryPermission', 'requestPermission']) {
if (typeof dir[mode] !== 'function') Object.defineProperty(dir, mode, { value: async () => 'granted' });
}
return dir;
};
},
{ origin: `http://127.0.0.1:${SAMPLE_PORT}`, files: FILES, folder: FOLDER, backup: BACKUP }
);
const page = await context.newPage();
// The restore asks before it replaces anything, and a dismissed dialog is a
// cancel — the run says yes the way a reader would.
page.on('dialog', (dialog) => void dialog.accept());
const note = () => page.$eval('.adm-note', (el) => el.textContent ?? '').catch(() => '');
const waitNote = (re) =>
page.waitForFunction(
(src) => new RegExp(src).test(document.querySelector('.adm-note')?.textContent ?? ''),
re.source,
{ timeout: 120_000 }
);
const count = () => page.$eval('[data-key="lib-count"]', (el) => el.textContent ?? '');
// The bytes of a frame actually read off the disk, counted inside the page: the
// whole claim of the catalogued size and write time is this number staying put.
const framesRead = () => page.evaluate(() => window.__frameReads ?? 0);
// The catalogue the page itself keeps, read straight out of the browser: what the
// restore puts back is a row in there, not a picture on the screen.
const rows = () =>
page.evaluate(
() =>
new Promise((resolve) => {
const open = indexedDB.open('recipescam-library');
open.onsuccess = () => {
const req = open.result.transaction('photos', 'readonly').objectStore('photos').getAll();
req.onsuccess = () =>
resolve(
req.result.map((r) => ({
id: r.id,
tile: r.thumb ? r.thumb.size : 0,
handle: !!r.handle,
star: r.star ?? null,
}))
);
req.onerror = () => resolve([]);
};
open.onerror = () => resolve([]);
})
);
// Everything the backup folder holds, walked the way a person looking at it in a
// file manager would.
const backupTree = () =>
page.evaluate(async (name) => {
const opfs = await navigator.storage.getDirectory();
const root = await opfs.getDirectoryHandle(name);
const out = [];
const walk = async (dir, at) => {
for await (const [child, handle] of dir.entries()) {
const rel = at ? `${at}/${child}` : child;
if (handle.kind === 'directory') await walk(handle, rel);
else out.push({ rel, size: (await handle.getFile()).size });
}
};
await walk(root, '');
return out;
}, BACKUP);
let code = 0;
try {
// --- the roll is read ------------------------------------------------------
await page.goto(`${base}/library`);
await page.click('[data-key="lib-add"]');
await waitNote(/Scanned TestRoll/);
const read = await rows();
step('the roll was read and both frames are in the catalogue', read.length === 2, `${read.length} rows`);
step('every frame has its tile', read.every((r) => r.tile > 0));
step('the count on the bar says two', /2/.test(await count()), await count());
const firstRead = await framesRead();
step('reading a roll of two frames costs two frames', firstRead === 2, `${firstRead} reads`);
// --- the catalogue is written ---------------------------------------------
await page.click('[data-key="lib-backup"]');
await waitNote(/Backed up 2 frames, 2 tiles written/);
const tree = await backupTree();
const cat = await page.evaluate(async (name) => {
const opfs = await navigator.storage.getDirectory();
const dir = await opfs.getDirectoryHandle(name);
return JSON.parse(await (await (await dir.getFileHandle('recipescam-catalogue.json')).getFile()).text());
}, BACKUP);
step('the catalogue file holds both rows', cat.photos.length === 2, `${cat.photos.length}`);
step(
'a row carries no handle and no tile',
cat.photos.every((r) => !('handle' in r) && !('thumb' in r)),
Object.keys(cat.photos[0]).join(',')
);
step(
'a row carries the size and the write time of its frame',
cat.photos.every((r) => r.size > 0 && r.mtime === Date.UTC(2016, 1, 15, 9, 30))
);
const tiles = tree.filter((f) => f.rel.startsWith('thumbs/'));
step(
'the tiles are beside it, one file per frame',
tiles.length === 2 && tiles.every((f) => f.size > 0) && tiles.every((f) => /^thumbs\/TestRoll\/[^/]+$/.test(f.rel)),
tiles.map((f) => f.rel).join(' ')
);
// --- a second run writes no tile ------------------------------------------
await page.click('[data-key="lib-backup"]');
await waitNote(/Backed up 2 frames, 0 tiles written/);
// --- the browser's storage is taken away ----------------------------------
await page.goto('about:blank');
await page.goto(`${base}/`);
const gone = await page.evaluate(
() =>
new Promise((resolve) => {
const req = indexedDB.deleteDatabase('recipescam-library');
req.onsuccess = () => resolve(true);
req.onerror = req.onblocked = () => resolve(false);
})
);
step('the catalogue is gone from the browser', gone === true);
await page.goto(`${base}/library`);
step('the library comes up empty', /^0\b/.test((await count()).trim()), await count());
step('and has read nothing', (await framesRead()) === 0);
// --- and it is put back ----------------------------------------------------
await page.click('[data-key="lib-restore"]');
await waitNote(/Restored 2 frames/);
const back = await rows();
step('both rows are back', back.length === 2, `${back.length} rows`);
step('each row came back with its tile', back.every((r) => r.tile > 0));
step('no row came back with a handle — a handle is not a file', back.every((r) => !r.handle));
step('the restore read no frame from the disk', (await framesRead()) === 0, `${await framesRead()} reads`);
step('the count on the bar says two again', /2/.test(await count()), await count());
// --- the folder is picked again, and no frame is read for it ---------------
await page.click('[data-key="lib-add"]');
await waitNote(/Scanned TestRoll/);
const joined = await rows();
step('the reading gave every row its handle back', joined.length === 2 && joined.every((r) => r.handle));
step('and read no frame to do it', (await framesRead()) === 0, `${await framesRead()} reads`);
} catch (err) {
step(`the run finished`, false, String(err).split('\n')[0]);
code = 1;
}
console.log(failures ? `FAIL ${failures} step(s)` : 'ok the catalogue survives its browser');
await browser.close();
app.kill('SIGTERM');
sampleServer.close();
process.exit(failures || code ? 1 : 0);
+122
View File
@@ -0,0 +1,122 @@
// NOISE REDUCTION blurs the CHROMA of the frame and leaves its LUMA alone —
// thay_doi_thong_so_giong_lightroom.md §4.1A, "chi tiết ảnh không bị mất đi 1 sợi
// tóc nào".
//
// The knob used to be a `MakeBlur` image filter on the draw, i.e. one sigma over
// all three channels, which is the doc's own §4 warning about losing hair and
// skin written into the engine — and a filter on the draw can only be handed the
// shader's INPUT, so the pass could never read the graded pixels it was meant to
// correct either. It is a two-child pass now (NR_SKSL: the frame, and a blurred
// copy of it), and this check pins both halves of that: the shape of the wiring
// in exportEngine, and the one property that makes the knob safe — the luma of
// the output is the luma of the input, at every amount.
//
// node scripts/denoise-check.mjs
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import ts from 'typescript';
import CanvasKitInit from 'canvaskit-wasm/bin/full/canvaskit.js';
const transpile = (path) =>
ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
const dir = mkdtempSync(join(tmpdir(), 'denoise-check-'));
writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts'));
writeFileSync(
join(dir, 'toneShader.mjs'),
transpile('../shared/utils/toneShader.ts').replace(
/^import .*from ['"]\.\/colorUtils['"];$/m,
'import { HSL_BANDS, hslBandGaps, isMonochromeBase } from "./colorUtils.mjs";',
),
);
const { NR_SKSL, NR_CHROMA_SPAN } = await import(pathToFileURL(join(dir, 'toneShader.mjs')).href);
const engine = readFileSync(new URL('../src/engine/exportEngine.ts', import.meta.url), 'utf8');
// The engine still builds this shader, still hands it the frame and its blur,
// and no longer blurs the whole draw.
assert.match(engine, /Skia\.RuntimeEffect\.Make\(NR_SKSL\)/, 'NR_SKSL is not compiled any more');
assert.match(
engine,
/denoiseEffect\.makeShaderWithChildren\(\s*\[Math\.min\(1, denoiseKnob \/ 10\)\],\s*\[\s*own\(imageShaderChild\(snap\)\), own\(imageShaderChild\(blurred\)\)\s*\]\s*\)/,
'the denoise pass no longer takes the frame and its blurred copy as two children'
);
assert.doesNotMatch(
engine,
/adjustments\.denoise > 0[\s\S]{0,200}?ImageFilter\.MakeBlur/,
'the whole frame is blurred on the draw again — that is the detail the knob used to take'
);
assert.ok(
NR_CHROMA_SPAN >= 0.003 && NR_CHROMA_SPAN <= 0.005,
`NR_CHROMA_SPAN ${NR_CHROMA_SPAN} is not the doc's 3..5 pixels of a frame`
);
const CanvasKit = await CanvasKitInit({
locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
});
const SIZE = 4;
const NR_LUM = [0.2126, 0.7152, 0.0722];
const luma = (c) => c[0] * NR_LUM[0] + c[1] * NR_LUM[1] + c[2] * NR_LUM[2];
const near = (a, b, why) => assert.ok(Math.abs(a - b) <= 2 / 255, `${why} (${a} vs ${b})`);
function flat(rgb) {
const surf = CanvasKit.MakeSurface(SIZE, SIZE);
const paint = new CanvasKit.Paint();
// Color4f, not Color: CanvasKit's Color takes 0..255 and would truncate a
// channel of 0.8 to zero (the rest of the checks in here pass ints for that
// reason, these colours are fractions).
paint.setColor(CanvasKit.Color4f(rgb[0], rgb[1], rgb[2], 1));
surf.getCanvas().drawPaint(paint);
return surf.makeImageSnapshot();
}
const asChild = (image) =>
image.makeShaderOptions(
CanvasKit.TileMode.Clamp, CanvasKit.TileMode.Clamp, CanvasKit.FilterMode.Linear, CanvasKit.MipmapMode.None,
);
// A warm pixel over a neutral neighbourhood: the two ends of the knob are the
// pixel itself and the neighbourhood's hue on the pixel's own brightness.
const sharp = [0.8, 0.2, 0.35];
const blurred = [0.4, 0.45, 0.5];
function render(amount) {
const effect = CanvasKit.RuntimeEffect.Make(NR_SKSL);
assert.ok(effect, 'NR_SKSL does not compile');
const src = flat(sharp);
const ref = flat(blurred);
const shader = effect.makeShaderWithChildren([amount], [asChild(src), asChild(ref)]);
assert.ok(shader, 'the pass did not take two children — the blur is not wired in');
const out = CanvasKit.MakeSurface(SIZE, SIZE);
const paint = new CanvasKit.Paint();
paint.setShader(shader);
out.getCanvas().drawPaint(paint);
const px = out.makeImageSnapshot().readPixels(0, 0, {
width: SIZE, height: SIZE, colorType: CanvasKit.ColorType.RGBA_8888,
alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB,
});
return [px[0] / 255, px[1] / 255, px[2] / 255];
}
const at0 = render(0);
near(at0[0], sharp[0], 'amount 0 is not the pixel itself (red)');
near(at0[1], sharp[1], 'amount 0 is not the pixel itself (green)');
near(at0[2], sharp[2], 'amount 0 is not the pixel itself (blue)');
// The colour difference is the neighbourhood's at amount 1 — and that is a real
// move: a pixel that came back unchanged would mean the pass did nothing.
const at1 = render(1);
const chroma = (c) => c.map((v) => v - luma(c));
near(chroma(at1)[0], chroma(blurred)[0], 'amount 1 does not carry the blurred red difference');
near(chroma(at1)[2], chroma(blurred)[2], 'amount 1 does not carry the blurred blue difference');
// The property the doc's §4.1A is about, and the reason the knob costs no
// detail: whatever the amount, the output's brightness is the pixel's own.
for (const amount of [0, 0.25, 0.5, 0.75, 1]) {
near(luma(render(amount)), luma(sharp), `amount ${amount} moved the pixel's luma`);
}
console.log('denoise ok: the chroma of the frame is blurred, its luma never is');
+633 -58
View File
@@ -1,27 +1,156 @@
// Highlight roll-off, both ends of the pipeline, as one soft knee:
// Highlight roll-off in the develop, and the four tonal knobs in the tone pass.
//
// THE DEVELOP holds the knee:
//
// L' = L , L < T
// L' = T + (L - T) / (1 + 2 S (L - T)) , L >= T
//
// The develop draws it on the sensor's own levels (T = 0.7, S = 1 / (2 (1 - T)),
// which puts the asymptote on 1.0) so the two stops the sensor holds above its
// white level are COMPRESSED into the frame instead of being thrown away by the
// old fade-to-white; the tone pass draws the same curve in linear light on the
// value the develop and the camera match left, where -HL is the knob (T = 0.5,
// S = |hl|). Before this, a blown sky left the develop on exactly 1.0 in all
// three channels and HL had a flat white to pull on: measured on DSC03453.ARW,
// where the camera's own preview is clipped the develop's luma was 253.4 with a
// standard deviation of 2.4, against 251.2 / 10.0 through the knee.
// drawn on the sensor's own levels (T = 0.7, S = 1 / (2 (1 - T)), which puts the
// asymptote on 1.0) so the two stops the sensor holds above its white level are
// COMPRESSED into the frame instead of being thrown away by the old
// fade-to-white — which is also the only reason HIGHLIGHT has detail left at the
// top to move.
//
// Both are SkSL, so the shape is pinned on the source; the curve itself is
// checked as arithmetic, with the constants the source is asserted to carry.
// THE TONE PASS moves the luma by the four knobs (HIGHLIGHT, SHADOW, WHITE,
// BLACK), and each one is the MOVE ITS OWN LIGHTROOM EXPORT MADE, measured:
// TONE_KNOBS in toneShader.ts holds, per knob and per side of zero, the knot
// table of that move on the encoded luma. They are NOT the doc's four compact
// windows any more — the windows were confined to a quarter of the ramp each and
// were worth a few hundredths of luma inside it and the exact identity outside,
// which is the "sau khi chỉnh sửa 4 thông số này không đúng, không thay đổi"
// report: WHITE ±100 was a literal no-op. The reference exports say something
// else: WHITE +100 takes everything above luma 0.50 to 1.0 (mean +0.43 over the
// frame), BLACK -100 takes everything below 0.21 to 0.0 (mean -0.14), HIGHLIGHT
// +100 is a +0.24 lift peaking on luma 0.18 and dead by 0.72.
//
// The four are applied ONE AFTER THE OTHER (BLACK -> SHADOW -> HIGHLIGHT ->
// WHITE), each clamped on the way to the next, so a composition of monotone maps
// is monotone by construction and the ramp needs no guard whatever the four
// sliders say — and the knot tables are cut so that L + move is non-decreasing on
// every segment, which the sweep below measures rather than asserts.
//
// Both are SkSL, so the SHAPE is pinned on the source; the tables are then
// re-run here as a twin and checked against the measurement they were FITTED to
// (the nine exports in the repo root), so a table that drifts off the export is a
// red check. The frame-through-the-real-shader diff lives in the scratchpad
// harness render.mjs, which needs the nine JPEGs.
//
// node scripts/highlight-knee-check.mjs
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import ts from 'typescript';
const develop = readFileSync(new URL('../src/engine/rawDevelop.ts', import.meta.url), 'utf8');
const tone = readFileSync(new URL('../shared/utils/toneShader.ts', import.meta.url), 'utf8');
const read = (p) => readFileSync(new URL(p, import.meta.url), 'utf8');
const develop = read('../src/engine/rawDevelop.ts');
const tone = read('../shared/utils/toneShader.ts');
// The tone shader is IMPORTED, transpiled, so the numbers below are the ones the
// shader is built with and not a regex's reading of them.
const transpile = (path) =>
ts.transpileModule(read(path), {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
const dir = mkdtempSync(join(tmpdir(), 'highlight-knee-check-'));
writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts'));
writeFileSync(
join(dir, 'toneShader.mjs'),
transpile('../shared/utils/toneShader.ts').replace(
/^import .*from ['"]\.\/colorUtils['"];$/m,
'import { HSL_BANDS, hslBandGaps, isMonochromeBase } from "./colorUtils.mjs";',
),
);
const {
TONE_SKSL: sksl,
TONE_MATH_SKSL: maths,
EXPOSURE_SKSL,
TONE_KNOBS,
TONE_BLACK_EDGE,
TONE_BASE_RADIUS,
TONE_BASE_SIGMA,
toneUniformArray,
} = await import(pathToFileURL(join(dir, 'toneShader.mjs')).href);
const text = (s) => new RegExp(s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const codeOf = (s) => s.replace(/\/\/[^\n]*/g, '');
// THE FOUR KNOBS, as the source declares them. Each curve is a knot table on the
// encoded luma: positions ascending from the floor to the head, values the move
// at +100 (the minus side is read with an amount below zero).
const KNOBS = ['bl', 'sh', 'hl', 'wh'];
for (const k of KNOBS) {
for (const side of ['plus', 'minus']) {
const c = TONE_KNOBS[k][side];
assert.equal(c.p.length, c.v.length, `${k}.${side}: ${c.p.length} knots against ${c.v.length} values`);
assert.equal(c.p[0], 0, `${k}.${side} does not start on the floor`);
assert.equal(c.p[c.p.length - 1], 1, `${k}.${side} does not end on the head`);
for (let i = 1; i < c.p.length; i++)
assert.ok(c.p[i] > c.p[i - 1], `${k}.${side} knot ${i} is not past the one before it`);
}
}
// One knob as the shader gets it: the table drawn STRAIGHT between the knots — a
// hinge per segment, written as a sum of clamps because SkSL indexes arrays by
// constant only — scaled by the amount, added to the luma it reads and clamped.
// The side of zero picks the table, and the clamp is what makes a -- knob's own
// clip a plateau instead of a fold.
for (const k of KNOBS)
assert.match(
maths,
text(`float tone${k}(float L, float amt) {\n return clamp(L + (amt >= 0.0 ? amt * (`),
`${k} is not the measured table read as a polyline`
);
assert.match(maths, /: -amt \* \(/);
// ...pinned at the SOURCE's own numbers: the hinge from knot 0.045 to knot 0.185
// on BLACK's plus side is worth 0.1155 - 0.0876, over a segment 0.140 wide.
assert.match(maths, text(' + (0.0279) * clamp((L - 0.045) / 0.140, 0.0, 1.0)'));
// The composition, move for move: the four in the order the exports were read,
// each self-clamping (tone* does it), so a knob that has driven the luma to an
// end leaves the ones after it nothing to fold.
assert.match(
maths,
text('float toneCurve(float L, float bl, float sh, float hl, float wh) {\n L = tonebl(L, bl);\n L = tonesh(L, sh);\n L = tonehl(L, hl);\n L = tonewh(L, wh);\n return clamp(L, 0.0, 1.0);\n}')
);
// ...and the four COMPACT WINDOWS the knobs used to be are gone from the CODE —
// they were the "không thay đổi" bug. The prose is not read for these:
// TONE_MATH_SKSL still tells the story of what replaced them.
assert.doesNotMatch(codeOf(maths), /toneBlackW|toneBlack\(|toneShadowW|toneHighW|toneWhiteW|toneBump|holdLo|holdHi/);
assert.doesNotMatch(
tone,
/export const TONE_WHITE_EDGE|export const TONE_BLACK_LIFT|export const TONE_BLACK_CRUSH|export const TONE_HIGH_GAIN|export const TONE_WHITE_GAIN|export const TONE_ANCHOR|export const TONE_BUMP_SLOPE/,
'a window edge or rate is still declared'
);
// The BLACK knob's Hunt chroma mask is the one thing that still reads the doc's
// 0.18 — it is a chroma floor, not a knob window.
assert.match(maths, new RegExp(text(`float uBl = clamp(1.0 - t / ${TONE_BLACK_EDGE}, 0.0, 1.0);`)));
const knobBlock = maths.slice(maths.indexOf('float tonebl'), maths.indexOf('float toneCurve'));
assert.ok(knobBlock.includes('float tonewh'), 'the four knob functions are gone');
assert.doesNotMatch(
knobBlock,
/smoothstep|pow\(|exp\(/,
'a knob is not the measured table any more — it bends again'
);
// DR rides the same four moves with a share of each instead of masked terms of
// its own, so it cannot fight a knob over a band or invert the ramp.
assert.match(maths, text('float blVal = clamp(bl + dr * 0.12, -1.0, 1.0);'));
assert.match(maths, text('float shVal = clamp(sh + dr * 0.06, -1.0, 1.0);'));
assert.doesNotMatch(maths, /o = clamp\(base/, 'the ramp clamps the base before the curve again — the ends of the ramp are the anchors');
assert.doesNotMatch(codeOf(sksl), /float a4 = /, 'the ramp is back inside the pass — one copy, not two');
// The uniform block: the shader's declarations, arrays expanded and in
// declaration order, have to be the numbers `toneUniformArray` writes — a
// mismatch is a silent off-by-one down the whole block.
const declared = [...sksl.matchAll(/uniform (float2|float) (\w+)(?:\[(\d+)\])?;/g)].reduce(
(n, [, kind, , len]) => n + (len ? Number(len) : kind === 'float2' ? 2 : 1),
0
);
const arrayFn = tone.match(/export function toneUniformArray\(u: ToneUniforms\): number\[\] \{\n return \[([\s\S]*?)\n \];/)?.[1];
assert.ok(arrayFn, 'toneUniformArray is gone');
const written = arrayFn
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.reduce((n, s) => n + (s.startsWith('...u.hsl') ? 8 : 1), 0);
assert.equal(written, declared, `toneUniformArray writes ${written} floats, the pass declares ${declared}`);
// The develop: knee on the sensor's max channel, the channel ratios kept, so the
// hue and the saturation of a blown area survive the pull-down.
@@ -30,57 +159,503 @@ assert.ok(dev, 'the develop knee is gone — a blown sky is flat 1.0 again');
assert.match(dev, /float over = mx - 0\.7;/);
assert.match(dev, /rgb \*= \(0\.7 \+ over \/ \(1\.0 \+ over \* 3\.3333\)\) \/ mx;/);
assert.doesNotMatch(develop, /mix\(rgb \/ mx, float3\(1\.0\)/, 'the fade-to-white is back');
const mask = readFileSync(new URL('../shared/utils/gradientMask.ts', import.meta.url), 'utf8');
assert.ok(mask.includes('${TONE_MATH_SKSL}'), 'the mask pass does not read the shared maths');
assert.match(mask, /c = half3\(exposureMove\(vec3\(c\), a\.x\)\);/);
// The mask hands the ramp its OWN pixel as the base, twice over: a shape has no
// neighbourhood of its own, and base == t is a ratio of exactly 1, so what a mask
// does with SHADOW is what it always did. The knob means the same thing on both
// sides of the call; what differs is the neighbourhood, and a mask has none.
assert.match(mask, /c = half3\(toneRamp\(vec3\(c\), lf, lf, tone\.w, tone\.y, tone\.x, tone\.z, 0\.0\)\);/);
assert.doesNotMatch(mask, /0\.55, 1\.35/, 'the mask kept its own arbitrary saturation clamp');
assert.doesNotMatch(mask, /cg = clamp\(lifted/, 'the mask is back on its own tone formula');
// The tone pass: the knee runs in LINEAR light and before the luma is read, the
// lift keeps its headroom weight — and the recovery must not also ride the
// additive term, which would darken the white the knee protects.
assert.match(tone, /if \(hl < 0\.0\) \{/);
assert.match(tone, /vec3 lin = toLinear\(rgb\);/);
assert.match(tone, /float l0 = dot\(lin, vec3\(0\.2126, 0\.7152, 0\.0722\)\);/);
assert.match(tone, /lin \*= \(0\.5 \+ over \/ \(1\.0 \+ S \* over \* 2\.0\)\) \/ l0;/);
assert.match(tone, /rgb = clamp\(toEncoded\(lin\), 0\.0, 1\.0\);/);
assert.match(tone, /float o = t \+ max\(hl, 0\.0\) \* hlMask \* \(1\.0 - t\) \+ sh \* 0\.34 \* shMask;/);
// The transfer pair has to be the accurate one, or the knee is drawn in a space
// that is not linear at all.
// The four tents, one per quarter of the ramp, each clipped by its neighbour so
// no luma is counted by two of them. These weight the stock SPLIT TONES now — the
// four knobs no longer ride them, they ride their own measured curves (TONE_KNOBS).
assert.match(sksl, /float blMask = 1\.0 - smoothstep\(0\.00, 0\.25, t\);/);
assert.match(sksl, /float shMask = clamp\(1\.0 - smoothstep\(0\.25, 0\.50, t\) - blMask, 0\.0, 1\.0\);/);
assert.match(sksl, /float whMask = smoothstep\(0\.75, 1\.00, t\);/);
assert.match(sksl, /float hlMask = clamp\(smoothstep\(0\.50, 0\.75, t\) - whMask, 0\.0, 1\.0\);/);
// The composition, and NOT a sum of bumps on the identity: the four MEASURED
// curves are applied one after the other, each self-clamping via its own tone*()
// helper, which is what makes one knob's travel independent of another's (the
// shared ceiling used to take 0.663 of BLACK's travel off on the monochrome
// stock) and the ramp monotone by construction rather than by a guard. No
// linear-light knee runs ahead of them either — CURVE_SKSL is a different pass.
assert.doesNotMatch(sksl, /float blackA = |float whiteA = /, 'the amplitudes are summed again — the moves must be sequential');
assert.doesNotMatch(sksl, /o \+= dr \* 0\.12/, 'DR is an additive term again — it folds the flat stretch at 0.238');
assert.doesNotMatch(sksl, /float lin\(/, 'the linear-light knee ahead of the knobs came back');
assert.doesNotMatch(sksl, /clamp\(0\.25 \+ 0\.25/, 'a knob is clamped against a neighbour knot again');
assert.match(sksl, /float target = clamp\(o, 0\.0, 1\.0\);/);
assert.doesNotMatch(sksl, /float target = base > 0\.0004 \? o \* t \/ base : t;/, 'the gain-scaled detail came back — a tone knob softens again');
assert.match(sksl, /vec3 res = lightMove\(c, t, target\);/);
assert.doesNotMatch(sksl, /float target = base > 0\.0004 \? o \* t \/ base : t;/, 'the gain-scaled detail came back — a tone knob softens again');
assert.match(sksl, /vec3 res = lightMove\(c, t, target\);/);
assert.doesNotMatch(sksl, /lightMove\(c, t, clamp\(o, 0\.0, 1\.0\)\)/, 'the ramp is read at the pixel again — a global curve');
assert.doesNotMatch(sksl, /mix\(a0, a1, smoothstep/, 'the ramp is smoothstepped again');
// The linear-light knee that used to run ahead of all this is GONE from the tone
// pass: HIGHLIGHT is one zone move in both directions now, and a second pass over
// the same knob would double-count it.
assert.doesNotMatch(tone, /if \(hl < 0\.0\) \{/, 'the linear-light recovery came back');
assert.doesNotMatch(sksl, /max\(hl, 0\.0\)/, 'the additive lift came back');
assert.doesNotMatch(sksl, /bl \* 0\.18 \* dk|wh \* 0\.18 \* rgb/, 'WHITE/BLACK are per-channel again');
// The rebuild after the ramp: the doc's ratio (R_new = R_old * Luma_new /
// Luma_old), as ONE shared scale o / t, so the differences move with the light
// and neither the hue nor the saturation goes with them. The caps are what make
// it fit — applying the ratio past the ceiling clips a channel outright and the
// hue goes with it (a skin tone at 24.0° came back at 48.0° at HIGHLIGHT +100,
// scratchpad hl-variants.mjs) — and the scale is held at 1.0 only below
// t = 0.0004, where the ratio would multiply a near-black pixel's cast by
// whatever pedestal BLACK has just lifted.
assert.match(maths, /float safeT = max\(t, 0\.005\);/);
assert.match(maths, /float k = t > 0\.0004 \? min\(o \/ safeT, 3\.5\) : 1\.0;/);
assert.match(maths, /if \(hiC > t\) k = min\(k, \(1\.0 - o\) \/ max\(hiC - t, 0\.0001\)\);/);
assert.match(maths, /if \(loC < t\) k = min\(k, o \/ max\(t - loC, 0\.0001\)\);/);
assert.match(maths, /return clamp\(vec3\(o\) \+ \(c - vec3\(t\)\) \* k, 0\.0, 1\.0\);/);
assert.match(maths, /vec3 res = lightMove\(c, t, target\);/);
assert.doesNotMatch(tone, /0\.55, 1\.35/, 'the arbitrary saturation clamp came back');
assert.doesNotMatch(maths, /float k = 1\.0;/, 'the chroma-constant scale came back — a shadow lift drains the colour');
// The transfer pair has to be the accurate one where it is still used (the
// exposure pass), or that pass is drawn in a space that is not linear at all.
assert.match(tone, /return mix\(c \/ 12\.92, pow\(\(c \+ 0\.055\) \/ 1\.055, vec3\(2\.4\)\), step\(vec3\(0\.04045\), c\)\);/);
// The arithmetic. T = 0.7 / S = 1 / (2 (1 - T)) is the develop's pair (S is what
// puts the asymptote on 1.0: T + 1/(2S) = 1); T = 0.5 with S = 1 is the top of
// the tone pass's knob.
// The develop's arithmetic. T = 0.7 / S = 1 / (2 (1 - T)) is its pair (S is what
// puts the asymptote on 1.0: T + 1/(2S) = 1).
const knee = (l, T, S) => (l < T ? l : T + (l - T) / (1 + 2 * S * (l - T)));
const T = 0.7;
const S = 1 / (2 * (1 - T));
for (const [T, S] of [
[0.7, 1 / (2 * (1 - 0.7))],
[0.5, 0.25],
[0.5, 1],
]) {
// Below the knee the frame is untouched, and the curve is continuous and C1 at
// T — slope 1 on both sides — so there is no seam for a later pass to mask.
assert.equal(knee(T - 0.2, T, S), T - 0.2);
assert.equal(knee(T, T, S), T);
const slope = (x) => (knee(x + 1e-6, T, S) - knee(x, T, S)) / 1e-6;
assert.ok(Math.abs(slope(T) - 1) < 1e-3, `seam at T=${T}: slope ${slope(T)}`);
// Monotone, and never a brightening: a recovery slider that lifted a highlight
// would be a lift in disguise, and an inverted pair of pixels is a visible edge.
let prev = -Infinity;
for (let l = 0; l <= 2; l += 1 / 512) {
assert.ok(slope(l) > 0, `inverted at ${l} (T=${T}, S=${S})`);
assert.ok(knee(l, T, S) <= l + 1e-9, `brightened ${l} -> ${knee(l, T, S)}`);
assert.ok(knee(l, T, S) >= prev);
prev = knee(l, T, S);
}
// The asymptote: everything the sensor held above the knee lands under it.
// The develop's pair puts it exactly on 1.0, the tone pass's S = 1 on 0.75.
assert.ok(Math.abs(knee(1e6, T, S) - (T + 1 / (2 * S))) < 1e-4);
// Below the knee the frame is untouched, and the curve is continuous and C1 at T
// — slope 1 on both sides — so there is no seam for a later pass to mask.
assert.equal(knee(T - 0.2, T, S), T - 0.2);
assert.equal(knee(T, T, S), T);
const slope = (x) => (knee(x + 1e-6, T, S) - knee(x, T, S)) / 1e-6;
assert.ok(Math.abs(slope(T) - 1) < 1e-3, `seam at T=${T}: slope ${slope(T)}`);
// Monotone, and never a brightening: an inverted pair of pixels is a visible edge.
let prev = -Infinity;
for (let l = 0; l <= 2; l += 1 / 512) {
assert.ok(slope(l) > 0, `inverted at ${l}`);
assert.ok(knee(l, T, S) <= l + 1e-9, `brightened ${l} -> ${knee(l, T, S)}`);
assert.ok(knee(l, T, S) >= prev);
prev = knee(l, T, S);
}
assert.ok(Math.abs(0.7 + 1 / (2 * (1 / (2 * (1 - 0.7)))) - 1) < 1e-9, 'the develop plateau left 1.0');
// The asymptote: everything the sensor held above the knee lands under it, on
// exactly 1.0.
assert.ok(Math.abs(knee(1e6, T, S) - (T + 1 / (2 * S))) < 1e-4);
assert.ok(Math.abs(T + 1 / (2 * S) - 1) < 1e-9, 'the develop plateau left 1.0');
// ...and the same pair in the encoded domain, which is the domain the develop
// hands over: mx = 1.0 (the white level) lands on 237, the sensor's own plateau
// (1.93, see the gain above) on 248 — a ramp of a dozen code values where the
// old fade-to-white left nothing above 250 at all.
// (1.93) on 248 — a ramp of a dozen code values where the old fade-to-white left
// nothing above 250 at all. This is the headroom the four tone knobs move.
const enc = (x) => (x <= 0.0031308 ? x * 12.92 : 1.055 * x ** (1 / 2.4) - 0.055);
const DEV_S = 1 / (2 * (1 - 0.7));
assert.equal(Math.round(enc(knee(1.0, 0.7, DEV_S)) * 255), 237);
assert.equal(Math.round(enc(knee(1.93, 0.7, DEV_S)) * 255), 248);
assert.equal(Math.round(enc(knee(1.0, T, S)) * 255), 237);
assert.equal(Math.round(enc(knee(1.93, T, S)) * 255), 248);
// The ramp as arithmetic — the same four windows, the same rates and the same
// sequential composition the SkSL above carries, so the shape is measured and
// not described. Each move is monotone for any amount in [-1, 1] and lands on 0
// and 1 without moving either, so the composition is monotone by construction
// and the two ends of the ramp are FIXED POINTS whatever the sliders say.
const clamp01 = (x) => Math.min(1, Math.max(0, x));
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
// Float-exact comparisons are a trap once a value has been through a division
// and a multiply (x / 0.25 * 0.25 is not x) — assert to within a code value.
const close = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-12, `${msg ?? ''} ${a} != ${b}`);
// The readings below come out of this same arithmetic to five decimals, so the
// tolerance is the rounding of the number written down, well inside the code
// value the cube can see.
const near4 = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-4, `${msg ?? ''} ${a} != ${b}`);
// Exact to the last digit where it matters: a window of zero is not "nearly"
// zero, and that is the claim the independence checks rest on.
const exact = (a, b, msg) => assert.equal(a, b, `${msg ?? ''} ${a} != ${b}`);
const smoothstep = (e0, e1, x) => {
const u = clamp01((x - e0) / (e1 - e0));
return u * u * (3 - 2 * u);
};
// ---------------------------------------------------------------------------
// The ramp as arithmetic — the same tables, the same order and the same clamps
// the SkSL above carries, so the shape is measured and not described.
//
// ONE knob's curve, as KNOB_SKSL emits it: the table drawn straight between the
// knots, scaled by the amount, added to the luma it reads and clamped.
const poly = (c, L) => {
let v = c.v[0];
for (let i = 1; i < c.p.length; i++)
v += (c.v[i] - c.v[i - 1]) * clamp((L - c.p[i - 1]) / (c.p[i] - c.p[i - 1]), 0, 1);
return v;
};
const knob = (L, k, amt) =>
clamp01(L + (amt >= 0 ? amt * poly(TONE_KNOBS[k].plus, L) : -amt * poly(TONE_KNOBS[k].minus, L)));
// toneCurve, move for move, with the clamp the shader puts after each one:
// BLACK, then SHADOW, then HIGHLIGHT, then WHITE. `afterBlack` is the luma the
// SHADOW move reads — the value a stock's own SHADOW cannot drag, because BLACK
// runs before it.
function ramp(t, k = {}) {
const { dr = 0, hl = 0, sh = 0, wh = 0, bl = 0 } = k;
const afterBlack = knob(t, 'bl', clamp(bl + dr * 0.12, -1, 1));
const o = knob(
knob(knob(afterBlack, 'sh', clamp(sh + dr * 0.06, -1, 1)), 'hl', clamp(hl - dr * 0.09, -1, 1)),
'wh',
clamp(wh - dr * 0.18, -1, 1)
);
return { o, afterBlack };
}
// THE MEASUREMENT THE TABLES WERE FITTED TO. Nine genuine Lightroom Classic
// 14.2 exports of Main.jpg — the frame at the repo root — each knob at +100 and
// at -100, the per-pixel luma difference against Main.jpg binned by base luma and
// averaged. The row is that move at nine luma positions, off the dense grid the
// tables were cut from (0.008 of luma is the worst the fit is allowed anywhere);
// the tolerance below is 0.01, a little over a code value at ±100.
const MEASURED_L = [0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7];
const MEASURED = {
'hl+': [0.0419, 0.1116, 0.1814, 0.2323, 0.1607, 0.1011, 0.078, 0.0452, 0.0108],
'hl-': [-0.0201, -0.0363, -0.0475, -0.0589, -0.0701, -0.0808, -0.1298, -0.1627, -0.1461],
'sh+': [0.1971, 0.24, 0.2467, 0.2533, 0.25, 0.23, 0.205, 0.175, 0.1333],
'sh-': [-0.0434, -0.0874, -0.1285, -0.1583, -0.1359, -0.0912, -0.0734, -0.0473, -0.012],
'wh+': [0.2036, 0.2901, 0.3714, 0.4422, 0.51, 0.5623, 0.4998, 0.3999, 0.3022],
'wh-': [-0.0019, -0.0038, -0.0058, -0.0085, -0.0123, -0.0203, -0.03, -0.0461, -0.0609],
'bl+': [0.0905, 0.1049, 0.1132, 0.1151, 0.099, 0.0843, 0.0702, 0.052, 0.0328],
'bl-': [-0.0501, -0.0997, -0.1498, -0.1983, -0.2429, -0.2247, -0.1574, -0.1141, -0.0773],
};
for (const [name, want] of Object.entries(MEASURED)) {
const k = name.slice(0, 2);
const amt = name.endsWith('+') ? 1 : -1;
want.forEach((move, i) => {
const got = knob(MEASURED_L[i], k, amt);
assert.ok(
Math.abs(got - clamp01(MEASURED_L[i] + move)) <= 0.01,
`${name} at luma ${MEASURED_L[i]}: the table moves to ${got.toFixed(4)}, the export to ${(clamp01(MEASURED_L[i] + move)).toFixed(4)}`
);
});
}
// The film stocks ride these same curves, so their numbers are their own.
const filmTone = tone.match(/const FILM_TONE[\s\S]*?\n};/)?.[0];
assert.ok(filmTone, 'FILM_TONE is gone — the stocks no longer shape the ramp at all');
// The keys are quoted or not depending on whether they are identifiers, so the
// quotes come off before the lookup.
const filmFlat = filmTone.replace(/['"]/g, '');
for (const [name, src, kn, toe, head] of [
['classic-chrome', 'sh: -0.47', { sh: -0.47 }, 0.1791844],
['classic-vivid', 'sh: -0.47', { sh: -0.47 }, 0.1791844],
['monochrome', 'sh: -0.20, hl: -0.1143', { sh: -0.2, hl: -0.1143 }, 0.2126235, 0.7396688],
['mono-high-contrast', 'sh: -1.0, wh: 0.26', { sh: -1.0, wh: 0.26 }, 0.1734727, 0.8150685],
]) {
assert.ok(
filmFlat.includes(`${name}: { ${src} }`),
`${name} is not on the ${src} its ends were solved at`
);
near4(ramp(0.25, kn).o, toe, `${name}'s shadow knot moved`);
if (head) near4(ramp(0.75, kn).o, head, `${name}'s highlight knot moved`);
}
// Non-decreasing on EVERY segment, for every amount in [-1, 1] — not just at the
// ±100 the exports were sampled at. A fold in a tone curve is a band the eye
// reads, and a knob's own shape is what has to rule it out: the tables are cut so
// L + move never turns back.
for (const k of KNOBS)
for (let amt = -1; amt <= 1.0001; amt += 1 / 64) {
let prev = -Infinity;
for (let i = 0; i <= 4000; i++) {
const o = knob(i / 4000, k, amt);
assert.ok(o >= prev - 1e-12, `${k} at ${amt.toFixed(4)} folds the ramp at luma ${i / 4000}`);
prev = o;
}
}
// Every knob on zero is EXACTLY the identity — the pass also runs for the stock
// split tones and for DR alone, so a neutral setting must not curve the frame.
for (let i = 0; i <= 2000; i++) exact(ramp(i / 2000, {}).o, i / 2000, `identity broke at ${i / 2000}`);
// The ends of the ramp are where the MEASUREMENT put them, and nowhere else. The
// floor is the one that surprises: HIGHLIGHT +100 lifts a true black by 0.084 —
// the biggest move any knob makes on luma 0 — while BLACK +100, the knob named
// for it, is worth 0.0042 there; and the head moves under WHITES -100 alone
// (-0.10 on a true white, which is why its table does not tail off to zero).
// Nothing may drag an end further than the export did.
for (const k of KNOBS)
for (const side of ['plus', 'minus']) {
const c = TONE_KNOBS[k][side];
const amt = side === 'plus' ? 1 : -1;
near4(knob(0, k, amt), clamp01(c.v[0]), `${k}.${side} floor travel`);
near4(knob(1, k, amt), clamp01(1 + c.v[c.v.length - 1]), `${k}.${side} head travel`);
}
for (const k of KNOBS)
for (const side of ['plus', 'minus']) {
const c = TONE_KNOBS[k][side];
assert.ok(Math.abs(c.v[0]) <= 0.15, `${k}.${side} drags the floor by ${c.v[0]} — past what any export did`);
assert.ok(Math.abs(c.v[c.v.length - 1]) <= 0.1, `${k}.${side} drags the head by ${c.v[c.v.length - 1]} — past what any export did`);
}
// The two landmarks the whole change is measured by, read at the export's own
// numbers: WHITES +100 puts everything above luma 0.50 on 1.0 — it rounds to the
// ceiling from there up, which is the "+0.43 mean" a user sees as the frame
// opening — and BLACKS -100 puts everything below 0.21 on 0.0 (under a code
// value), the crushed toe.
for (let L = 0.5; L <= 1.0001; L += 1 / 512)
assert.equal(Math.round(knob(L, 'wh', 1) * 255), 255, `WHITES +100 leaves luma ${L} short of the ceiling`);
for (let L = 0; L <= 0.2; L += 1 / 512)
assert.ok(knob(L, 'bl', -1) < 0.006, `BLACKS -100 leaves luma ${L} above the floor`);
// On a mid-grey the four are all live — the old windows left the 0.50 midpoint to
// nothing but the two ends, which is the "không thay đổi" the report is about.
for (const k of KNOBS) {
assert.ok(Math.abs(knob(0.5, k, 1) - 0.5) > 0.01, `${k} +100 does nothing on a mid-grey`);
assert.ok(Math.abs(knob(0.5, k, -1) - 0.5) > 0.001, `${k} -100 does nothing on a mid-grey`);
}
// ...and no combination of the four at full deflection can drive an end of the
// ramp off the cube, fold the ramp back on itself, or cross the toe over the
// head — the composition is monotone by construction, and this is that
// construction run over all 162 combinations.
const combos = [];
for (const bl of [-1, 0, 1])
for (const sh of [-1, 0, 1])
for (const hl of [-1, 0, 1])
for (const wh of [-1, 0, 1]) for (const dr of [0, 1]) combos.push({ bl, sh, hl, wh, dr });
for (const k of combos) {
const lo = ramp(0, k).o;
const hi = ramp(1, k).o;
assert.ok(lo >= 0 && hi <= 1, `the ramp left the cube at ${JSON.stringify(k)}`);
assert.ok(lo <= hi, `the toe ${lo} climbed over the head ${hi} at ${JSON.stringify(k)}`);
let prev = -Infinity;
for (let i = 0; i <= 2000; i++) {
const o = ramp(i / 2000, k).o;
assert.ok(o >= prev - 1e-12, `the ramp folds at ${i / 2000} with ${JSON.stringify(k)}`);
prev = o;
}
}
// The colour rebuild, as the shader emits it: the ramp's luma, the pixel's own
// chroma difference, and the one scale o / t the cube then gets to pull back.
const lumaOf = (c) => clamp01(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]);
// lightMove, as TONE_MATH_SKSL emits it — the one move every brightness change in
// the pass goes through (a tone knob, a mask's tone knob, the exposure knob).
// NOT clamped on the way out here: the check below wants to see that the scale
// alone already landed the pixel inside the cube, and a silent clamp would hide
// the case where it did not.
function lightMove(rgb, t, o) {
let k = t > 0.0004 ? Math.min(o / Math.max(t, 0.005), 3.5) : 1;
const hiC = Math.max(...rgb);
const loC = Math.min(...rgb);
if (hiC > t) k = Math.min(k, (1 - o) / (hiC - t));
if (loC < t) k = Math.min(k, o / Math.max(t - loC, 0.0001));
return rgb.map((c) => o + (c - t) * k);
}
// The rebuild, with the base layer the shader now draws the ramp through. `base`
// defaults to the pixel itself — the degenerate call, and the one a mask makes —
// which lands `target` back on `o` and is the move this function had before there
// was a base at all. Away from that the pixel's own DIFFERENCE from the base is
// ADDED to the neighbourhood's new luma (Base' + Detail), never scaled by it.
function rebuild(rgb, k, base) {
const t = lumaOf(rgb);
const b = base ?? t;
const o = ramp(t, k).o;
const target = clamp01(o);
const out = lightMove(rgb, t, target);
return { out, clamped: out.map((c) => clamp01(c)), o, t, base: b, target };
}
// The transfer pair the exposure pass crosses into linear light with, and back.
const srgbToLin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const linToSrgb = (c) => (c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055);
// exposureMove, as TONE_MATH_SKSL emits it: the linear sensor moves by the stops,
// and the encoded value that lands there is the luma the pixel is rebuilt onto.
// The light moves by exp2(ev) in LINEAR light; the colour moves by the one shared
// scale of lightMove. A per-channel multiply does neither — it clips the three
// channels by three different amounts and takes the hue with it (29.2° at +1 EV
// on the scratchpad probe, exp-variant.mjs; this variant measures 0.00°).
function exposureMove(rgb, ev) {
const c = rgb.map(clamp01);
const t = lumaOf(c);
// The stop as a RATIO on the pixel's own encoded luma, which is what makes the
// knob the identity at 0 EV: pointing the luma straight at the encoded linear
// target brightens a colour by a couple of code values even on zero.
const lin = Math.max(lumaOf(c.map(srgbToLin)), 1e-6);
const stop = linToSrgb(Math.min(1, lin * 2 ** ev)) / linToSrgb(lin);
return lightMove(c, t, clamp01(t * stop)).map(clamp01);
}
function hueOf(c) {
const mx = Math.max(...c), mn = Math.min(...c), d = mx - mn;
if (d < 1e-9) return NaN;
let h;
if (mx === c[0]) h = (c[1] - c[2]) / d + (c[1] < c[2] ? 6 : 0);
else if (mx === c[1]) h = (c[2] - c[0]) / d + 2;
else h = (c[0] - c[1]) / d + 4;
return ((h * 60) % 360 + 360) % 360;
}
const colourCases = [
[0.9, 0.72, 0.6], // skin — the case that moved 24° under the ratio
[1, 0.97, 0.92], // a warm white at the very top of the ramp
[0.45, 0.65, 0.9], // sky
[1, 0.6, 0.2], // orange, one channel already on the ceiling
[0.45, 0.85, 0.4], // green
[0.05, 0.03, 0.02], // a shadow with a cast
[0.01, 0.008, 0.006],// and the same cast with almost no light on it at all
];
const greyCases = [[0.1, 0.1, 0.1], [0.5, 0.5, 0.5], [0.7, 0.7, 0.7], [0.9, 0.9, 0.9], [0.97, 0.97, 0.97]];
const knobSets = [];
for (const hl of [-1, -0.5, 0, 0.5, 1])
for (const wh of [-1, 0, 1])
for (const sh of [-1, 0, 1])
for (const bl of [-1, 0, 1]) knobSets.push({ hl, wh, sh, bl });
for (const k of knobSets) {
for (const rgb of colourCases) {
const { out, clamped, o } = rebuild(rgb, k);
// The clamp is never what saves the pixel: the scale already landed the
// result inside the cube, which is the whole point of it.
for (let i = 0; i < 3; i++)
assert.ok(Math.abs(out[i] - clamped[i]) < 1e-12, `the cube clipped ${i} of ${rgb} at ${JSON.stringify(k)}`);
// Hue cannot move: every channel difference is scaled by the same number.
const dh = hueOf(clamped) - hueOf(rgb);
assert.ok(Number.isNaN(dh) || Math.abs(dh) < 1e-9, `hue moved ${dh} for ${rgb} at ${JSON.stringify(k)}`);
// ...and the new luma is the ramp's, exactly (the differences sum to zero
// in this weighting, so the scale drops out of the luma).
close(lumaOf(clamped), o, `luma ${rgb} at ${JSON.stringify(k)}`);
}
// A grey is a grey: no difference to carry, so it lands on the ramp value and
// picks up no cast on the way.
for (const rgb of greyCases) {
const { clamped, o } = rebuild(rgb, k);
for (const c of clamped) close(c, o, `grey drifted at ${JSON.stringify(k)}`);
}
}
// Every knob on zero is the identity for the colour too, not just the luma.
for (const rgb of [...colourCases, ...greyCases]) {
const { clamped } = rebuild(rgb, {});
for (let i = 0; i < 3; i++) close(clamped[i], rgb[i], 'the colour rebuild is not the identity at zero');
}
// The chroma RIDES THE RATIO: where the cube has room the channel differences
// come out multiplied by the one scale o / t. That is what keeps the saturation —
// an HSL saturation is a ratio of differences and a common scale never touches it
// — and the hue along with it, which is the report behind this move: held at
// k = 1.0 (the chroma carried unchanged) a dark red came back at 0.505 of
// saturation from 0.746 with SHADOW at +100, and at 0.370 with SHADOW and BLACK
// both, which is a colour going grey under a lift.
for (const [rgb, knobs] of [
[[0.7, 0.55, 0.45], { hl: 0.5 }],
[[0.25, 0.12, 0.08], { sh: 0.5, bl: 0.5 }],
]) {
const lifted = rebuild(rgb, knobs);
const grew = (lifted.clamped[0] - lifted.clamped[1]) / (rgb[0] - rgb[1]);
assert.ok(Math.abs(lifted.target / lifted.t - 1) > 1e-3, `the lift left the ramp alone on ${rgb} — the case proves nothing`);
close(grew, lifted.target / lifted.t, `the chroma did not ride the ratio on ${rgb} at ${JSON.stringify(knobs)}`);
}
// THE BASE LAYER. A band with texture in it — SHADOW's own quarter, 0.26 to
// 0.50, at the deflection this was reported at (the full +100, which is +1 here):
//
// read at the pixel every pixel lands on its own o, so the band's spread
// comes out multiplied by whatever slope the curve has
// over it — 1.2347 of its own spread at this deflection,
// the SHADOW +100 table steepening through 0.26..0.50.
// read at the base every pixel of ONE neighbourhood takes the same move,
// o(base) - base, and its own difference from the base is
// added to it, so the texture inside the region comes out
// at ITS OWN size and the same lift lands on the pixels
// either way.
//
// The base is the whole point of the second reading: what SHADOW does to a
// frame's texture is a property of the neighbourhood, not of the pixel. (The
// spread here is kept WHOLE because this twin's band sits on ONE base, where the
// reconstruction is exact by construction; on a real frame the band spans many
// neighbourhoods and the live number is the one to read.)
const band = Array.from({ length: 32 }, (_, i) => 0.26 + 0.24 * (i / 31));
const spread = (xs) => Math.max(...xs) - Math.min(...xs);
const bandBase = band.reduce((a, b) => a + b, 0) / band.length;
const movedGlobally = band.map((t) => rebuild([t, t, t], { sh: 1 }).clamped[0]);
// Every knob on zero is the identity, whatever base is handed in
for (const b of [0.01, 0.1, 0.35, 0.7, 0.99])
for (const rgb of [...colourCases, ...greyCases]) {
const g = rebuild(rgb, {}, b);
for (let i = 0; i < 3; i++) close(g.clamped[i], rgb[i], `the base path is not the identity at zero, base ${b}`);
}
// THE EXPOSURE KNOB, the same move on a different input. Behind it: -2..+2 EV in
// half stops, on the frame and inside a gradient mask.
// A grey is the knob it always was — a stop on a neutral is a stop on its light,
// and nothing else: this is the number the old linear per-channel multiply put
// there, so no exposure a user has dialled in moves.
for (const g of [0.05, 0.1, 0.5, 0.7, 0.9, 0.97])
for (const ev of [-2, -1, -0.5, 0.5, 1, 2])
close(
exposureMove([g, g, g], ev)[0],
linToSrgb(Math.min(1, srgbToLin(g) * 2 ** ev)),
`the exposure is no longer a stop on a grey: ${g} at ${ev} EV`,
);
// ...and zero stops is the identity on a COLOUR too, exactly — the knob has to be
// able to leave the frame alone.
for (const rgb of [...colourCases, ...greyCases])
for (let i = 0; i < 3; i++)
close(exposureMove(rgb, 0)[i], rgb[i], 'the exposure move is not the identity at 0 EV');
// Hue cannot move, at any stop, on any colour: this is the whole fix. The old
// pass multiplied the three channels by the same number in LINEAR light and then
// clipped them by three different amounts, and the hue went with them — 29.2° on
// the skin tone at +1 EV, 33.3° at +2 EV (scratchpad exp-variant.mjs), against
// 0.00° here.
for (const rgb of colourCases)
for (const ev of [-2, -1, -0.5, 0, 0.5, 1, 2]) {
const out = exposureMove(rgb, ev);
const dh = hueOf(out) - hueOf(rgb);
assert.ok(Number.isNaN(dh) || Math.abs(dh) < 1e-9, `the exposure moved the hue ${dh}° on ${rgb} at ${ev} EV`);
assert.ok(out.every((c) => c >= -1e-12 && c <= 1 + 1e-12), `the exposure left the cube on ${rgb} at ${ev} EV`);
}
// A pixel already on the ceiling: the channel that used to clip lands exactly ON
// the ceiling and the other two follow it down at the one shared scale, so the
// pixel gives up saturation rather than having the three clip by three different
// amounts — which is where the old pass lost the hue.
const blown = exposureMove([1, 0.6, 0.2], 5);
assert.ok(blown.every((c) => c >= -1e-12 && c <= 1 + 1e-12), 'the exposure overshot the ceiling');
close(blown[0], 1, 'the channel that hit the ceiling stopped short of it');
assert.ok(blown[1] > 0.9 && blown[2] > 0.85, 'the pixel collapsed to white instead of keeping its colour');
assert.ok(Math.abs(hueOf(blown) - hueOf([1, 0.6, 0.2])) < 1e-9, 'the pixel lost its hue at the ceiling');
// ...and a pixel the move really does drive to 1.0 (an exposure past the head of
// the ramp) is white, in all three channels at once.
const white = exposureMove([0.98, 0.98, 0.98], 5);
for (let i = 0; i < 3; i++) close(white[i], 1, 'a blown pixel stopped short of white');
// Darkening is the mirror: the light comes down, and a colour with no room below
// gives up saturation and arrives neutral, not negative.
const crushed = exposureMove([0.02, 0.01, 0.005], -5);
assert.ok(crushed.every((c) => c >= -1e-12 && c <= 1 + 1e-12), 'the exposure went outside the cube on the way down');
assert.ok(crushed[0] >= crushed[1] && crushed[1] >= crushed[2], 'the exposure inverted the channel order on the way down');
// Black has no light to move: every stop leaves it where it is, and none of them
// divides by zero on the way.
for (const ev of [-5, -1, 0, 1, 5]) assert.equal(exposureMove([0, 0, 0], ev)[0], 0, `black moved at ${ev} EV`);
// THE PASS ITSELF, compiled and run. Everything above is a twin, and a twin is
// only as good as its reading of the source; nothing else compiles EXPOSURE_SKSL,
// so a wrapper whose uniform stopped matching its own main would only show up in
// the app. Four pixels through the real shader, against the twin.
const exposureSrc = EXPOSURE_SKSL;
assert.match(exposureSrc, /uniform float ev;/, 'the exposure pass no longer takes its stops');
assert.match(exposureSrc, /return vec4\(exposureMove\(clamp\(c\.rgb, 0\.0, 1\.0\), ev\), c\.a\);/, 'the pass stopped calling exposureMove');
const { default: CanvasKitInit } = await import('canvaskit-wasm/bin/full/canvaskit.js');
const ck = await CanvasKitInit({
locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
});
const effect = ck.RuntimeEffect.Make(exposureSrc);
assert.ok(effect, 'EXPOSURE_SKSL does not compile — the whole frame loses its exposure');
const throughPass = (rgb, ev) => {
const surface = ck.MakeSurface(4, 4);
const paint = new ck.Paint();
paint.setColor(ck.Color(...rgb));
surface.getCanvas().drawPaint(paint);
const child = surface.makeImageSnapshot().makeShaderOptions(
ck.TileMode.Clamp, ck.TileMode.Clamp, ck.FilterMode.Linear, ck.MipmapMode.None,
);
const shaderPaint = new ck.Paint();
shaderPaint.setShader(effect.makeShaderWithChildren([ev], [child]));
const out = ck.MakeSurface(4, 4);
out.getCanvas().drawRect(ck.XYWHRect(0, 0, 4, 4), shaderPaint);
const px = out.getCanvas().readPixels(0, 1, {
width: 4, height: 1, colorType: ck.ColorType.RGBA_8888, alphaType: ck.AlphaType.Unpremul, colorSpace: ck.ColorSpace.SRGB,
});
return [px[0], px[1], px[2]];
};
for (const [rgb, ev] of [[[128, 128, 128], 1], [[230, 150, 50], 1], [[230, 150, 50], 2], [[20, 10, 5], -2]]) {
const want = exposureMove(rgb.map((v) => v / 255), ev).map((v) => Math.round(v * 255));
const got = throughPass(rgb, ev);
assert.ok(
got.every((v, i) => Math.abs(v - want[i]) <= 1),
`the pass and its twin disagree on ${rgb} at ${ev} EV: ${got} against ${want}`,
);
}
console.log('highlight-knee-check ok');
+466
View File
@@ -0,0 +1,466 @@
// Immich as a folder beside the folders on the disk: the dialog that adds a
// server, what the column draws for it, what an album tick means, what the
// album-ID field makes of a whole address and of rubbish, and the one thing that
// must never happen — the page talking to Immich itself.
//
// The app answers `/api` on its own origin, and the Immich side of it is stood in
// for here the way `backend/src/immich.ts` answers: two keys that see two
// different sets of albums, one of them without the right to download originals,
// and a share link, which is one album and no picker.
//
// npm run build && node scripts/immich-check.mjs
// APP_PORT=4410 PLAYWRIGHT_CORE=<playwright-core/index.mjs> CHROME=<chrome> \
// node scripts/immich-check.mjs
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
const APP_PORT = Number(process.env.APP_PORT ?? 4410);
let failures = 0;
function step(name, ok, detail = '') {
if (!ok) failures++;
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
return ok;
}
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
if (!playwright) {
console.log('SKIP playwright-core is not installed');
process.exit(0);
}
// --- the server side of the check -------------------------------------------
// A uuid per thing, spelled the way the backend spells one: eight, four, four,
// four, twelve, all hex. The routes that take an id refuse anything else.
const uid = (n) => `${String(n).padStart(8, '0')}-0000-4000-8000-${String(n).padStart(12, '0')}`;
const A1 = uid(101);
const A2 = uid(102);
const B1 = uid(201);
const ALBUM_NAME = { [A1]: 'A1', [A2]: 'A2', [B1]: 'B1' };
// What each album holds: five frames over the first two, one over the third.
const ASSETS = [];
let nth = 0;
for (const [album, names] of [
[A1, ['A1_1.JPG', 'A1_2.JPG']],
[A2, ['A2_1.JPG', 'A2_2.JPG', 'A2_3.JPG']],
[B1, ['B1_1.JPG']],
]) {
for (const name of names) ASSETS.push({ id: uid(1000 + ++nth), album, name });
}
// The three boxes an address can name, and the key each of them answers to.
const HOSTS = {
'https://photos.example.com': { key: 'good-key', version: '1.120.0', canDownload: true, albums: [A1, A2] },
'https://view.example.com': { key: 'view-only', version: '1.119.0', canDownload: false, albums: [B1] },
'https://share.example.com': { key: 'share-xyz', version: null, canDownload: false, albums: [B1] },
};
// What the backend holds: a saved server, by the id it minted.
const saved = [];
let minted = 0;
// Album ids as the backend cleans them: a uuid out of whatever was typed, once
// each, at most two hundred of them.
function cleanAlbums(raw) {
const out = [];
for (const one of Array.isArray(raw) ? raw : []) {
const hit = String(one).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
const id = hit ? hit[0].toLowerCase() : '';
if (id && !out.includes(id)) out.push(id);
}
return out.slice(0, 200);
}
// One pixel of PNG, so a tile that lands is a picture the browser decodes.
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64'
);
// Anything the page asks a photos host for directly. Nothing may: the address and
// the key live on the backend, and the page only ever calls its own origin.
let direct = 0;
let thumbs = 0;
const app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
cwd: FRONTEND,
stdio: 'ignore',
});
const base = `http://127.0.0.1:${APP_PORT}`;
let up = false;
for (let i = 0; i < 60 && !up; i++) {
up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false);
if (!up) await new Promise((r) => setTimeout(r, 500));
}
if (!up) {
app.kill('SIGTERM');
console.log(`FAIL no preview on ${base} — run \`npm run build\` first`);
process.exit(1);
}
const browser = await playwright.chromium.launch({
executablePath: process.env.CHROME || undefined,
args: ['--no-sandbox', '--enable-unsafe-swiftshader'],
});
const context = await browser.newContext();
await context.route(/^https:\/\/[a-z]+\.example\.com\//, (route) => {
direct++;
return route.fulfill({ status: 200, contentType: 'image/png', body: PNG });
});
await context.route('**/api/**', (route) => {
const req = route.request();
const url = new URL(req.url());
const path = url.pathname;
const send = (status, body) =>
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
const body = () => {
try {
return JSON.parse(req.postData() ?? '{}');
} catch {
return {};
}
};
const host = (serverId) => HOSTS[saved.find((s) => s.id === serverId)?.url ?? ''];
const view = (s) => ({ ...s });
if (path.includes('/auth/me')) return send(200, { user: { id: 1, email: 'check@example.com', name: 'check' } });
if (path.includes('/photos')) return send(200, { photos: [] });
if (path.includes('/recipes')) return send(200, { recipes: [] });
if (path === '/api/immich/config') {
if (req.method() === 'GET') {
return send(200, { ok: true, servers: saved.map(view) });
}
if (req.method() === 'DELETE') {
const at = saved.findIndex((s) => s.id === url.searchParams.get('id'));
if (at < 0) return send(404, { ok: false, error: 'invalid' });
saved.splice(at, 1);
return send(200, { ok: true, servers: saved.map(view) });
}
const b = body();
// An edit names the node and says only what changes — a new key, or nothing
// at all — so what it does not repeat is what the node already has.
const at = b.id ? saved.findIndex((s) => s.id === b.id) : -1;
if (b.id && at < 0) return send(404, { ok: false, error: 'invalid' });
const had = at < 0 ? null : saved[at];
const url0 = b.url ?? had?.url;
const key0 = b.key ?? (had ? HOSTS[had.url]?.key : undefined);
if (!url0 || !key0) return send(400, { ok: false, error: 'invalid' });
const box = HOSTS[url0];
if (!box) return send(200, { ok: false, error: 'unreachable' });
if (box.key !== key0) return send(200, { ok: false, error: 'auth' });
const server = {
id: had?.id ?? `s${++minted}`,
name: String(b.name ?? had?.name ?? '').trim() || new URL(url0).hostname,
url: url0,
type: (b.type ?? had?.type) === 'share' ? 'share' : 'api',
version: box.version,
canDownload: box.canDownload,
albums: had && b.albums === undefined ? had.albums : cleanAlbums(b.albums),
keyMasked: `••••${String(key0).slice(-4)}`,
};
if (had) saved[at] = server;
else saved.push(server);
return send(200, { ok: true, server: view(server) });
}
if (path === '/api/immich/probe') {
const b = body();
if (!b.url || !b.key) return send(400, { ok: false, error: 'invalid' });
const box = HOSTS[b.url];
if (!box) return send(200, { ok: false, error: 'unreachable' });
if (box.key !== b.key) return send(200, { ok: false, error: 'auth' });
return send(200, {
ok: true,
version: box.version,
albums: box.albums.map((id) => ({
id,
albumName: ALBUM_NAME[id],
assetCount: ASSETS.filter((a) => a.album === id).length,
coverId: null,
shared: false,
})),
canDownload: box.canDownload,
});
}
if (path === '/api/immich/albums') {
if (req.method() === 'PUT') {
const b = body();
const server = saved.find((s) => s.id === b.id);
if (!server) return send(404, { ok: false, error: 'invalid' });
server.albums = cleanAlbums(b.selected);
return send(200, { ok: true, albums: server.albums });
}
const box = host(url.searchParams.get('id') ?? '');
if (!box) return send(502, { ok: false, error: 'unreachable' });
return send(200, {
ok: true,
albums: box.albums.map((id) => ({
id,
albumName: ALBUM_NAME[id],
assetCount: ASSETS.filter((a) => a.album === id).length,
coverId: null,
shared: false,
})),
});
}
if (path === '/api/immich/assets') {
const box = host(url.searchParams.get('server') ?? '');
if (!box) return send(502, { ok: false, error: 'unreachable' });
const album = url.searchParams.get('album');
const page = Number(url.searchParams.get('page') ?? 1);
const size = Number(url.searchParams.get('size') ?? 200);
const rows = ASSETS.filter((a) => (album && album !== 'all' ? a.album === album : box.albums.includes(a.album)));
const items = rows.slice((page - 1) * size, page * size).map((a) => ({
id: a.id,
name: a.name,
takenAt: '2026-04-01T10:00:00.000Z',
width: 6000,
height: 4000,
size: 12345678,
}));
return send(200, { ok: true, page, size, total: rows.length, hasMore: items.length === size, items });
}
if (path === '/api/immich/thumb' || path === '/api/immich/original') {
const id = url.searchParams.get('id') ?? '';
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
return send(400, { ok: false, error: 'invalid' });
}
const box = host(url.searchParams.get('server') ?? '');
if (!box) return send(502, { ok: false, error: 'unreachable' });
if (path.endsWith('original') && !box.canDownload) return send(403, { ok: false, error: 'auth' });
if (path.endsWith('thumb')) thumbs++;
return route.fulfill({ status: 200, contentType: 'image/png', body: PNG });
}
return send(404, { ok: false, error: 'no' });
});
const page = await context.newPage();
await page.goto(`${base}/library`);
// The rows the column draws, and the number beside each name.
const counts = () =>
page.$$eval('[data-key^="lib-node-"]', (els) => {
const out = {};
for (const e of els) {
out[e.dataset.key.replace('lib-node-', '')] = (e.querySelector('.adm-album-count')?.textContent ?? '').trim();
}
return out;
});
const same = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort());
const settle = (key, n) =>
page
.waitForFunction(
({ key, n }) => {
const row = document.querySelector(`[data-key="lib-node-${key}"]`);
return (row?.querySelector('.adm-album-count')?.textContent ?? '').trim() === String(n);
},
{ key, n },
{ timeout: 30_000, polling: 50 }
)
.catch(() => {});
const open = async (name) => {
// A node's albums are rows under it, and the column folds a folder away until
// it is opened — the same click a reader makes.
const row = `[data-key="lib-node-${name}"]`;
if ((await page.getAttribute(row, 'aria-expanded')) === 'false') await page.click(row);
await page.waitForTimeout(300);
};
const addDialog = async (url, key, share = false) => {
await page.click('[data-key="lib-add-immich"]');
await page.waitForSelector('[data-key="immich-url"]');
await page.fill('[data-key="immich-url"]', url);
await page.fill('[data-key="immich-key"]', key);
if (share) await page.check('[data-key="immich-share"]');
await page.click('[data-key="immich-check"]');
await page.waitForSelector('[data-key="immich-status"]');
};
const editDialog = async (node) => {
await page.click(`[data-key="lib-node-${node}"]`, { button: 'right' });
await page.waitForSelector(`[data-key="lib-immich-albums-${node}"]`);
await page.click(`[data-key="lib-immich-albums-${node}"]`);
await page.waitForSelector('[data-key="immich-ids"]');
// The albums are asked for live, so the list may land a moment after the form.
await page.waitForSelector('[data-key="immich-albums"]').catch(() => {});
};
// --- adding a server ---------------------------------------------------------
await page.waitForSelector('[data-key="lib-add-immich"]', { timeout: 60_000 });
await page.click('[data-key="lib-add-immich"]');
await page.waitForSelector('[data-key="immich-url"]');
step(
'the dialog opens blank: the address is each reader’s to type, with a nudge in the field',
(await page.inputValue('[data-key="immich-url"]')) === '' &&
(await page.getAttribute('[data-key="immich-url"]', 'placeholder')) === 'https://photos.example.com',
await page.inputValue('[data-key="immich-url"]')
);
await page.fill('[data-key="immich-url"]', 'https://photos.example.com');
await page.fill('[data-key="immich-key"]', 'good-key');
await page.click('[data-key="immich-check"]');
await page.waitForSelector('[data-key="immich-status"]');
const saidOk = await page.$eval('[data-key="immich-status"]', (e) => !e.className.includes('err'));
step('a key that answers is said to be good', saidOk);
step(
'and the door asks for nothing else: a fresh server is every album its key can see',
(await page.$('[data-key="immich-albums"]')) === null &&
(await page.$('[data-key="immich-album-' + A1 + '"]')) === null
);
// The ID field is the only place an album can be named at the door, and it says
// what it made of the lines: one with no id in it, and one this key never named.
await page.fill('[data-key="immich-ids"]', `khong-phai-uuid\n${B1}`);
await page.waitForSelector('[data-key="immich-bad-ids"]', { timeout: 5_000 }).catch(() => {});
step(
'a line with no album id in it is a line the field says it dropped',
(await page.$('[data-key="immich-bad-ids"]')) !== null &&
(await page.$eval('[data-key="immich-bad-ids"]', (e) => e.textContent)).includes('1'),
await page.$eval('[data-key="immich-bad-ids"]', (e) => e.textContent).catch(() => 'no warning')
);
step(
'and an id this key cannot see is an id the field says it does not know',
(await page.$('[data-key="immich-unknown-ids"]')) !== null,
await page.$eval('[data-key="immich-unknown-ids"]', (e) => e.textContent).catch(() => 'no warning')
);
await page.fill('[data-key="immich-ids"]', '');
await page.waitForTimeout(200);
step(
'and clearing the field takes both warnings with it',
(await page.$('[data-key="immich-bad-ids"]')) === null &&
(await page.$('[data-key="immich-unknown-ids"]')) === null
);
await page.click('[data-key="immich-save"]');
await settle('Immich', 5);
await open('Immich');
step(
'the server becomes a folder on the column, counted album by album',
same(await counts(), { Immich: '5', 'Immich/A1': '2', 'Immich/A2': '3' }),
JSON.stringify(await counts())
);
step(
'and its node wears the source and the name it was given',
(await page.$eval('[data-key="lib-node-Immich"] .adm-album-name', (e) => e.textContent)) ===
'Immich · photos.example.com'
);
step('and its frames were tiled off the backend, not off Immich', thumbs > 0, `${thumbs} tiles`);
// --- an album unticked -------------------------------------------------------
await editDialog('Immich');
await page.uncheck('[data-key="immich-album-' + A2 + '"]');
await page.click('[data-key="immich-save"]');
await settle('Immich', 2);
await open('Immich');
step(
'an album unticked is an album the column stops drawing',
same(await counts(), { Immich: '2', 'Immich/A1': '2' }),
JSON.stringify(await counts())
);
// --- the album-ID field ------------------------------------------------------
// A whole address is an id, and a line that is not a uuid is nothing at all: the
// reader copies an album out of their browser and pastes what they got.
await editDialog('Immich');
await page.uncheck('[data-key="immich-album-' + A1 + '"]');
await page.fill('[data-key="immich-ids"]', `https://photos.example.com/albums/${A2}?x=1\nkhong-phai-uuid`);
await page.click('[data-key="immich-save"]');
await settle('Immich', 3);
await open('Immich');
step(
'an album pasted as a whole address is read as its id, and a line that is not one is dropped',
same(await counts(), { Immich: '3', 'Immich/A2': '3' }),
JSON.stringify(await counts())
);
// Nothing ticked and nothing typed is the empty state, which means every album
// the key can see — a decision, not a missing value.
await editDialog('Immich');
await page.uncheck('[data-key="immich-album-' + A2 + '"]');
await page.fill('[data-key="immich-ids"]', '');
await page.click('[data-key="immich-save"]');
await settle('Immich', 5);
await open('Immich');
step(
'nothing ticked means every album the key can see, not none of them',
same(await counts(), { Immich: '5', 'Immich/A1': '2', 'Immich/A2': '3' }),
JSON.stringify(await counts())
);
// --- a key given to a node that is already there -----------------------------
// The one place a node's key is replaced: the same dialog that narrows its
// albums. It is probed before it is stored, so a key that does not answer is a
// dialog still open on a node unchanged.
await editDialog('Immich');
await page.fill('[data-key="immich-newkey"]', 'not-the-key');
await page.click('[data-key="immich-save"]');
await page.waitForSelector('[data-key="immich-error"]', { timeout: 5_000 }).catch(() => {});
step(
'a key given in place of the one it has is refused out loud, dialog still open',
(await page.$('[data-key="immich-error"]')) !== null &&
(await page.$('[data-key="immich-modal"]')) !== null,
await page.$eval('[data-key="immich-error"]', (e) => e.textContent).catch(() => 'no word said')
);
await page.fill('[data-key="immich-newkey"]', 'good-key');
await page.click('[data-key="immich-save"]');
await page.waitForSelector('[data-key="immich-modal"]', { state: 'detached', timeout: 10_000 }).catch(() => {});
await settle('Immich', 5);
await open('Immich');
step(
'and one that answers is taken, the node and its albums exactly as they were',
(await page.$('[data-key="immich-modal"]')) === null &&
same(await counts(), { Immich: '5', 'Immich/A1': '2', 'Immich/A2': '3' }),
JSON.stringify(await counts())
);
// --- a share link ------------------------------------------------------------
await addDialog('https://share.example.com', 'share-xyz', true);
step('a share link is not offered an album picker: it is one album', (await page.$('[data-key="immich-albums"]')) === null);
await page.click('[data-key="immich-save"]');
await page.waitForSelector('[data-key="lib-node-Immich 2"]', { timeout: 30_000 });
await settle('Immich 2', 1);
step(
'and it becomes a second node beside the first, named by its own server',
(await page.$eval('[data-key="lib-node-Immich 2"] .adm-album-name', (e) => e.textContent)) ===
'Immich · share.example.com',
JSON.stringify(await counts())
);
await page.click('[data-key="lib-node-Immich 2"]', { button: 'right' });
await page.waitForSelector('[data-key="lib-immich-sync-Immich 2"]');
step(
'and its menu carries no album picker',
(await page.$('[data-key="lib-immich-albums-Immich 2"]')) === null
);
step(
'nor a rescan, a rename or a tile job — those are the disk’s',
(await page.$('[data-key="lib-rescan-Immich 2"]')) === null &&
(await page.$('[data-key="lib-rename-Immich 2"]')) === null &&
(await page.$('[data-key="lib-thumbs-Immich 2"]')) === null
);
await page.keyboard.press('Escape');
// --- a key that does not work ------------------------------------------------
await addDialog('https://photos.example.com', 'not-the-key');
const saidBad = await page.$eval('[data-key="immich-status"]', (e) => e.className.includes('err'));
step('a key that is refused is said to be refused', saidBad);
await page.click('[data-key="immich-save"]');
await page.waitForTimeout(500);
const stillSaving = (await page.$('[data-key="immich-save"]')) !== null;
step(
'and a refused key is never a node: nothing is stored and the dialog stays open',
stillSaving && !('Immich 3' in (await counts())),
JSON.stringify(await counts())
);
await page.click('[data-key="immich-cancel"]');
// --- what must never happen --------------------------------------------------
step('the page never talks to Immich itself', direct === 0, `${direct} calls off-origin`);
await browser.close();
app.kill('SIGTERM');
console.log(failures ? `\n${failures} failed` : '\nall ok');
process.exit(failures ? 1 : 0);
+388
View File
@@ -0,0 +1,388 @@
// The catalogue, checked where it actually lives: in a browser. A directory
// handle, IndexedDB, a grid of tiles and a handover into the studio have no
// meaning outside one, so this is a Playwright run rather than a pure-node assert.
//
// What it holds down is the two faults that read the same on the screen and have
// nothing to do with each other:
//
// 1. The catalogue is a folder the visitor picked, and the browser keeps the
// handle across a reload while dropping the permission on it. Only a gesture
// can hand it back, so the click on the folder is where it is asked for —
// both for the folder's own handle and for the handle a row of an older build
// carries. Without the ask every frame of a folder read yesterday answers
// "moved or deleted" while the file sits untouched on the disk.
// 2. Two builds behind one domain — a test server and a release server — open
// the one database the origin owns. A build that names a version older than
// the one on the disk is answered `VersionError` and every read falls back on
// an empty column, with no note and nothing to read off the screen.
//
// npm run build && node scripts/library-check.mjs
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> node scripts/library-check.mjs
// CHROME=<path to a chromium> PLAYWRIGHT_CORE=... node scripts/library-check.mjs
// BASE=http://127.0.0.1:8090 node scripts/library-check.mjs # a server already up
//
// Playwright is not a dependency of this package (a browser run needs it, a build
// does not), so a run without it says SKIP and exits 0. Only Chromium can hand a
// page a folder, and only a person can answer the picker: the check stands in for
// both, serving two real frames off a local server and answering
// `showDirectoryPicker` with a folder made of them.
import { spawn } from 'node:child_process';
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
const SAMPLES = process.env.SAMPLE_DIR ?? path.resolve(FRONTEND, '..');
const APP_PORT = Number(process.env.APP_PORT ?? 4185);
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4319);
const JPG = 'P1010256.JPG';
const RAW = 'P1010256.RW2';
const DB = 'recipescam-library';
const STORES = ['folders', 'photos', 'thumbs', 'edits', 'dirs'];
let failures = 0;
function step(name, ok, detail = '') {
if (!ok) failures++;
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
return ok;
}
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
if (!playwright) {
console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE=<path to its index.mjs>');
process.exit(0);
}
// --- the two frames, off a local server (the page fetches them by CORS) ------
const sampleServer = createServer(async (req, res) => {
const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0]));
if (name !== JPG && name !== RAW) {
res.writeHead(404).end();
return;
}
const bytes = await readFile(path.join(SAMPLES, name));
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': bytes.length,
'Access-Control-Allow-Origin': '*',
'Cross-Origin-Resource-Policy': 'cross-origin',
});
res.end(bytes);
});
await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve));
// A server already up, when one is named; otherwise the build in `dist/`.
let app = null;
const base = process.env.BASE ?? `http://127.0.0.1:${APP_PORT}`;
if (!process.env.BASE) {
app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
cwd: FRONTEND,
stdio: 'ignore',
});
let up = false;
for (let i = 0; i < 60 && !up; i++) {
up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false);
if (!up) await new Promise((r) => setTimeout(r, 500));
}
if (!up) {
app.kill('SIGTERM');
sampleServer.close();
console.log(`FAIL no preview on ${base} — run \`npm run build\` first (vite preview serves dist/)`);
process.exit(1);
}
}
// --- the browser ------------------------------------------------------------
const browser = await playwright.chromium.launch({
// Playwright's own Chromium when it has one; CHROME points at any other.
executablePath: process.env.CHROME || undefined,
args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
});
// A folder of one frame, handed over the way the browser hands one over: the
// handle is kept across a reload, its permission is not, and only a gesture can
// hand the permission back. `check.allow` stands in for the gesture, since a run
// has no hand; `check.prompt` is the reload that dropped the permission.
const INIT = ({ origin, names, folder }) => {
const asked = () => localStorage.getItem('check.prompt') === '1';
const granted = () => localStorage.getItem('check.granted') === '1';
const bump = (key) => localStorage.setItem(key, String(Number(localStorage.getItem(key) ?? 0) + 1));
const perm = () => (!asked() || granted() ? 'granted' : 'prompt');
const refuse = (what) => {
bump('check.refused');
localStorage.setItem(`check.refused.${what}`, '1');
throw new DOMException('no permission', 'NotAllowedError');
};
const bytes = new Map();
const load = async (n) => {
if (!bytes.has(n)) bytes.set(n, fetch(`${origin}/${n}`).then((r) => r.arrayBuffer()));
return bytes.get(n);
};
// The browser's answer to an ask for permission: granted with nothing to ask,
// refused while the screen is drawing itself, and granted off the reader's own
// gesture.
const askPerm = async () => {
bump('check.requested');
if (!asked()) return 'granted';
if (localStorage.getItem('check.allow') !== '1') return 'denied';
bump('check.answered');
localStorage.setItem('check.granted', '1');
return 'granted';
};
const fileHandle = (name) => {
const h = { kind: 'file', name, __fake: true };
Object.defineProperties(h, {
getFile: {
value: async () => {
if (perm() !== 'granted') refuse('getFile');
return new File([await load(name)], name, {
type: name.endsWith('.JPG') ? 'image/jpeg' : 'application/octet-stream',
lastModified: Date.UTC(2026, 0, 1),
});
},
},
// A real file handle answers these as well, and the row a catalogue of an
// older build wrote is opened off its own handle.
queryPermission: { value: async () => perm() },
requestPermission: { value: askPerm },
});
return h;
};
const dirHandle = (label, node) => {
const h = { kind: 'directory', name: label, __fake: true };
Object.defineProperties(h, {
values: {
value: () =>
[
...(node.files ?? []).map(fileHandle),
...Object.entries(node.dirs ?? {}).map(([n, k]) => dirHandle(n, k)),
][Symbol.iterator](),
},
queryPermission: { value: async () => perm() },
requestPermission: { value: askPerm },
getDirectoryHandle: {
value: async (name) => {
const k = node.dirs?.[name];
if (!k) throw new DOMException('no folder', 'NotFoundError');
return dirHandle(name, k);
},
},
getFileHandle: {
value: async (name) => {
if (!(node.files ?? []).includes(name)) throw new DOMException('no frame', 'NotFoundError');
if (perm() !== 'granted') refuse('getFileHandle');
return fileHandle(name);
},
},
});
return h;
};
const root = () => dirHandle(folder, { files: names, dirs: {} });
window.showDirectoryPicker = async () => root();
// A handle out of IndexedDB is a plain object with no methods; put them back.
const revive = (row) => {
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile'))
row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name);
return row;
};
for (const m of ['get', 'getAll']) {
const original = IDBObjectStore.prototype[m];
IDBObjectStore.prototype[m] = function (...a) {
const req = original.apply(this, a);
const d = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result');
Object.defineProperty(req, 'result', {
configurable: true,
get() {
const v = d.get.call(this);
return Array.isArray(v) ? v.map(revive) : revive(v);
},
});
return req;
};
}
};
// The library is signed-in ground and neither `vite preview` nor a bare container
// has an API behind it, so a run is a guest the app bounces back to the landing
// page before the column is ever drawn. The one call that decides which it is is
// answered here; the rest of `/api` fails the way a serverless build's does.
const open = async () => {
const context = await browser.newContext();
await context.route('**/api/auth/me', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ user: { id: 1, email: 'check@local', admin: true, verified: true, pro: true } }),
})
);
await context.addInitScript(INIT, { origin: `http://127.0.0.1:${SAMPLE_PORT}`, names: [JPG], folder: 'CheckRoll' });
const page = await context.newPage();
const errors = [];
const logs = [];
page.on('pageerror', (e) => errors.push(e.message));
page.on('console', (m) => m.type() === 'error' && logs.push(m.text()));
return { context, page, errors, logs };
};
const tiles = '[data-key^="lib-thumb-"]';
const status = (page) =>
page
.$eval('[data-key="lib-status"]', (e) => e.textContent.trim())
.catch(() => '');
// The handover into the studio is an address; the studio reads it and clears it,
// so a run that opened a frame lands on `/app`.
const inStudio = (page) => /^\/app(\?|$)/.test(new URL(page.url()).pathname + new URL(page.url()).search);
const openFrame = async (page) => {
await page.evaluate(() => localStorage.setItem('check.allow', '1'));
await page.click('[data-key="lib-open"]');
await page.waitForTimeout(3000);
};
// --- 1. a folder whose permission the reload dropped -------------------------
{
const { context, page, errors } = await open();
await page.goto(`${base}/library`);
await page.click('[data-key="lib-add"]');
await page.waitForSelector(tiles, { timeout: 120_000 });
step('the roll is indexed', true);
// The tab closed: the browser kept the handle, not the permission.
await page.evaluate(() => {
localStorage.setItem('check.prompt', '1');
for (const k of ['check.granted', 'check.requested', 'check.refused', 'check.allow', 'check.answered'])
localStorage.removeItem(k);
});
await page.reload();
await page.waitForSelector(tiles, { timeout: 60_000 });
const noOneToAnswer = await page.evaluate(() => localStorage.getItem('check.granted'));
const startupAsks = await page.evaluate(() => Number(localStorage.getItem('check.requested') ?? 0));
const before = errors.length;
await openFrame(page);
const answered = Number(await page.evaluate(() => localStorage.getItem('check.answered') ?? 0));
const asked = Number(await page.evaluate(() => localStorage.getItem('check.requested') ?? 0));
const granted = await page.evaluate(() => localStorage.getItem('check.granted'));
const note = await status(page);
step('the folder really has no permission while the screen draws itself', noOneToAnswer === null, `start-up asked x${startupAsks}, granted=${noOneToAnswer}`);
step('the click asks the browser for the folder back', answered > 0 && granted === '1', `answered x${answered} of x${asked}`);
step('the frame opens once the click has handed the permission back', inStudio(page), page.url());
step(
'no "moved or deleted" note for a frame that is still on the disk',
inStudio(page) && !/moved or deleted|di chuyển|lib\.missing/.test(note),
note || '(no note)'
);
step('no uncaught error while opening', errors.length === before, errors.slice(before).join(' | '));
await context.close();
}
// --- 2. a catalogue a newer build wrote -------------------------------------
{
const { context, page, errors, logs } = await open();
await page.goto(`${base}/`);
await page.evaluate(
({ DB, STORES }) =>
new Promise((resolve, reject) => {
const req = indexedDB.open(DB, 99);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORES[0])) db.createObjectStore(STORES[0], { keyPath: 'name' });
if (!db.objectStoreNames.contains(STORES[1])) {
const s = db.createObjectStore(STORES[1], { keyPath: 'id' });
s.createIndex('folder', 'folder');
s.createIndex('taken', 'taken');
}
if (!db.objectStoreNames.contains(STORES[2])) db.createObjectStore(STORES[2], { keyPath: 'id' });
if (!db.objectStoreNames.contains(STORES[3])) db.createObjectStore(STORES[3], { keyPath: 'photoId' });
if (!db.objectStoreNames.contains(STORES[4])) db.createObjectStore(STORES[4], { keyPath: 'folder' });
};
req.onsuccess = () => {
req.result.close();
resolve();
};
req.onerror = () => reject(req.error);
}),
{ DB, STORES }
);
step('a catalogue stands at version 99', true);
const before = errors.length;
await page.goto(`${base}/library`);
await page.click('[data-key="lib-add"]');
await page.waitForSelector(tiles, { timeout: 120_000 });
step('the older build reads and writes the newer catalogue', true);
const version = await page.evaluate(
async (DB) => (await indexedDB.databases()).find((d) => d.name === DB)?.version,
DB
);
step('it was taken as it stood, not upgraded', version === 99, `version ${version}`);
step('no uncaught error', errors.length === before, errors.slice(before).join(' | '));
step(
'no "the database is missing"',
!logs.some((l) => l.includes('the database is missing')),
logs.join(' | ')
);
await context.close();
}
// --- 3. a frame out of a catalogue an older build wrote ---------------------
// That build kept a handle on every row, and a handle out of IndexedDB comes back
// without its permission. The shortcut that opens a frame off its own row has to
// ask for it back the same way the folder's does, or the frame answers "nothing
// at" while it sits untouched on the disk.
{
const { context, page, errors } = await open();
await page.goto(`${base}/library`);
await page.click('[data-key="lib-add"]');
await page.waitForSelector(tiles, { timeout: 120_000 });
const legacy = await page.evaluate(
(name) =>
new Promise((resolve, reject) => {
const req = indexedDB.open(name);
req.onsuccess = () => {
const db = req.result;
const tx = db.transaction('photos', 'readwrite');
const store = tx.objectStore('photos');
const all = store.getAll();
all.onsuccess = () => {
const rows = all.result;
// A record, not a live handle: the browser hands it back with its
// methods and with no permission.
for (const row of rows) store.put({ ...row, handle: { kind: 'file', name: row.name, __fake: true } });
resolve(rows.map((r) => r.id));
};
all.onerror = () => reject(all.error);
};
req.onerror = () => reject(req.error);
}),
DB
);
step('the catalogue keeps a handle on every row, as the older build wrote it', legacy.length === 1, legacy.join());
await page.evaluate(() => {
localStorage.setItem('check.prompt', '1');
for (const k of ['check.granted', 'check.requested', 'check.allow', 'check.answered']) localStorage.removeItem(k);
});
await page.reload();
await page.waitForSelector(tiles, { timeout: 60_000 });
const before = errors.length;
await openFrame(page);
const note = await status(page);
step(
'a frame off a legacy row opens instead of reading "nothing at"',
inStudio(page) && !/nothing at|lib\.missing/.test(note),
note || page.url()
);
step('no uncaught error', errors.length === before, errors.slice(before).join(' | '));
await context.close();
}
await browser.close();
sampleServer.close();
app?.kill('SIGTERM');
console.log(failures ? `\n${failures} FAILED` : '\nall ok');
process.exit(failures ? 1 : 0);
+71
View File
@@ -0,0 +1,71 @@
// The manifest needs its icons at fixed sizes, and the only artwork there is ships
// at 256x256 (public/assets/RecipesCamIcon.png). Skia is already a dependency — the
// engine grades through it — so it does the resize here rather than a new image
// tool or a runtime fetch of an oversized PNG. One-off, run by hand: the output is
// committed, so a build never has to decode and rescale anything.
//
// node scripts/make-icons.mjs
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import CanvasKitInit from 'canvaskit-wasm/bin/full/canvaskit.js';
const SOURCE = 'public/assets/RecipesCamIcon.png';
const OUT = 'public/icons';
const CanvasKit = await CanvasKitInit({
locateFile: () =>
fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
});
// Buffers are typed arrays already, but Skia's binding wants one it can read as a
// plain byte view, not node's pooled slab.
const art = CanvasKit.MakeImageFromEncoded(new Uint8Array(readFileSync(SOURCE)));
if (!art) throw new Error(`${SOURCE} is not an image Skia can decode`);
// `scale` is the art's share of the square. The plain icons bleed to the edge,
// exactly as the source does. The maskable one cannot: the launcher supplies the
// mask, and most of them are circles, so the art has to stay inside the 80%
// safe zone the spec draws — the background around it is the app's own #0a0a0a
// rather than nothing at all, because a masked icon with transparent corners
// shows whatever the launcher paints behind it.
const icons = [
{ file: 'icon-192.png', size: 192, scale: 1, background: false },
{ file: 'icon-512.png', size: 512, scale: 1, background: false },
{ file: 'maskable-512.png', size: 512, scale: 0.8, background: true },
];
mkdirSync(OUT, { recursive: true });
for (const { file, size, scale, background } of icons) {
const surface = CanvasKit.MakeSurface(size, size);
if (!surface) throw new Error(`could not make a ${size}x${size} surface`);
const canvas = surface.getCanvas();
if (background) {
const paint = new CanvasKit.Paint();
paint.setColor(CanvasKit.Color(10, 10, 10));
canvas.drawPaint(paint);
paint.delete();
}
const side = size * scale;
const offset = (size - side) / 2;
// Mitchell cubic (B = C = 1/3): the 256 -> 192 step is a downscale, where
// nearest or bilinear would drop detail off the camera's thin outlines.
canvas.drawImageRectCubic(
art,
CanvasKit.XYWHRect(0, 0, art.width(), art.height()),
CanvasKit.XYWHRect(offset, offset, side, side),
1 / 3,
1 / 3,
null,
);
surface.flush();
const png = surface.makeImageSnapshot().encodeToBytes();
surface.dispose();
if (!png) throw new Error(`could not encode ${file}`);
writeFileSync(`${OUT}/${file}`, png);
console.log(`${OUT}/${file} ${size}x${size} ${png.length} bytes`);
}
art.delete();
+209
View File
@@ -0,0 +1,209 @@
// A gradient mask carries the LIGHT column's WB pair now — COLOR TEMP and TINT,
// on the mask's own pixels. Two things can go wrong and neither shows up as a
// crash: the mask's gain drifts from the frame-wide one (two opinions about what
// 3000K means), or the uniform block and the shader's declarations fall out of
// step (one buffer, expanded arrays — an off-by-one there reads the frame size as
// a gain). So this checks the arithmetic and then renders the pass for real:
// CanvasKit is already a dependency, so the shader is compiled and a mid-grey is
// pushed through it rather than the source being eyeballed.
//
// node scripts/mask-wb-check.mjs
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import ts from 'typescript';
import CanvasKitInit from 'canvaskit-wasm/bin/full/canvaskit.js';
const transpile = (path) =>
ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
// gradientMask.ts imports its two helpers, so the graph is transpiled into one
// temp directory and the specifiers are pointed at the .mjs copies (the
// convention auto-tone-check.mjs uses). colorUtils and toneShader import types
// only, so the compiler drops those lines itself.
const dir = mkdtempSync(join(tmpdir(), 'mask-wb-check-'));
writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts'));
writeFileSync(
join(dir, 'toneShader.mjs'),
transpile('../shared/utils/toneShader.ts').replace(
/^import .*from ['"]\.\/colorUtils['"];$/m,
'import { HSL_BANDS, hslBandGaps, isMonochromeBase } from "./colorUtils.mjs";',
),
);
writeFileSync(
join(dir, 'gradientMask.mjs'),
transpile('../shared/utils/gradientMask.ts')
.replace(/^import .*from ['"]\.\/colorUtils['"];$/m, 'import { whiteBalanceGain } from "./colorUtils.mjs";')
.replace(
/^import \{[^\n}]*\} from ['"]\.\/toneShader['"];$/m,
'import { CLARITY_GAIN, DEHAZE_FLOOR_T, DEHAZE_MAX_OMEGA, DEHAZE_PATCH_STEP, DEHAZE_PATCH_TAPS, TONE_MATH_SKSL } from "./toneShader.mjs";',
),
);
const { readMasks, maskUniforms, gradientMaskSkSL } = await import(
pathToFileURL(join(dir, 'gradientMask.mjs')).href
);
const { whiteBalanceGain } = await import(pathToFileURL(join(dir, 'colorUtils.mjs')).href);
// --- The gain itself: the one function the frame-wide matrix folds in too.
const close = (a, b, eps = 1e-12) => assert.ok(Math.abs(a - b) < eps, `${a} != ${b}`);
const luma = (g) => 0.2126 * g.r + 0.7152 * g.g + 0.0722 * g.b;
const neutral = whiteBalanceGain(5500, 0);
close(neutral.r, 1);
close(neutral.g, 1);
close(neutral.b, 1);
// The ruler runs the way the engine does: K names the light the frame is said to
// have been shot under, and the gain compensates for it, so the picture WARMS as
// K rises (kelvinToRGB's own doc, and the swatch under the ruler). 10000K up and
// 2500K down, on the same two ends the frame-wide row has.
const warm = whiteBalanceGain(10000, 0);
assert.ok(warm.r > 1 && warm.b < 1, '10000K is not warm');
const cool = whiteBalanceGain(2500, 0);
assert.ok(cool.b > 1 && cool.r < 1, '2500K is not cool');
// The cast moves, the exposure does not: every gain is divided by its own luma.
for (const g of [warm, cool, whiteBalanceGain(4000, 7), whiteBalanceGain(9000, -7)]) close(luma(g), 1);
// TINT is the green<->magenta axis, and symmetric about 0.
const magenta = whiteBalanceGain(5500, 10);
const green = whiteBalanceGain(5500, -10);
assert.ok(magenta.r > 1 && magenta.b > 1 && magenta.g < 1, '+TINT is not magenta');
assert.ok(green.g > 1 && green.r < 1 && green.b < 1, '-TINT is not green');
// Symmetric in the two channels it moves, and only in those: R and B take the
// same factor, G its own, and the luma normalisation is what keeps the pair from
// being a brightness knob (the weights are unequal, so the two ends are not exact
// mirrors of each other in the third decimal).
close(magenta.r, magenta.b);
close(green.r, green.b);
assert.ok(Math.abs(Math.log(magenta.r) + Math.log(green.r)) < 0.01, 'TINT is not symmetric about 0');
// --- readMasks: a mask that never touched the pair reads the neutral, and a
// hand-written file cannot push the gain outside the ruler.
const shape = { kind: 'linear', x: 0, y: 0, ex: 1, ey: 1, rx: 0, ry: 0, angle: 0, feather: 0.5 };
const mask = (extra) => ({ ...shape, exposure: 0, contrast: 0, saturation: 0, ...extra });
const bare = readMasks([mask({})])[0];
assert.equal(bare.temperature, 5500, 'an absent WB is not neutral');
assert.equal(bare.tint, 0, 'an absent TINT is not neutral');
const high = readMasks([mask({ temperature: 2000, tint: 99 })])[0];
assert.equal(high.temperature, 2500);
assert.equal(high.tint, 10);
const low = readMasks([mask({ temperature: 20000, tint: -99 })])[0];
assert.equal(low.temperature, 10000);
assert.equal(low.tint, -10);
// --- The uniform block: arrays expanded, declaration order, so the gain has to
// land on the slot the shader reads as `wb[i]` and the frame size has to stay one
// array further on.
const n = 1;
const one = maskUniforms([mask({ temperature: 10000 })], 64, 64, [0.5, 0.5, 0.5]);
assert.equal(one.length, (6 * n + 1 + 1) * 4, 'the buffer is not six arrays, the frame and the air');
const g = whiteBalanceGain(10000, 0);
close(one[5 * n * 4 + 0], g.r, 1e-6);
// The gain is the SAME function the frame-wide matrix folds in, so 10000K cannot
// come to mean one thing on the whole frame and another inside a mask.
assert.match(
readFileSync(new URL('../shared/utils/colorUtils.ts', import.meta.url), 'utf8'),
/const wbGain = whiteBalanceGain\(temperature, tint\);/,
'the matrix grew a white balance of its own again',
);
close(one[5 * n * 4 + 1], g.g, 1e-6);
close(one[5 * n * 4 + 2], g.b, 1e-6);
assert.equal(one[6 * n * 4 + 0], 64, 'the width no longer sits where the shader reads `size`');
assert.equal(one[6 * n * 4 + 1], 64);
assert.equal(one[(6 * n + 1) * 4 + 0], 0.5, 'the atmospheric light moved');
const plain = maskUniforms([mask({})], 64, 64);
for (let c = 0; c < 3; c++) close(plain[5 * n * 4 + c], 1, 1e-6);
// --- The pass itself. A radial mask whose semi-axis is four frames wide sits far
// enough inside its own feather that every pixel has alpha 1, so what comes out
// is the gain the mask was handed and nothing else: a wrong slot shows up as a
// wrong pixel rather than as a wrong number in a buffer.
const CanvasKit = await CanvasKitInit({
locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
});
const SIZE = 64;
const wide = { kind: 'radial', x: 0.5, y: 0.5, ex: 0.5, ey: 0.5, rx: 4, ry: 4, angle: 0, feather: 0.5 };
function render(masks, spatial, fillRGB = [128, 128, 128]) {
const effect = CanvasKit.RuntimeEffect.Make(gradientMaskSkSL(masks.length, spatial));
assert.ok(effect, `the mask shader for ${masks.length} masks does not compile (spatial ${spatial})`);
// The frame the pass reads: a flat mid-grey, drawn once and handed in as the
// first child exactly as exportEngine's replaceThrough hands its snapshot in.
const src = CanvasKit.MakeSurface(SIZE, SIZE);
const fill = new CanvasKit.Paint();
fill.setColor(CanvasKit.Color(...fillRGB));
src.getCanvas().drawPaint(fill);
const grey = src.makeImageSnapshot();
const asChild = () =>
grey.makeShaderOptions(
CanvasKit.TileMode.Clamp, CanvasKit.TileMode.Clamp, CanvasKit.FilterMode.Linear, CanvasKit.MipmapMode.None,
);
const air = spatial ? [0.5, 0.5, 0.5] : null;
const children = [asChild()];
if (spatial) children.push(asChild());
const paint = new CanvasKit.Paint();
paint.setShader(effect.makeShaderWithChildren(maskUniforms(masks, SIZE, SIZE, air), children));
const out = CanvasKit.MakeSurface(SIZE, SIZE);
out.getCanvas().drawRect(CanvasKit.XYWHRect(0, 0, SIZE, SIZE), paint);
const px = out.getCanvas().readPixels(0, 0, {
width: SIZE,
height: SIZE,
colorType: CanvasKit.ColorType.RGBA_8888,
alphaType: CanvasKit.AlphaType.Unpremul,
colorSpace: CanvasKit.ColorSpace.SRGB,
});
const at = (x, y) => {
const i = (y * SIZE + x) * 4;
return [px[i], px[i + 1], px[i + 2]];
};
return at(32, 32);
}
const knob = (extra) => ({ ...wide, exposure: 0, contrast: 0, saturation: 0, ...extra });
const same = (p, want = 128) => p.every((v) => Math.abs(v - want) <= 1);
assert.ok(same(render([knob({})], false)), `a neutral mask moved the grey: ${render([knob({})], false)}`);
const after = render([knob({ temperature: 10000 })], false);
assert.ok(after[0] > 130 && after[2] < 126, `10000K did not warm the mask's pixels: ${after}`);
const tinted = render([knob({ tint: 10 })], false);
assert.ok(tinted[0] > 130 && tinted[2] > 130 && tinted[1] < 126, `+TINT is not magenta: ${tinted}`);
// The spatial build is a second shader text with a second signature: it has to
// compile with the same WB and read `wb` all the same.
const spatialPx = render([knob({ temperature: 10000 })], true);
assert.ok(spatialPx[0] > 130 && spatialPx[2] < 126, `the spatial pass dropped the WB: ${spatialPx}`);
// Two masks: the second array's slot, not the first one's, and both applied.
const two = render([knob({}), knob({ temperature: 10000 })], false);
assert.ok(two[0] > 130 && two[2] < 126, `the second mask's WB did not land: ${two}`);
// --- EXPOSURE inside a mask, which is now the frame's own move (exposureMove, the
// shared TONE_MATH_SKSL): a stop on LIGHT, decided in the linear domain. The pass
// this replaced multiplied the three channels in linear light, so a channel that
// reached the ceiling clipped by a different amount than its neighbours and the
// hue went with it — 29.2° on a warm skin tone at +1 EV, 33.3° at +2 (scratchpad
// exp-variant.mjs), while a mid-grey is the same number both ways (0.6858) and
// cannot tell the two apart.
const hue = (p) => {
const [r, g, b] = p.map((v) => v / 255);
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
if (d < 1e-9) return NaN;
const h = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4;
return ((h * 60) % 360 + 360) % 360;
};
const up = render([knob({ exposure: 1 })], false);
assert.ok(Math.abs(up[0] - 175) <= 2, `+1 EV is not a stop on light: 128 came out ${up[0]}, not 175`);
const down = render([knob({ exposure: -1 })], false);
assert.ok(Math.abs(down[0] - 93) <= 2, `-1 EV is not a stop on light: 128 came out ${down[0]}, not 93`);
const warm8 = [230, 150, 50];
const lit = render([knob({ exposure: 1 })], false, warm8);
assert.ok(lit.every((v, i) => v >= warm8[i]), `+1 EV darkened a channel: ${lit}`);
assert.ok(Math.abs(hue(lit) - hue(warm8)) < 3, `the exposure moved the hue to ${hue(lit)}° from ${hue(warm8)}°`);
// A pixel already against the ceiling gives up saturation rather than hue, and
// the channel on the ceiling lands ON it: 255 out, not three clipped at three
// different points.
const hot = render([knob({ exposure: 2 })], false, warm8);
assert.ok(Math.abs(hue(hot) - hue(warm8)) < 3, `the exposure moved the hue at the ceiling: ${hue(hot)}°`);
assert.equal(hot[0], 255, 'the channel against the ceiling did not land on it');
assert.ok(hot[1] > lit[1] && hot[2] > lit[2], `+2 EV did not brighten past +1 EV: ${lit} then ${hot}`);
console.log(
'mask WB: neutral 5500K/0 leaves the pixel, 10000K warms it, +TINT magenta-ises it; the block, the plain and the spatial shaders all read it',
);
@@ -0,0 +1,152 @@
// The offline queue, checked in a browser: a write made with no server is held
// rather than lost, a second write to the same route replaces the first, the
// reload keeps it, and a server answering sends it on its own.
//
// The API is stubbed on the page's own origin the way the container's nginx
// serves it, and "no server" is a route that fails the request — which is
// exactly what a dead server looks like from `fetch`.
//
// npm run build && node scripts/offline-queue-check.mjs
// APP_PORT=4885 PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> \
// node scripts/offline-queue-check.mjs
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
const APP_PORT = Number(process.env.APP_PORT ?? 4186);
// The key pwa/offline.ts holds the queue under.
const KEY = 'rc.pending';
let failures = 0;
function step(name, ok, detail = '') {
if (!ok) failures++;
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
return ok;
}
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
if (!playwright) {
console.log('SKIP playwright-core is not installed');
process.exit(0);
}
const app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
cwd: FRONTEND,
stdio: 'ignore',
});
const base = `http://127.0.0.1:${APP_PORT}`;
let up = false;
for (let i = 0; i < 60 && !up; i++) {
up = await fetch(`${base}/`).then((r) => r.ok).catch(() => false);
if (!up) await new Promise((r) => setTimeout(r, 500));
}
if (!up) {
app.kill('SIGTERM');
console.log(`FAIL no preview on ${base} — run \`npm run build\` first`);
process.exit(1);
}
const browser = await playwright.chromium.launch({
executablePath: process.env.CHROME || undefined,
args: ['--no-sandbox', '--enable-unsafe-swiftshader'],
});
const context = await browser.newContext();
let gone = false; // whether the page's server is there
const sent = []; // every write the stub let through
const PHOTO = {
id: 1,
createdAt: '2026-01-01T00:00:00Z',
slots: ['strip'],
tag: '#CHECK',
title: 'Check frame',
meta: '#1',
consent: true,
};
await context.route('**/api/**', (route) => {
if (gone) return route.abort('failed');
const req = route.request();
const url = req.url();
const json = (body) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
if (req.method() !== 'GET') {
sent.push({ method: req.method(), path: new URL(url).pathname, body: req.postData() });
return json({ key: 'photo:1', rating: { avg: 5, n: 1, mine: 5 } });
}
if (url.includes('/auth/me')) return json({ user: { id: 1, email: 'check@example.com' } });
if (url.includes('/photos')) return json({ photos: [PHOTO] });
if (url.includes('/ratings')) return json({ ratings: {} });
if (url.includes('/highlights')) return json({ highlights: { day: [], week: [], ever: [] } });
if (url.includes('/recipes')) return json({ recipes: [] });
return json({});
});
// The bar's own words are asserted, so the page starts in Vietnamese.
await context.addInitScript(() => localStorage.setItem('rc.lang', 'vi'));
const page = await context.newPage();
const star = (n) => `[data-key="lp-star-photo:1-${n}"]`;
const bar = '[data-key="offline-bar"]';
const noise = [];
page.on('console', (m) => {
// The outage is the check's own doing, and a request that fails is a request
// the browser logs. Everything else is a real complaint.
if (m.type() === 'error' && !m.text().includes('net::ERR_FAILED')) noise.push(m.text());
});
const queued = () =>
page.evaluate((k) => JSON.parse(localStorage.getItem(k) ?? '[]'), KEY);
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.waitForSelector(star(5), { timeout: 60_000 });
// 1 — a write with no server is held, not lost.
gone = true;
await page.$eval(star(5), (el) => el.click());
await page
.waitForFunction((k) => JSON.parse(localStorage.getItem(k) ?? '[]').length === 1, KEY, { timeout: 15_000 })
.catch(() => {});
let held = await queued();
step(
'a vote cast with no server is held',
held.length === 1 && held[0].method === 'POST' && held[0].path === '/ratings',
JSON.stringify(held)
);
const notice = await page.textContent(bar).catch(() => null);
step('and the bar says how many are waiting', notice !== null && notice.includes('Chưa gửi được: 1'), notice ?? 'no bar');
// 2 — the same vote again replaces the held one instead of stacking.
await page.$eval(star(3), (el) => el.click());
await page.waitForTimeout(500);
held = await queued();
step(
'a second vote on the same frame is one write, the later one',
held.length === 1 && String(held[0].body).includes('"stars":3'),
JSON.stringify(held)
);
// 3 — the reload keeps it.
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.waitForSelector(star(5), { timeout: 60_000 });
held = await queued();
step('and it is still held across a reload with no server', held.length === 1, JSON.stringify(held));
// 4 — a server answering sends it, with no one asking.
gone = false;
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page
.waitForFunction((k) => JSON.parse(localStorage.getItem(k) ?? '[]').length === 0, KEY, { timeout: 30_000 })
.catch(() => {});
held = await queued();
step(
'the held write goes out when a server answers',
held.length === 0 &&
sent.some((s) => s.method === 'POST' && s.path === '/api/ratings' && s.body === '{"key":"photo:1","stars":3}'),
JSON.stringify(sent)
);
step('and the bar goes with it', (await page.$(bar)) === null);
step('nothing thrown along the way', noise.length === 0, noise[0] ?? '');
await browser.close();
app.kill('SIGTERM');
console.log(failures === 0 ? 'all pass' : `${failures} failed`);
process.exit(failures === 0 ? 0 : 1);
@@ -0,0 +1,155 @@
// The offline answer to a big read, checked in a browser: the strip's listing is
// a few hundred kilobytes and used to have no offline answer at all, because the
// last one was kept in localStorage under a cap of two hundred thousand
// characters. It is kept whole in IndexedDB now, and an offline visit opens on it.
//
// The API is stubbed on the page's own origin the way the container's nginx
// serves it, and "no server" is a route that fails the request — which is exactly
// what a dead server looks like from `fetch`.
//
// npm run build && node scripts/offline-read-check.mjs
// APP_PORT=4886 PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> \
// node scripts/offline-read-check.mjs
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
const APP_PORT = Number(process.env.APP_PORT ?? 4187);
// The cap the read used to be kept under, and the store it is kept in now.
const OLD_CAP = 200_000;
const DB = 'recipescam-offline';
const READS = 'reads';
let failures = 0;
function step(name, ok, detail = '') {
if (!ok) failures++;
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
return ok;
}
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
if (!playwright) {
console.log('SKIP playwright-core is not installed');
process.exit(0);
}
// The listing as a studio with a real catalogue has it: enough frames that the
// JSON is past the old cap on its own, each carrying the look it was saved with —
// a curve is the bulk of one, and a hundred points of it is a small one.
const PHOTOS = Array.from({ length: 60 }, (_, i) => ({
id: i + 1,
createdAt: '2026-01-01T00:00:00Z',
slots: ['strip'],
tag: `#P${i + 1}`,
title: `Frame ${i + 1}`,
meta: `ISO 400 · 35mm`,
consent: true,
hasPreset: false,
recipe: { name: 'PROVIPES', curve: Array.from({ length: 512 }, (_, n) => n / 512) },
}));
const LISTING = JSON.stringify({ photos: PHOTOS });
const app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
cwd: FRONTEND,
stdio: 'ignore',
});
const base = `http://127.0.0.1:${APP_PORT}`;
let up = false;
for (let i = 0; i < 60 && !up; i++) {
up = await fetch(`${base}/`).then((r) => r.ok).catch(() => false);
if (!up) await new Promise((r) => setTimeout(r, 500));
}
if (!up) {
app.kill('SIGTERM');
console.log(`FAIL no preview on ${base} — run \`npm run build\` first`);
process.exit(1);
}
const browser = await playwright.chromium.launch({
executablePath: process.env.CHROME || undefined,
args: ['--no-sandbox', '--enable-unsafe-swiftshader'],
});
const context = await browser.newContext();
let gone = false; // whether the page's server is there
await context.route('**/api/**', (route) => {
if (gone) return route.abort('failed');
const req = route.request();
const url = req.url();
const json = (body) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
if (url.includes('/auth/me')) return json({ user: null });
if (url.includes('/photos')) return route.fulfill({ status: 200, contentType: 'application/json', body: LISTING });
if (url.includes('/ratings')) return json({ ratings: {} });
if (url.includes('/highlights')) return json({ highlights: { day: [], week: [], ever: [] } });
if (url.includes('/recipes')) return json({ recipes: [] });
return json({});
});
const page = await context.newPage();
const noise = [];
page.on('console', (m) => {
// The outage is the check's own doing, and a request that fails is a request
// the browser logs. Everything else is a real complaint.
if (m.type() === 'error' && !m.text().includes('net::ERR_FAILED')) noise.push(m.text());
});
const frames = () => page.$$eval('.lp-frame', (els) => els.length);
const readStale = (key) =>
page
.evaluate(
([db, store, key]) =>
new Promise((resolve, reject) => {
const req = indexedDB.open(db, 1);
req.onupgradeneeded = () => {
if (!req.result.objectStoreNames.contains(store)) req.result.createObjectStore(store);
};
req.onsuccess = () => {
const d = req.result;
if (!d.objectStoreNames.contains(store)) return resolve(null);
const get = d.transaction(store, 'readonly').objectStore(store).get(key);
get.onsuccess = () => resolve(get.result ?? null);
get.onerror = () => reject(get.error);
};
req.onerror = () => reject(req.error);
}),
[DB, READS, key]
)
.catch(() => null);
step('the listing this check serves is past the cap the old code kept', LISTING.length > OLD_CAP, `${LISTING.length} chars`);
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.lp-frame', { timeout: 60_000 });
await page.waitForFunction(() => document.querySelectorAll('.lp-frame').length >= 60, null, { timeout: 30_000 }).catch(() => {});
const online = await frames();
const stored = await readStale('/photos');
step(
'a listing past the old cap is kept for offline',
typeof stored === 'string' && stored.length > OLD_CAP,
typeof stored === 'string' ? `${stored.length} chars in the store` : 'no record'
);
const leftBehind = await page.evaluate(() => Object.keys(localStorage).filter((k) => k.startsWith('rc.stale.')).length);
step('and nothing is left in localStorage under the old key', leftBehind === 0, `${leftBehind} keys`);
const ratings = await readStale('/ratings');
step('a small read is kept the same way', typeof ratings === 'string', ratings === null ? 'no record' : 'kept');
// The reload with no server: the listing is the last one it was shown, so the
// reel is the same reel rather than an empty band.
gone = true;
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.lp-frame', { timeout: 30_000 }).catch(() => {});
const offline = await frames();
step(
'and the offline visit opens on the listing it kept',
online >= 60 && offline === online,
`${online} frames online, ${offline} offline`
);
step('nothing thrown along the way', noise.length === 0, noise[0] ?? '');
await browser.close();
app.kill('SIGTERM');
console.log(failures === 0 ? 'all pass' : `${failures} failed`);
process.exit(failures === 0 ? 0 : 1);
@@ -1,102 +0,0 @@
// The per-file colour fit is pure arithmetic, so it can be checked here rather
// than in a browser: hand fitMatch a grid and the same grid through a known 3x3,
// and it has to hand that 3x3 back. Done that way because the function only ever
// runs inside the RAW develop, which needs LibRaw and CanvasKit — neither of
// which a node check has. The module is TypeScript, so it is transpiled on the
// fly out of the installed compiler (the repo's convention for checks: see
// raw-develop-check.mjs, which reads the shader source instead).
//
// node scripts/preview-match-check.mjs
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import ts from 'typescript';
const file = new URL('../src/engine/previewMatch.ts', import.meta.url);
const js = ts.transpileModule(readFileSync(file, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
const { fitMatch, MATCH_GRID } = await import(
`data:text/javascript;base64,${Buffer.from(js).toString('base64')}`
);
// A grid with colour in it: three independent ramps, none of them clipped and
// none at the floor, so every block carries something to fit. Independent on
// purpose — three correlated channels leave the 3x3 ill-conditioned, and a fit
// that comes back a percent off on such a scene says nothing about the code.
function scene() {
let s = 1;
const rnd = () => ((s = (s * 1103515245 + 12345) & 0x7fffffff) % 4096) / 4096;
const g = new Uint8Array(MATCH_GRID * MATCH_GRID * 4);
for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) {
for (let c = 0; c < 3; c++) g[i * 4 + c] = 25 + Math.round(rnd() * 190);
g[i * 4 + 3] = 255;
}
return g;
}
const M = [1.06, -0.05, 0.01, 0.02, 0.98, 0.03, -0.04, 0.06, 1.02];
const dev = scene();
const ref = new Uint8Array(dev.length);
for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) {
const e = [dev[i * 4], dev[i * 4 + 1], dev[i * 4 + 2]];
for (let r = 0; r < 3; r++) {
const v = M[r * 3] * e[0] + M[r * 3 + 1] * e[1] + M[r * 3 + 2] * e[2];
ref[i * 4 + r] = Math.max(0, Math.min(255, Math.round(v)));
}
ref[i * 4 + 3] = 255;
}
const fit = fitMatch(dev, ref);
assert.ok(fit, 'a fitted grid has to fit');
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) {
assert.ok(Math.abs(fit.m[r * 3 + c] - M[r * 3 + c]) < 0.01, `recovered ${fit.m[r * 3 + c]} for ${M[r * 3 + c]}`);
}
}
// This scene was made by a 3x3 and nothing else, so the curve fitted on what the
// 3x3 leaves has to come back flat — the identity, up to the rounding the scene
// carries.
assert.equal(fit.tone.length, 12);
for (let i = 0; i < 12; i++) {
const flat = i % 4 === 1 ? 1 : 0;
assert.ok(Math.abs(fit.tone[i] - flat) < 0.15, `tone ${i} came back ${fit.tone[i]}, not ${flat}`);
}
// Nothing to fit against: the camera clipped the whole frame, so no block says
// anything about its rendering, and the caller develops as the sensor left it.
assert.equal(fitMatch(dev, new Uint8Array(dev.length).fill(255)), null);
// A near-neutral frame through a matrix whose rows carry a negative element — what
// the bodies the fit used to refuse looked like (X-T3 -0.04, X100V -1.67 on rows
// that also carry +1.05 and +1.63). Three channels that share nearly all their
// content leave the normal equations near-singular, and the plain solve came back
// with a diagonal element at or below zero, which the caller read as a degeneracy
// and dropped the whole fit for. It is a colour: the ridge lifts the system back
// to definite and the fit has to come back a matrix.
function neutral() {
let s = 7;
const rnd = () => ((s = (s * 1103515245 + 12345) & 0x7fffffff) % 4096) / 4096;
const g = new Uint8Array(MATCH_GRID * MATCH_GRID * 4);
for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) {
const x = 40 + Math.round(rnd() * 180);
for (let c = 0; c < 3; c++) g[i * 4 + c] = Math.max(0, Math.min(255, x + Math.round((rnd() - 0.5) * 8)));
g[i * 4 + 3] = 255;
}
return g;
}
const N = [1.05, -1.67, 1.63, 0.02, 0.95, 0.03, -0.04, 0.06, 1.02];
const nDev = neutral();
const nRef = new Uint8Array(nDev.length);
for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) {
const e = [nDev[i * 4], nDev[i * 4 + 1], nDev[i * 4 + 2]];
for (let r = 0; r < 3; r++) {
const v = N[r * 3] * e[0] + N[r * 3 + 1] * e[1] + N[r * 3 + 2] * e[2];
nRef[i * 4 + r] = Math.max(0, Math.min(255, Math.round(v)));
}
nRef[i * 4 + 3] = 255;
}
const nFit = fitMatch(nDev, nRef);
assert.ok(nFit, 'a near-neutral frame has to fit, not come back null');
for (const v of nFit.m) assert.ok(Number.isFinite(v) && Math.abs(v) <= 4, `degenerate fit ${v}`);
console.log('preview-match-check ok');
+116
View File
@@ -0,0 +1,116 @@
// Install support is not visible in the source. It is the manifest the browser
// reads, the worker it registers, and what that worker still holds once the network
// is gone — so unlike the other checks here, which read a module and assert on it,
// this one has to drive a real browser at a real build. Five assertions, one line
// each, against a preview server:
//
// npx vite build && npx vite preview --port 4183 &
// node scripts/pwa-check.mjs
//
// The browser is Playwright's own, installed by whoever runs this — there is no
// test runner in this repo, and software GL is what the studio needs to paint at
// all. PLAYWRIGHT_CORE points at another copy, CHROME at another browser.
const BASE = process.argv[2] ?? 'http://localhost:4183';
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
if (!playwright) {
console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE=<path to its index.mjs>');
process.exit(0);
}
const { chromium } = playwright;
const browser = await chromium.launch({
// Playwright's own Chromium when it has one; CHROME points at any other.
executablePath: process.env.CHROME || undefined,
args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
});
const context = await browser.newContext();
// Every assertion prints, rather than throwing on the first one: which of the five
// works and which does not is the whole answer.
let failed = 0;
const check = (ok, label) => {
console.log(`${ok ? 'ok ' : 'FAIL'} ${label}`);
if (!ok) failed++;
};
const page = await context.newPage();
await page.goto(`${BASE}/app`);
// (a) The manifest, as the browser fetches it before it offers anything.
const manifest = await page.evaluate(async () => await (await fetch('/manifest.json')).json());
check(Array.isArray(manifest.icons) && manifest.icons.length === 3, `/manifest.json parses with ${manifest.icons?.length} icons`);
// (b) The worker registration main.tsx makes on load. `ready` only ever resolves
// with a registration that has an `active` worker; that worker's own state string
// is 'activated' (the ServiceWorkerState enum has no 'active' in it).
const registration = await page.evaluate(async () => {
const reg = await navigator.serviceWorker.ready;
return { scope: reg.scope, state: reg.active?.state ?? null };
});
check(registration.state === 'activated', `serviceWorker.ready resolves — scope ${registration.scope}, active.state '${registration.state}'`);
// (c) What it put away. Read after a reload, because the first load is the one that
// installs it and the reload is the first one it is in control of.
await page.waitForFunction(async () => (await caches.match(new URL('/app', location.origin).href)) !== undefined);
await page.reload();
const cached = await page.evaluate(async () => {
const out = {};
for (const name of await caches.keys()) {
out[name] = (await (await caches.open(name)).keys()).map((r) => new URL(r.url).pathname);
}
return out;
});
const names = Object.keys(cached);
const urls = Object.values(cached).flat().sort();
check(
urls.includes('/') && urls.includes('/app') && urls.some((u) => /^\/assets\/index-/.test(u)),
`worker cache ${names.join(', ')} after reload: ${urls.join(' ')}`,
);
// (d) The point of all of it: the network gone, the shell still paints.
await context.setOffline(true);
let brand = 'nothing';
try {
await page.reload();
brand = await page.evaluate(() => document.querySelector('header.header .brand')?.textContent ?? null);
} catch (err) {
brand = `reload failed: ${err.message.split('\n')[0]}`;
}
check(/RecipesCam/.test(brand ?? ''), `offline reload of /app paints the app's own header: ${brand}`);
// (e) The offer is asked once. The browser's sheet is stubbed, because a check cannot
// click the real one: waved away it is a refusal, and a refusal is stored, so the next
// visit renders no dialog at all. The stub goes in an init script so it is there on
// both loads.
await context.setOffline(false);
await page.addInitScript(() => {
window.__offerInstall = () => {
const event = new Event('beforeinstallprompt');
event.prompt = () => Promise.resolve();
event.userChoice = Promise.resolve({ outcome: 'dismissed' });
window.dispatchEvent(event);
window.dispatchEvent(new Event('rc:install-offer'));
};
});
const offer = async () => {
await page.evaluate(() => window.__offerInstall());
await page.waitForTimeout(150);
return page.locator('[data-key="install-yes"]').count();
};
await page.reload();
const first = await offer();
await page.locator('[data-key="install-yes"]').click();
const stored = await page.evaluate(() => localStorage.getItem('rc.install.dismissed.v1'));
await page.reload();
const again = await offer();
check(
first === 1 && stored === '1' && again === 0,
`install dialog shown ${first} time(s), refused (localStorage '${stored}'), shown again after reload: ${again}`,
);
await browser.close();
if (failed) {
console.error(`pwa-check: ${failed} of 5 failed`);
process.exit(1);
}
console.log('pwa-check ok');
+338
View File
@@ -0,0 +1,338 @@
// What a reading costs the page, measured rather than argued: a roll of many
// folders, a strip drawing it as it lands, and the heap sampled with a forced
// collection between samples, so what is left is what somebody still holds.
//
// npm run build && node scripts/ram-check.mjs
// APP_PORT=4950 SAMPLE_PORT=4951 SAMPLE_DIR=<sample folder> \
// PLAYWRIGHT_CORE=<playwright-core/index.mjs> CHROME=<chrome> \
// node scripts/ram-check.mjs
import { spawn } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
const SAMPLES = process.env.SAMPLE_DIR ?? path.resolve(FRONTEND, '..', 'sample');
const APP_PORT = Number(process.env.APP_PORT ?? 4950);
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4951);
const JPG = 'P1010256.JPG';
const RAW = 'P1010256.RW2';
// One small JPEG served under every name: a hundred copies of an eight-megabyte
// frame is eight hundred megabytes to prove a point about rows.
const SMALL = 'Main.jpg';
const SUBDIRS = Number(process.env.SUBDIRS ?? 12);
const PER_DIR = Number(process.env.PER_DIR ?? 300);
const QUEUE_AT = Number(process.env.QUEUE_AT ?? 4);
const QUEUE_N = Number(process.env.QUEUE_N ?? 4);
const BUDGET_MS = Number(process.env.BUDGET_MS ?? 90_000);
const SLOW = Number(process.env.SLOW ?? 12);
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
if (!playwright) {
console.log('SKIP playwright-core is not installed');
process.exit(0);
}
// The roll: a picked folder of `SUBDIRS` folders, each of `PER_DIR` frames.
let n = 0;
const rolls = {};
const dirs = {};
for (let d = 0; d < SUBDIRS; d++) {
const names = [];
for (let f = 0; f < PER_DIR; f++) names.push(`B${String(n++).padStart(6, '0')}.JPG`);
dirs[`D${String(d).padStart(2, '0')}`] = { files: names, dirs: {} };
}
rolls.RamRoll = { files: [`B${String(n++).padStart(6, '0')}.JPG`], dirs };
const mobile = Number(process.env.MOBILE ?? 0);
// BASE_URL runs the same reading against a stack already up (the deployed one),
// so what is measured is the page the reader actually opens.
const app = process.env.BASE_URL
? null
: spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
cwd: FRONTEND,
stdio: 'ignore',
});
const base = process.env.BASE_URL ?? `http://127.0.0.1:${APP_PORT}`;
let up = false;
for (let i = 0; i < 60 && !up; i++) {
up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false);
if (!up) await new Promise((r) => setTimeout(r, 500));
}
if (!up) {
app?.kill('SIGTERM');
console.log(`FAIL no preview on ${base} — run \`npm run build\` first`);
process.exit(1);
}
const args = ['--no-sandbox', '--enable-unsafe-swiftshader'];
if (mobile) args.push(`--js-flags=--max-old-space-size=${mobile}`);
const browser = await playwright.chromium.launch({
executablePath: process.env.CHROME || undefined,
args,
});
const context = await browser.newContext();
await context.route(`http://127.0.0.1:${SAMPLE_PORT}/**`, async (route) => {
const name = path.basename(decodeURIComponent(new URL(route.request().url()).pathname));
const file = /^B\d+\.JPG$/.test(name) ? SMALL : /\.jpe?g$/i.test(name) ? JPG : /\.rw2$/i.test(name) ? RAW : null;
if (!file) return route.fulfill({ status: 404, body: '' });
return route.fulfill({
status: 200,
contentType: 'application/octet-stream',
headers: { 'Access-Control-Allow-Origin': '*' },
body: await readFile(path.join(SAMPLES, file)),
});
});
await context.route('**/api/**', (route) => {
const url = route.request().url();
const json = (body) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
if (url.includes('/auth/me')) return json({ user: { id: 1, email: 'check@example.com', name: 'check' } });
if (url.includes('/photos')) return json({ photos: [] });
if (url.includes('/recipes')) return json({ recipes: [] });
return route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'no' }) });
});
await context.addInitScript(
({ origin, folder, rolls, small }) => {
const bytes = new Map();
const load = async (name) => {
const file = /^B\d+\.JPG$/.test(name) ? small : name;
if (!bytes.has(file)) bytes.set(file, fetch(`${origin}/${file}`).then((r) => r.arrayBuffer()));
return bytes.get(file);
};
const fileHandle = (name) => {
const handle = { kind: 'file', name, __fake: true };
Object.defineProperty(handle, 'getFile', {
value: async () => {
// A read of a frame is nearly all waiting on a real disk, and a
// reading that is over in a blink is one the heap cannot be watched
// against. `check.slow` is that wait, in milliseconds.
const slow = Number(localStorage.getItem('check.slow') ?? 0);
if (slow) await new Promise((r) => setTimeout(r, slow));
return new File([await load(name)], name, { type: 'application/octet-stream', lastModified: Date.UTC(2026, 0, 1) });
},
});
return handle;
};
const dirHandle = (label, node) => {
const handle = { kind: 'directory', name: label, __fake: true };
Object.defineProperties(handle, {
values: {
value: () => {
if (node.fail) throw new Error(`${label} will not open`);
return [
...(node.files ?? []).map(fileHandle),
...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)),
][Symbol.iterator]();
},
},
queryPermission: { value: async () => 'granted' },
requestPermission: { value: async () => 'granted' },
getDirectoryHandle: {
value: async (name) => {
const kid = node.dirs?.[name];
if (!kid) throw new Error(`no folder named ${name}`);
return dirHandle(name, kid);
},
},
getFileHandle: {
value: async (name) => {
if (!(node.files ?? []).includes(name)) throw new Error(`no frame named ${name}`);
return fileHandle(name);
},
},
});
return handle;
};
const root = () => dirHandle(folder, { files: rolls[folder].files, dirs: rolls[folder].dirs });
window.showDirectoryPicker = async () => root();
const revive = (row) => {
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name);
}
return row;
};
for (const method of ['get', 'getAll']) {
const original = IDBObjectStore.prototype[method];
IDBObjectStore.prototype[method] = function (...args) {
const request = original.apply(this, args);
const descriptor = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result');
Object.defineProperty(request, 'result', {
configurable: true,
get() {
const value = descriptor.get.call(this);
return Array.isArray(value) ? value.map(revive) : revive(value);
},
});
return request;
};
}
},
{ origin: `http://127.0.0.1:${SAMPLE_PORT}`, folder: 'RamRoll', small: SMALL, rolls }
);
const page = await context.newPage();
const cdp = await context.newCDPSession(page);
await cdp.send('Performance.enable');
const sample = async () => {
await cdp.send('HeapProfiler.collectGarbage');
const { metrics } = await cdp.send('Performance.getMetrics');
const m = Object.fromEntries(metrics.map((x) => [x.name, x.value]));
const dom = await page.evaluate(() => document.querySelectorAll('*').length);
return { heap: m.JSHeapUsedSize, nodes: m.Nodes, dom };
};
const line = () => page.$eval('[data-key="lib-progress"]', (e) => (e.textContent ?? '').trim()).catch(() => '');
const stored = () =>
page
.evaluate(async () => {
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open('recipescam-library');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return new Promise((resolve, reject) => {
const req = db.transaction('photos', 'readonly').objectStore('photos').count();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
})
.catch(() => -1);
await page.goto(`${base}/library`);
await page.evaluate((s) => localStorage.setItem('check.slow', String(s)), SLOW);
await page.click('[data-key="lib-add"]');
await page.waitForSelector('[data-key="lib-node-RamRoll"]', { timeout: 60_000 });
if (Number(process.env.PROFILE ?? 0)) await cdp.send('HeapProfiler.startSampling', { samplingInterval: 4096 });
const series = [];
const t0 = Date.now();
let queued = false;
while (Date.now() - t0 < BUDGET_MS) {
const s = await sample();
s.t = Math.round((Date.now() - t0) / 100) / 10;
s.line = await line();
series.push(s);
// The reader right-clicks a few folders under the roll: each is a reading
// queued behind the one running, which is what the report is about.
if (!queued && series.length >= QUEUE_AT) {
queued = true;
const keys = await page.evaluate(() => [...document.querySelectorAll('[data-key^="lib-node-RamRoll/D"]')].map((e) => e.dataset.key));
for (const key of keys.slice(0, QUEUE_N)) {
await page.click(`[data-key="${key}"]`, { button: 'right' }).catch(() => {});
await page.click('[data-key="lib-update-RamRoll"]', { timeout: 5000 }).catch(() => {});
await page.waitForTimeout(150);
}
console.log(`queued ${Math.min(QUEUE_N, keys.length)} folders`);
}
await page.waitForTimeout(1000);
}
const rest = await sample();
const first = series[0] ?? { heap: 0 };
const peak = series.reduce((a, b) => (b.heap > a.heap ? b : a), first);
const last = series[series.length - 1] ?? first;
console.log(`samples ${series.length} over ${last.t}s rows ${await stored()}`);
console.log(`heap first ${Math.round(first.heap / 1e6)}MB peak ${Math.round(peak.heap / 1e6)}MB@${peak.t}s last ${Math.round(last.heap / 1e6)}MB resting ${Math.round(rest.heap / 1e6)}MB`);
console.log(`nodes first ${first.nodes} dom ${first.dom} -> nodes ${last.nodes} dom ${last.dom}`);
if (Number(process.env.SNAPSHOT ?? 0)) {
const chunks = [];
cdp.on('HeapProfiler.addHeapSnapshotChunk', (e) => chunks.push(e.chunk));
await cdp.send('HeapProfiler.enable');
await cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false });
const snap = JSON.parse(chunks.join(''));
const fields = snap.snapshot.meta.node_fields;
const types = snap.snapshot.meta.node_types[fields.indexOf('type')];
const stride = fields.length;
const at = (i, f) => snap.nodes[i * stride + fields.indexOf(f)];
const byKey = new Map();
for (let i = 0; i < snap.nodes.length / stride; i++) {
const key = `${types[at(i, 'type')]} ${snap.strings[at(i, 'name')]}`;
const cur = byKey.get(key) ?? { n: 0, bytes: 0 };
cur.n++;
cur.bytes += at(i, 'self_size');
byKey.set(key, cur);
}
console.log('--- who is alive (self_size) ---');
for (const [key, v] of [...byKey.entries()].sort((a, b) => b[1].bytes - a[1].bytes).slice(0, 30)) {
console.log(` ${String(Math.round(v.bytes / 1024)).padStart(8)}KB ${String(v.n).padStart(7)}x ${key}`);
}
if (Number(process.env.RETAIN ?? 0)) {
// Who keeps the named object alive: walk the edges into the first node of a
// given name and print the path back to a root the way the panel does.
const nodeCount = snap.nodes.length / stride;
const eStride = snap.snapshot.meta.edge_fields.length;
let maxTo = 0;
for (let i = 0; i < snap.edges.length / eStride; i++) maxTo = Math.max(maxTo, snap.edges[i * eStride + 2]);
const scale = maxTo >= nodeCount ? eStride : 1;
console.log(` nodes ${nodeCount} edges ${snap.edges.length / eStride} maxTo ${maxTo} -> to_node scale ${scale}`);
const firstEdge = new Int32Array(nodeCount + 1);
{
let run = 0;
for (let i = 0; i < nodeCount; i++) {
firstEdge[i] = run;
run += at(i, 'edge_count');
}
firstEdge[nodeCount] = run;
}
const want = process.env.RETAIN;
const typesOf = (i) => types[at(i, 'type')];
const nameOf = (i) => snap.strings[at(i, 'name')];
const targets = [];
for (let i = 0; i < nodeCount && targets.length < 5; i++) if (typesOf(i) === 'object' && nameOf(i) === want) targets.push(i);
const parent = new Map();
const queue = [0];
let head = 0;
const seen = new Uint8Array(nodeCount);
seen[0] = 1;
const wantSet = new Set(targets);
let hit = -1;
while (head < queue.length) {
const n0 = queue[head++];
if (wantSet.has(n0)) {
hit = n0;
break;
}
for (let e = firstEdge[n0]; e < firstEdge[n0 + 1]; e++) {
const child = snap.edges[e * eStride + 2] / scale;
if (!seen[child]) {
seen[child] = 1;
parent.set(child, [n0, snap.strings[snap.edges[e * eStride]]]);
queue.push(child);
}
}
}
console.log(`--- retained path to "${want}" (${targets.length} found) ---`);
if (hit >= 0) {
const path = [];
let cur = hit;
while (cur !== undefined && cur !== 0) {
const p = parent.get(cur);
if (!p) break;
path.push(`${nameOf(cur)}(${typesOf(cur)}) -[${p[1]}]-`);
cur = p[0];
}
console.log(` ${path.reverse().join(' ')}`);
}
}
}
if (Number(process.env.PROFILE ?? 0)) {
const { profile } = await cdp.send('HeapProfiler.stopSampling');
const rows = [];
const walk = (node, stack) => {
const f = node.callFrame ?? {};
const here = stack.concat([`${f.functionName || '(anonymous)'}@${(f.url ?? '').split('/').pop()}:${(f.lineNumber ?? 0) + 1}`]);
if (node.selfSize) rows.push({ name: f.functionName || '(anonymous)', url: (f.url ?? '').split('/').pop(), size: node.selfSize, stack: here.slice(-6).join(' <- ') });
for (const kid of node.children ?? []) walk(kid, here);
};
walk(profile.head, []);
console.log('--- who allocates (selfSize, leaf) ---');
for (const r of [...rows].sort((a, b) => b.size - a.size).slice(0, 25)) console.log(` ${String(Math.round(r.size / 1024)).padStart(8)}KB ${r.name} ${r.url}`);
const byStack = new Map();
for (const r of rows) byStack.set(r.stack, (byStack.get(r.stack) ?? 0) + r.size);
console.log('--- where it comes from (stack, selfSize summed) ---');
for (const [stack, size] of [...byStack.entries()].sort((a, b) => b[1] - a[1]).slice(0, 25)) {
console.log(` ${String(Math.round(size / 1024)).padStart(8)}KB ${stack}`);
}
}
for (const s of series) console.log(` ${String(s.t).padStart(6)}s ${String(Math.round(s.heap / 1e6)).padStart(5)}MB nodes ${String(s.nodes).padStart(7)} dom ${String(s.dom).padStart(7)} ${s.line}`);
await browser.close();
app?.kill('SIGTERM');
+189 -41
View File
@@ -36,9 +36,12 @@ assert.match(code, /float3 lin = n \* mul\.rgb;/);
// across the whole sky. Measured on that frame in the app against the camera's own
// JPEG: mean dRGB +1.2, -5.9, -6.1 with the sensor's clip alone, +0.8, +1.0, +1.1
// with both, mean |dL| 21.5 against 11.1.
// The gate opens below the white level, not at it: 0.82 against 0.98 was measured
// after the pairs above, so the tint reads on the way up rather than only once the
// frame has already run out of range.
assert.match(
code,
/float hi = max\(max\(n\.r, n\.g\), n\.b\);\s*hi = max\(hi, max\(max\(lin\.r, lin\.g\), lin\.b\)\);\s*rgb = mix\(rgb, float3\(mx\), smoothstep\(0\.95, 1\.0, hi\)\);/
/float hi = max\(max\(n\.r, n\.g\), n\.b\);\s*hi = max\(hi, max\(max\(lin\.r, lin\.g\), lin\.b\)\);\s*float blown = smoothstep\(0\.82, 0\.98, hi\);\s*rgb = mix\(rgb, float3\(mx\), blown\);/
);
// The inverse is built on the CPU side and has to be the first uniform of the
@@ -46,7 +49,8 @@ assert.match(
// the frame itself ran out at — `maximum` alone left every frame a stop bright,
// and a fixed factor two only fitted the two bodies it was measured on. The
// probe and its own check live in src/engine/sensorWhite.ts.
assert.match(src, /^ *uniforms\[0\] = SAMPLE_MAX \/ sensorWhite\(data, cd\.maximum, cd\.black\);$/m);
assert.match(src, /^ *const white = sensorWhite\(data, cd\.maximum, cd\.black\);$/m);
assert.match(src, /^ *uniforms\[0\] = SAMPLE_MAX \/ white;$/m);
assert.match(src, /^import \{ sensorWhite \} from '\.\/sensorWhite';$/m);
// On the FX30 that inverse is 2.065 (65535 / (2 (16380 - 512))), against the
@@ -68,50 +72,83 @@ assert.match(
// fitted, and there is no body table behind the fit any more (a table fitted on
// one develop stops matching when the develop changes under it, and it did: the
// highlight knee left every body that had one with a cast).
assert.match(src, /preview = await cameraPreview\(raw\);/);
assert.match(src, /const match = blocks && ref \? fitMatch\(blocks, ref\) : null;/);
assert.match(src, /extractEmbeddedJpeg\(bytes\);/);
assert.doesNotMatch(src, /cameraMatch/, 'the body table is back');
assert.doesNotMatch(code, /uniform float4 w0;/, 'the body table is back in the shader');
// The fit is drawn by the develop that was fitted, so the frame goes through it
// twice: once on the sensor alone, to fit against the preview, and then again
// with the fit in the shader.
assert.doesNotMatch(src, /fitMatch/, 'the 3x3 preview match is back');
assert.doesNotMatch(code, /uniform float4 f0;/, 'the 3x3 preview match is back in the shader');
assert.match(src, /const first = develop\(null\);/);
assert.match(src, /const matched = match \? develop\(match\) : null;/);
assert.match(src, /const jpeg = \(matched \?\? first\)\.encodeToBytes/);
assert.match(src, /if \(preview\) return preview;/);
assert.match(src, /if \(thumb\?\.format !== 'jpeg' \|\| !thumb\.data\?\.length\) return null;/);
assert.match(src, /const jpeg = finalShot\.encodeToBytes\(Skia\.ImageFormat\.JPEG, 92\);/);
assert.match(src, /preview && preview\.length > 10000/);
// The fit has to land on the encoded value in float, not as an 8-bit colour filter
// painted over the frame afterwards: the fit carries an exposure, and a channel it
// lifted past the white level was cut where it stood — blue first, the channel the
// WB gains lift most — which is what left the bright end of the frame short of
// blue. Measured on the A5100 frame, pixels at 255: 5.25% blue, 2.10% of them blue
// alone through the filter, against 2.46% / 2.44% through this path.
// The balance the frame opens with is read off the file's own preview — the same
// reference the tone curve is fitted to — and only falls back to LibRaw's tags when
// the preview has nothing neutral and bright left to read. The tags alone were not
// the balance: on the Fujifilm RAF this was reported on, `pre_mul` was short of blue
// twice over (R/G 2.296 against the frame's 1.795, B/G 1.307 against 1.626) and left
// every white pink, `cam_mul` came closer, and the pair read off the preview matched
// it: block a* -1.9 against the preview's -2.1 where the tags gave 0.0 and -1.0,
// dE00 4.93 where they gave 6.73 and 5.27. And it holds across bodies for the same
// reason — it is read on the frame, not on the tags.
assert.ok(
src.indexOf('return cd.cam_mul;') < src.indexOf('return cd.pre_mul;'),
'pre_mul is preferred over the frame\'s own balance again'
);
assert.match(src, /^ *const refGrid = preview \? previewGrid\(preview, w, h, MATCH_GRID\) : null;$/m);
assert.match(
src,
/const rawMul = \(refGrid && previewWhiteBalance\(data, w, h, refGrid, MATCH_GRID, white\)\)\s*\?\? getBrandWbMultipliers\(brand, cd\);/
);
assert.match(src, /^export function previewWhiteBalance\($/m);
// ...and a balance read off too little of the frame is no balance: a frame that
// offers fewer than this many sensor pixels across its neutral bright blocks keeps
// the tags rather than a ratio read off noise.
assert.match(src, /if \(count < 4096 \|\| sums\[0\] <= 0 \|\| sums\[1\] <= 0 \|\| sums\[2\] <= 0\) return null;/);
// What a RAW opens as is the colour the camera wrote into it. Not fitted: the
// develop carries the difference between the file's own preview and this develop's
// rendering of the same frame, at the preview's resolution, and adds it back on
// the value it left. So there is no curve to come back folded, no body table, and
// nothing that can disagree with the file it is opening.
//
// No rolloff after the fit. The fit is luma-preserving, not white-preserving —
// its rows sum to ~0.79 / 1.07 / 0.92, so white leaves it past 1.0 in green only —
// and dividing by that max pulled red and blue down with it: highlights came out
// cyan and not one cell of the frame reached white on all three channels (0.0%)
// against 2.3% now and 3.3% in the camera's preview, dE00 7.1 against 6.6.
assert.match(code, /uniform float4 f0;/);
assert.doesNotMatch(src, /drawMatched|colorMatrix/, 'the fit is painted through a colour filter again');
assert.match(code, /float3 q = clamp\(float3\(dot\(f0\.xyz, e\), dot\(f1\.xyz, e\), dot\(f2\.xyz, e\)\), 0\.0, 1\.0\);/);
assert.doesNotMatch(code, /float mq = /, 'the rolloff after the fit is back');
// A 3x3 can only scale a channel and the gap to the camera is mostly a shape, so
// the frame leaves through the per-channel curve the fit carries as well. Without
// it the shadows stayed bright and green: measured on the A5100 frame, dE00 6.6
// against 4.4, and at the bottom of the lightness range dL +13.8 with green
// +0.128 against +4.5 and +0.017 through the curve (both against the camera's own
// JPEG).
assert.match(code, /float tone\(float4 w, float x\) \{/);
assert.match(code, /return half4\(half3\(tone\(t0, q\.r\), tone\(t1, q\.g\), tone\(t2, q\.b\)\), 1\.0\);/);
assert.match(code, /uniform float4 t0;/);
// ...and it is the tail of the uniform buffer, one float4 per channel, which the
// second develop overwrites on the buffer it already built.
assert.match(src, /^ *uniforms\.set\(fit\?\.tone \?\? FLAT_TONE, 33\);$/m);
assert.match(src, /const uniforms = new Float32Array\(45\);/);
assert.match(src, /^ *uniforms\[21\] = f\[0\]; uniforms\[22\] = f\[1\]; uniforms\[23\] = f\[2\]; uniforms\[24\] = 0;$/m);
// Measured against the file's own preview: dE00 6.04 for the per-channel cubic
// this replaced, 0.54 for the residual, and 0.00 on the two bodies (a Nikon NEF
// and a DNG) that write a full-size preview — where the two frames agree block for
// block and the residual is nothing.
const res = src.match(/const RESIDUAL_SKSL = `([\s\S]*?)`;/)?.[1];
assert.ok(res, 'RESIDUAL_SKSL not found');
const resCode = res.replace(/\/\/[^\n]*/g, '');
assert.match(
resCode,
/return half4\(half3\(clamp\(ref\.eval\(p\)\.rgb - dev\.eval\(p\)\.rgb \+ 0\.5, 0\.0, 1\.0\)\), 1\.0\);/
);
assert.match(code, /uniform shader res;/);
assert.match(code, /uniform float2 rscl;/);
// The 0.5 that keeps a negative difference inside a byte, taken back off on the
// way out, and read at the frame's own coordinates — `pos` is local to this band.
assert.match(code, /float2 rp = pos \* rscl;/);
assert.match(code, /o = clamp\(o \+ \(res\.eval\(rp\)\.rgb - 0\.5\), 0\.0, 1\.0\);/);
// The residual is read at the develop's own resolution, so it is filtered up
// rather than tapped: one byte a channel, which is finer than the shading it
// carries, is the whole difference between two renderings.
assert.match(src, /const resChild = \(residual\?\.img \?\? noResidual\(\)\)\.makeShaderOptions\(/);
assert.match(src, /const shader = effect\.makeShaderWithChildren\(uniforms, \[child, resChild\]\);/);
// A file with nothing to carry opens on a residual that says nothing — 0.5 in
// half — rather than down a branch of its own in the shader.
assert.match(src, /NO_RESIDUAL = Skia\.Image\.MakeImage\(/);
assert.match(src, /new Uint16Array\(\[0x3800, 0x3800, 0x3800, 0x3c00\]\)/);
// The develop runs twice, not once: the residual is read off the first frame and
// carried by the second.
assert.match(src, /^ *const second = develop\(residual\);$/m);
// The per-channel curve this replaced is gone, and so is the buffer slot it rode
// in: 33 floats became 23, the last two of them the residual's own scale.
assert.doesNotMatch(code, /float tone\(float4 w, float x\) \{/, 'the per-channel curve is back');
assert.doesNotMatch(code, /uniform float4 t0;/, 'the per-channel curve is back in the shader');
assert.doesNotMatch(src, /FLAT_TONE/, 'the flat tone is back');
assert.doesNotMatch(src, /toneMatch/, 'the tone fit is back');
assert.match(src, /const uniforms = new Float32Array\(23\);/);
assert.match(src, /^ *uniforms\[21\] = residual \? residual\.sx : 0;$/m);
assert.match(src, /^import \{ MATCH_GRID \} from '\.\/previewMatch';$/m);
// A RAW opens at the sensor's own resolution, not at the quarter the half-size
// demosaic reports: the GR's DNG came back 3010x2012 against the 6000x4000 of the
@@ -119,4 +156,115 @@ assert.match(src, /^ *uniforms\[21\] = f\[0\]; uniforms\[22\] = f\[1\]; uniforms
// 6020x4024, and every one of the eight bodies checked doubled its frame.
assert.match(src, /^ *halfSize: false,$/m, 'the develop is half-size again');
// The matrix the develop applies is the camera->sRGB one LibRaw already hands
// back, not one derived from `cam_xyz` beside it. They disagree on the Olympus
// ORF this was reported on: rgb_cam x cam_mul lands on R/G 1.021 B/G 0.920
// against the file's own preview's 1.013 / 0.841, the cam_xyz chain on
// 0.921 / 0.886 — green, and blue for a scene the camera left neutral, which is
// the cast the frame opened with. And the matrix must not be divided by `pre_mul`
// again: that undoes the row-sum-to-one normalisation dcraw built into rgb_cam.
{
const xyz = src.match(/^const XYZ_TO_SRGB = \[[\s\S]*?\n\];$/m)?.[0];
const fn = src.match(/^export function getCamToSrgbMatrix\([\s\S]*?\n\}\n/m)?.[0];
assert.ok(xyz && fn, 'getCamToSrgbMatrix not found');
// The file is TypeScript; the annotations in this block are all that stop
// `new Function` from reading it.
const js = (xyz + '\n' + fn)
.replace(/^export /m, '')
.replace(/: any/g, '')
.replace(/: number\[\]\[\]/g, '')
.replace(/\?: CameraBrand/g, '');
const getCamToSrgbMatrix = new Function(`${js}; return getCamToSrgbMatrix;`)();
const rgb_cam = [
[1.631760597229004, -0.39896339178085327, -0.23279713094234467],
[-0.298429936170578, 1.7056032419204712, -0.4071732759475708],
[0.02502557262778282, -0.45518237352371216, 1.4301568269729614],
];
const cam_xyz = [
[0.9422, -0.3258, -0.0711],
[-0.2655, 1.0898, 0.2015],
[-0.0512, 0.1354, 0.5512],
];
const pre_mul = [2.0312116146087646, 0.9462084174156189, 1.4558180570602417];
assert.deepEqual(
getCamToSrgbMatrix({ rgb_cam, cam_xyz, pre_mul }),
rgb_cam.map((row) => row.slice(0, 3)),
'rgb_cam is no longer applied as LibRaw handed it back'
);
// A neutral camera triple stays neutral through what is left: every row of the
// matrix has to sum to one, or the frame opens with a cast of its own.
for (const [name, cd] of [
['rgb_cam', { rgb_cam }],
['cam_xyz', { cam_xyz }],
['nothing', {}],
]) {
const rows = getCamToSrgbMatrix(cd);
for (const row of rows) {
assert.ok(Math.abs(row[0] + row[1] + row[2] - 1) < 1e-6, `${name}: row sums to what white is not`);
}
}
}
// The balance read off the preview is arithmetic on the plane and the preview
// map, so both can be handed to it here. A plane whose red stands 1.5x its green
// and whose blue 1.2x, under a map the camera left bright and neutral, has one
// balance that would equalise those blocks, and it is the one that comes back.
{
const fn = src.match(/^export function previewWhiteBalance\([\s\S]*?\n\}\n/m)?.[0];
assert.ok(fn, 'previewWhiteBalance not found');
const previewWhiteBalance = new Function(
`${fn.replace(/^export /m, '').replace(/:\s*(ArrayLike<number>|Uint8Array|number\[\] \| null|number)/g, '')}; return previewWhiteBalance;`
)();
const N = 4;
const W = 128;
const H = 128;
const WHITE = 10000;
const plane = (r, g, b) => {
const d = new Uint16Array(W * H * 3);
for (let i = 0; i < W * H; i++) {
d[i * 3] = r;
d[i * 3 + 1] = g;
d[i * 3 + 2] = b;
}
return d;
};
const block = (g, bx, by, r, gg, b) => {
const i = (by * N + bx) * 4;
g[i] = r;
g[i + 1] = gg;
g[i + 2] = b;
g[i + 3] = 255;
};
const map = (f) => {
const g = new Uint8Array(N * N * 4);
for (let by = 0; by < N; by++) for (let bx = 0; bx < N; bx++) f(g, bx, by);
return g;
};
const allWhite = map((g, bx, by) => block(g, bx, by, 200, 200, 200));
const mul = previewWhiteBalance(plane(3000, 2000, 2400), W, H, allWhite, N, WHITE);
assert.ok(mul, 'a frame of bright neutral blocks has to read a balance');
assert.ok(
Math.abs(mul[0] - 2 / 3) < 1e-6 && mul[1] === 1 && Math.abs(mul[2] - 5 / 6) < 1e-6,
`read ${mul} off a 1.5x red / 1.2x blue plane`
);
// A block the camera did not leave neutral is not read, however bright it is:
// it carries the scene's colour, not the frame's balance.
const tinted = previewWhiteBalance(
plane(3000, 2000, 2400),
W,
H,
map((g, bx, by) => (bx ? block(g, bx, by, 200, 200, 200) : block(g, bx, by, 200, 120, 200))),
N,
WHITE
);
assert.deepEqual(tinted, mul, 'a tinted block moved the balance');
// Too little of the frame left to read is no balance at all — the caller keeps
// the tags rather than a ratio read off a handful of pixels.
assert.equal(previewWhiteBalance(plane(3000, 2000, 2400), 30, 30, allWhite, N, WHITE), null);
}
console.log('raw-develop-check ok');
+175
View File
@@ -0,0 +1,175 @@
// Develops a real ORF through the app's own pipeline in Node — libraw-wasm on a
// worker_threads shim, CanvasKit on the raster backend, the shipped
// RAW_DEVELOP_SKSL and the shipped toneMatch — and measures the frame's
// highlights against the preview the camera wrote inside the file. A cast that
// lives only in the highlights is invisible to a source-regex check and to the
// eye of anyone looking at the moonlit part of the frame.
//
// node scripts/raw-orf-check.mjs [file.ORF]
//
// Writes the develop next to the file for eyeballing.
import assert from 'node:assert/strict';
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import ts from 'typescript';
const transpile = (path) =>
ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
// libraw-wasm drives an Emscripten worker written for the browser: it speaks
// `self.onmessage` / `self.postMessage`, where Node has parentPort. So the worker
// is booted behind a shim that renames one into the other, and the package's own
// message protocol ({ id, fn, args } / { id, out, error }) is spoken here.
// The file bytes are handed over in the transfer list exactly as the package
// does — so opening detaches them, and this check fails on any develop that
// forgets to keep the caller's array intact.
const LIBRAW_NODE_SRC = `
import { Worker } from 'node:worker_threads';
const SHIM = \`
import { parentPort, workerData } from 'node:worker_threads';
import { readFile } from 'node:fs/promises';
globalThis.self = globalThis;
globalThis.postMessage = (m, t) => parentPort.postMessage(m, t);
Object.defineProperty(globalThis, 'onmessage', {
configurable: true,
set(fn) { parentPort.on('message', (data) => fn({ data })); },
});
// Emscripten asks for the wasm by the bare name it was built with ('libraw.wasm')
// and Node's fetch has no base to resolve that against.
const base = new URL('.', workerData.url);
globalThis.fetch = async (u) =>
new Response(await readFile(new URL(u, base)), { headers: { 'content-type': 'application/wasm' } });
await import(workerData.url);
\`;
export default class LibRaw {
constructor() {
const url = new URL('worker.js', ${JSON.stringify(import.meta.resolve('libraw-wasm/dist/index.js'))}).href;
this.worker = new Worker(SHIM, { eval: true, workerData: { url } });
this.pending = new Map();
this.next = 0;
this.worker.on('message', ({ id, out, error }) => {
const p = this.pending.get(id);
if (!p) return;
this.pending.delete(id);
if (error) p.reject(new Error(error));
else p.resolve(out);
});
}
run(fn, ...args) {
return new Promise((resolve, reject) => {
const id = this.next++;
this.pending.set(id, { resolve, reject });
const transfer = args.filter((a) => ArrayBuffer.isView(a)).map((a) => a.buffer);
this.worker.postMessage({ id, fn, args }, transfer);
});
}
open(bytes, settings) { return this.run('open', bytes, settings); }
metadata(full) { return this.run('metadata', !!full); }
imageData() { return this.run('imageData'); }
thumbnailData() { return this.run('thumbnailData'); }
dispose() { return this.worker.terminate(); }
}
`;
const dir = mkdtempSync(join(tmpdir(), 'raw-orf-check-'));
const ckEntry = import.meta.resolve('canvaskit-wasm/full');
writeFileSync(
join(dir, 'skiaShim.mjs'),
transpile('../src/engine/skiaShim.ts').replace(
/^import CanvasKitInit from ['"]canvaskit-wasm\/full['"];$/m,
`import CanvasKitInit from ${JSON.stringify(ckEntry)};`,
),
);
writeFileSync(join(dir, 'sensorWhite.mjs'), transpile('../src/engine/sensorWhite.ts'));
writeFileSync(join(dir, 'halfFloat.mjs'), transpile('../src/engine/halfFloat.ts'));
writeFileSync(join(dir, 'previewMatch.mjs'), transpile('../src/engine/previewMatch.ts'));
writeFileSync(
join(dir, 'rawDevelop.mjs'),
transpile('../src/engine/rawDevelop.ts')
.replace(/^import LibRaw from ['"]libraw-wasm['"];$/m, 'import LibRaw from "./librawNode.mjs";')
.replace(/['"]\.\/sensorWhite['"]/, '"./sensorWhite.mjs"')
.replace(/['"]\.\/halfFloat['"]/, '"./halfFloat.mjs"')
.replace(/['"]\.\/previewMatch['"]/, '"./previewMatch.mjs"')
.replace(/['"]\.\/skiaShim['"]/, '"./skiaShim.mjs"'),
);
writeFileSync(join(dir, 'librawNode.mjs'), LIBRAW_NODE_SRC);
const { developRaw, extractEmbeddedJpeg } = await import(pathToFileURL(join(dir, 'rawDevelop.mjs')).href);
// `Skia` is a live binding filled in by initSkia, so read it after the call.
const Skia = await (await import(pathToFileURL(join(dir, 'skiaShim.mjs')).href)).initSkia();
// --- measure -----------------------------------------------------------------
const file = process.argv[2] ?? '/home/locpham/RecipesCam/docker/_3279791.ORF';
assert.ok(existsSync(file), `no such file: ${file}`);
const bytes = new Uint8Array(readFileSync(file));
// The preview the curve is fitted to is the one the develop itself reads —
// `extractEmbeddedJpeg`, the same call the app makes. The byte-scanning copy that
// stood here found a JPEG in an ORF and nothing in a DNG or a RAF (their previews
// live behind a container this scanner's marker walk does not follow), so the
// reference came back undecodable and the check died on those two bodies.
const jpeg = await developRaw(bytes);
const out = file.replace(/\.\w+$/, '') + '-develop-check.jpg';
writeFileSync(out, jpeg);
console.log(`develop ${jpeg.length} bytes -> ${out}`);
function readPixels(jpegBytes, w, h) {
const img = Skia.Image.MakeImageFromEncoded(jpegBytes);
assert.ok(img, 'decode failed');
const surface = Skia.Surface.Make(w, h);
const paint = Skia.Paint();
paint.setAntiAlias(false);
surface.getCanvas().drawImageRectOptions(
img, Skia.XYWHRect(0, 0, img.width(), img.height()), Skia.XYWHRect(0, 0, w, h),
Skia.FilterMode.Linear, Skia.MipmapMode.None, paint,
);
surface.flush();
const shot = surface.makeImageSnapshot();
const px = shot.readPixels(0, 0, {
width: w, height: h, colorType: Skia.ColorType.RGBA_8888,
alphaType: Skia.AlphaType.Unpremul, colorSpace: Skia.ColorSpace.SRGB,
});
img.delete();
shot.delete();
surface.dispose();
paint.delete();
return px;
}
const N = 256;
const dev = readPixels(jpeg, N, N);
const ref = readPixels(extractEmbeddedJpeg(bytes), N, N);
// Bands by how bright the *camera's own* rendering says the block is: a neutral
// render has both ratios at 1 there, a green/yellow veil pulls them apart.
const rows = [];
for (let i = 0; i < N * N; i++) {
const r = ref[i * 4], g = ref[i * 4 + 1], b = ref[i * 4 + 2];
rows.push({
l: (r + g + b) / 3,
r, g, b,
d: [dev[i * 4], dev[i * 4 + 1], dev[i * 4 + 2]],
});
}
rows.sort((a, b) => b.l - a.l);
const report = (label, sel) => {
const m = (f) => sel.reduce((s, x) => s + f(x), 0) / sel.length;
const [r, g, b] = [m((x) => x.d[0]), m((x) => x.d[1]), m((x) => x.d[2])];
const [rr, rg, rb] = [m((x) => x.r), m((x) => x.g), m((x) => x.b)];
console.log(
`${label} n=${sel.length}` +
` | develop R/G ${(r / g).toFixed(3)} B/G ${(b / g).toFixed(3)}` +
` | preview R/G ${(rr / rg).toFixed(3)} B/G ${(rb / rg).toFixed(3)}` +
` | dRGB ${(r - rr).toFixed(1)},${(g - rg).toFixed(1)},${(b - rb).toFixed(1)}`,
);
};
report('all ', rows);
report('bright top 5% ', rows.slice(0, Math.round(N * N * 0.05)));
report('bright top 20% ', rows.slice(0, Math.round(N * N * 0.2)));
report('shadows bot 50% ', rows.slice(Math.round(N * N * 0.5)));
+146
View File
@@ -0,0 +1,146 @@
// The walk that reads a picked folder (src/engine/rollWalk.ts) is the part of a
// scan that can run for minutes, and its order is what the reader sees: the
// picked folder with its own frames, then every folder one layer down with
// theirs, then the layer below that — and the folder they just clicked in front
// of everything. None of that can be watched in a browser, so it is walked here
// over a stand-in tree.
//
// node scripts/roll-walk-check.mjs
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import ts from 'typescript';
const src = readFileSync(new URL('../src/engine/rollWalk.ts', import.meta.url), 'utf8');
const js = ts.transpileModule(src, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
const { DIRS_PER_PASS, jumpQueue, walkPass } = await import(`data:text/javascript,${encodeURIComponent(js)}`);
// A directory handle is only ever read for `values()` and `getDirectoryHandle()`,
// so a stand-in tree needs nothing else: `{ name: { … } }` for folders and
// `name: null` for a file. `@eaDir` and `.thumbnails` are the folders a real roll
// drags along, and the recycle bins below are the ones an external volume puts at
// its root — each holds a frame-named file, so only the folder rule can be what
// keeps them out.
const dir = (name, children) => ({
kind: 'directory',
name,
values: async function* () {
for (const [child, kids] of Object.entries(children)) {
yield kids === null ? { kind: 'file', name: child } : dir(child, kids);
}
},
getDirectoryHandle: async (part) => {
if (!(part in children) || children[part] === null) throw new DOMException('not a folder', 'NotFoundError');
return dir(part, children[part]);
},
});
const keep = (name) => /\.(jpe?g|rw2)$/i.test(name);
// A walk is its root, its queue, and the folders it has read.
const at = (root, pending) => ({ root, pending, walked: new Set() });
const roll = dir('CheckRoll', {
'.thumbnails': { hidden: null },
'@eaDir': { junk: null },
'P1.JPG': null,
'Empty': { 'notes.txt': null },
'2026': { 'P2.JPG': null, '04': { 'P3.RW2': null, 'notes.txt': null } },
'2025': { 'P4.JPG': null },
'$RECYCLE.BIN': { 'deleted.JPG': null },
'$Recycle.Bin': { 'also-deleted.JPG': null },
'RECYCLER': { 'older.JPG': null },
'System Volume Information': { 'W.JPG': null },
'#recycle': { 'synology.JPG': null },
'lost+found': { 'linux.JPG': null },
});
// Walks to the end, one click per pass, which is what the browser does between
// passes: whatever the reader clicked last is what `jump` names. `seen` is the
// folders each pass named, which is what the column draws.
const walk = async (jumps = []) => {
const tree = at(roll, [{ dir: roll, rel: '' }]);
const out = [];
let clicked = 0;
let passes = 0;
let walked = false;
let seen = null;
while (!walked) {
const names = [];
walked = await walkPass(tree, out, names, keep, () => jumps[clicked++] ?? null);
passes++;
seen ??= names;
}
return { out, names: seen, passes };
};
// Nothing clicked: the picked folder's own frames, then a layer down, then the
// deepest — a folder is never read before the folder that holds it. Every folder
// is named in the pass that finds it, which is how a row exists before its own
// frames do.
const { out, names, passes } = await walk();
const rels = out.map((f) => f.rel);
assert.deepEqual(rels, ['P1.JPG', '2025/P4.JPG', '2026/P2.JPG', '2026/04/P3.RW2'], 'layer by layer from the parent down');
assert.ok(
rels.every((rel, i) => i === 0 || rel.split('/').length >= rels[i - 1].split('/').length),
'no deeper frame is read before a shallower one'
);
assert.ok(!rels.some((rel) => /\.thumbnails|@eaDir|notes\.txt/.test(rel)), 'hidden and unsupported names are skipped');
// The frames inside the recycle bins are named like frames, so nothing about the
// files keeps them out: the folders themselves are what the walk refuses.
const SYSTEM = /\$Recycle\.Bin|RECYCLER|System Volume Information|#recycle|lost\+found/i;
assert.ok(!rels.some((rel) => SYSTEM.test(rel)), 'a recycle bin or system folder yields no frame');
assert.ok(!names.some((rel) => SYSTEM.test(rel)), 'and never becomes a row either');
assert.deepEqual(
names,
['2025/', '2026/', 'Empty/', '2026/04/'],
'the first pass names every folder it walked into, frames or no frames'
);
assert.ok(names.includes('Empty/'), 'a folder holding no frame at all is still a row');
assert.equal(passes, 1, 'a roll this size is one pass, and the names are in it');
// A click on a folder one layer down, while that layer is still to be read, puts
// it first: the frames the reader is looking at are read before the branch beside
// them.
const clicked = await walk([null, '2026/']);
assert.deepEqual(
clicked.out.map((f) => f.rel),
['P1.JPG', '2026/P2.JPG', '2025/P4.JPG', '2026/04/P3.RW2'],
'the clicked folder jumps the queue'
);
// A folder the walk has not reached yet — a row on screen from the parent pass —
// is opened from the picked folder and read next: `getDirectoryHandle` walks the
// path, and a path that will not open is left where it was.
const deep = at(roll, [{ dir: dir('Other', { 'X.JPG': null }), rel: 'other/' }]);
const deepOut = [];
await walkPass(deep, deepOut, [], keep, () => '2026/04/');
assert.deepEqual(
deepOut.map((f) => f.rel),
['2026/04/P3.RW2', 'other/X.JPG'],
'a folder the queue never held is opened and read first'
);
const missing = at(roll, [{ dir: roll, rel: '' }]);
await jumpQueue(missing, '2026/nope/');
assert.equal(missing.pending.length, 1, 'a path that does not open leaves the queue alone');
// And the rule without a browser around it: a pass reads the queue in its own
// order unless the click says otherwise.
const wide = dir('Wide', { a: { 'A.JPG': null }, b: { 'B.JPG': null } });
const before = [];
await walkPass(at(wide, [{ dir: wide, rel: '' }]), before, [], keep, () => null);
assert.deepEqual(before.map((f) => f.rel), ['a/A.JPG', 'b/B.JPG'], 'untouched, the folders are read in the order they were found');
const after = [];
await walkPass(at(wide, [{ dir: wide, rel: '' }]), after, [], keep, () => 'b/');
assert.deepEqual(after.map((f) => f.rel), ['b/B.JPG', 'a/A.JPG'], 'the folder the reader clicked is read first');
// A pass is bounded: a library wider than the budget is read over several passes,
// and the rest waits in the queue rather than in a loop that never yields.
const children = {};
for (let i = 0; i < DIRS_PER_PASS + 5; i++) children[`d${String(i).padStart(3, '0')}`] = { 'x.jpg': null };
const many = at(dir('Many', children), [{ dir: dir('Many', children), rel: '' }]);
const found = [];
assert.equal(await walkPass(many, found, [], keep, () => null), false, 'the queue is not empty after one pass');
assert.equal(found.length, DIRS_PER_PASS - 1, `one pass read the picked folder and ${DIRS_PER_PASS - 1} below it`);
assert.equal(many.pending.length, 6, 'the rest is left in the queue, for the next pass');
console.log(`ok roll walk — ${rels.length} frames layer by layer, recycle bins and system folders skipped, names a pass ahead, click jumps the queue, ${DIRS_PER_PASS} folders a pass`);
+302
View File
@@ -0,0 +1,302 @@
// TEMPORARY — the bug this proves: reading a roll used to die the moment the
// catalogue handed the visitor to the studio, because every internal link was a
// full page load and the scan lived in that page's memory. Run it, then delete
// it.
//
// node scripts/scan-nav-check.mjs
import { spawn } from 'node:child_process';
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
const SAMPLES = process.env.SAMPLE_DIR ?? path.resolve(FRONTEND, '..');
const APP_PORT = Number(process.env.APP_PORT ?? 4186);
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4320);
const DELAY = Number(process.env.SCAN_DELAY ?? 900);
const JPG = 'P1010256.JPG';
const RAW = 'P1010256.RW2';
// Twelve frames: four at the top, four a folder down, four two folders down.
const names = (prefix) => Array.from({ length: 4 }, (_, i) => `${prefix}${i + 1}.JPG`);
const ROLL = {
files: names('a'),
dirs: { '2026': { files: names('b'), dirs: { '04': { files: names('c'), dirs: {} } } } },
};
const TOTAL = 12;
let failures = 0;
function step(name, ok, detail = '') {
if (!ok) failures++;
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
return ok;
}
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
if (!playwright) {
console.log('SKIP playwright-core is not installed');
process.exit(0);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// One frame at a time, however many the page asks for at once: a roll that reads
// itself in parallel is over before a handover can be timed against it.
let turn = Promise.resolve();
const waitTurn = () => {
const mine = turn.then(() => sleep(DELAY));
turn = mine;
return mine;
};
const sampleServer = createServer(async (req, res) => {
const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0]));
if (!/\.(jpg|rw2)$/i.test(name)) {
res.writeHead(404).end();
return;
}
const bytes = await readFile(path.join(SAMPLES, /\.rw2$/i.test(name) ? RAW : JPG));
await waitTurn();
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': bytes.length,
'Access-Control-Allow-Origin': '*',
});
res.end(bytes);
});
await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve));
const app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
cwd: FRONTEND,
stdio: 'ignore',
});
const base = `http://127.0.0.1:${APP_PORT}`;
let up = false;
for (let i = 0; i < 60 && !up; i++) {
up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false);
if (!up) await sleep(500);
}
if (!up) {
app.kill('SIGTERM');
sampleServer.close();
console.log(`FAIL no preview on ${base}`);
process.exit(1);
}
const browser = await playwright.chromium.launch({
executablePath: process.env.CHROME || undefined,
args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
});
const context = await browser.newContext();
// Documents this tab has loaded, across navigations: the count only moves when a
// click threw the page away and started a new one.
await context.addInitScript(() => {
const n = Number(sessionStorage.getItem('docLoads') ?? '0') + 1;
sessionStorage.setItem('docLoads', String(n));
});
await context.addInitScript(
({ origin, roll, folder }) => {
const bytes = new Map();
const load = async (name) => {
if (!bytes.has(name)) bytes.set(name, fetch(`${origin}/${name}`).then((r) => r.arrayBuffer()));
return bytes.get(name);
};
// A second scan of the same roll reads the same names again; dropping what the
// stand-in already holds makes it go back to the server for each of them, so
// a re-reading roll has the same minutes in it a first one does.
window.__forget = () => bytes.clear();
const fileHandle = (name) => {
const handle = { kind: 'file', name, __fake: true };
Object.defineProperty(handle, 'getFile', {
value: async () => new File([await load(name)], name, { type: 'image/jpeg' }),
});
return handle;
};
const dirHandle = (label, node) => {
// `__fake` on a folder too: the catalogue reads its folders back out of the
// store after every remount, and a handle that does not say what it is comes
// back as a plain object with no `queryPermission` — a folder it cannot ask
// about is a folder it offers to reconnect.
const handle = { kind: 'directory', name: label, __fake: true };
Object.defineProperties(handle, {
values: {
value: () =>
[
...(node.files ?? []).map(fileHandle),
...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)),
][Symbol.iterator](),
},
queryPermission: { value: async () => 'granted' },
requestPermission: { value: async () => 'granted' },
});
return handle;
};
const root = () => dirHandle(folder, roll);
window.showDirectoryPicker = async () => root();
const revive = (row) => {
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name);
}
return row;
};
for (const method of ['get', 'getAll']) {
const original = IDBObjectStore.prototype[method];
IDBObjectStore.prototype[method] = function (...args) {
const request = original.apply(this, args);
const descriptor = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result');
Object.defineProperty(request, 'result', {
configurable: true,
get() {
const value = descriptor.get.call(this);
return Array.isArray(value) ? value.map(revive) : revive(value);
},
});
return request;
};
}
},
{ origin: `http://127.0.0.1:${SAMPLE_PORT}`, roll: ROLL, folder: 'SlowRoll' }
);
const page = await context.newPage();
const indexed = () =>
page.evaluate(async () => {
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open('recipescam-library');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return new Promise((resolve, reject) => {
const req = db.transaction('photos').objectStore('photos').count();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
});
await page.goto(`${base}/library`);
await page.click('[data-key="lib-add"]');
await page.waitForSelector('[data-key="lib-stop"]', { timeout: 15_000 });
step('the scan is running', true);
// The rows only reach IndexedDB in one batch at the end of the scan, so the
// toolbar's own line is what says how far the reading is.
const progressText = (timeout) =>
page
.waitForFunction(() => document.querySelector('[data-key="lib-progress"]')?.textContent?.trim() || null, null, {
timeout,
polling: 100,
})
.then((h) => h.jsonValue())
.catch(() => null);
const docs = () => page.evaluate(() => Number(sessionStorage.getItem('docLoads') ?? '0'));
const tiles = () => page.$$eval('[data-key^="lib-thumb-"]', (els) => els.length);
// The count of documents standing when the page was last settled: every step that
// follows a link or a frame asserts against this, because a link followed with no
// scan in flight is *meant* to load the page again.
let seen = await docs();
const noReload = async () => (await docs()) === seen;
// Hand over to the studio while the roll is still being read, which is the whole
// point: part of it in, the rest still to come.
const midway = await progressText(15_000);
step('the roll is half read when the studio is opened', !!midway, String(midway));
await page.click('[data-key="lib-studio"]');
await page.waitForSelector('[data-key="nav-library"]', { timeout: 15_000 });
step('the handover stays in the page, it does not reload it', await noReload(), `${await docs()} document load(s)`);
step('and it lands in the studio', new URL(page.url()).pathname === '/app', page.url());
// The reading belongs to the tab, so the header of the screen that did not start
// it says it is still going, beside the way back into the catalogue.
const badge = await page
.waitForSelector('[data-key="studio-scan-count"]', { timeout: 15_000 })
.then((el) => el.textContent())
.catch(() => null);
step('the studio header shows the reading the tab is doing', /^\d+\/\d+$/.test(badge ?? ''), String(badge));
// Back to the catalogue mid-scan: the screen that started the scan is gone and
// back, and the roll is still being read — the reading belongs to the tab.
await page.click('[data-key="nav-library"]');
await page.waitForSelector('[data-key="lib-frame"]', { timeout: 15_000 });
step('coming back stays in the page too', await noReload(), `${await docs()} document load(s)`);
const progress = await progressText(15_000);
step('the catalogue comes back showing the scan it missed', !!progress, String(progress));
await page.waitForSelector('[data-key^="lib-node-"]', { timeout: 15_000 });
const tree = await page.$$eval('[data-key^="lib-node-"]', (els) => els.map((e) => e.dataset.key));
step('the tree is there, from the scan that outlived its screen', tree.includes('lib-node-SlowRoll'), JSON.stringify(tree));
// Now leave it alone in the studio and let it finish: nothing up there reads the
// roll, so every frame that lands from here on landed for a screen that is gone.
await page.click('[data-key="lib-studio"]');
await page.waitForSelector('[data-key="nav-library"]', { timeout: 15_000 });
let indexed_to = 0;
for (let i = 0; i < 300 && indexed_to < TOTAL; i++) {
indexed_to = await indexed().catch(() => indexed_to);
if (indexed_to < TOTAL) await sleep(200);
}
step('the whole roll is read with the studio up', indexed_to === TOTAL, `${indexed_to}/${TOTAL} indexed`);
step('and the studio is still the page it was', await noReload(), `${await docs()} document load(s)`);
await page.click('[data-key="nav-library"]');
await page.waitForFunction(
(n) => document.querySelectorAll('[data-key^="lib-thumb-"]').length === n,
TOTAL,
{ timeout: 60_000, polling: 200 }
).catch(() => {});
step('every frame ends up as a thumbnail', (await tiles()) === TOTAL, `${await tiles()} tiles`);
// The tree opens folded, so the folder two down is a row only once the rows above
// it are opened: the walk goes down it and comes back, which leaves the screen on
// the roll the steps below read it as.
await page.click('[data-key="lib-node-SlowRoll"]');
await page.waitForSelector('[data-key="lib-node-SlowRoll/2026"]', { timeout: 15_000 });
await page.click('[data-key="lib-node-SlowRoll/2026"]');
await page.waitForSelector('[data-key="lib-node-SlowRoll/2026/04"]', { timeout: 15_000 });
const grown = await page.$$eval('[data-key^="lib-node-"]', (els) => els.map((e) => e.dataset.key));
step('the tree ends up holding the deepest folder of the roll', grown.includes('lib-node-SlowRoll/2026/04'), JSON.stringify(grown));
await page.click('[data-key="lib-node-SlowRoll/2026"]');
await page.click('[data-key="lib-node-SlowRoll"]');
// Nothing was scanning at that last click, so that one did load the page again —
// which is the whole point of the guard. Everything below counts from here.
seen = await docs();
// Now the reader's own way into the studio, and the noisiest one for this page: a
// frame opened from a roll that is being read again. The rows are drawn from the
// catalogue it already has, so this is the one route out of here that the browser
// could take with no link anywhere in sight.
const rows = () => page.$eval('[data-key="lib-node-SlowRoll"] .adm-album-count', (el) => Number(el.textContent));
await page.evaluate(() => window.__forget());
await page.click('[data-key="lib-node-SlowRoll"]', { button: 'right' });
await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 });
const menu = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key));
const rescan = menu.find((k) => /^lib-(rescan|reconnect)-/.test(k));
step('the roll can be read again from its own menu', !!rescan, JSON.stringify(menu));
await page.click(`[data-key="${rescan}"]`);
await page.waitForSelector('[data-key="lib-stop"]', { timeout: 15_000 });
await sleep(DELAY);
const early = await rows();
await sleep(DELAY * 2);
const later = await rows();
step('the row counts the reading as it comes, not the catalogue standing still', later !== early, `${early} → ${later} of ${TOTAL}`);
step('the strip is still the catalogue the roll left behind', (await tiles()) === TOTAL, `${await tiles()} tiles`);
await page.click('[data-key="lib-open"]');
await page.waitForSelector('[data-key="nav-library"]', { timeout: 30_000 });
step('opening a frame stays in the page, it does not reload it', await noReload(), `${await docs()} document load(s)`);
// The studio takes the frame out of the address as soon as it has it, so the
// route is what is left to say the handover arrived.
step('and it lands in the studio', new URL(page.url()).pathname === '/app', page.url());
await page.click('[data-key="nav-library"]');
await page.waitForSelector('[data-key="lib-frame"]', { timeout: 15_000 });
step('and the scan it interrupted is still the scan in hand', await noReload(), `${await docs()} document load(s)`);
const steady = await progressText(15_000);
step('the catalogue is still reading the roll it was reading', !!steady, String(steady));
await browser.close();
app.kill('SIGTERM');
sampleServer.close();
console.log(failures ? `\n${failures} step(s) failed` : '\nall steps passed');
process.exit(failures ? 1 : 0);
+139
View File
@@ -0,0 +1,139 @@
// SHARPENING is an unsharp mask MASKED BY AN EDGE DETECTOR and CORED —
// thay_doi_thong_so_giong_lightroom.md §4.2, "Chỉ có viền mắt, lông mi, sợi vải,
// kẽ lá nhận Mask = 1; bầu trời phẳng và làn da mịn màng nhận Mask = 0".
//
// The knob used to be the same 3x3 kernel with Mask = 1 everywhere and no
// coring, so a flat field of grain took the same gain an eyelash did and a
// rising SHARPENING raised the frame's noise with it. This check pins the three
// things the doc asks for and that the old kernel could not do:
//
// 1. a flat field comes back untouched (no gain on noise),
// 2. a soft step below the edge threshold comes back untouched (Mask = 0),
// 3. a hard step gains contrast on both sides of it (Mask = 1).
//
// node scripts/sharpen-check.mjs
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import ts from 'typescript';
import CanvasKitInit from 'canvaskit-wasm/bin/full/canvaskit.js';
const transpile = (path) =>
ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
const dir = mkdtempSync(join(tmpdir(), 'sharpen-check-'));
writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts'));
writeFileSync(
join(dir, 'toneShader.mjs'),
transpile('../shared/utils/toneShader.ts').replace(
/^import .*from ['"]\.\/colorUtils['"];$/m,
'import { HSL_BANDS, hslBandGaps, isMonochromeBase } from "./colorUtils.mjs";',
),
);
const { SHARPEN_SKSL, SHARPEN_CORE, SHARPEN_MASK_LO, SHARPEN_MASK_HI } = await import(
pathToFileURL(join(dir, 'toneShader.mjs')).href
);
const engine = readFileSync(new URL('../src/engine/exportEngine.ts', import.meta.url), 'utf8');
assert.match(engine, /Skia\.RuntimeEffect\.Make\(SHARPEN_SKSL\)/, 'SHARPEN_SKSL is not compiled any more');
assert.doesNotMatch(engine, /CLARITY_SKSL/, 'the unmasked kernel is still wired in somewhere');
assert.ok(
SHARPEN_MASK_LO > 0 && SHARPEN_MASK_HI > SHARPEN_MASK_LO,
`the doc's smoothstep(T, T + 0.1, G) is not a ramp (${SHARPEN_MASK_LO}..${SHARPEN_MASK_HI})`,
);
assert.ok(SHARPEN_CORE > 0, 'Detail coring is off, so a speckle is amplified again');
const CanvasKit = await CanvasKitInit({
locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
});
const W = 16;
const H = 16;
const AMOUNT = 0.8; // the engine's own gain at SHARPENING 100: (100 / 10) * 0.8
const near = (a, b, why) => assert.ok(Math.abs(a - b) <= 2 / 255, `${why} (${a} vs ${b})`);
// One 8-bit RGBA image from a per-pixel grey level.
function imaged(grey) {
const px = new Uint8Array(W * H * 4);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const v = Math.round(Math.max(0, Math.min(1, grey(x, y))) * 255);
const i = (y * W + x) * 4;
px[i] = px[i + 1] = px[i + 2] = v;
px[i + 3] = 255;
}
}
return CanvasKit.MakeImage(
{
width: W, height: H,
colorType: CanvasKit.ColorType.RGBA_8888,
alphaType: CanvasKit.AlphaType.Unpremul,
colorSpace: CanvasKit.ColorSpace.SRGB,
},
px,
W * 4,
);
}
function render(grey) {
const effect = CanvasKit.RuntimeEffect.Make(SHARPEN_SKSL);
assert.ok(effect, 'SHARPEN_SKSL does not compile');
const src = imaged(grey);
const child = src.makeShaderOptions(
CanvasKit.TileMode.Clamp, CanvasKit.TileMode.Clamp, CanvasKit.FilterMode.Linear, CanvasKit.MipmapMode.None,
);
const shader = effect.makeShaderWithChildren([AMOUNT, 1, 1], [child]); // px = 1,1: 1:1 export canvas
assert.ok(shader, 'the pass did not take the frame as its child');
const out = CanvasKit.MakeSurface(W, H);
const paint = new CanvasKit.Paint();
paint.setShader(shader);
out.getCanvas().drawPaint(paint);
const bytes = out.makeImageSnapshot().readPixels(0, 0, {
width: W, height: H, colorType: CanvasKit.ColorType.RGBA_8888,
alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB,
});
const at = (x, y) => bytes[(y * W + x) * 4] / 255;
const values = [];
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) values.push(at(x, y));
const mean = values.reduce((a, b) => a + b, 0) / values.length;
const std = Math.sqrt(values.reduce((a, b) => a + (b - mean) ** 2, 0) / values.length);
return { at, mean, std };
}
// 1. A flat field: the Sobel magnitude is 0 and the high-pass is 0, so the whole
// Mask is 0 and the frame is handed back exactly. The old kernel gained here.
const flat = render(() => 0.5);
near(flat.mean, 0.5, 'a flat field moved — Mask = 0 is not being honoured');
assert.ok(flat.std <= 2 / 255, `a flat field gained texture (std ${flat.std})`);
// 2. The same picture with grain on it: the noise is under the edge threshold, so
// SHARPENING must not raise the frame's noise — the doc's §1 "sạn hạt cát".
let seed = 12345;
const rnd = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff - 0.5);
const grain = new Float64Array(W * H);
for (let i = 0; i < grain.length; i++) grain[i] = 0.5 + rnd() * 0.04;
const grainy = render((x, y) => grain[y * W + x]);
const noiseIn = Math.sqrt(grain.reduce((a, b) => a + (b - 0.5) ** 2, 0) / grain.length);
assert.ok(
grainy.std <= noiseIn + 2 / 255,
`grain was amplified by SHARPENING (std ${grainy.std} for a noise of ${noiseIn})`,
);
// 3. A soft step, well under the edge threshold: Mask = 0, so it is not touched.
const soft = render((x) => (x < W / 2 ? 0.5 : 0.52));
near(soft.at(6, 8), 0.5, 'a sub-threshold step was sharpened on its dark side');
near(soft.at(9, 8), 0.52, 'a sub-threshold step was sharpened on its light side');
// 4. A hard step: Sobel saturates the mask, the high-pass is coring-open, and
// both sides move apart. A pass that changed nothing would fail here.
const hard = render((x) => (x < W / 2 ? 0.25 : 0.75));
assert.ok(hard.at(7, 8) < 0.25 - 2 / 255, `the dark side of an edge did not move (${hard.at(7, 8)})`);
assert.ok(hard.at(8, 8) > 0.75 + 2 / 255, `the light side of an edge did not move (${hard.at(8, 8)})`);
// ...and only the two columns beside the edge: the flat halves keep Mask = 0.
near(hard.at(1, 8), 0.25, 'a flat half of the frame was sharpened');
near(hard.at(14, 8), 0.75, 'a flat half of the frame was sharpened');
console.log('sharpen ok: edges move, flat fields and grain do not');
@@ -0,0 +1,173 @@
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import ts from 'typescript';
const transpile = (path) =>
ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
const LIBRAW_NODE_SRC = `
import { Worker } from 'node:worker_threads';
const SHIM = \`
import { parentPort, workerData } from 'node:worker_threads';
import { readFile } from 'node:fs/promises';
globalThis.self = globalThis;
globalThis.postMessage = (m, t) => parentPort.postMessage(m, t);
Object.defineProperty(globalThis, 'onmessage', {
configurable: true,
set(fn) { parentPort.on('message', (data) => fn({ data })); },
});
const base = new URL('.', workerData.url);
globalThis.fetch = async (u) =>
new Response(await readFile(new URL(u, base)), { headers: { 'content-type': 'application/wasm' } });
await import(workerData.url);
\`;
export default class LibRaw {
constructor() {
const url = new URL('worker.js', ${JSON.stringify(import.meta.resolve('libraw-wasm/dist/index.js'))}).href;
this.worker = new Worker(SHIM, { eval: true, workerData: { url } });
this.pending = new Map();
this.next = 0;
this.worker.on('message', ({ id, out, error }) => {
const p = this.pending.get(id);
if (!p) return;
this.pending.delete(id);
if (error) p.reject(new Error(error));
else p.resolve(out);
});
}
run(fn, ...args) {
return new Promise((resolve, reject) => {
const id = this.next++;
this.pending.set(id, { resolve, reject });
const transfer = args.filter((a) => ArrayBuffer.isView(a)).map((a) => a.buffer);
this.worker.postMessage({ id, fn, args }, transfer);
});
}
open(bytes, settings) {
const copy = Uint8Array.from(bytes);
return this.run('open', copy, settings);
}
metadata(full) { return this.run('metadata', !!full); }
imageData() { return this.run('imageData'); }
thumbnailData() { return this.run('thumbnailData'); }
dispose() { return this.worker.terminate(); }
}
`;
const dir = join(tmpdir(), 'test-all-raws-acc');
import { mkdtempSync } from 'node:fs';
const testDir = mkdtempSync(dir);
const ckEntry = import.meta.resolve('canvaskit-wasm/full');
writeFileSync(
join(testDir, 'skiaShim.mjs'),
transpile('../src/engine/skiaShim.ts').replace(
/^import CanvasKitInit from ['"]canvaskit-wasm\/full['"];$/m,
`import CanvasKitInit from ${JSON.stringify(ckEntry)};`,
),
);
writeFileSync(join(testDir, 'sensorWhite.mjs'), transpile('../src/engine/sensorWhite.ts'));
writeFileSync(join(testDir, 'halfFloat.mjs'), transpile('../src/engine/halfFloat.ts'));
writeFileSync(join(testDir, 'previewMatch.mjs'), transpile('../src/engine/previewMatch.ts'));
writeFileSync(
join(testDir, 'tiffDecode.mjs'),
transpile('../src/engine/tiffDecode.ts').replace(/['"]\.\/skiaShim['"]/, '"./skiaShim.mjs"')
);
// Transpile rawDevelop.ts with fix
let rawDevTs = readFileSync(new URL('../src/engine/rawDevelop.ts', import.meta.url), 'utf8');
// Fix WB multiplier order in rawDevelop.ts code before transpiling
rawDevTs = rawDevTs.replace(
` if (cd?.cam_mul && Array.isArray(cd.cam_mul) && cd.cam_mul.some((v: number) => v > 0 && v !== 1)) {
return cd.cam_mul;
}
// 2. Try pre-calculated WB multipliers from LibRaw (pre_mul)
if (cd?.pre_mul && Array.isArray(cd.pre_mul) && cd.pre_mul.some((v: number) => v > 0 && v !== 1)) {
return cd.pre_mul;
}`,
` if (cd?.pre_mul && Array.isArray(cd.pre_mul) && cd.pre_mul.some((v: number) => v > 0 && v !== 1)) {
return cd.pre_mul;
}
// 2. Try camera WB multipliers from LibRaw (cam_mul)
if (cd?.cam_mul && Array.isArray(cd.cam_mul) && cd.cam_mul.some((v: number) => v > 0 && v !== 1)) {
return cd.cam_mul;
}`
);
const rawDevelopCode = ts.transpileModule(rawDevTs, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText
.replace(/^import LibRaw from ['"]libraw-wasm['"];$/m, 'import LibRaw from "./librawNode.mjs";')
.replace(/['"]\.\/sensorWhite['"]/, '"./sensorWhite.mjs"')
.replace(/['"]\.\/halfFloat['"]/, '"./halfFloat.mjs"')
.replace(/['"]\.\/previewMatch['"]/, '"./previewMatch.mjs"')
.replace(/['"]\.\/skiaShim['"]/, '"./skiaShim.mjs"')
.replace(/['"]\.\/tiffDecode['"]/, '"./tiffDecode.mjs"');
writeFileSync(join(testDir, 'rawDevelop.mjs'), rawDevelopCode);
writeFileSync(join(testDir, 'librawNode.mjs'), LIBRAW_NODE_SRC);
const { developRaw, extractEmbeddedJpeg } = await import(pathToFileURL(join(testDir, 'rawDevelop.mjs')).href);
const Skia = await (await import(pathToFileURL(join(testDir, 'skiaShim.mjs')).href)).initSkia();
const getAvgRgb = (img) => {
const w = 64, h = 64;
const surface = Skia.Surface.MakeOffscreen(w, h);
const canvas = surface.getCanvas();
canvas.drawImageRectCubic(img, Skia.XYWHRect(0, 0, img.width(), img.height()), Skia.XYWHRect(0, 0, w, h), 1/3, 1/3);
surface.flush();
const px = canvas.readPixels(0, 0, { width: w, height: h, colorType: Skia.ColorType.RGBA_8888, alphaType: Skia.AlphaType.Unpremul, colorSpace: Skia.ColorSpace.SRGB });
surface.dispose();
let r = 0, g = 0, b = 0;
for (let i = 0; i < px.length; i += 4) {
r += px[i]; g += px[i+1]; b += px[i+2];
}
const count = px.length / 4;
return [ (r/count).toFixed(2), (g/count).toFixed(2), (b/count).toFixed(2) ];
};
const sampleDir = '../sample';
const files = readdirSync(sampleDir).filter(f => /\.(raf|nef|dng|rw2|orf|arw|cr2|cr3)$/i.test(f));
for (const f of files) {
const filePath = join(sampleDir, f);
const bytes = new Uint8Array(readFileSync(filePath));
console.log(`\n=== Testing ${f} ===`);
try {
const devJpeg = await developRaw(bytes, f);
const embJpeg = extractEmbeddedJpeg(bytes);
const devImg = Skia.Image.MakeImageFromEncoded(devJpeg);
const embImg = embJpeg ? Skia.Image.MakeImageFromEncoded(embJpeg) : null;
const devRgb = getAvgRgb(devImg);
console.log(`Dev dim: ${devImg.width()}x${devImg.height()} RGB: [ ${devRgb.join(', ')} ]`);
if (embImg) {
const embRgb = getAvgRgb(embImg);
console.log(`Emb dim: ${embImg.width()}x${embImg.height()} RGB: [ ${embRgb.join(', ')} ]`);
const rDiff = Math.abs(parseFloat(devRgb[0]) - parseFloat(embRgb[0]));
const gDiff = Math.abs(parseFloat(devRgb[1]) - parseFloat(embRgb[1]));
const bDiff = Math.abs(parseFloat(devRgb[2]) - parseFloat(embRgb[2]));
console.log(`RGB Abs Diffs: R=${rDiff.toFixed(2)}, G=${gDiff.toFixed(2)}, B=${bDiff.toFixed(2)}`);
const pass = rDiff < 15 && gDiff < 15 && bDiff < 15;
console.log(`COLOR ACCURACY PASS: ${pass ? 'YES ✅' : 'NO ❌'}`);
}
devImg.delete();
if (embImg) embImg.delete();
} catch (err) {
console.error(`FAILED ${f}:`, err.message);
}
}
+28
View File
@@ -0,0 +1,28 @@
import { readFileSync } from 'node:fs';
import LibRaw from 'libraw-wasm';
const files = [
'D0004128.DNG',
'DSCF1701.RAF',
'P1010256.RW2',
'_3279791.ORF',
'APACHAI-20160215-357.NEF'
];
for (const file of files) {
try {
const bytes = readFileSync('../' + file);
const raw = new LibRaw();
await raw.open(bytes, { halfSize: true, noAutoScale: true });
const meta = await raw.metadata(true);
const cd = meta?.color_data;
console.log(`=== ${file} ===`);
console.log('cam_mul:', cd?.cam_mul);
console.log('pre_mul:', cd?.pre_mul);
console.log('rgb_cam:', cd?.rgb_cam);
console.log('cam_xyz:', cd?.cam_xyz);
raw.dispose();
} catch (err) {
console.log(`Error reading ${file}:`, err.message);
}
}
@@ -0,0 +1,168 @@
// The tile for a `.tif` is read out of the file by `src/engine/tiffDecode.ts`,
// because nothing else in the app draws a TIFF at all: `createImageBitmap`
// answers InvalidStateError to a baseline 5472x3648 scan, CanvasKit's
// `MakeImageFromEncoded` answers null, and LibRaw opens the file only to report a
// 0x0 frame. So the reader is the only thing standing between a scan and a blank
// cell in the wall, and it is checked here against a reference it had no hand in:
// one 8x8 crop of the sample frame, written by libvips to a PPM and to four
// TIFFs that differ only in how the strip is compressed — none, PackBits, LZW
// with horizontal differencing, and JPEG — the first three of which therefore
// all have to come back as the PPM's own pixels, exactly.
//
// cd docker/frontend && node scripts/tiff-decode-check.mjs
//
// The same crop through the real path (rawThumbnail -> a JPEG tile) is the
// browser's job: `scripts/ram-check.mjs` and the wall itself, where the JPEG
// decode needs CanvasKit.
import { build } from 'esbuild';
import { mkdir, rm } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
// `vips crop ../sample/P1010256.JPG out.v 1200 1600 8 8`, then
// `vips copy out.v out.{ppm,tif}`, `[compression=none|packbits|lzw]` — and once
// more with `[compression=jpeg,Q=100]`, which is the other TIFF a scan comes as
// and the only one whose strip is a JPEG: that one is lossy, so its pixels are
// near the PPM's rather than equal to them.
const FIXTURES = {
'none.tif':
'SUkqAMgAAABTR0lRQ0JuYViajoKsoJKvo5esoJShlIxMQEJURkZzZl6Ie3KWinyckIKZjYGShXxHPT5KPj5qXFl2aWGHenGUiHyUiHyOgXhHPEBQREZjV1dlWlhzaGKQhX+YjoWRh35IPUFRRkpbUVJeVFV0amiSiIaUioiMgoBJQEFNREVUS0xdVFV9c3KUiomPhoeKgYJPRkdQR0hTSktaUVJ9dXOUi4ySiYqMg4ZTTU9US05VTE9jWl2Hf32Ui4yIgoKFf4EPAAABAwABAAAACAAAAAEBAwABAAAACAAAAAIBAwADAAAAkgEAAAMBAwABAAAAAQAAAAYBAwABAAAAAgAAABEBBAABAAAACAAAABIBAwABAAAACAAAABUBAwABAAAAAwAAABYBAwABAAAAgAAAABcBBAABAAAAwAAAABoBBQABAAAAggEAABsBBQABAAAAigEAABwBAwABAAAAAQAAACgBAwABAAAAAgAAAFMBAwADAAAAmAEAAAAAAAC0AAAAAQAAALQAAAABAAAACAAIAAgAAQABAAEA',
'pb.tif':
'SUkqANAAAAAXU0dJUUNCbmFYmo6CrKCSr6OXrKCUoZSMF0xAQlRGRnNmXoh7cpaKfJyQgpmNgZKFfBdHPT5KPj5qXFl2aWGHenGUiHyUiHyOgXgXRzxAUERGY1dXZVpYc2hikIV/mI6FkYd+F0g9QVFGSltRUl5UVXRqaJKIhpSKiIyCgBdJQEFNREVUS0xdVFV9c3KUiomPhoeKgYIXT0ZHUEdIU0pLWlFSfXVzlIuMkomKjIOGF1NNT1RLTlVMT2NaXYd/fZSLjIiCgoV/gQ8AAAEDAAEAAAAIAAAAAQEDAAEAAAAIAAAAAgEDAAMAAACaAQAAAwEDAAEAAAAFgAAABgEDAAEAAAACAAAAEQEEAAEAAAAIAAAAEgEDAAEAAAAIAAAAFQEDAAEAAAADAAAAFgEDAAEAAACAAAAAFwEEAAEAAADIAAAAGgEFAAEAAACKAQAAGwEFAAEAAACSAQAAHAEDAAEAAAABAAAAKAEDAAEAAAACAAAAUwEDAAMAAACgAQAAAAAAALQAAAABAAAAtAAAAAEAAAAIAAgACAABAAEAAQA=',
'lzw.tif':
'SUkqAMQAAACAFMjkl/Px8h0PBYWC0VBIJBABgMCv2KPV6PgmEAhAgDAQPiAMBUKhQHA8FAaURR/vl8Psjj0fAMAgAQB4NgwGggIhEIA0HAsAUF9Pl+EceEAExwJhMIgKZA4HAoO1MEAkDPmsEgekEE10FAsEREBhYLBMPWcBWl8WskkAggS4Ae5V0EiAPh0L3l9vx/Pu/E8jEcA4OxXIDiMSCELhYMv7HPp9Pwpk0ngF/P/BgGoA4SCUQA0GA96Pd7SqAhAAAAEDAAEAAAAIAAAAAQEDAAEAAAAIAAAAAgEDAAMAAACaAQAAAwEDAAEAAAAFAAAABgEDAAEAAAACAAAAEQEEAAEAAAAIAAAAEgEDAAEAAAAIAAAAFQEDAAEAAAADAAAAFgEDAAEAAACAAAAAFwEEAAEAAAC8AAAAGgEFAAEAAACKAQAAGwEFAAEAAACSAQAAHAEDAAEAAAABAAAAKAEDAAEAAAACAAAAPQEDAAEAAAACAAAAUwEDAAMAAACgAQAAAAAAALQAAAABAAAAtAAAAAEAAAAIAAgACAABAAEAAQA=',
'crop.ppm':
'UDYKI3ZpcHMycHBtIC0gMjAyNi0xMC0wOVQxNTowMjozNi41MDg0MDIrMDcKOCA4CjI1NQpTR0lRQ0JuYViajoKsoJKvo5esoJShlIxMQEJURkZzZl6Ie3KWinyckIKZjYGShXxHPT5KPj5qXFl2aWGHenGUiHyUiHyOgXhHPEBQREZjV1dlWlhzaGKQhX+YjoWRh35IPUFRRkpbUVJeVFV0amiSiIaUioiMgoBJQEFNREVUS0xdVFV9c3KUiomPhoeKgYJPRkdQR0hTSktaUVJ9dXOUi4ySiYqMg4ZTTU9US05VTE9jWl2Hf32Ui4yIgoKFf4E=',
'jpeg.tif':
'SUkqALwAAAD/2P/AABEIAAgACANSEQBHEQBCEQD/2gAMA1IARwBCAAA/APLf2HvHvimy8KeI/DQvbqy0bS7E3d4Ec2r3GqajdSBWaZCtu37v5G8zdM6IgySeflj9h3xf4qs/CviXwyL67s9G0yye7vUic2rzapqN1LtJmULC37vKN5geZ0RBz3+Qf2MdR8SnQfEWgxajf6bpmn2b3l4FY2rXGqX90wUvKmyB/wB3iNvM3zOqRkYJYn//2QAQAAABAwABAAAACAAAAAEBAwABAAAACAAAAAIBAwADAAAAkgEAAAMBAwABAAAABwAAAAYBAwABAAAAAgAAABEBBAABAAAACAAAABIBAwABAAAACAAAABUBAwABAAAAAwAAABYBAwABAAAAgAAAABcBBAABAAAAswAAABoBBQABAAAAggEAABsBBQABAAAAigEAABwBAwABAAAAAQAAACgBAwABAAAAAgAAAFMBAwADAAAAmAEAAFsBBwAhAQAAngEAAAAAAAC0AAAAAQAAALQAAAABAAAACAAIAAgAAQABAAEA/9j/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHBFVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4eLj5OXm5+jp6vHy8/T19vf4+fr/2Q==',
};
function fixture(name) {
return new Uint8Array(Buffer.from(FIXTURES[name], 'base64'));
}
// P6, with libvips' own comment line, which is the reason this is not a
// `split('\n')`.
function readPpm(bytes) {
let at = 0;
const token = () => {
for (;;) {
if (at >= bytes.length) return '';
const c = bytes[at];
if (c === 0x23) {
while (at < bytes.length && bytes[at] !== 0x0a) at++;
} else if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) {
at++;
} else break;
}
const from = at;
while (at < bytes.length && ![0x20, 0x09, 0x0a, 0x0d].includes(bytes[at])) at++;
return String.fromCharCode(...bytes.subarray(from, at));
};
if (token() !== 'P6') throw new Error('not a PPM');
const width = Number(token());
const height = Number(token());
if (Number(token()) !== 255) throw new Error('not 8-bit');
at++;
return { width, height, pixels: bytes.subarray(at, at + width * height * 3) };
}
const failures = [];
const check = (what, ok, detail = '') => {
if (!ok) failures.push(`${what}${detail ? `: ${detail}` : ''}`);
console.log(`${ok ? 'ok ' : 'FAIL'} ${what}${detail ? ` — ${detail}` : ''}`);
};
const cache = path.join(FRONTEND, 'node_modules', '.cache');
await mkdir(cache, { recursive: true });
const bundle = path.join(cache, 'tiff-decode-check.mjs');
// `jpeg.tif` is the one fixture whose strip is a JPEG, and the reader decodes
// that with the very CanvasKit the app boots — so the two have to come out of
// ONE module instance, or the shim's `Skia` stays null inside the reader. Hence
// the single stdin entry that re-exports both, rather than two bundles.
await build({
stdin: {
contents:
"export { decodeTiff } from './src/engine/tiffDecode';\n" +
"export { initSkia } from './src/engine/skiaShim';",
resolveDir: FRONTEND,
loader: 'ts',
},
bundle: true,
format: 'esm',
platform: 'node',
target: 'es2022',
external: ['canvaskit-wasm/full'],
outfile: bundle,
});
let decodeTiff, initSkia;
try {
({ decodeTiff, initSkia } = await import(pathToFileURL(bundle).href));
} finally {
await rm(bundle, { force: true });
}
await initSkia({
locateFile: () =>
fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
});
const reference = readPpm(fixture('crop.ppm'));
for (const name of ['none.tif', 'pb.tif', 'lzw.tif']) {
const frame = decodeTiff(fixture(name), 640);
if (!frame) {
check(name, false, 'no frame');
continue;
}
if (frame.width !== reference.width || frame.height !== reference.height) {
check(name, false, `${frame.width}x${frame.height} against ${reference.width}x${reference.height}`);
continue;
}
let wrong = 0;
let opaque = true;
for (let i = 0; i < reference.width * reference.height; i++) {
if (
frame.rgba[i * 4] !== reference.pixels[i * 3] ||
frame.rgba[i * 4 + 1] !== reference.pixels[i * 3 + 1] ||
frame.rgba[i * 4 + 2] !== reference.pixels[i * 3 + 2]
) wrong++;
if (frame.rgba[i * 4 + 3] !== 255) opaque = false;
}
check(name, wrong === 0 && opaque, `${frame.width}x${frame.height}, ${wrong} wrong pixels`);
}
// The JPEG strip is lossy — Q=100 is close to the PPM, not equal to it — so this
// one is checked by how far a channel strays rather than by equality. It is also
// the fixture that fails loudly when CanvasKit is not up (its `Skia` is null) or
// when the pixels are read out of the decoded image the wrong way (all black).
{
const frame = decodeTiff(fixture('jpeg.tif'), 640);
if (!frame) {
check('jpeg.tif', false, 'no frame');
} else if (frame.width !== reference.width || frame.height !== reference.height) {
check('jpeg.tif', false, `${frame.width}x${frame.height} against ${reference.width}x${reference.height}`);
} else {
let worst = 0;
let opaque = true;
for (let i = 0; i < reference.width * reference.height; i++) {
for (let c = 0; c < 3; c++) {
worst = Math.max(worst, Math.abs(frame.rgba[i * 4 + c] - reference.pixels[i * 3 + c]));
}
if (frame.rgba[i * 4 + 3] !== 255) opaque = false;
}
check('jpeg.tif', worst <= 12 && opaque, `${frame.width}x${frame.height}, worst channel ${worst}`);
}
}
check('a file that is not a TIFF', decodeTiff(fixture('crop.ppm'), 640) === null);
check('a TIFF cut short', decodeTiff(fixture('none.tif').subarray(0, 40), 640) === null);
if (failures.length) {
console.error(`\n${failures.length} failed`);
process.exit(1);
}
console.log('\nall tiff decode checks passed');
+166
View File
@@ -0,0 +1,166 @@
// The tone ramp is drawn through a BASE layer, and that base is now a blurred
// CHILD of the pass rather than a ring of point samples inside it — see
// TONE_BASE_RADIUS in toneShader.ts for the mottle that ring caused. Two things
// can go wrong with that and neither is a crash: the shader stops compiling once
// it takes a second child (SkSL is only checked at runtime, and no other check
// here compiles TONE_SKSL as a whole), or the second child is wired to the wrong
// slot and the pass quietly reads the sharp image as its own base — which is
// exactly the identity the default (bx = 0) used to give, so a regression would
// look like nothing happening. So the pass is compiled and rendered for real:
// the frame and the base are held at two different flat values, and the pixel
// has to land on the somewhere-between value the ramp over THAT base predicts.
//
// node scripts/tone-base-check.mjs
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import ts from 'typescript';
import CanvasKitInit from 'canvaskit-wasm/bin/full/canvaskit.js';
const transpile = (path) =>
ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
const dir = mkdtempSync(join(tmpdir(), 'tone-base-check-'));
writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts'));
writeFileSync(
join(dir, 'toneShader.mjs'),
transpile('../shared/utils/toneShader.ts').replace(
/^import .*from ['"]\.\/colorUtils['"];$/m,
'import { HSL_BANDS, hslBandGaps, isMonochromeBase } from "./colorUtils.mjs";',
),
);
const {
TONE_SKSL, TONE_BASE_RADIUS, TONE_BASE_SIGMA, getToneUniforms, toneUniformArray, toneIsActive,
TONE_KNOBS,
} = await import(pathToFileURL(join(dir, 'toneShader.mjs')).href);
// The shader has to NAME a base child — a ring of taps would not need one.
assert.match(TONE_SKSL, /uniform shader base;/);
const CanvasKit = await CanvasKitInit({
locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
});
const SIZE = 8;
function flat(value) {
const surf = CanvasKit.MakeSurface(SIZE, SIZE);
const paint = new CanvasKit.Paint();
paint.setColor(CanvasKit.Color(value, value, value));
surf.getCanvas().drawPaint(paint);
return surf.makeImageSnapshot();
}
const asChild = (image) =>
image.makeShaderOptions(
CanvasKit.TileMode.Clamp, CanvasKit.TileMode.Clamp, CanvasKit.FilterMode.Linear, CanvasKit.MipmapMode.None,
);
// SHADOW full, everything else off — the knob the mottle was reported on.
const adjustments = { shadow: 10 };
const uniforms = getToneUniforms(adjustments);
assert.ok(toneIsActive(uniforms), 'SHADOW +100 no longer turns the pass on');
function render(srcValue, baseValue) {
const effect = CanvasKit.RuntimeEffect.Make(TONE_SKSL);
assert.ok(effect, 'TONE_SKSL does not compile');
const src = flat(srcValue);
const base = flat(baseValue);
const shader = effect.makeShaderWithChildren(uniforms ? toneUniformArray(uniforms) : [], [
asChild(src),
asChild(base),
]);
assert.ok(shader, 'the pass did not take two children — the base is not wired in');
const out = CanvasKit.MakeSurface(SIZE, SIZE);
const paint = new CanvasKit.Paint();
paint.setShader(shader);
out.getCanvas().drawPaint(paint);
const pixels = out.makeImageSnapshot().readPixels(0, 0, {
width: SIZE, height: SIZE, colorType: CanvasKit.ColorType.RGBA_8888,
alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB,
});
return pixels[0];
}
// The ramp, in the same arithmetic the shader runs: ONE KNOB is the measured
// knot table (TONE_KNOBS in toneShader.ts) drawn STRAIGHT between the knots,
// scaled by the amount, added to the luma it reads and clamped — the minus side
// of zero reads the minus table — and the four are applied ONE AFTER THE OTHER
// and clamped on the way to the next, so the four moves compose instead of
// summing. SHADOW carries the whole knob on its own, so SHADOW +100 reads
// toneCurve(b, 0, 1, 0, 0).
const clamp01 = (x) => Math.min(1, Math.max(0, x));
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
const poly = (c, L) => {
let v = c.v[0];
for (let i = 1; i < c.p.length; i++)
v += (c.v[i] - c.v[i - 1]) * clamp((L - c.p[i - 1]) / (c.p[i] - c.p[i - 1]), 0, 1);
return v;
};
const knob = (L, k, amt) =>
clamp01(L + (amt >= 0 ? amt * poly(TONE_KNOBS[k].plus, L) : -amt * poly(TONE_KNOBS[k].minus, L)));
// toneCurve, move for move, with the clamp the shader puts after each one.
// `afterBlack` is the L the SHADOW move reads.
function toneCurve(L, bl, sh, hl, wh) {
const afterBlack = knob(L, 'bl', bl);
return {
o: knob(knob(knob(afterBlack, 'sh', sh), 'hl', hl), 'wh', wh),
afterBlack,
};
}
const ramp = (b, k = {}) => toneCurve(b, k.bl ?? 0, k.sh ?? 0, k.hl ?? 0, k.wh ?? 0);
const srcValue = 128; // the pixel: 0.501961 encoded
const baseValue = 76; // its neighbourhood, darker: 0.298039
const t = srcValue / 255;
const b = baseValue / 255;
// The neighbourhood's new luma, plus the pixel's own DIFFERENCE from the base —
// Base' + Detail, the reconstruction the shader emits (see the note in
// toneRamp). The ratio Base' * (Input / Base) is what this used to predict, and
// it is what took the texture out of the frame at BLACK -100.
const diff = Math.abs(t - b);
const smooth = (x) => x * x * (3 - 2 * x);
const expected = Math.round(255 * clamp01(ramp(t, { sh: 1 }).o));
const got = render(srcValue, baseValue);
assert.ok(
Math.abs(got - expected) <= 2,
`a base of ${b.toFixed(4)} under a pixel of ${t.toFixed(4)} gave ${got}, the ramp predicts ${expected}`,
);
// With the base handed in as the sharp image the difference is exactly zero and
// the pass is the GLOBAL move — the ramp at the pixel — which is what a mask (no
// neighbourhood of its own) needs it to be. It is NOT the identity any more:
// the shadow window spans the middle, so the same knobs move a mid-grey.
const globalMove = Math.round(255 * clamp01(ramp(t, { sh: 1 }).o));
const selfBase = render(srcValue, srcValue);
assert.ok(
Math.abs(selfBase - globalMove) <= 2,
`a base equal to the pixel must be the ramp at the pixel (${globalMove}), got ${selfBase} for ${srcValue}`,
);
assert.ok(
Math.abs(selfBase - srcValue) > 2,
`the pass returned the pixel (${selfBase}) — the base is the sharp image again`,
);
// There is no guard any more, and nothing to hold under one: the four moves are
// each monotone for any amount in [-1, 1] and clamped on the way to the next, so
// BLACK and SHADOW both at +100 compose into a ramp that still rises — measured
// on a 1/400 grid, where the two used to sum past a slope of 1 and carry the
// curve backwards — and BLACK's own travel no longer depends on the stock's
// SHADOW, because BLACK runs FIRST: the value SHADOW reads is the same whatever
// SHADOW says, which is the "kéo theo sự thay đổi của thông số khác" report.
const rises = (f) => Array.from({ length: 400 }, (_, i) => f((i + 1) / 400) > f(i / 400)).every(Boolean);
assert.ok(rises((b) => ramp(b, { bl: 1, sh: 1 }).o), 'BLACK and SHADOW both at +100 fold the ramp back on itself');
assert.equal(
ramp(0.05, { bl: -1 }).afterBlack,
ramp(0.05, { bl: -1, sh: -0.2 }).afterBlack,
'SHADOW drags BLACK’s own travel with it again',
);
console.log(
`tone base ok: pixel ${srcValue} over a base of ${baseValue} -> ${got} ` +
`(ramp predicts ${expected}, global move ${globalMove}, sharp-base ${selfBase}); ` +
`radius ${TONE_BASE_RADIUS} of the frame, sigma ${TONE_BASE_SIGMA} of it`,
);
+751
View File
@@ -0,0 +1,751 @@
// The three things the reader reported, checked in a browser: the counts beside
// the folder names while a reading is running, what the column holds after the
// tab comes back — every folder the walk went into, the empty ones included, with
// an interrupted reading picking itself up — and a long roll's tiles, every frame
// the wall draws read out of the reading that brought them.
//
// The app is behind an account and answers `/api` on its own origin, so the check
// stands in for the API the way the container's nginx does; without it the page
// is a guest and there is no folder to pick. The catalogue is read back out of
// IndexedDB rather than out of the strip, which draws one node's own frames.
//
// npm run build && node scripts/tree-scan-check.mjs
// APP_PORT=4395 SAMPLE_PORT=4396 SAMPLE_DIR=<folder of sample frames> \
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> CHROME=<path to chrome> \
// node scripts/tree-scan-check.mjs
import { spawn } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
const SAMPLES = process.env.SAMPLE_DIR ?? path.resolve(FRONTEND, '..', 'sample');
const APP_PORT = Number(process.env.APP_PORT ?? 4185);
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4319);
const JPG = 'P1010256.JPG';
const RAW = 'P1010256.RW2';
// What the frames of the long roll are served: a small JPEG, because a hundred
// copies of an eight-megabyte one is eight hundred megabytes to prove a point
// about tiles.
const SMALL = 'Main.jpg';
const LONG = 60;
const LONG_NAMES = Array.from({ length: LONG }, (_, i) => `B${String(i).padStart(3, '0')}.JPG`);
let failures = 0;
function step(name, ok, detail = '') {
if (!ok) failures++;
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
return ok;
}
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
if (!playwright) {
console.log('SKIP playwright-core is not installed');
process.exit(0);
}
// Every frame the page asks for, by extension: one real JPEG and one real RAW — see
// the route that hands them over, below.
const app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
cwd: FRONTEND,
stdio: 'ignore',
});
const base = `http://127.0.0.1:${APP_PORT}`;
let up = false;
for (let i = 0; i < 60 && !up; i++) {
up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false);
if (!up) await new Promise((r) => setTimeout(r, 500));
}
if (!up) {
app.kill('SIGTERM');
console.log(`FAIL no preview on ${base} — run \`npm run build\` first`);
process.exit(1);
}
const browser = await playwright.chromium.launch({
executablePath: process.env.CHROME || undefined,
args: ['--no-sandbox', '--enable-unsafe-swiftshader'],
});
const context = await browser.newContext();
// The sample frames are handed over inside the browser rather than off a socket of
// its own: a page is given six sockets to a host, and the walk's sixty reads fill
// all six, so the shelf's own reads would queue behind the reading they are meant
// to keep up with — a queue no real disk puts in the way.
await context.route(`http://127.0.0.1:${SAMPLE_PORT}/**`, async (route) => {
const name = path.basename(decodeURIComponent(new URL(route.request().url()).pathname));
const file = /^B\d+\.JPG$/.test(name) ? SMALL : /\.jpe?g$/i.test(name) ? JPG : /\.rw2$/i.test(name) ? RAW : null;
if (!file) return route.fulfill({ status: 404, body: '' });
return route.fulfill({
status: 200,
contentType: 'application/octet-stream',
headers: { 'Access-Control-Allow-Origin': '*' },
body: await readFile(path.join(SAMPLES, file)),
});
});
// The catalogue is behind an account, and the header reads the account's own
// folder: the API answers for both, the way the container beside the page does.
await context.route('**/api/**', (route) => {
const url = route.request().url();
const json = (body) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
if (url.includes('/auth/me')) return json({ user: { id: 1, email: 'check@example.com', name: 'check' } });
if (url.includes('/photos')) return json({ photos: [] });
if (url.includes('/recipes')) return json({ recipes: [] });
return route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'no' }) });
});
await context.addInitScript(
({ origin, folder, rolls, small }) => {
const bytes = new Map();
// One fetch per real file whatever the handle is called: the frames of the
// long roll are one small JPEG served under sixty names.
const load = async (name) => {
const file = /^B\d+\.JPG$/.test(name) ? small : name;
if (!bytes.has(file)) bytes.set(file, fetch(`${origin}/${file}`).then((r) => r.arrayBuffer()));
return bytes.get(file);
};
const fileHandle = (name) => {
const handle = { kind: 'file', name, __fake: true };
Object.defineProperty(handle, 'getFile', {
value: async () => {
const gets = JSON.parse(sessionStorage.getItem('check.gets') ?? '{}');
gets[name] = (gets[name] ?? 0) + 1;
sessionStorage.setItem('check.gets', JSON.stringify(gets));
while (localStorage.getItem('check.hold') === '1') await new Promise((r) => setTimeout(r, 25));
// A read of a frame is nearly all waiting on a real disk, and a reading
// that is over in a blink is one the wall's own work cannot be watched
// against. `check.slow` is that wait, in milliseconds.
const slow = Number(localStorage.getItem('check.slow') ?? 0);
if (slow) await new Promise((r) => setTimeout(r, slow));
return new File([await load(name)], name, { type: 'application/octet-stream', lastModified: Date.UTC(2026, 0, 1) });
},
});
return handle;
};
const dirHandle = (label, node) => {
const handle = { kind: 'directory', name: label, __fake: true };
Object.defineProperties(handle, {
values: {
value: () => {
const lists = JSON.parse(sessionStorage.getItem('check.lists') ?? '{}');
lists[label] = (lists[label] ?? 0) + 1;
sessionStorage.setItem('check.lists', JSON.stringify(lists));
if (node.fail && localStorage.getItem('check.broken') !== 'open') throw new Error(`${label} will not open`);
return [
...(node.files ?? []).map(fileHandle),
...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)),
][Symbol.iterator]();
},
},
queryPermission: { value: async () => 'granted' },
requestPermission: { value: async () => 'granted' },
getDirectoryHandle: {
value: async (name) => {
const kid = node.dirs?.[name];
if (!kid) throw new Error(`no folder named ${name}`);
return dirHandle(name, kid);
},
},
getFileHandle: {
value: async (name) => {
if (!(node.files ?? []).includes(name)) throw new Error(`no frame named ${name}`);
return fileHandle(name);
},
},
});
return handle;
};
const pick = (name) => dirHandle(name, { files: rolls[name].files, dirs: rolls[name].dirs });
// The roll in play, not the one this script was handed: a handle that comes
// back out of the catalogue belongs to the folder being read, and the reading
// a reload takes up is of that one.
const root = () => pick(localStorage.getItem('check.roll') ?? folder);
window.showDirectoryPicker = async () => root();
// What the disk holds, for the check to change behind the app's back: a frame
// taken off the disk is a folder the walk meets one frame fewer in.
window.__rolls = rolls;
const revive = (row) => {
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name);
}
return row;
};
for (const method of ['get', 'getAll']) {
const original = IDBObjectStore.prototype[method];
IDBObjectStore.prototype[method] = function (...args) {
const request = original.apply(this, args);
const descriptor = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result');
Object.defineProperty(request, 'result', {
configurable: true,
get() {
const value = descriptor.get.call(this);
return Array.isArray(value) ? value.map(revive) : revive(value);
},
});
return request;
};
}
},
{
origin: `http://127.0.0.1:${SAMPLE_PORT}`,
folder: 'CheckRoll',
small: SMALL,
rolls: {
// One frame at the top, one a folder down, one two folders down, one in a
// second branch — and a folder holding no frame at all.
CheckRoll: {
files: [JPG],
dirs: {
2026: { files: [RAW], dirs: { '04': { files: [JPG], dirs: {} } } },
2025: { files: [JPG], dirs: {} },
Empty: { files: ['notes.txt'], dirs: {} },
},
},
// A roll with a folder that will not open: a permission taken back, a
// volume pulled out — a folder the walk meets and cannot list.
BrokenRoll: {
files: [JPG],
dirs: { Broken: { fail: true, files: [JPG], dirs: {} } },
},
// A roll long enough that its reading outlasts the wall's own work: sixty
// frames read at a walking pace, so the wall has time to tile them while
// the reading is still going.
BigRoll: {
files: LONG_NAMES,
dirs: {},
},
// The same roll read for the first time, on a tab that goes away.
CutRoll: {
files: [JPG],
dirs: { Broken: { fail: true, files: [JPG], dirs: {} } },
},
// A roll picked at a drive root, which Chromium hands over named `\` — the
// one name a folder under it is spelled from and that is itself no folder
// name at all. See the check at the foot of this file.
'\\': {
files: [JPG],
dirs: { Roll: { files: [RAW], dirs: {} } },
},
},
}
);
const page = await context.newPage();
const rowStates = () =>
page.$$eval('[data-key^="lib-node-"]', (els) =>
els.map((e) => `${e.dataset.key.replace('lib-node-', '')}:${(e.querySelector('.adm-album-count')?.textContent ?? '').trim()}`)
);
const progress = () => page.$eval('[data-key="lib-progress"]', (e) => e.textContent ?? '').catch(() => '');
const counts = async () => {
const out = {};
for (const row of await rowStates()) {
const [key, n] = row.split(':');
out[key] = n;
}
return out;
};
// Row order is the tree's path order, not the order of this file's expectations.
const same = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort());
// What the reading has stored, asked of the catalogue rather than of the strip:
// the strip draws the open node's own frames, and three of these four are one
// folder down.
const stored = () =>
page
.evaluate(async () => {
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open('recipescam-library');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return new Promise((resolve, reject) => {
const req = db.transaction('photos', 'readonly').objectStore('photos').count();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
})
.catch(() => -1);
// What the walk found, which is the whole of what the column is asked to say.
const WANT = {
CheckRoll: '4',
'CheckRoll/2025': '1',
'CheckRoll/2026': '2',
'CheckRoll/2026/04': '1',
'CheckRoll/Empty': '0',
};
// --- a reading, watched while it runs ---------------------------------------
await page.goto(`${base}/library`);
// Held on the first frame it reads, so the walk is through its first pass — the
// names and their numbers are the reading's, and the reading is not done.
await page.evaluate(() => localStorage.setItem('check.hold', '1'));
await page.click('[data-key="lib-add"]');
await page.waitForSelector('[data-key="lib-node-CheckRoll"]', { timeout: 30_000 });
// Every row of the pass is a row now, and a count beside each name is what the
// walk met — the reading itself has read nothing. The whole tree is drawn in one
// go and never a row at a time, so the wait is for all of it: a count in between
// is a state this screen does not have, and waiting for one is a wait that runs
// out its clock and then measures whatever the reading has done since. The
// reading is held on the frame it is reading, and how long a held frame stays
// held is the engine's to decide (`READ_MS`), not this file's.
await page
.waitForFunction(() => {
const rows = [...document.querySelectorAll('[data-key^="lib-node-"]')];
return rows.length >= 5 && /\d+\/\d+/.test(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '');
}, null, { timeout: 60_000, polling: 50 })
.catch(() => {});
const midFloat = await counts();
const midLine = (await progress()).replace(/\s+/g, ' ').trim();
const midStored = await stored();
// The whole roll is one pass of four folders, so the numbers are the walk's the
// moment the reading has listed the tree — before a single frame is read.
step(
'the counts beside the folder names are up while the reading runs',
same(midFloat, WANT),
`${JSON.stringify(midFloat)} at "${midLine}"`
);
step(
'and they are the walk’s own, not the catalogue’s: nothing has been read yet',
midStored === 0,
`${midStored} rows stored`
);
step('and the reading is in flight while the column already says so', Boolean(await page.$('[data-key="lib-progress"]')), `"${midLine}"`);
// --- the reading runs out ----------------------------------------------------
await page.evaluate(() => localStorage.removeItem('check.hold'));
// The progress span is in the page whether or not a reading is, and its text is
// empty until the first pass has said what the roll is: silence is the reading
// being through.
await page
.waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, {
timeout: 120_000,
polling: 50,
})
.catch(() => {});
const done = await counts();
step(
'a reading that ran out leaves the roll counted, empty folders and all',
same(done, WANT),
`${JSON.stringify(done)}, ${await stored()} rows stored`
);
// --- the tab comes back ------------------------------------------------------
await page.reload();
await page.waitForSelector('[data-key="lib-node-CheckRoll"]', { timeout: 60_000 });
await page.waitForTimeout(1000);
const back = await counts();
step(
'and the next visit draws the same column with nothing right-clicked',
same(back, done),
JSON.stringify(back)
);
step(
'a folder the walk went into and met no frame in keeps its row',
typeof back['CheckRoll/Empty'] === 'string',
`Empty: ${back['CheckRoll/Empty']}`
);
// Nothing is left to read, so this visit offers nothing and starts nothing: the
// catalogue comes back off the disk and the screen waits to be asked.
step(
'and a visit with nothing left to read reads nothing at all',
!(await page.$('[data-key="lib-resume"]')) && (await progress()).trim() === '',
`resume ${Boolean(await page.$('[data-key="lib-resume"]'))}, line "${(await progress()).trim()}"`
);
// --- a reading cut off -------------------------------------------------------
// The catalogue is emptied first, so every frame is one this reading has to read
// itself: a reading that does not go on by itself leaves the strip at nothing.
await page.evaluate(async () => {
sessionStorage.clear();
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open('recipescam-library');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
await new Promise((resolve, reject) => {
const tx = db.transaction('photos', 'readwrite');
tx.objectStore('photos').clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
localStorage.setItem('check.hold', '1');
});
const counting = () =>
page
.waitForFunction(() => /\d+\s*\/\s*\d+/.test(document.querySelector('[data-key="lib-progress"]')?.textContent ?? ''), null, {
timeout: 60_000,
polling: 50,
})
.then(() => progress())
.then((line) => line.replace(/\s+/g, ' ').trim())
.catch(() => '');
// A reading of the whole roll, held on the first frame it reads: what a tab that
// is closed mid-reading leaves behind is exactly this.
await page.click('[data-key="lib-node-CheckRoll"]', { button: 'right' });
await page.waitForSelector('[data-key="lib-rescan-CheckRoll"]', { timeout: 10_000 });
await page.click('[data-key="lib-rescan-CheckRoll"]');
const cut = await counting();
// The walk writes its own position out at most once a second, and the reload is
// what has to be cut off mid-reading rather than before it started.
await page.waitForTimeout(2000);
// Nothing here asks for another reading, and neither does the visit that comes
// back: the roll the reload cut off is put on offer beside the progress line, and
// the press of that offer is the only thing that starts the second reading. The
// first frame is still held, so a visit that went on by itself would hang on it
// rather than pass.
await page.reload();
await page.waitForSelector('[data-key="lib-resume"]', { timeout: 60_000 });
const offered = (await progress()).trim();
step(
'a reading a reload cut off is offered by the next visit, not run by it',
/\d+\/\d+/.test(cut) && offered === '',
`"${cut}" then idle "${offered}"`
);
await page.click('[data-key="lib-resume"]');
const cutAgain = await counting();
step('and the offer is that reading taken up where it stopped', /\d+\/\d+/.test(cutAgain), `"${cutAgain}"`);
await page.evaluate(() => localStorage.removeItem('check.hold'));
// The progress span is in the page whether or not a reading is, and its text is
// empty until the first pass has said what the roll is: silence is the reading
// being through.
await page
.waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, {
timeout: 120_000,
polling: 50,
})
.catch(() => {});
const resumed = await stored();
const lists = await page.evaluate(() => JSON.parse(sessionStorage.getItem('check.lists') ?? '{}'));
step(
'and the visit after it finishes the roll on its own, without listing a folder twice',
resumed === 4 && lists.CheckRoll === 1 && lists['2026'] === 1 && lists.Empty === 1 && lists['2025'] === 1,
`${resumed} rows stored from ${JSON.stringify(lists)}`
);
const keys = (store) =>
page
.evaluate(
(store) =>
new Promise((resolve, reject) => {
const req = indexedDB.open('recipescam-library');
req.onsuccess = () => {
const db = req.result;
const get = db.transaction(store, 'readonly').objectStore(store).getAllKeys();
get.onsuccess = () => resolve(get.result);
get.onerror = () => reject(get.error);
};
req.onerror = () => reject(req.error);
}),
store
)
.catch(() => []);
const quiet = () =>
page
.waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, {
timeout: 120_000,
polling: 50,
})
.catch(() => {});
// A reading, watched out: the line is up once it has told the column what the
// roll is, and silence is the reading being through. Waiting for silence alone
// would read the line before the reading has written it.
const readOut = async () => {
await page
.waitForFunction(() => /\d+\s*\/\s*\d+/.test(document.querySelector('[data-key="lib-progress"]')?.textContent ?? ''), null, {
timeout: 20_000,
polling: 50,
})
.catch(() => {});
await quiet();
};
// A row for a frame the disk has not got, with the tile and the marks that go
// with it — the state a catalogue is left in when a frame is taken off the disk.
const seed = (ids) =>
page.evaluate(
(ids) =>
new Promise((resolve, reject) => {
const req = indexedDB.open('recipescam-library');
req.onsuccess = () => {
const db = req.result;
const tx = db.transaction(['photos', 'thumbs', 'edits'], 'readwrite');
for (const id of ids) {
const parts = id.split('/');
tx.objectStore('photos').put({
id,
folder: parts[0],
dir: parts.slice(1, -1).join('/'),
name: parts[parts.length - 1],
handle: { kind: 'file', name: parts[parts.length - 1], __fake: true },
taken: 1,
size: 1,
mtime: 1,
addedAt: 1,
});
tx.objectStore('thumbs').put({ id, thumb: new Blob(['x']) });
tx.objectStore('edits').put({ photoId: id, recipe: {}, updatedAt: 1 });
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
};
req.onerror = () => reject(req.error);
}),
ids
);
// --- the catalogue brought to the disk --------------------------------------
// The reader's report: a folder's frames and the number kept for it disagree, and
// a rescan of the parent does not put them right. Both halves are here — a frame
// taken off the disk while its row stayed in the catalogue, and a row for a frame
// that was never on the disk at all — and one rescan of the roll has to settle
// both, the numbers under the subfolders included.
await seed(['CheckRoll/2025/Gone.JPG', 'BrokenRoll/Broken/x.JPG']);
await page.evaluate(() => {
window.__rolls.CheckRoll.dirs['2025'].files.length = 0;
});
step(
'the catalogue is holding two rows the disk has nothing for',
(await keys('photos')).includes('CheckRoll/2025/Gone.JPG') &&
(await keys('photos')).includes('CheckRoll/2025/P1010256.JPG'),
JSON.stringify(await keys('photos'))
);
await page.click('[data-key="lib-node-CheckRoll"]', { button: 'right' });
await page.waitForSelector('[data-key="lib-rescan-CheckRoll"]', { timeout: 10_000 });
await page.click('[data-key="lib-rescan-CheckRoll"]');
await readOut();
const repaired = await counts();
const rows = (await keys('photos')).filter((k) => k.startsWith('CheckRoll/'));
step(
'a rescan of the parent brings every number under it to the disk',
same(repaired, {
...WANT,
CheckRoll: '3',
'CheckRoll/2025': '0',
}),
JSON.stringify(repaired)
);
step(
'and the frames the reading did not meet are gone from the catalogue',
rows.length === 3 && !rows.includes('CheckRoll/2025/P1010256.JPG') && !rows.includes('CheckRoll/2025/Gone.JPG'),
JSON.stringify(rows)
);
step(
'with their tiles and their marks',
!(await keys('thumbs')).includes('CheckRoll/2025/Gone.JPG') &&
!(await keys('edits')).includes('CheckRoll/2025/Gone.JPG'),
JSON.stringify(await keys('edits'))
);
// --- a folder the walk could not open ---------------------------------------
// The other side of the same rule: the walk met nothing under `Broken`, and a
// reading that read silence for absence would empty a folder it never listed.
// The number beside its name is the catalogue's, which is the last reading that
// could open it: a reading that could not open a folder writes no nought there —
// a nought takes the place of the frames the catalogue holds and reads as a
// folder gone empty until the reader rescans the roll.
await page.evaluate(() => localStorage.setItem('check.roll', 'BrokenRoll'));
await page.click('[data-key="lib-add"]');
await page.waitForSelector('[data-key="lib-node-BrokenRoll"]', { timeout: 60_000 });
await readOut();
const broken = await counts();
step('a folder the walk could not open keeps its row', broken['BrokenRoll/Broken'] === '1', JSON.stringify(broken));
step(
'and nothing under it is dropped: silence is not absence',
(await keys('photos')).includes('BrokenRoll/Broken/x.JPG'),
JSON.stringify(await keys('photos'))
);
step(
'while the frame the walk did meet is stored',
(await keys('photos')).includes('BrokenRoll/P1010256.JPG'),
JSON.stringify(await keys('photos'))
);
// --- a folder the reading could not open, and a tab that goes away -----------
// Why the number beside a folder stays at nothing through update after update,
// which is the state the reader photographed. A roll is read for the first time
// and the tab goes while the reading is in it: the position it leaves behind must
// not say it walked a folder it could not open, because an update takes that
// position up — a folder filed as read is a folder no update opens again, and its
// number stays at nothing however many times the reader asks. The folder opens on
// the visit that takes the reading up, and the reading is there to open it.
const opens = () => page.evaluate(() => JSON.parse(sessionStorage.getItem('check.lists') ?? '{}'));
const opened = (m, name) => m[name] ?? 0;
await page.evaluate(async () => {
sessionStorage.removeItem('check.lists');
localStorage.setItem('check.roll', 'CutRoll');
localStorage.setItem('check.hold', '1');
// Nothing in hand: the frame is one this reading has to read itself, which is
// what the hold is holding — and a reading held on the frame it is reading is
// the reading a closed tab leaves behind.
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open('recipescam-library');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
await new Promise((resolve, reject) => {
const tx = db.transaction('photos', 'readwrite');
tx.objectStore('photos').clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
});
await page.click('[data-key="lib-add"]');
await page.waitForSelector('[data-key="lib-node-CutRoll"]', { timeout: 60_000 });
await counting();
await new Promise((r) => setTimeout(r, 1500));
const askedBroken = opened(await opens(), 'Broken');
const held = await page.evaluate(async () => {
const root = await navigator.storage.getDirectory();
try {
const dir = await root.getDirectoryHandle('walk', { create: false });
const fh = await dir.getFileHandle(`${encodeURIComponent('CutRoll')}.json`, { create: false });
return JSON.parse(await (await fh.getFile()).text());
} catch {
return null;
}
});
step(
'the position a closed tab leaves files no folder it could not open as walked',
!!held && !held.walked.some((r) => r.startsWith('Broken')) && held.pending.includes('Broken'),
JSON.stringify(held && { walked: held.walked, pending: held.pending })
);
// The tab goes, and the roll it cut off is offered on the visit that comes back:
// the offer is pressed, and the reading it takes up is the reading that opens the
// folder the last one could not.
await page.evaluate(() => localStorage.setItem('check.broken', 'open'));
await page.reload();
await page.waitForSelector('[data-key="lib-resume"]', { timeout: 60_000 });
await page.click('[data-key="lib-resume"]');
const again = await counting();
await page.evaluate(() => localStorage.removeItem('check.hold'));
await readOut();
const restarted = await counts();
step(
'and the visit that takes the reading up is the visit that opens it',
opened(await opens(), 'Broken') > askedBroken,
`Broken asked ${askedBroken} times, then ${opened(await opens(), 'Broken')}`
);
step(
'so the folder is counted on the disk, not left at nothing',
restarted['CutRoll'] === '2' && restarted['CutRoll/Broken'] === '1',
JSON.stringify(restarted)
);
// --- a roll picked at a drive root ------------------------------------------
// The reader picks a whole drive and every number beside a folder name stands at
// nought. The picker hands that folder over named `\`, and a row's number is
// looked up by the path that name spells: swapped for a bare backslash the roll
// is `/` and its folders `//Roll`, while the walk files its frames — and writes
// its counts — under the empty root a `normPath` fold leaves behind. Two
// spellings of one folder, and a column of noughts over a roll full of frames.
await page.evaluate(async () => {
sessionStorage.removeItem('check.lists');
localStorage.removeItem('check.hold');
localStorage.setItem('check.roll', '\\');
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open('recipescam-library');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
await new Promise((resolve, reject) => {
const tx = db.transaction('photos', 'readwrite');
tx.objectStore('photos').clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
});
await page.click('[data-key="lib-add"]');
await page.waitForSelector('[data-key="lib-node-/Roll"]', { timeout: 60_000 }).catch(() => {});
// The reading runs out on its own, and silence is the reading being through.
await page
.waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, {
timeout: 120_000,
polling: 50,
})
.catch(() => {});
const drive = await counts();
step(
'a roll picked at a drive root counts its frames beside its own name',
drive[''] === '2' && drive['/Roll'] === '1',
JSON.stringify(drive)
);
// --- the shelf, out of a reading ---------------------------------------------
// The reading hands the screen the frames it found a batch at a time, and a batch
// used to be handed on only when it was full: the reader's folder of 26 RAWs is
// under one batch, so the screen stood empty for the whole of the reading and then
// filled at once, and the tiles had nothing to be drawn from until the reading was
// over. Frames go down on the clock as well now, so the count climbs and the first
// tiles appear while the roll is still being read. Sixty frames read at a walking
// pace is a long roll read the way the reader read theirs.
await page.evaluate(async () => {
sessionStorage.removeItem('check.lists');
localStorage.removeItem('check.hold');
localStorage.setItem('check.slow', '900');
localStorage.setItem('check.roll', 'BigRoll');
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open('recipescam-library');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
await new Promise((resolve, reject) => {
const tx = db.transaction(['photos', 'thumbs'], 'readwrite');
tx.objectStore('photos').clear();
tx.objectStore('thumbs').clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
});
await page.click('[data-key="lib-add"]');
await page.waitForSelector('[data-key="lib-node-BigRoll"]', { timeout: 60_000 });
await page
.waitForFunction(() => (document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim().length > 0, null, {
timeout: 60_000,
polling: 25,
})
.catch(() => {});
// The reading is in flight and the frames are being read: what is asked of it here
// is whether the screen is told of them as they land. A roll under one `BATCH`
// reached the screen in a single lump at the end of the reading, however long the
// reading was — the reader's folder of 26 RAWs was one batch, and so one empty
// screen for the whole of it. Every count between none and all is a reading that
// said something on the way past.
const landed = [];
for (let i = 0; i < 120; i++) {
const reading = await page
.$eval('[data-key="lib-progress"]', (e) => (e.textContent ?? '').trim())
.catch(() => '');
const listed = Number(
(await page.$eval('[data-key="lib-listed"]', (e) => e.textContent ?? '').catch(() => '0')).replace(/\D+/g, '')
);
if (reading && listed > 0) landed.push(listed);
if (!reading) break;
await new Promise((r) => setTimeout(r, 150));
}
const seen = [...new Set(landed)];
step(
'the frames the reading lands are on screen as it lands them',
seen.length >= 2,
`${seen.length} counts while the reading ran: ${seen.join(', ') || 'none'}`
);
await page
.waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, {
timeout: 120_000,
polling: 25,
})
.catch(() => {});
// The frames the wall draws are read one behind another and the tiles land in
// starts and stops, so the count is taken once it has stood still for a while.
let atEnd = 0;
for (let i = 0, same = 0; i < 160 && same < 10; i++) {
await new Promise((r) => setTimeout(r, 250));
const n = (await keys('thumbs')).length;
same = n === atEnd ? same + 1 : 0;
atEnd = n;
}
step('the wall tiles the frames a reading brought it', atEnd >= 10, `${atEnd} tiles of ${LONG} drawn`);
await page.evaluate(() => localStorage.removeItem('check.slow'));
await browser.close();
app.kill('SIGTERM');
console.log(failures ? `\n${failures} failed` : '\nall checks passed');
process.exit(failures ? 1 : 0);
@@ -0,0 +1,218 @@
// Where a reading had got to is written down for the whole origin, not for the
// tab. The tab that reads a roll for minutes is often not the one that finishes
// it: the app is closed and the installed one opens, or the tab is thrown away
// and the reader comes back. The position used to live in sessionStorage, which
// is the tab's own, so the second context found nothing, walked the roll from the
// top and listed every folder again — the frame counter starting over while the
// frames it already held were read again only to be skipped.
//
// Two pages of one browser context are two contexts of one origin: the first
// picks the stand-in roll and is closed mid-walk, the second opens and takes the
// reading up. What it costs is what says whether it did — a reading that took the
// position up lists nothing, since the folders behind it came back with it, where
// one that started at the top lists the whole roll a second time.
//
// npm run build && node scripts/walk-share-check.mjs
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> node scripts/walk-share-check.mjs
//
// Playwright is not a dependency of this package, so a run without it says SKIP
// and exits 0. APP_PORT says where the preview goes, CHROME says which browser.
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
const PORT = Number(process.env.APP_PORT ?? 4197);
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
if (!playwright) {
console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE=<path to its index.mjs>');
process.exit(0);
}
// `--host 127.0.0.1`: without it vite's preview binds the name `localhost`, which
// on a dual-stack box can be ::1 alone and every 127.0.0.1 fetch below fails.
const app = spawn('npx', ['vite', 'preview', '--port', String(PORT), '--strictPort', '--host', '127.0.0.1'], {
cwd: FRONTEND,
stdio: 'ignore',
});
const base = `http://127.0.0.1:${PORT}`;
let up = false;
for (let i = 0; i < 60 && !up; i++) {
up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false);
if (!up) await new Promise((r) => setTimeout(r, 500));
}
if (!up) {
app.kill('SIGTERM');
console.log(`FAIL no preview on ${base} — run \`npm run build\` first (vite preview serves dist/)`);
process.exit(1);
}
// Dated folders of five frames each: a roll large enough that the reading is
// still going when the first page is taken away, and small enough to finish.
const DIRS = {};
for (let i = 0; i < 40; i++) {
DIRS[`d${String(i).padStart(2, '0')}`] = { files: [1, 2, 3, 4, 5].map((n) => `F${i}_${n}.JPG`), t: 1_700_000_000_000 };
}
const browser = await playwright.chromium.launch({
// Playwright's own Chromium when it has one; CHROME points at any other.
executablePath: process.env.CHROME || undefined,
args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
});
const context = await browser.newContext();
// The picker stands in for the person: a folder of handles over frames made in
// the page (a beat apiece, so the reading can be caught in the middle of the
// roll), and the same handles put back on the rows IndexedDB hands out — a cloned
// handle is a plain object with no `getFile` on it.
await context.addInitScript(
({ dirs }) => {
let bytes = null;
const jpeg = () =>
(bytes ??= new Promise((resolve) => {
const c = document.createElement('canvas');
c.width = 24;
c.height = 16;
const g = c.getContext('2d');
g.fillStyle = '#b3412a';
g.fillRect(0, 0, 24, 16);
c.toBlob(async (b) => resolve(new Uint8Array(await b.arrayBuffer())), 'image/jpeg', 0.8);
}));
const fileHandle = (name, t) => {
const handle = { kind: 'file', name, __fake: true };
Object.defineProperty(handle, 'getFile', {
value: async () => {
await new Promise((r) => setTimeout(r, 120));
return new File([await jpeg()], name, { type: 'image/jpeg', lastModified: t });
},
});
return handle;
};
const dirHandle = (label, node) => {
const handle = { kind: 'directory', name: label, __fake: true };
Object.defineProperties(handle, {
values: {
value: () => {
// Every listing a reading asks for, counted for the whole origin: a
// reading that starts over lists the same folder a second time, and
// that second listing is the whole of what the position saves.
localStorage.setItem('walkcheck.lists', String(Number(localStorage.getItem('walkcheck.lists') ?? 0) + 1));
return [
...(node.files ?? []).map((f) => fileHandle(f, node.t)),
...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)),
][Symbol.iterator]();
},
},
queryPermission: { value: async () => 'granted' },
requestPermission: { value: async () => 'granted' },
getDirectoryHandle: {
value: async (name) => {
const kid = node.dirs?.[name];
if (!kid) throw new Error(`no folder named ${name}`);
return dirHandle(name, kid);
},
},
getFileHandle: {
value: async (name) => {
if (!(node.files ?? []).includes(name)) throw new Error(`no frame named ${name}`);
return fileHandle(name, node.t);
},
},
});
return handle;
};
const root = () => dirHandle('BigRoll', { files: [], dirs });
window.showDirectoryPicker = async () => root();
const revive = (row) => {
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name, 1_700_000_000_000);
}
return row;
};
for (const method of ['get', 'getAll']) {
const original = IDBObjectStore.prototype[method];
IDBObjectStore.prototype[method] = function (...args) {
const request = original.apply(this, args);
const descriptor = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result');
Object.defineProperty(request, 'result', {
configurable: true,
get() {
const value = descriptor.get.call(this);
return Array.isArray(value) ? value.map(revive) : revive(value);
},
});
return request;
};
}
},
{ dirs: DIRS }
);
const progress = (p) => p.$eval('[data-key="lib-progress"]', (e) => e.textContent ?? '').catch(() => '');
const at = (text) => {
const m = /(\d+)\/(\d+)/.exec(text);
return m ? { n: Number(m[1]), t: Number(m[2]) } : null;
};
const listed = (p) => p.evaluate(() => Number(localStorage.getItem('walkcheck.lists') ?? 0));
const held = (p) =>
p.evaluate(() => {
const raw = localStorage.getItem('recipescam.library.walk.BigRoll');
if (!raw) return null;
const s = JSON.parse(raw);
return { done: s.progress?.done, walked: s.walked?.length, frames: s.frames?.length };
});
// --- the first context: it starts the reading and is taken away -------------
const first = await context.newPage();
await first.goto(`${base}/library`);
await first.evaluate(() => {
localStorage.removeItem('walkcheck.lists');
localStorage.removeItem('recipescam.library.walk.BigRoll');
sessionStorage.clear();
});
await first.click('[data-key="lib-add"]');
let stopped = null;
for (let i = 0; i < 600; i++) {
const now = at(await progress(first));
if (now && now.n >= 40 && now.t >= 200) {
stopped = now;
break;
}
await first.waitForTimeout(50);
}
const wrote = await held(first);
const listsA = await listed(first);
await first.close();
if (!stopped || !wrote) {
await browser.close();
app.kill('SIGTERM');
console.log(`FAIL the first reading never got going — ${stopped ? `${stopped.n}/${stopped.t}` : 'no counter'}, position ${JSON.stringify(wrote)}`);
process.exit(1);
}
// --- the next context: it opens the app and reads where it starts -----------
const second = await context.newPage();
await second.goto(`${base}/library`);
let opened = null;
for (let i = 0; i < 1200; i++) {
const now = at(await progress(second));
if (now) {
opened = now;
break;
}
await second.waitForTimeout(25);
}
// Let it work for a moment, then look at what it was: a reading that took the
// position up has nothing to walk — the queue and the folders behind it came
// back with it — where one that started at the top lists the whole roll again.
await second.waitForTimeout(2500);
const listsB = await listed(second);
const session = await second.evaluate(() => sessionStorage.getItem('recipescam.library.walk.BigRoll') !== null);
await browser.close();
app.kill('SIGTERM');
const ok = opened !== null && listsB === listsA && !session;
console.log(`${ok ? 'ok ' : 'FAIL'} walk share — a reading another context left behind carries on`);
console.log(` first context stopped at ${stopped.n}/${stopped.t}, ${listsA} folders listed, wrote ${JSON.stringify(wrote)}`);
console.log(` next context opened at ${opened ? `${opened.n}/${opened.t}` : 'nothing — it never started'}, ${listsB} folders listed, position under the tab's own key=${session}`);
process.exit(ok ? 0 : 1);
+245
View File
@@ -0,0 +1,245 @@
// The wall, checked where it can actually go wrong: in a browser, on a shelf long
// enough that the wall is drawing a few of its rows and standing in for the rest.
//
// One failure is what this guards, and it is the one that has already cost a
// white screen: a window written from the window before it. The wall measures
// itself after every render, writes what it measured, and renders again — so an
// input the measure takes off the window it just wrote feeds itself, pass after
// pass, until React gives up with #185, unmounts the tree, and leaves the reader
// with nothing (there is no error boundary under it).
//
// The trigger is not spelled out — no browser reproduces the reader's own,
// which is the point — so the check drives the *class*: the wall's own box
// reported at two different heights on every pass, plus one scroll to start it.
// That is what any read-back is, and the wall must survive it.
//
// npm run build && node scripts/wall-check.mjs
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> node scripts/wall-check.mjs
//
// Playwright is not a dependency of this package (a browser run needs it, a
// build does not), so a run without it says SKIP and exits 0.
import { spawn } from 'node:child_process';
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
const SAMPLE_FILE = process.env.SAMPLE_FILE ?? path.resolve(FRONTEND, '../sample/_3279791-develop-check.jpg');
const APP_PORT = Number(process.env.APP_PORT ?? 5033);
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 5034);
const FRAMES = Number(process.env.FRAMES ?? 300);
const FOLDER = 'WallRoll';
// Every frame is the same bytes under a different name: what is being checked is
// how many cards the wall will draw, not what is on them.
const names = Array.from({ length: FRAMES }, (_, i) => `frame-${String(i + 1).padStart(4, '0')}.jpg`);
let failures = 0;
function step(name, ok, detail = '') {
if (!ok) failures++;
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
return ok;
}
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
if (!playwright) {
console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE=<path to its index.mjs>');
process.exit(0);
}
// --- the roll, off a local server (the page fetches it by CORS) --------------
const jpg = await readFile(SAMPLE_FILE);
const sampleServer = createServer((req, res) => {
const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0]));
if (!names.includes(name)) {
res.writeHead(404).end();
return;
}
res.writeHead(200, {
'Content-Type': 'image/jpeg',
'Content-Length': jpg.length,
'Access-Control-Allow-Origin': '*',
});
res.end(jpg);
});
await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve));
// `--host 127.0.0.1`: without it vite's preview binds the name `localhost`, which
// on a dual-stack box can be ::1 alone and every 127.0.0.1 fetch below fails.
// `BASE_URL` points the check at a build already being served instead of at
// `dist/`, which is how the bundle on the box is checked as it stands.
// `detached` so the whole group can be signalled at the end: `npx` spawns a shell
// which spawns vite, and killing only the wrapper leaves vite holding the port.
const app = process.env.BASE_URL
? null
: spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
cwd: FRONTEND,
stdio: 'ignore',
detached: true,
});
const stopApp = () => {
if (!app?.pid) return;
try {
process.kill(-app.pid, 'SIGTERM');
} catch {}
};
const base = process.env.BASE_URL ?? `http://127.0.0.1:${APP_PORT}`;
let up = !app;
for (let i = 0; i < 60 && !up; i++) {
up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false);
if (!up) await new Promise((r) => setTimeout(r, 500));
}
if (!up) {
stopApp();
sampleServer.close();
console.log(`FAIL no preview on ${base} — run \`npm run build\` first (vite preview serves dist/)`);
process.exit(1);
}
// --- the browser ------------------------------------------------------------
const browser = await playwright.chromium.launch({
executablePath: process.env.CHROME || undefined,
args: ['--no-sandbox', '--enable-unsafe-swiftshader'],
});
const context = await browser.newContext({ viewport: { width: 1500, height: 950 } });
// The account the shelf belongs to. `vite preview` has no backend, and the app
// reads a shape off every one of these calls, so the check answers them: an
// empty account is all the wall needs.
await context.route('**/api/**', (route) => {
const url = route.request().url();
const json = (body) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
if (url.includes('/auth/me')) return json({ user: { id: 1, email: 'check@example.com', name: 'check', verified: true, pro: true, admin: false } });
if (url.includes('/photos')) return json({ photos: [] });
if (url.includes('/place')) return json({ place: 'Hanoi' });
if (url.includes('/ratings')) return json({ ratings: [] });
if (url.includes('/highlights')) return json({ highlights: { day: [], week: [], ever: [] } });
if (url.includes('/recipes')) return json({ recipes: [] });
return json({});
});
// Only a person can answer a folder picker; the check stands in for one. The
// stand-in handles carry their methods as *non-enumerable* properties, which is
// what lets them through IndexedDB's structured clone at all (a clone copies
// enumerable own keys, and a function anywhere else is a DataCloneError), and
// they are put back where the catalogue reads them, because a cloned handle is a
// plain object with no `getFile`.
await context.addInitScript(
({ origin, names, folder }) => {
const fileHandle = (name) => {
const handle = { kind: 'file', name, __fake: true };
Object.defineProperty(handle, 'getFile', {
value: async () =>
new File([await (await fetch(`${origin}/${name}`)).arrayBuffer()], name, {
type: 'image/jpeg',
lastModified: Date.UTC(2026, 0, 1),
}),
});
return handle;
};
const dirHandle = (label, node) => {
const handle = { kind: 'directory', name: label, __fake: true };
Object.defineProperties(handle, {
values: { value: () => [...node.files.map(fileHandle)][Symbol.iterator]() },
queryPermission: { value: async () => 'granted' },
requestPermission: { value: async () => 'granted' },
getDirectoryHandle: { value: async () => { throw new Error('no such folder'); } },
getFileHandle: {
value: async (name) => {
if (!node.files.includes(name)) throw new Error(`no frame named ${name}`);
return fileHandle(name);
},
},
});
return handle;
};
const root = () => dirHandle(folder, { files: names });
window.showDirectoryPicker = async () => root();
const revive = (row) => {
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name);
}
return row;
};
for (const method of ['get', 'getAll']) {
const original = IDBObjectStore.prototype[method];
IDBObjectStore.prototype[method] = function (...args) {
const request = original.apply(this, args);
const descriptor = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result');
Object.defineProperty(request, 'result', {
configurable: true,
get() {
const value = descriptor.get.call(this);
return Array.isArray(value) ? value.map(revive) : revive(value);
},
});
return request;
};
}
},
{ origin: `http://127.0.0.1:${SAMPLE_PORT}`, names, folder: FOLDER }
);
const page = await context.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(String(e.message).slice(0, 200)));
const read = () =>
page.evaluate(() => {
const el = document.querySelector('.adm-grid');
return {
cards: el ? el.querySelectorAll('.adm-card').length : 0,
wall: el ? el.scrollHeight > el.clientHeight : false,
// The white screen itself: no rail, no words, nothing drawn.
blank: document.body.innerText.trim().length < 40 && !document.querySelector('nav.rail'),
};
});
await page.goto(`${base}/library`);
await page.click('[data-key="lib-add"]');
await page.waitForSelector(`[data-key="lib-node-${FOLDER}"]`, { timeout: 60_000 });
await page
.waitForFunction(
(n) => (document.querySelector('[data-key="lib-count"]')?.textContent ?? '').includes(String(n)),
FRAMES,
{ timeout: 240_000, polling: 250 }
)
.catch(() => {});
const counted = await page.$eval('[data-key="lib-count"]', (e) => e.textContent?.trim() ?? '');
step('the roll is read whole', counted.includes(String(FRAMES)), counted);
await page.click('[data-key="lib-view-grid"]');
await page.waitForSelector('.adm-grid', { timeout: 15_000 });
await page.waitForFunction(() => document.querySelectorAll('.adm-card').length > 0, null, { timeout: 30_000 });
await page.waitForTimeout(2000);
const wall = await read();
step('the wall draws a screenful of the shelf', wall.cards > 0 && wall.cards < FRAMES, `${wall.cards} of ${FRAMES} cards drawn`);
step('and stands in for the rows it is not drawing', wall.wall, 'the shelf scrolls');
// The class of trigger, made to happen: one input the wall reads back off itself,
// moving on every pass.
await page.evaluate(() => {
const el = document.querySelector('.adm-grid');
const h = el.clientHeight;
let n = 0;
Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => (n++ % 2 ? h : h + 300) });
el.dispatchEvent(new Event('scroll'));
});
await page.waitForTimeout(8000);
const after = await read();
const looped = errors.filter((e) => e.includes('185') || e.includes('update depth'));
step(
'a wall fed its own window does not take the screen down',
!after.blank && after.cards > 0 && looped.length === 0,
`blank=${after.blank}, cards=${after.cards}, errors=${looped.length ? looped[0] : 'none'}`
);
step('the wall is still the wall', after.cards > 0, `${after.cards} cards after the feed`);
await page.screenshot({ path: process.env.SHOT ?? '/tmp/wall-check.png' }).catch(() => {});
if (errors.length) console.log('page errors:', errors.slice(0, 6).join(' // '));
await browser.close();
stopApp();
sampleServer.close();
console.log(failures ? `\n${failures} FAILED` : '\nall ok');
process.exit(failures ? 1 : 0);
@@ -0,0 +1,79 @@
// The WB preset table's two colour decisions, checked where they are made.
//
// A preset button is filled with temperatureSwatch(kelvin) and labelled in
// readableInk(fill). Both are one formula over the whole range, and both fail
// quietly in the direction nobody notices: a swatch that stops moving with K
// still looks like a swatch, and an ink that is merely the theme's text colour
// on a mid-grey fill is legible to whoever picked the theme and unreadable to
// everyone on the other one. So this walks the ruler and holds every swatch to
// WCAG's 4.5:1 against the ink it was given — plus the direction of the cast,
// which is the whole reason the fill is a colour rather than a name.
//
// node scripts/wb-table-check.mjs
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import ts from 'typescript';
const transpile = (path) =>
ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
// colorUtils imports types only, so the compiler drops that line itself.
const dir = mkdtempSync(join(tmpdir(), 'wb-table-check-'));
writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts'));
const { readableInk, temperatureSwatch } = await import(pathToFileURL(join(dir, 'colorUtils.mjs')).href);
// WCAG contrast ratio between two #rrggbb colours.
const channel = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
const rel = (hex) => {
const n = parseInt(hex.slice(1), 16);
return 0.2126 * channel(((n >> 16) & 255) / 255) + 0.7152 * channel(((n >> 8) & 255) / 255) + 0.0722 * channel((n & 255) / 255);
};
const ratio = (a, b) => {
const [hi, lo] = [rel(a), rel(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
const PRESETS = [5500, 5500, 5500, 6500, 7500, 3200, 4000]; // AUTO..FLUOR, WB_PRESETS' kelvins
const RULER = [...Array(151).keys()].map((i) => 2500 + i * 50);
// 1. The ink is always one of the two, and always the one that reads better.
const inkOf = (k) => readableInk(temperatureSwatch(k));
assert.ok(['#0b0e12', '#ffffff'].includes(readableInk('#808080')));
assert.ok(['#0b0e12', '#ffffff'].includes(readableInk('#000000')));
assert.ok(['#0b0e12', '#ffffff'].includes(readableInk('#ffffff')));
assert.equal(readableInk('#ffffff'), '#0b0e12', 'a white fill carries dark ink');
assert.equal(readableInk('#000000'), '#ffffff', 'a black fill carries light ink');
for (const fill of ['#7f7f7f', '#808080', '#517eff', '#947d61', '#e8e8e8', '#101010']) {
const ink = readableInk(fill);
const other = ink === '#ffffff' ? '#0b0e12' : '#ffffff';
assert.ok(ratio(fill, ink) >= ratio(fill, other), `${fill}: ${ink} is not the better of the two`);
}
// 2. Every swatch on the ruler, and every preset's, clears 4.5:1 under its ink.
for (const k of [...RULER, ...PRESETS]) {
const bg = temperatureSwatch(k);
assert.match(bg, /^#[0-9a-f]{6}$/, `${k}K paints ${bg}`);
const ink = readableInk(bg);
const r = ratio(bg, ink);
assert.ok(r >= 4.5, `${k}K: ${ink} on ${bg} is ${r.toFixed(2)}:1`);
}
// 3. The fill runs the way the ruler does: warmer as K rises, i.e. red up and
// blue down, so the table reads as a scale rather than seven greys.
const low = temperatureSwatch(2500);
const high = temperatureSwatch(10000);
const chan = (hex, shift) => (parseInt(hex.slice(1), 16) >> shift) & 255;
assert.ok(chan(low, 16) < chan(high, 16), `red does not rise: ${low} -> ${high}`);
assert.ok(chan(low, 0) > chan(high, 0), `blue does not fall: ${low} -> ${high}`);
// 4. And the fills collapse the way the kelvins do — five over the seven, since
// the three 5500K presets differ by TINT and the swatch is the temperature's.
const fills = new Set(PRESETS.map(temperatureSwatch));
assert.equal(fills.size, 5, `the presets paint ${fills.size} different fills`);
console.log(`wb table: ${RULER.length} swatches >= 4.5:1 under readableInk, ${low} -> ${high} across the ruler, ${fills.size} fills over 7 presets`);
+17 -1
View File
@@ -4,7 +4,10 @@ export type FrameId =
| 'classic-white'
| 'cinematic'
| 'wallframe' // WALL FRAME PORTRAIT — the artwork hung 90° CW
| 'wallframe-landscape'; // the same artwork upright: WALL FRAME LANDSCAPE
| 'wallframe-landscape' // the same artwork upright: WALL FRAME LANDSCAPE
| 'old-film' // OLD FILM LANDSCAPE — the distressed sheet as it is
| 'old-film-portrait' // OLD FILM PORTRAIT — the same sheet turned 90° CW
| 'custom'; // CUSTOM IMPORTED FRAME — transparent PNG/WebP overlay
// Viewfinder/composition aspect-ratio selector. Convention is an UPRIGHT
// (portrait) picture: "4:3" = standing 3:4 photo (w/h = 3/4), "3:2" = standing
@@ -164,8 +167,20 @@ export interface GradientMask {
shadows?: number;
whites?: number;
blacks?: number;
highlight?: number;
shadow?: number;
white?: number;
black?: number;
clarity?: number;
dehaze?: number;
vibrance?: number;
// The LIGHT column's WB pair on the mask's own pixels: the same two rulers,
// the same units (kelvin, and the store's ±10 tint), the same gain — a local
// white balance, which is what a mask that sits under a mixed light is for.
// Absent on a mask stored before they existed, and then the gain is exactly 1
// (5500K / 0), so nothing moves and the recipe reads the same.
temperature?: number;
tint?: number;
}
export interface ColorAdjustments {
@@ -231,6 +246,7 @@ export interface Recipe {
baseFilter: BaseFilter;
adjustments: ColorAdjustments;
frameId?: FrameId; // e.g. 'none', 'polaroid', 'classic-white', 'cinematic', 'wallframe'
framePanX?: number; // horizontal pan offset (-0.5 to 0.5) when landscape photo is in portrait frame
cinema?: CinemaSeason | null; // seasonal atmosphere grade (optional)
useGeotag: boolean;
}
+55 -13
View File
@@ -117,6 +117,40 @@ export function kelvinToRGB(kelvin: number): { r: number; g: number; b: number }
]);
}
// The whole white balance in one place — Kelvin AND tint — because it is now
// asked for twice: once frame-wide by the colour matrix below, once per gradient
// mask by gradientMask.ts, where a mask's WB is meant to be the same move on a
// smaller area rather than a second opinion about what the two knobs mean.
//
// Tint is the green↔magenta axis, and it is symmetric: + pushes magenta (red and
// blue up, green down), - pushes green (green up, red and blue down). The old
// version only ever ADDED a channel (magenta lifted R+B, green lifted G), so the
// knob brightened the frame instead of tinting it. Normalising the product keeps
// that at a tint shift, not an exposure one. `tint` is the store's own -10..+10.
export function whiteBalanceGain(temperature: number, tint: number): { r: number; g: number; b: number } {
const rgbTemp = kelvinToRGB(temperature);
const tintMagenta = (tint / 10) * 0.08;
return normalizeGainLuma([
rgbTemp.r * (1 + tintMagenta),
rgbTemp.g * (1 - tintMagenta),
rgbTemp.b * (1 + tintMagenta),
]);
}
// Kelvin -> the CAST a WB swatch prints. Not the colour of a blackbody at that
// K: the number names the light the scene was shot under, so the engine
// compensates for it and WARMS the picture as K rises (kelvinToRGB). A swatch
// painted with the light's own colour would therefore run the other way from the
// picture — orange under a knob that blue-d the frame. Measuring the ruler means
// painting what the render does: the engine's own gains on a mid grey, which is
// the same matrix step the preview puts on every pixel, so this can never drift
// from it. Halved, so the swatch sits mid-grey in tone whatever the cast.
export function temperatureSwatch(kelvin: number): string {
const g = kelvinToRGB(kelvin);
const chan = (v: number) => Math.round(Math.min(255, Math.max(0, v * 255))).toString(16).padStart(2, '0');
return `#${chan(0.5 * g.r)}${chan(0.5 * g.g)}${chan(0.5 * g.b)}`;
}
// How hard that excursion is allowed to bite. The physical ratio is brutal (the
// blue channel carries 12x at 2500K) and it lands on sRGB-encoded, already
// tone-mapped pixels, where the raw value clips the ends flat. 0.5 is the
@@ -159,6 +193,25 @@ function normalizeGainLuma(gain: number[]): { r: number; g: number; b: number }
return { r: gain[0] * s, g: gain[1] * s, b: gain[2] * s };
}
// The text colour a #rrggbb fill can carry. A white-balance swatch is a mid grey
// by construction — the engine's gains halved — so a label left at the theme's
// own text colour sits on it at whatever contrast the two happen to have, and
// the pale end of the ruler leaves it unreadable. The pair is chosen by the one
// rule that answers "which of the two reads better here": the WCAG relative
// luminance of the fill against black and against white, whichever is the larger
// ratio — the smaller of the two can fall under 4.5:1, the larger cannot.
// ponytail: two inks, not a per-theme palette; a third is a design decision, not
// a bug.
export function readableInk(bg: string): string {
const n = parseInt(bg.slice(1), 16);
const lin = (v: number) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
const rel =
0.2126 * lin(((n >> 16) & 255) / 255) + 0.7152 * lin(((n >> 8) & 255) / 255) + 0.0722 * lin((n & 255) / 255);
const withBlack = (rel + 0.05) / 0.05;
const withWhite = 1.05 / (rel + 0.05);
return withBlack >= withWhite ? '#0b0e12' : '#ffffff';
}
// Sim-owned exposure bias: brightness a stock needs that is part of the stock
// itself, never a user knob. Kept beside the matrices it compensates so the
// values are one hop from the rows they lift. Only LC STREETLIFE VIVID carries
@@ -178,7 +231,7 @@ const SIM_EXPOSURE_BIAS_EV: Partial<Record<BaseFilter, number>> = {
// button decides. Raise it toward 0.5 for Lightroom's full travel, and accept
// that every recipe already saved with a non-zero EXPOSURE gets brighter with
// it.
const EV_PER_UNIT = 0.25;
export const EV_PER_UNIT = 0.25;
// The EXPOSURE knob and the sim bias, in stops, for the linear-light pass in
// exportEngine. Separate from the colour matrix on purpose: the matrix works on
@@ -413,18 +466,7 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
// 5. White Balance (Kelvin Temperature and Tint) — skip for monochrome to prevent color leakage
if (!isMonochromeBase(baseFilter)) {
const rgbTemp = kelvinToRGB(temperature);
// Tint is the green↔magenta axis, and it is symmetric: + pushes magenta
// (red and blue up, green down), - pushes green (green up, red and blue
// down). The old version only ever ADDED a channel (magenta lifted R+B,
// green lifted G), so the knob brightened the frame instead of tinting it.
// Normalising the product keeps that at a tint shift, not an exposure one.
const tintMagenta = (tint / 10) * 0.08;
const wbGain = normalizeGainLuma([
rgbTemp.r * (1 + tintMagenta),
rgbTemp.g * (1 - tintMagenta),
rgbTemp.b * (1 + tintMagenta),
]);
const wbGain = whiteBalanceGain(temperature, tint);
const wbMat = [
wbGain.r, 0, 0, 0, 0,
+20 -14
View File
@@ -95,6 +95,12 @@ export function filmSimToRecipe(sim: FilmSim): Recipe {
};
}
// The shipped recipes. HIGHLIGHT and SHADOW here are written in the KNOB's unit,
// and both knobs ride HALF an anchor per unit (see a1/a3 in toneShader.ts), so a
// recipe that wants a given roll-off asks for double what the knot move reads —
// the same note FILM_TONE carries. The look is the knot, not the number, which is
// why these were doubled when the two knobs' reach was halved: a recipe that had
// its highlights rolled to 0.70 still rolls them there.
export const DEFAULT_RECIPES: Recipe[] = [
{
id: 'classic-neg-default',
@@ -106,8 +112,8 @@ export const DEFAULT_RECIPES: Recipe[] = [
saturation: -1,
temperature: 6300,
tint: 2,
highlight: -2,
shadow: 1,
highlight: -4,
shadow: 2,
denoise: 1,
clarity: 2,
grain: 4,
@@ -130,8 +136,8 @@ export const DEFAULT_RECIPES: Recipe[] = [
saturation: 3,
temperature: 5500,
tint: 0,
highlight: 1,
shadow: -1,
highlight: 2,
shadow: -2,
denoise: 2,
clarity: 4,
grain: 1,
@@ -208,8 +214,8 @@ export const DEFAULT_RECIPES: Recipe[] = [
saturation: 1,
temperature: 7500,
tint: -2,
highlight: -3,
shadow: 2,
highlight: -6,
shadow: 4,
denoise: 3,
clarity: -2,
grain: 5,
@@ -233,8 +239,8 @@ export const DEFAULT_RECIPES: Recipe[] = [
saturation: 1,
temperature: 5800,
tint: 0,
highlight: -1,
shadow: 1,
highlight: -2,
shadow: 2,
denoise: 2,
clarity: 1,
grain: 3,
@@ -258,8 +264,8 @@ export const DEFAULT_RECIPES: Recipe[] = [
saturation: 2,
temperature: 6000,
tint: -1,
highlight: 1,
shadow: -1,
highlight: 2,
shadow: -2,
denoise: 1,
clarity: 2,
grain: 2,
@@ -283,8 +289,8 @@ export const DEFAULT_RECIPES: Recipe[] = [
saturation: 2,
temperature: 7200,
tint: -2,
highlight: -2,
shadow: 1,
highlight: -4,
shadow: 2,
denoise: 2,
clarity: 0,
grain: 4,
@@ -308,8 +314,8 @@ export const DEFAULT_RECIPES: Recipe[] = [
saturation: -2,
temperature: 4500,
tint: 2,
highlight: -2,
shadow: 2,
highlight: -4,
shadow: 4,
denoise: 3,
clarity: -2,
grain: 3,
+87 -60
View File
@@ -1,10 +1,12 @@
import type { GradientMask } from '../types';
import { whiteBalanceGain } from './colorUtils';
import {
CLARITY_GAIN,
DEHAZE_FLOOR_T,
DEHAZE_MAX_OMEGA,
DEHAZE_PATCH_STEP,
DEHAZE_PATCH_TAPS,
TONE_MATH_SKSL,
} from './toneShader';
// FX tab > LINEAR / RADIAL GRADIENT — Lightroom's two gradient masks, the local
@@ -30,17 +32,18 @@ import {
//
// The spec's section 4 — "the system needs to restrict all the above effects to
// operate only within the mask's area" — is the rest of the block below: the
// smoothstep soft masks that carry HIGHLIGHT and SHADOW, the two ends WHITE and
// BLACK move, and the two spatial ones (CLARITY against the frame's own blur,
// DEHAZE through the dark channel) that the caller hands in the blurred
// reference for and the atmospheric light for. Every one of them rides the same
// alpha the shape produces and lands through the same `mix`, so a mask at half
// strength is half of the move.
// four tonal-range knobs (the frame-wide ramp, moved on the mask's own pixels),
// and the two spatial ones (CLARITY against the frame's own blur, DEHAZE through
// the dark channel) that the caller hands in the blurred reference for and the
// atmospheric light for. Every one of them rides the same alpha the shape
// produces and lands through the same `mix`, so a mask at half strength is half
// of the move.
export const MASK_KIND = { linear: 0, radial: 1 } as const;
// Exposure is stored as the EV itself — the spec's own -5..+5 — because that is
// what `pow(2.0, e)` reads, and a stop is a stop whatever the app's slider units
// are elsewhere. The other two are -10..+10 like every other knob, and are turned
// into the spec's -1..+1 on the way to the shader.
// what `exp2(e)` spends on the light (toneShader.EXPOSURE_SKSL), and a stop is a
// stop whatever the app's slider units are elsewhere. The other two are -10..+10
// like every other knob, and are turned into the spec's -1..+1 on the way to the
// shader.
export const MASK_EXPOSURE_MAX = 5;
// How much of the semi-axis a fresh ellipse fades over. Half: the edge is soft
// enough to be a gradient mask rather than a cut-out, and every pixel of the
@@ -57,6 +60,10 @@ const num = (v: unknown, fallback: number) => {
};
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
const clampA = (v: number) => (v < -10 ? -10 : v > 10 ? 10 : v);
// The frame-wide WB ruler's own two ends, and its own neutral: a mask that never
// touched WB carries 5500K / 0, whose gain is exactly 1.
const NEUTRAL_K = 5500;
const clampK = (v: number) => (v < 2500 ? 2500 : v > 10000 ? 10000 : v);
const clampEV = (v: number) => (v < -MASK_EXPOSURE_MAX ? -MASK_EXPOSURE_MAX : v > MASK_EXPOSURE_MAX ? MASK_EXPOSURE_MAX : v);
// The stored masks, made readable: numbers, inside the frame, one kind — the
@@ -75,6 +82,9 @@ export type ReadMask = GradientMask & {
blacks: number;
clarity: number;
dehaze: number;
vibrance: number;
temperature: number;
tint: number;
};
export function readMasks(masks: GradientMask[] | undefined): ReadMask[] {
@@ -91,18 +101,24 @@ export function readMasks(masks: GradientMask[] | undefined): ReadMask[] {
ry: Math.max(0, num(m.ry, 0)),
angle: num(m.angle, 0),
feather: clamp01(num(m.feather, MASK_DEFAULT_FEATHER)),
exposure: clampEV(num(m.exposure, 0)),
exposure: clampEV(num(m.exposure ?? (m as any).ev, 0)),
contrast: clampA(num(m.contrast, 0)),
saturation: clampA(num(m.saturation, 0)),
saturation: clampA(num(m.saturation ?? (m as any).color, 0)),
// The spec's section 4 knobs: the two tone soft masks, the two ends, and
// the two spatial moves. All -10..+10 like every other knob here, all 0 on
// a mask stored before they existed.
highlights: clampA(num(m.highlights, 0)),
shadows: clampA(num(m.shadows, 0)),
whites: clampA(num(m.whites, 0)),
blacks: clampA(num(m.blacks, 0)),
highlights: clampA(num(m.highlights ?? m.highlight, 0)),
shadows: clampA(num(m.shadows ?? m.shadow, 0)),
whites: clampA(num(m.whites ?? m.white, 0)),
blacks: clampA(num(m.blacks ?? m.black, 0)),
clarity: clampA(num(m.clarity, 0)),
dehaze: clampA(num(m.dehaze, 0)),
vibrance: clampA(num(m.vibrance, 0)),
// The WB pair, the two rulers the LIGHT column already carries, read on the
// mask's own pixels: kelvin on its own scale, tint in the store's ±10. Both
// absent on a mask stored before they existed, and then the gain is 1.
temperature: clampK(num(m.temperature, NEUTRAL_K)),
tint: clampA(num(m.tint, 0)),
}))
.filter((m) => (m.kind === 'linear' ? Math.hypot(m.ex - m.x, m.ey - m.y) > MASK_MIN : m.rx > 0 && m.ry > 0));
}
@@ -119,11 +135,12 @@ export function masksHaveSpatial(masks: GradientMask[]): boolean {
// The uniform block the shader for `n` masks reads: the shapes, the ellipse
// parameters, the knobs with the kind, the tone soft masks, the spatial pair,
// then the frame the fractions are of — and, when the caller has one, the
// atmospheric light the dehaze reads. Declaration order, arrays expanded — one
// buffer is one upload per render, the same shape healUniforms and
// mosaicUniforms use. Its length is a function of the list, not a fixed
// capacity, because the shader carries exactly the masks the recipe holds.
// the white balance gain, then the frame the fractions are of — and, when the
// caller has one, the atmospheric light the dehaze reads. Declaration order,
// arrays expanded — one buffer is one upload per render, the same shape
// healUniforms and mosaicUniforms use. Its length is a function of the list, not
// a fixed capacity, because the shader carries exactly the masks the recipe
// holds.
export function maskUniforms(
masks: GradientMask[],
width: number,
@@ -132,7 +149,7 @@ export function maskUniforms(
): Float32Array {
const list = readMasks(masks);
const n = list.length;
const u = new Float32Array((5 * n + 1 + (air ? 1 : 0)) * 4);
const u = new Float32Array((6 * n + 1 + (air ? 1 : 0)) * 4);
for (let i = 0; i < n; i++) {
const m = list[i];
u.set([m.x, m.y, m.ex, m.ey], i * 4);
@@ -143,28 +160,33 @@ export function maskUniforms(
[m.exposure, m.contrast / 10, m.saturation / 10, MASK_KIND[m.kind]],
(2 * n + i) * 4
);
// The tone soft masks and the two ends, then the spatial pair.
// The tone soft masks and the two ends, then the spatial pair & vibrance.
u.set([m.highlights / 10, m.shadows / 10, m.whites / 10, m.blacks / 10], (3 * n + i) * 4);
u.set([m.clarity / 10, m.dehaze / 10, 0, 0], (4 * n + i) * 4);
u.set([m.clarity / 10, m.dehaze / 10, m.vibrance / 10, 0], (4 * n + i) * 4);
// The mask's own white balance, resolved here and not in the shader: the
// Kelvin fit is a curve, and a curve in SkSL would be a second copy of it
// (colorUtils.whiteBalanceGain is the one). Three gains and a zero pad, so
// one array of the same shape as the rest.
const wb = whiteBalanceGain(m.temperature, m.tint);
u.set([wb.r, wb.g, wb.b, 0], (5 * n + i) * 4);
}
u.set([width, height, 0, 0], 5 * n * 4);
if (air) u.set([air[0], air[1], air[2], 0], (5 * n + 1) * 4);
u.set([width, height, 0, 0], 6 * n * 4);
if (air) u.set([air[0], air[1], air[2], 0], (6 * n + 1) * 4);
return u;
}
// The colour inside a mask, in the spec's own order and, for the knobs the app
// also has frame-wide, in the app's own formulas: exposure first (a power of
// two, so a stop is a stop), then contrast about the middle, then saturation as
// a mix away from the pixel's own REC-709 luma. Then the spec's section 2 and 3
// on top — HIGHLIGHT and SHADOW through the two smoothstep soft masks its own
// formula names, WHITE and BLACK as the per-channel point moves TONE_SKSL
// makes, and the two spatial ones against the blurred reference the caller hands
// in. Every one of them means inside the mask what it means on the whole frame
// (toneShader.ts is the reference the four tonal ones are written from), because
// the same name on the same knob should not be two different moves. The result
// is clamped to the range a file can hold — the spec's own guard, and it is
// `mix`ed back over the base by the mask's alpha, so a mask at half strength is
// half of the move rather than the whole of it.
// The colour inside a mask, in the spec's own order and, for every knob the app
// also has frame-wide, in the app's own formulas: EXPOSURE first (one stop of
// LIGHT, through exposureMove), then contrast about the middle, then saturation
// as a mix away from the pixel's own REC-709 luma. Then the four tonal-range
// knobs and the two spatial ones — the four through the frame-wide ramp
// (TONE_MATH_SKSL), CLARITY and DEHAZE against the blurred reference the caller
// hands in. Every one of them means inside the mask what it means on the whole
// frame, because the same name on the same knob must not be two different moves;
// toneShader.ts is the one copy all of them are read from. The result is clamped
// to the range a file can hold — the spec's own guard, and it is `mix`ed back
// over the base by the mask's alpha, so a mask at half strength is half of the
// move rather than the whole of it.
//
// `blur` is the frame's bilateral reference (the same one CLARITY uses
// frame-wide) and `air` the atmospheric light; both are the constants 0 when the
@@ -173,31 +195,34 @@ export function maskUniforms(
// for them (masksHaveSpatial). `dark` is the patch's dark channel for DEHAZE,
// read in the block below (the frame's own pixels are only in reach there).
const adjustFn = (spatial: boolean) => `
half3 maskAdjust(half3 c, float4 a, float4 tone, float4 fx, half dark${spatial ? ', half3 blur, float3 air' : ''}) {
c = c * half(pow(2.0, a.x));
half3 maskAdjust(half3 c, half3 wb, float4 a, float4 tone, float4 fx, half dark${spatial ? ', half3 blur, float3 air' : ''}) {
// EXPOSURE through the frame-wide function, on the mask's pixels: one stop of
// LIGHT (a linear-light move, so 2^ev is what it multiplies) and a move of the
// luma rather than of the three channels, so the knob brightens a colour
// instead of shifting its hue when a channel reaches the ceiling.
c = half3(exposureMove(vec3(c), a.x));
// The mask's own white balance, the frame-wide WB gain on the mask's pixels: a
// gain on each channel, hoisted to the JS side because the Kelvin fit is a
// curve (colorUtils.whiteBalanceGain). 5500K / 0 hands over (1,1,1), so a mask
// that never touched the pair costs three multiplies by one and nothing else.
c = clamp(c * wb, half3(0.0), half3(1.0));
c = (c - half(0.5)) * half(1.0 + a.y) + half(0.5);
half l = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722));
// The tonal four, in TONE_SKSL's own formulas: a mask's HIGHLIGHT is meant to
// be the same move as the whole-frame HIGHLIGHT on a smaller area, not a
// second opinion about what the name means. HIGHLIGHT and SHADOW are additive
// shifts of the luma, HIGHLIGHT weighted by the headroom left (1 - l) so it
// cannot drag a blown white to grey, and the colour difference rides along at
// a damped gain (TONE_SKSL's own cg) so a lift or a pull cannot collapse a
// colour. WHITE and BLACK are per-channel point moves, cubic in each channel's
// own distance from the end it owns: the toe and the shoulder move, the
// midtones do not, and a white that is lowered stays white.
// The spec's soft masks, computed in float and narrowed: smoothstep on half is
// one more type the shader does not have to guess at.
// The tonal four, through the frame-wide ramp (TONE_MATH_SKSL): a mask's
// HIGHLIGHT is the same move as the whole-frame HIGHLIGHT on a smaller area,
// not a second opinion about what the name means — the four knots, their
// ordering clamp, the 0.50 midpoint no knob reaches, and the luma-preserving
// rebuild are all the frame's. DR is the one knob the ramp also carries that a
// mask does not have, so it is spent as 0 here.
float lf = clamp(float(l), 0.0, 1.0);
float mh = smoothstep(0.50, 1.00, lf);
float ms = 1.0 - smoothstep(0.00, 0.55, lf);
half lifted = half(clamp(lf + tone.x * mh * (1.0 - lf) + tone.y * 0.34 * ms, 0.0, 1.0));
half cg = clamp(lifted / max(l, half(0.0004)), half(0.55), half(1.35));
c = clamp(half3(lifted) + (c - half3(l)) * cg, half3(0.0), half3(1.0));
half3 dk = half3(1.0) - c;
c = clamp(c + half(tone.w * 0.18) * dk * dk * dk + half(tone.z * 0.18) * c * c * c, half3(0.0), half3(1.0));
c = half3(toneRamp(vec3(c), lf, lf, tone.w, tone.y, tone.x, tone.z, 0.0));
half nl = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722));
c = mix(half3(nl), c, half(1.0 + a.z));
// Saturation & Vibrance matching toneShader's exact chroma-masked formula
half mx = max(max(c.r, c.g), c.b);
half mn = min(min(c.r, c.g), c.b);
half chroma = mx > half(0.0001) ? (mx - mn) / mx : half(0.0);
half kv = half(1.0) + half(fx.z) * half(0.75) * (half(1.0) - chroma);
c = clamp(mix(half3(nl), c, half(1.0 + a.z) * kv), half3(0.0), half3(1.0));
${spatial ? ` // DEHAZE before CLARITY, the frame-wide order and for the frame-wide reason:
// sharpening haze only makes it read as detail.
if (fx.y != 0.0) {
@@ -270,7 +295,7 @@ ${spatial ? ` // The patch's dark channel for DEHAZE, one tap per DEHAZE_PA
}
}
}` : ' half dark = half(0.0);'}
c.rgb = mix(c.rgb, maskAdjust(c.rgb, adj[${i}], tone[${i}], fx[${i}], dark${spatial ? ', blurred.eval(pos).rgb, air.xyz' : ''}), half(a));
c.rgb = mix(c.rgb, maskAdjust(c.rgb, wb[${i}].rgb, adj[${i}], tone[${i}], fx[${i}], dark${spatial ? ', blurred.eval(pos).rgb, air.xyz' : ''}), half(a));
}
}
`;
@@ -293,8 +318,10 @@ uniform float4 rads[${count}];
uniform float4 adj[${count}];
uniform float4 tone[${count}];
uniform float4 fx[${count}];
uniform float4 wb[${count}];
uniform float4 size;
${spatial ? 'uniform shader blurred;\nuniform float4 air;' : ''}
${TONE_MATH_SKSL}
${adjustFn(spatial)}
half4 main(float2 pos) {
half4 c = img.eval(pos);${Array.from({ length: count }, (_, i) => maskBlock(i, spatial)).join('')}
+65 -39
View File
@@ -1,4 +1,5 @@
import { ColorAdjustments } from '../types';
import { EV_PER_UNIT } from './colorUtils';
// Static metadata for the continuous (slider) adjustments of the IQ/WB/FILTERS
// tabs — shared between AdjustmentPanel (chips + slider rows) and App (which
@@ -21,30 +22,54 @@ export interface ContinuousParamDef {
const sign = (v: number) => (v > 0 ? `+${v}` : String(v));
const oneEv = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} EV`;
// The spec reads EXPOSURE to two decimals, so "+0.50" and "-0.25" and a plain
// "0.00" — the EV unit is already on the row's own label, not in its readout.
const twoStops = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(2)}`;
// The bipolar develop knobs read -100..+100: the spec's own numbers (the panel
// draws every TONE and PRESENCE slider from -100 to 100, and the tone-mapping doc
// normalises that /100 on the way to its uniforms). The recipe keeps the ±10 unit
// every look has always been filed in, so the ×10 belongs to the slider and not
// to the store: a look saved, shared or shipped in DEFAULT_RECIPES before this
// still means exactly what it meant. A knob that is not bipolar — a temperature
// in kelvin, an amount that only goes up — keeps its own scale, because -100 of
// a percentage is nothing. EXPOSURE is the odd one out: its slider is in stops
// (-5..+5, two decimals, the spec's own numbers) while the store still holds the
// ±10 units EV_PER_UNIT converts, so its accessors translate instead of scaling
// and every recipe's brightness is untouched.
const HUNDRED = new Set(['contrast', 'color', 'vibrance', 'highlight', 'shadow', 'whites', 'blacks', 'tint', 'denoise', 'clarity', 'dehaze', 'sharpening']);
const deepen = (def: ContinuousParamDef): ContinuousParamDef =>
HUNDRED.has(def.key)
? { ...def, min: -100, max: 100, get: (a) => def.get(a) * 10, set: (v) => def.set(v / 10) }
: def;
export const PARAM_DEFS: {
iq: ContinuousParamDef[];
wb: ContinuousParamDef[];
filters: ContinuousParamDef[];
} = {
iq: [
iq: ([
{
key: 'exposure',
label: 'EXPOSURE',
min: -10,
max: 10,
step: 1,
min: -5,
max: 5,
step: 0.01,
defaultValue: 0,
display: sign,
get: (a) => a.exposure,
set: (v) => ({ exposure: v }),
display: twoStops,
// Stops out, units in: the store has carried ±10 units since the first
// recipe, so the knob translates rather than replacing the scale. The
// /4 lands on a whole 0.04 for every step the slider can emit, which keeps
// the stored number readable in a recipe file.
get: (a) => (a.exposure ?? 0) * EV_PER_UNIT,
set: (v) => ({ exposure: Math.round((v / EV_PER_UNIT) * 1e4) / 1e4 }),
},
{
key: 'ev',
label: 'EV',
min: -3,
max: 3,
step: 0.5,
step: 0.1,
defaultValue: 0,
display: oneEv,
get: (a) => a.exposureCompensation ?? 0,
@@ -105,8 +130,35 @@ export const PARAM_DEFS: {
get: (a) => a.shadow ?? 0,
set: (v) => ({ shadow: v }),
},
],
wb: [
{
// The two ends of the same ramp the four tone sliders move together (see
// TONE_SKSL): WHITE is the knot on 1.00 and BLACK the one on 0.00, each
// pulled toward the middle as the knob comes down. Not a white-balance
// move any more, so they sit with the other two on the TONE panel. Same
// range as the CREATE form's rows, so a look round-trips.
key: 'whites',
label: 'WHITE',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.whites ?? 0,
set: (v) => ({ whites: v }),
},
{
key: 'blacks',
label: 'BLACK',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.blacks ?? 0,
set: (v) => ({ blacks: v }),
},
] as ContinuousParamDef[]).map(deepen),
wb: ([
{
key: 'temperature',
label: 'COLOR TEMP',
@@ -129,34 +181,8 @@ export const PARAM_DEFS: {
get: (a) => a.tint ?? 0,
set: (v) => ({ tint: v }),
},
{
// The two points live on WB, not on LIGHT: they shift each channel's own
// end of the ramp, which balances a cast at the toe and the shoulder
// rather than adding another tone knob. Same range as the CREATE form's
// rows, so a look round-trips.
key: 'whites',
label: 'WHITE',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.whites ?? 0,
set: (v) => ({ whites: v }),
},
{
key: 'blacks',
label: 'BLACK',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.blacks ?? 0,
set: (v) => ({ blacks: v }),
},
],
filters: [
] as ContinuousParamDef[]).map(deepen),
filters: ([
{
key: 'denoise',
label: 'NOISE REDUCTION',
@@ -256,5 +282,5 @@ export const PARAM_DEFS: {
get: (a) => a.vignette ?? 0,
set: (v) => ({ vignette: v }),
},
],
] as ContinuousParamDef[]).map(deepen),
};
+4 -1
View File
@@ -13,7 +13,10 @@ const KEY = 'RecipesCam::recipe-share::v1';
const ALGORITHM = 'xor16-v1';
const BASE_FILTERS: BaseFilter[] = [...FILM_SIMS.map((s) => s.baseFilter), 'none'];
const FRAMES: FrameId[] = ['none', 'polaroid', 'classic-white', 'cinematic', 'wallframe', 'wallframe-landscape'];
const FRAMES: FrameId[] = [
'none', 'polaroid', 'classic-white', 'cinematic', 'wallframe', 'wallframe-landscape',
'old-film', 'old-film-portrait',
];
const hash = (s: string): number => {
let h = 0x811c9dc5;
File diff suppressed because it is too large Load Diff
+20 -3
View File
@@ -183,9 +183,10 @@ export function Admin() {
setAlbum(userId);
};
// Block/unblock and remove/restore share one route; the list is reloaded
// because either flag also changes what the built-in viewer would show.
const setUserState = (id: number, patch: { blocked?: boolean; removed?: boolean }) =>
// Block/unblock, remove/restore and the PRO grant share one route; the list
// is reloaded because any of the flags also changes what the built-in viewer
// would show.
const setUserState = (id: number, patch: { blocked?: boolean; removed?: boolean; pro?: boolean }) =>
run(async () => {
await api.adminSetUser(id, patch);
await refreshPhotos();
@@ -604,6 +605,7 @@ export function Admin() {
<th>{t('adm.userName')}</th>
<th>{t('adm.userEmail')}</th>
<th>{t('adm.userRole')}</th>
<th className="adm-pro">{t('adm.userPro')}</th>
<th>{t('adm.userPhotos')}</th>
<th>{t('adm.userJoined')}</th>
<th />
@@ -644,6 +646,21 @@ export function Admin() {
{u.admin ? t('adm.roleAdmin') : t('adm.roleMember')}
</span>
</td>
<td className="adm-pro">
{/* The PRO grant. Ticking it hands this account the
studio's PRO tier on its next request; the API
reads the row, not the session, so nothing has
to be signed in again. An admin account is PRO
by allowlist, so its box only reports that. */}
<input
type="checkbox"
data-key={`adm-user-pro-${u.id}`}
aria-label={t('adm.userPro')}
checked={u.pro}
disabled={busy || u.admin}
onChange={() => void setUserState(u.id, { pro: !u.pro })}
/>
</td>
<td>{u.photos}</td>
<td>{new Date(u.createdAt).toLocaleDateString()}</td>
<td>
+1492 -282
View File
File diff suppressed because it is too large Load Diff
+52 -19
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api, type Highlights, type Photo, type PhotoSlot, type Rating, type User } from './api';
import { refreshAuth } from './auth';
import { clearSession } from './engine/session';
import { useI18n } from './i18n/I18nProvider';
import { ACCENTS, FONT_SETS, useTheme, type FontSetId } from './theme/ThemeProvider';
@@ -29,6 +30,14 @@ type Txt = { en: string; vi: string };
// hand (see the effect).
const AWARD_MS = 5000;
const AWARD_FADE_MS = 1000;
// What a frame's votes won it. The first two are windows of the clock; `ever`
// is the fallback the API sends when neither window has a vote in it, and it
// claims no window — a quiet week must not dress a month-old best as today's.
const AWARD_LABEL = {
day: { en: 'Recipe of the day', vi: 'Công thức của ngày' },
week: { en: 'Recipe of the week', vi: 'Công thức của tuần' },
ever: { en: 'Most-loved recipe', vi: 'Công thức được yêu nhất' },
} as const;
// No page can really stop a save — the bytes are already on the machine. What it
// can do is refuse to hand over more pixels than it draws: the photo is decoded,
@@ -284,7 +293,7 @@ export function Landing() {
const [user, setUser] = useState<User | null>(null);
const [photos, setPhotos] = useState<Photo[]>([]);
const [ratings, setRatings] = useState<Record<string, Rating>>({});
const [highlights, setHighlights] = useState<Highlights>({ day: [], week: [] });
const [highlights, setHighlights] = useState<Highlights>({ day: [], week: [], ever: [] });
// Which award frame the hero shows. It steps forward forever and wraps modulo
// the list, so a reload that changes the list cannot land it out of range.
const [awardIdx, setAwardIdx] = useState(0);
@@ -319,10 +328,10 @@ export function Landing() {
api.listPhotos().then((r) => setPhotos(r.photos)).catch(() => undefined);
api.ratings().then((r) => setRatings(r.ratings)).catch(() => undefined);
api.highlights().then((r) => setHighlights(r.highlights)).catch(() => undefined);
return api.me().then((r) => {
setUser(r.user);
return r.user;
}).catch(() => null);
return refreshAuth().then((u) => {
setUser(u);
return u;
});
}, []);
useEffect(() => {
@@ -443,14 +452,20 @@ export function Landing() {
}, [photos]);
// The hero's award column: today's best-rated contributions first, then this
// week's, each frame carrying the award its own votes won — a frame that took
// both is the day's. The labels and the file come from the public listing, so
// a tally whose photo is no longer in it is dropped rather than shown bare.
// week's, then — only when neither window has a vote — the all-time bests.
// Each frame carries the award its own votes won, and a frame that took more
// than one keeps the tighter claim: the day's before the week's, both before
// ever. The labels and the file come from the public listing, so a tally whose
// photo is no longer in it is dropped rather than shown bare.
const awards = useMemo(() => {
const by = new Map(photos.map((p) => [p.id, p]));
const seen = new Set<number>();
const out: { photo: Photo; avg: number; n: number; kind: 'day' | 'week' }[] = [];
for (const [kind, list] of [['day', highlights.day], ['week', highlights.week]] as const) {
const out: { photo: Photo; avg: number; n: number; kind: keyof typeof AWARD_LABEL }[] = [];
for (const [kind, list] of [
['day', highlights.day],
['week', highlights.week],
['ever', highlights.ever],
] as const) {
for (const tally of list) {
const photo = by.get(tally.id);
if (!photo || seen.has(tally.id)) continue;
@@ -599,14 +614,14 @@ export function Landing() {
className={`chip${mode === 'light' ? ' on' : ''}`}
onClick={() => setMode('light')}
>
Light
<span className="chip-label">Light</span>
</button>
<button
type="button"
className={`chip${mode === 'dark' ? ' on' : ''}`}
onClick={() => setMode('dark')}
>
Dark
<span className="chip-label">Dark</span>
</button>
<button
type="button"
@@ -614,7 +629,7 @@ export function Landing() {
data-key="theme-auto"
onClick={() => setMode('auto')}
>
Auto
<span className="chip-label">Auto</span>
</button>
</div>
{/* The same colour groups the workspace offers; the page's own
@@ -628,7 +643,7 @@ export function Landing() {
className={`chip${accent === entry.id ? ' on' : ''}`}
onClick={() => setAccent(entry.id)}
>
{entry.label}
<span className="chip-label">{entry.label}</span>
</button>
))}
</div>
@@ -653,7 +668,16 @@ export function Landing() {
aria-label={t('nav.language')}
onClick={() => setLang(lang === 'vi' ? 'en' : 'vi')}
>
{lang.toUpperCase()}
{/* On a phone the button wears a globe; on a wide screen it wears
the code, where there is room to say which language is up.
Both live in the DOM — neither is the button's name (`aria-label`
above is), so which one is drawn is a stylesheet's business. */}
<svg className="lp-tool-globe" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<ellipse cx="12" cy="12" rx="4.2" ry="9" />
<path d="M3.6 9h16.8M3.6 15h16.8" />
</svg>
<span className="lp-tool-code">{lang.toUpperCase()}</span>
</button>
</span>
{/* Sign in is the default: the dialog opens here, on the page the
@@ -706,6 +730,16 @@ export function Landing() {
))}
</div>
</div>
{/* The trial notice: the last shelf of the bar, under the menu line. It
scrolls because it is one long sentence that would otherwise wrap on
a phone and stand as a paragraph. One copy only, and it walks the
whole way — in past the right edge, out past the left — so exactly
one sentence is in flight at any moment, phone included. */}
<div className="lp-notice" role="status" data-key="trial-notice">
<div className="lp-notice-track">
<span>{t('notice.trial')}</span>
</div>
</div>
{menu ? (
<div className="lp-sheet">
{nav.map((entry) => <a key={entry.href} href={entry.href} onClick={() => setMenu(false)}>{c(entry.label)}</a>)}
@@ -767,11 +801,10 @@ export function Landing() {
{award ? (
<aside className="lp-award" data-key="lp-award">
{/* The award is the frame's own: today's winner wears the day's,
a frame only this week's votes name wears the week's. */}
a frame only this week's votes name wears the week's, and one
no window named wears the all-time label. */}
<span className="lp-award-kind" data-key="lp-award-kind">
{c(award.kind === 'day'
? { en: 'Recipe of the day', vi: 'Công thức của ngày' }
: { en: 'Recipe of the week', vi: 'Công thức của tuần' })}
{c(AWARD_LABEL[award.kind])}
</span>
{/* Every frame of the column is drawn and only the one standing is
opaque, so the swap IS the two fades — no state machine on top
File diff suppressed because it is too large Load Diff
+53 -12
View File
@@ -2,6 +2,8 @@
// /api), the Vite dev server proxies it too — so no base URL, no CORS.
import type { Recipe } from '../shared/types';
import { shrinkForUpload, resizedJpeg } from './engine/imageOps';
import { forgetStale, hold, keepStale, serverAnswered, serverGone, stale } from './pwa/offline';
import { t } from './i18n/I18nProvider';
// Upload caps. The API + nginx both refuse oversized bodies, so shrink in the
// browser first; the server then sniffs the bytes and requires the declared
@@ -19,6 +21,9 @@ export interface User {
// Signed-in accounts only unlock the PRO tier once their address is proven;
// an unverified one is served exactly like a guest. Admins count as verified.
verified?: boolean;
// The PRO tier itself: the proof above, or a grant the operator ticked for
// this account in the admin users table.
pro?: boolean;
// A ready-to-use picture URL (`/api/users/<id>/avatar?v=<file>`), or null.
// The version segment is the file's own name, so a replacement is never
// served from cache.
@@ -102,12 +107,15 @@ export interface PhotoTally {
at: string;
}
// The two windows the award column reads: `day` is today's votes, `week` this
// ISO week's — so today's winners appear in both, and the column shows them
// once, as the day's.
// The windows the award column reads: `day` is today's votes, `week` this ISO
// week's — so today's winners appear in both, and the column shows them once,
// as the day's. `ever` is every vote ever cast, and it is filled only when both
// windows came back empty: with nothing to name a frame today or this week, the
// best of all time is the only honest answer the column has left.
export interface Highlights {
day: PhotoTally[];
week: PhotoTally[];
ever: PhotoTally[];
}
// One account as /api/admin/users reports it. `avatar` is the ready-made URL
@@ -123,6 +131,8 @@ export interface AdminUser {
// but restorable (a hard delete drops the row entirely).
blocked: boolean;
removed: boolean;
// The "Activated Pro" box: true hands this account the studio's PRO tier.
pro: boolean;
}
// One row of a grouped count on the traffic screen. The API sorts them, largest
@@ -149,14 +159,38 @@ export interface Stats {
}
async function call<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`/api${path}`, {
credentials: 'same-origin',
headers: init?.body ? { 'content-type': 'application/json' } : undefined,
...init,
});
// A read is answered from what the server last said. A write is not answered
// at all when no server is there — pretending it landed is a lie the screen
// would act on — but it is not lost either: it is held, in order, and sent
// when one answers. See `hold`/`flush` in pwa/offline.ts.
const read = !init?.method || init.method === 'GET';
let res: Response;
try {
res = await fetch(`/api${path}`, {
credentials: 'same-origin',
headers: init?.body ? { 'content-type': 'application/json' } : undefined,
...init,
});
} catch (err) {
// No answer at all: the server is gone. Serve the last one it gave for this
// read, or fail as before — the caller has its own empty state.
serverGone();
// A write goes on the queue instead. The one exception is the sole call that
// is not about the server's own data: `open-explorer` asks it to pop a
// folder on this machine, and popping it minutes later is not popping it.
if (!read && path !== '/open-explorer') {
hold(path, init?.method ?? 'POST', typeof init?.body === 'string' ? init.body : null);
throw new Error(t('offline.queued'));
}
const staleOne = read ? await stale<T>(path) : null;
if (staleOne !== null) return staleOne;
throw err;
}
serverAnswered();
if (res.status === 204) return undefined as T;
const body = await readJson(res);
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
if (read) keepStale(path, body);
return body as T;
}
@@ -214,10 +248,14 @@ export const api = {
// null user = signed out; the API answers 200 either way.
me: () => call<{ user: User | null }>('/auth/me'),
signup: (email: string, password: string) =>
call<{ user: User }>('/auth/signup', { method: 'POST', body: JSON.stringify({ email, password }) }),
call<{ user: User }>('/auth/signup', { method: 'POST', body: JSON.stringify({ email, password }) })
// The answers held offline are the previous account's and the next one must
// not be shown them; this browser is not an account until it has one.
.finally(forgetStale),
login: (email: string, password: string) =>
call<{ user: User }>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
logout: () => call<void>('/auth/logout', { method: 'POST' }),
call<{ user: User }>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) })
.finally(forgetStale),
logout: () => call<void>('/auth/logout', { method: 'POST' }).finally(forgetStale),
// Mail the verification link to the signed-in address again. Works while
// unverified (that is the whole point); 429 once the hourly cap is spent.
resendVerification: () => call<{ ok: boolean; verified?: boolean }>('/auth/resend-verification', { method: 'POST' }),
@@ -310,7 +348,7 @@ export const api = {
// Moderation of an account. Both flags are reversible; `adminDeleteUser` is
// the final act and takes the account's photos and recipes with it.
adminSetUser: (id: number, patch: { blocked?: boolean; removed?: boolean }) =>
adminSetUser: (id: number, patch: { blocked?: boolean; removed?: boolean; pro?: boolean }) =>
call<{ user: AdminUser }>(`/admin/users/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
adminDeleteUser: (id: number) => call<void>(`/admin/users/${id}`, { method: 'DELETE' }),
@@ -345,4 +383,7 @@ export const api = {
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
return body as unknown as { user: User };
},
// Open photo location in native system file manager (Windows Explorer / Finder / Linux file manager)
openExplorer: (path: string) => call<{ ok: boolean }>('/open-explorer', { method: 'POST', body: JSON.stringify({ path }) }),
};
+68
View File
@@ -0,0 +1,68 @@
// Who this browser is signed in as, in one place.
//
// Three screens ask the same question — the studio (its account menu and its
// tier), the library (whether to open at all) and the top bar (whether the
// LIBRARY link is a link). Read separately, each of them answers it once and then
// keeps that answer for the life of the page — the library is mounted once and
// never asked again — so a visitor who signs in at the studio left the library
// behind holding "guest"
// — the next click on LIBRARY handed them a panel asking for a login they had just
// given. One record, one reader.
import { useSyncExternalStore } from 'react';
import { api, type User } from './api';
export interface AuthState {
user: User | null;
// The API has answered once: the account is known, guest or not. Before that
// `user` is null because nobody has said — which is not the same as "guest", and
// is why the LIBRARY link is held back rather than switched off, and why opening
// `/library` on a record nobody has answered for yet is not a reason to send the
// visitor home.
known: boolean;
}
let state: AuthState = { user: null, known: false };
let inFlight: Promise<User | null> | null = null;
const listeners = new Set<() => void>();
export function authState(): AuthState {
return state;
}
export function subscribeAuth(fn: () => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
// The snapshot is a value the store replaces only when it changes, so React can
// compare it by identity — one object per answer, not one per render.
export function useAuth(): AuthState {
return useSyncExternalStore(subscribeAuth, authState);
}
// Take the answer the caller already has — a login, a logout, `/me` on the studio's
// own mount — so every subscribed screen moves at once.
export function setAuthUser(user: User | null): void {
if (state.known && state.user === user) return;
state = { user, known: true };
for (const fn of listeners) fn();
}
// Ask the API again. One request at a time: the studio and the library both ask as
// they mount, and one page load is one round trip. A call that fails leaves the
// record as it was — the API being away is not the visitor's account ending.
export function refreshAuth(): Promise<User | null> {
if (!inFlight) {
inFlight = api
.me()
.then((me) => {
setAuthUser(me?.user ?? null);
return me?.user ?? null;
})
.catch(() => null)
.finally(() => {
inFlight = null;
});
}
return inFlight;
}
+13 -6
View File
@@ -1,12 +1,18 @@
// One table for the guest/user split, so the policy can change without touching
// the UI. Guest = the landing + a capped, watermarked demo; user = full tools,
// full-resolution export, saved recipes.
// One table for the three tiers, so the policy can change without touching the
// UI. guest = the landing + a capped, watermarked demo; member = any signed-in
// account, which is the basic tier: recipes, the photo folder and a clean export
// of the photo it is editing; pro = the operator's Activated Pro box, which adds
// the PRO tools (HSL, TOOLS and GRADIENT MASK, RAW) and lets the export run past
// the photo's own pixels.
//
// `maxExportDim` here is the tier's own ceiling. The basic tier's ceiling is the
// photo being edited, which only the caller knows — see `exportCap` in App.
export type TierId = 'guest' | 'user';
export type TierId = 'guest' | 'member' | 'pro';
export interface Tier {
id: TierId;
/** Longest edge of an exported photo, or null for the original size. */
/** Longest edge of an exported photo, or null for the caller's own ceiling. */
maxExportDim: number | null;
/** Text burned into every export, or null for a clean file. */
watermark: string | null;
@@ -15,5 +21,6 @@ export interface Tier {
export const TIERS: Record<TierId, Tier> = {
guest: { id: 'guest', maxExportDim: 2048, watermark: 'RecipesCam demo', batchExport: false },
user: { id: 'user', maxExportDim: null, watermark: null, batchExport: true },
member: { id: 'member', maxExportDim: null, watermark: null, batchExport: false },
pro: { id: 'pro', maxExportDim: null, watermark: null, batchExport: true },
};
+34 -4
View File
@@ -10,14 +10,37 @@ let assets: RenderAssets | null = null;
// font service in a browser, so the list is exactly what the bundle carries —
// and each family is ONE file, because drawText has no glyph fallback: a name
// mapped to only the latin subset would print a Vietnamese place name as tofu.
// The two here are the site's own self-hosted faces (styles/fonts.css, SIL OFL),
// The first two are the site's own self-hosted faces (styles/fonts.css, SIL OFL),
// with their latin / latin-ext / vietnamese woff2 subsets decompressed, pinned
// to weight 400 @ opsz 14 and merged into one TTF per family (fontTools).
// The rest come from google/fonts (SIL OFL), subset to the same latin /
// latin-ext / vietnamese ranges with pyftsubset (fontTools); the variable ones
// (Hahmlet, Dancing Script @ wght 400, Tilt Warp @ XROT 0 YROT 0) are pinned to
// that one named instance first. Every file keeps whatever glyph its source had
// inside those ranges — no invented ones: Tilt Warp has no U+0111, the rest do.
export const MARK_FACES: { family: string; file: string }[] = [
{ family: 'Inter', file: '/assets/Inter-Regular.ttf' },
{ family: 'Fraunces', file: '/assets/Fraunces-Regular.ttf' },
{ family: 'VT323', file: '/assets/VT323-Regular.ttf' },
{ family: 'Alex Brush', file: '/assets/AlexBrush-Regular.ttf' },
{ family: 'Tilt Warp', file: '/assets/TiltWarp-Regular.ttf' },
{ family: 'Hahmlet', file: '/assets/Hahmlet-Regular.ttf' },
{ family: 'Pinyon Script', file: '/assets/PinyonScript-Regular.ttf' },
{ family: 'Allura', file: '/assets/Allura-Regular.ttf' },
{ family: 'Dancing Script', file: '/assets/DancingScript-Regular.ttf' },
{ family: 'Amatic SC', file: '/assets/AmaticSC-Regular.ttf' },
];
// The two frame sheets are drawn by hand and dropped over the file of the same
// name, and that name is the one thing neither cache lets go of: nginx answers
// /assets/ immutable for 30 days (nginx.conf) and the service worker serves the
// path cache-first (sw.js) — both stored for the sheet that was there the day the
// visitor first loaded the app, so a sheet swapped in today arrives as yesterday's.
// The build stamp in the query is what makes the URL move with the build; the old
// entry stays in both caches, unasked for, and ages out on its own.
const ART = `?v=${import.meta.env.VITE_VERSION ?? ''}`;
// The fonts never change under a build, so they keep the plain URL.
async function buf(url: string): Promise<ArrayBuffer> {
const res = await fetch(url);
if (!res.ok) throw new Error(`fetch ${url}: ${res.status}`);
@@ -34,13 +57,20 @@ export function probeGpu(): boolean {
export async function loadEngine(): Promise<{ assets: RenderAssets; gpu: boolean }> {
await initSkia({ locateFile: (file) => `/wasm/${file}` });
if (!assets) {
const [textFont, emojiFont, wallframePng, markFonts] = await Promise.all([
const [textFont, emojiFont, wallframePng, oldFilmPng, markFonts] = await Promise.all([
buf('/assets/Cousine-Regular.ttf'),
buf('/assets/NotoEmoji-GPS.ttf'),
buf('/assets/wallframe.png'),
buf(`/assets/wallframe.png${ART}`),
buf(`/assets/old_film.png${ART}`),
Promise.all(MARK_FACES.map(async (face) => ({ family: face.family, bytes: await buf(face.file) }))),
]);
assets = { textFont, emojiFont, wallframe: new Uint8Array(wallframePng), markFonts };
assets = {
textFont,
emojiFont,
wallframe: new Uint8Array(wallframePng),
oldFilm: new Uint8Array(oldFilmPng),
markFonts,
};
}
return { assets, gpu: probeGpu() };
}
+253
View File
@@ -0,0 +1,253 @@
// Custom imported transparent photo frame engine and storage
export interface CustomFrameHole {
x: number; // 0..1 fraction of frame width
y: number; // 0..1 fraction of frame height
w: number; // 0..1 fraction of frame width
h: number; // 0..1 fraction of frame height
}
export interface CustomFrameData {
id: string;
name: string;
bytes: Uint8Array;
dataUrl: string;
width: number;
height: number;
hole: CustomFrameHole;
scale: number; // default 1.0 (cover/fill hole)
panX: number; // offset in frame pixels
panY: number; // offset in frame pixels
}
const DB_NAME = 'recipescam-studio';
const STORE = 'custom_frames';
const FRAME_KEY = 'active_custom_frame';
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 2);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains('session')) {
db.createObjectStore('session');
}
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function saveCustomFrame(frame: CustomFrameData): Promise<void> {
try {
const db = await openDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(frame, FRAME_KEY);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
} catch (err) {
console.warn('Failed to save custom frame to IndexedDB', err);
}
}
export async function loadCustomFrame(): Promise<CustomFrameData | null> {
try {
const db = await openDb();
const row = await new Promise<CustomFrameData | undefined>((resolve, reject) => {
const tx = db.transaction(STORE, 'readonly');
const req = tx.objectStore(STORE).get(FRAME_KEY);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
db.close();
if (row && row.bytes instanceof Uint8Array && row.hole) {
return row;
}
return null;
} catch {
return null;
}
}
export async function clearCustomFrame(): Promise<void> {
try {
const db = await openDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).delete(FRAME_KEY);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
} catch {}
}
/**
* Analyzes an imported frame file (PNG, WebP, SVG, etc.),
* decodes it, and detects the transparent opening/hole via alpha channel analysis.
*/
export async function analyzeCustomFrame(file: File): Promise<CustomFrameData> {
const bytes = new Uint8Array(await file.arrayBuffer());
const blob = new Blob([bytes], { type: file.type || 'image/png' });
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error('Cannot decode frame image'));
img.src = dataUrl;
});
const w = img.naturalWidth || img.width;
const h = img.naturalHeight || img.height;
if (!(w > 0 && h > 0)) {
throw new Error('Invalid image dimensions');
}
// Downscale slightly for fast and accurate pixel analysis (max 600px)
const maxDim = 600;
const sampleScale = Math.min(1, maxDim / Math.max(w, h));
const sw = Math.max(10, Math.round(w * sampleScale));
const sh = Math.max(10, Math.round(h * sampleScale));
const canvas = document.createElement('canvas');
canvas.width = sw;
canvas.height = sh;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) {
throw new Error('Canvas 2D context not available');
}
ctx.drawImage(img, 0, 0, sw, sh);
const imgData = ctx.getImageData(0, 0, sw, sh);
const data = imgData.data;
// Transparent threshold: alpha < 70 (out of 255)
const isAlphaLow = (x: number, y: number) => {
return data[(y * sw + x) * 4 + 3] < 70;
};
// 1. Flood-fill from outer edges to distinguish outer transparent padding from the inner transparent hole
const visited = new Uint8Array(sw * sh);
const queue: number[] = [];
for (let x = 0; x < sw; x++) {
if (isAlphaLow(x, 0)) {
visited[0 * sw + x] = 1;
queue.push(x, 0);
}
if (isAlphaLow(x, sh - 1)) {
visited[(sh - 1) * sw + x] = 1;
queue.push(x, sh - 1);
}
}
for (let y = 0; y < sh; y++) {
if (isAlphaLow(0, y) && !visited[y * sw + 0]) {
visited[y * sw + 0] = 1;
queue.push(0, y);
}
if (isAlphaLow(sw - 1, y) && !visited[y * sw + (sw - 1)]) {
visited[y * sw + (sw - 1)] = 1;
queue.push(sw - 1, y);
}
}
let head = 0;
while (head < queue.length) {
const qx = queue[head++];
const qy = queue[head++];
const neighbors = [
[qx + 1, qy],
[qx - 1, qy],
[qx, qy + 1],
[qx, qy - 1],
];
for (const [nx, ny] of neighbors) {
if (nx >= 0 && nx < sw && ny >= 0 && ny < sh) {
const idx = ny * sw + nx;
if (!visited[idx] && isAlphaLow(nx, ny)) {
visited[idx] = 1;
queue.push(nx, ny);
}
}
}
}
// 2. Find bounding box of inner transparent pixels (not reached by flood-fill from border)
let minX = sw;
let minY = sh;
let maxX = -1;
let maxY = -1;
let innerTransparentCount = 0;
for (let y = 0; y < sh; y++) {
for (let x = 0; x < sw; x++) {
const idx = y * sw + x;
if (isAlphaLow(x, y) && !visited[idx]) {
innerTransparentCount++;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
// 3. Fallback: if no inner transparent pixel was isolated, search for any transparent pixels
if (innerTransparentCount < 20 || minX > maxX) {
minX = sw;
minY = sh;
maxX = -1;
maxY = -1;
let anyCount = 0;
for (let y = 0; y < sh; y++) {
for (let x = 0; x < sw; x++) {
if (isAlphaLow(x, y)) {
anyCount++;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
innerTransparentCount = anyCount;
}
// 4. Compute normalized hole geometry
let hole: CustomFrameHole = { x: 0.1, y: 0.1, w: 0.8, h: 0.8 };
if (innerTransparentCount > 20 && minX <= maxX && minY <= maxY) {
const holeW = Math.max(0.05, (maxX - minX + 1) / sw);
const holeH = Math.max(0.05, (maxY - minY + 1) / sh);
hole = {
x: minX / sw,
y: minY / sh,
w: holeW,
h: holeH,
};
}
return {
id: `custom-${Date.now()}`,
name: file.name,
bytes,
dataUrl,
width: w,
height: h,
hole,
scale: 1.0,
panX: 0,
panY: 0,
};
}
+502 -48
View File
@@ -6,15 +6,17 @@
// - `Skia.Data.fromURI` + expo file APIs → the caller hands in the JPEG bytes
// and gets JPEG bytes back; the file save / gallery / MediaLibrary tail is
// gone (the browser downloads a Blob instead).
// - `MakeMatrixConvolution` does not exist in CanvasKit. The two convolution
// steps (CLARITY, output sharpen) run through `CLARITY_SKSL` from
// `toneShader.ts` — the same kernel `[0,-a,0,-a,1+4a,-a,0,-a,0]`, evaluated
// as a shader pass. `convolve` replaces the pixels outright (the shader's
// output is opaque and covers the same rect), so no clearing pass is needed.
// - `MakeMatrixConvolution` does not exist in CanvasKit. The output sharpen
// (and CLARITY's own kernel) run through shader passes instead —
// `SHARPEN_SKSL` for the knob, the same kernel with the doc's Sobel edge
// mask and detail coring on it. `convolve` replaces the pixels outright (the
// shader's output is opaque and covers the same rect), so no clearing pass
// is needed.
// - The negative-side blurs stay on `ImageFilter.MakeBlur`, but a blur cannot
// be composited over the un-blurred content, so those passes snapshot, clear
// and redraw. Order matches RN's nested filter chain: denoise → clarity →
// mist → soften.
// mist → soften. NOISE REDUCTION's chroma reference is one of those blurs,
// and only its chroma (NR_SKSL) — the knob used to smear all three channels.
// - EXIF rewrite (`writeJpegExif`) is not ported yet; `patchJpegDpi(300)`
// alone keeps the print size metadata.
// ponytail: EXIF says "W4 if the web export needs the camera/lens tags".
@@ -27,14 +29,16 @@ import {
TONE_SKSL,
EXPOSURE_SKSL,
GLOW_SKSL,
CLARITY_SKSL,
SHARPEN_SKSL,
CLARITY_BLUR_SKSL,
CLARITY_BLEND_SKSL,
CLARITY_GAIN,
NR_SKSL,
NR_CHROMA_SPAN,
DEHAZE_SKSL,
dehazeUniformArray,
dehazePatchStep,
getToneUniforms,
TONE_BASE_RADIUS,
TONE_BASE_SIGMA,
toneIsActive,
toneUniformArray,
glowUniformArray,
@@ -72,6 +76,7 @@ export interface RenderAssets {
textFont: ArrayBuffer; // assets/Cousine-Regular.ttf
emojiFont: ArrayBuffer; // assets/NotoEmoji-GPS.ttf
wallframe: Uint8Array; // wallframe.png
oldFilm: Uint8Array; // old_film.png
// The FONT option's faces beside DEFAULT (see assets.ts MARK_FACES).
markFonts: { family: string; bytes: ArrayBuffer }[];
}
@@ -95,6 +100,13 @@ export interface RenderOptions {
// quarter turn and the fine angle (they ride on top of both).
photoFlipH?: boolean;
photoFlipV?: boolean;
customFrame?: {
bytes: Uint8Array;
hole: { x: number; y: number; w: number; h: number };
scale?: number;
panX?: number;
panY?: number;
} | null;
// The strip the FILE carries: the amber `#TAG` over the photo and a caption
// band appended below it, mirroring the landing page's `.lp-shot` +
// `.lp-frame-meta`. Left off the live preview, which must not show it.
@@ -166,10 +178,11 @@ let halationEffect: any = null;
let curveEffect: any = null;
let clarityBlurEffect: any = null;
let clarityBlendEffect: any = null;
let nrEffect: any = null;
let dehazeEffect: any = null;
function effects() {
if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(CLARITY_SKSL);
if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(SHARPEN_SKSL);
if (!toneEffect) toneEffect = Skia.RuntimeEffect.Make(TONE_SKSL);
if (!exposureEffect) exposureEffect = Skia.RuntimeEffect.Make(EXPOSURE_SKSL);
if (!cinemaEffect) cinemaEffect = Skia.RuntimeEffect.Make(CINEMA_SKSL);
@@ -178,6 +191,7 @@ function effects() {
if (!curveEffect) curveEffect = Skia.RuntimeEffect.Make(CURVE_SKSL);
if (!clarityBlurEffect) clarityBlurEffect = Skia.RuntimeEffect.Make(CLARITY_BLUR_SKSL);
if (!clarityBlendEffect) clarityBlendEffect = Skia.RuntimeEffect.Make(CLARITY_BLEND_SKSL);
if (!nrEffect) nrEffect = Skia.RuntimeEffect.Make(NR_SKSL);
if (!dehazeEffect) dehazeEffect = Skia.RuntimeEffect.Make(DEHAZE_SKSL);
return {
sharpenEffect,
@@ -189,6 +203,7 @@ function effects() {
curveEffect,
clarityBlurEffect,
clarityBlendEffect,
nrEffect,
dehazeEffect,
};
}
@@ -236,7 +251,7 @@ function maskEffectFor(count: number, spatial: boolean): any {
return effect;
}
// CLARITY_SKSL uniforms are (a, px.x, px.y); px = one source pixel = 1 unit on
// SHARPEN_SKSL uniforms are (a, px.x, px.y); px = one source pixel = 1 unit on
// a 1:1 export canvas, so the radius matches what the preview tuned.
function convolvePaint(srcImage: any, amount: number): any {
const { sharpenEffect: effect } = effects();
@@ -310,6 +325,68 @@ function drawBlurred(canvas: any, surface: any, w: number, h: number, sigma: num
disposeAll([paint, filter, snap]);
}
// The BASE layer of the tone ramp: the frame blurred out to TONE_BASE_RADIUS, as
// an image the tone shader takes as its second child and reads once per pixel.
// Blurred with Skia's own MakeBlur — the same draw drawBlurred makes for the
// sharpening pass — because what the ramp needs is an AVERAGE of the
// neighbourhood and a ring of point samples is not one: sampled on a fine ring
// the luma aliases, the gain o(base)/base inherits the alias, and the
// reconstruction paints it back as mottle (a 1160x774 frame at SHADOW +100 came
// out with 0.063 of high-frequency gain against 0.005 for this blur — see
// TONE_BASE_RADIUS in toneShader.ts). `shaderOf` is left owned by the caller: the
// tone pass reads the same shader as its first child.
function blurredBase(
shaderOf: () => any,
w: number,
h: number,
sigma: number
): any | null {
const surf = createSurface(w, h);
if (!surf) return null;
try {
const shader = shaderOf();
if (!shader) return null;
const filter = Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null);
const paint = Skia.Paint();
paint.setImageFilter(filter);
paint.setShader(shader);
surf.getCanvas().drawRect(Skia.XYWHRect(0, 0, w, h), paint);
flush(surf);
disposeAll([paint, filter]);
return surf.makeImageSnapshot() ?? null;
} finally {
surf.dispose();
}
}
// A blurred copy of what the surface holds right now, as an image a pass can
// take as a child. Same `MakeBlur` as the ramp's base and the negative blurs,
// but built from a SNAPSHOT rather than from a shader the caller still holds —
// so the snapshot's own shader is freed here, and NOISE REDUCTION can read the
// graded pixels of the frame without re-deriving the draw that made them.
function blurredFrame(surface: any, w: number, h: number, sigma: number): any | null {
flush(surface);
const snap = surface.makeImageSnapshot();
if (!snap) return null;
const surf = createSurface(w, h);
const child = surf ? imageShaderChild(snap) : null;
try {
if (!surf || !child) return null;
const filter = Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null);
const paint = Skia.Paint();
paint.setImageFilter(filter);
paint.setShader(child);
surf.getCanvas().drawRect(Skia.XYWHRect(0, 0, w, h), paint);
flush(surf);
disposeAll([paint, filter]);
return surf.makeImageSnapshot() ?? null;
} finally {
if (child) child.dispose();
surf?.dispose();
snap.dispose();
}
}
// --- spatial passes: CLARITY and DEHAZE -------------------------------------
//
// Both of the scratchpad docs' algorithms (raw_parameter_processing... §3 and
@@ -381,6 +458,11 @@ function spatialReference(surface: any, w: number, h: number): any | null {
// answer is the pixel whose DARKEST channel is brightest, so the 4096-pixel copy
// only has to be searched for that one. Null when the readback is unavailable —
// the caller then leaves the pass out rather than guessing a value.
// Where a pixel stops being evidence of A, and the share of the survivors that
// speaks for it (see the note inside).
const DEHAZE_AIR_BLOWN = 0.9;
const DEHAZE_AIR_FRACTION = 0.02;
function atmosphericLight(surface: any, w: number, h: number): [number, number, number] | null {
const n = 32;
const small = createSurface(n, n);
@@ -407,19 +489,39 @@ function atmosphericLight(surface: any, w: number, h: number): [number, number,
colorSpace: Skia.ColorSpace.SRGB,
});
if (!px) return null;
let best = -1;
let air: [number, number, number] = [1, 1, 1];
// A blown pixel's dark channel is 1 whatever the haze is — white cloth the
// camera clipped reads exactly like white haze — so a frame with a white
// anything in it answered A = [1, 1, 1]. That is the worst A to hand
// `J = (I - A)/t + A`: every pixel below 1 goes negative on some channel and
// is clipped back channel by channel, which is the lilac cast on white cloth
// and the bright rim around every dark detail (one pixel over, the patch
// holds a shadow and t is 1 again).
//
// So the pixels that can be evidence of the light are the ones short of
// white, and A is the mean of the brightest of those — the doc's 0.1% is one
// pixel of this 32x32 copy, which a single specular dot would own.
const evidence: Array<[number, number, number]> = [];
for (let i = 0; i < n * n; i++) {
const r = px[i * 4] / 255;
const g = px[i * 4 + 1] / 255;
const b = px[i * 4 + 2] / 255;
const dark = Math.min(r, g, b);
if (dark > best) {
best = dark;
air = [r, g, b];
}
const rgb: [number, number, number] = [
px[i * 4] / 255,
px[i * 4 + 1] / 255,
px[i * 4 + 2] / 255,
];
if (Math.min(rgb[0], rgb[1], rgb[2]) < DEHAZE_AIR_BLOWN) evidence.push(rgb);
}
return air;
// A frame with nothing short of white in it is all haze: the doc's answer
// would be A = 1 and the inversion would be all clipping, so DEHAZE stays off
// instead (the same null the readback failure returns).
if (evidence.length < 2) return null;
evidence.sort((l, r) => Math.min(r[0], r[1], r[2]) - Math.min(l[0], l[1], l[2]));
const keep = Math.max(1, Math.round(evidence.length * DEHAZE_AIR_FRACTION));
const air: [number, number, number] = [0, 0, 0];
for (let i = 0; i < keep; i++) {
air[0] += evidence[i][0];
air[1] += evidence[i][1];
air[2] += evidence[i][2];
}
return [air[0] / keep, air[1] / keep, air[2] / keep];
} catch {
// No readback (a surface that refuses one, a shader that failed): DEHAZE
// stays off for this render.
@@ -430,6 +532,132 @@ function atmosphericLight(surface: any, w: number, h: number): [number, number,
}
}
// DEHAZE's dark channel, the prior's own quantity, built as an image for
// DEHAZE_SKSL to sample — the pass itself turns it into t.
//
// The pass used to take the dark channel's minimum inline, over five taps spaced
// 0.625% of the frame's width apart — one tap every 7 pixels of a 1067-pixel
// preview. A dark detail thinner than that spacing sat between two taps on one
// row of the frame and under a tap on the next, so t swung between "this patch
// holds a shadow, leave it alone" and "this patch is all haze, divide hard" with
// a 7-pixel period around every dark thing in the photo. A rising DEHAZE drew
// that period as bright copies of the detail, stacked beside it.
//
// Reading the dark channel off a small copy of the frame is what removes it: one
// cell of that copy already covers a whole patch, so the neighbourhood is
// covered rather than sampled, and the bilinear upscale interpolates it back with
// no period left in it. The box passes after that are the doc's soft matting in
// the one form that is free here — the map is smoothed, not the pixels.
//
// The dark channel and not t is what goes into that image, because a channel is
// eight bits and only carries 0..1: t is 1 + 0.95 at the negative end of the
// knob, and would arrive clipped to 1, turning "scatter the light back in" into a
// pass that does nothing at all. The dark channel is 0..1 by construction, and
// the signed amount stays a uniform, where it costs no range.
const DEHAZE_MAP_SIZE = 64;
const DEHAZE_MAP_TAPS = 1; // patch reach, in cells of that copy
const DEHAZE_MAP_BLUR = 1; // box radius, in cells of that copy
// One separable box pass over the copy's own grid.
function boxBlurMap(src: Float32Array, n: number, r: number): Float32Array {
const mid = new Float32Array(n * n);
const out = new Float32Array(n * n);
const edge = (v: number) => Math.min(n - 1, Math.max(0, v));
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
let sum = 0;
for (let i = -r; i <= r; i++) sum += src[y * n + edge(x + i)];
mid[y * n + x] = sum / (2 * r + 1);
}
}
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
let sum = 0;
for (let j = -r; j <= r; j++) sum += mid[edge(y + j) * n + x];
out[y * n + x] = sum / (2 * r + 1);
}
}
return out;
}
// Null when the copy cannot be read: the caller then leaves DEHAZE out for this
// render rather than correcting with a t it made up (atmosphericLight's own
// answer to the same failure).
function dehazeDarkChannel(
surface: any,
w: number,
h: number,
air: [number, number, number]
): any | null {
const n = DEHAZE_MAP_SIZE;
const small = createSurface(n, n);
if (!small) return null;
let snap: any = null;
let paint: any = null;
try {
flush(surface);
snap = surface.makeImageSnapshot();
if (!snap) return null;
paint = Skia.Paint();
small.getCanvas().drawImageRect(
snap,
Skia.XYWHRect(0, 0, w, h),
Skia.XYWHRect(0, 0, n, n),
paint
);
flush(small);
const px = small.getCanvas().readPixels(0, 0, {
width: n,
height: n,
colorType: Skia.ColorType.RGBA_8888,
alphaType: Skia.AlphaType.Unpremul,
colorSpace: Skia.ColorSpace.SRGB,
});
if (!px) return null;
const a = [Math.max(air[0], 0.05), Math.max(air[1], 0.05), Math.max(air[2], 0.05)];
const dark = new Float32Array(n * n);
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
let d = 1;
for (let j = -DEHAZE_MAP_TAPS; j <= DEHAZE_MAP_TAPS; j++) {
const cy = Math.min(n - 1, Math.max(0, y + j));
for (let i = -DEHAZE_MAP_TAPS; i <= DEHAZE_MAP_TAPS; i++) {
const o = (cy * n + Math.min(n - 1, Math.max(0, x + i))) * 4;
d = Math.min(d, px[o] / 255 / a[0], px[o + 1] / 255 / a[1], px[o + 2] / 255 / a[2]);
}
}
dark[y * n + x] = Math.min(1, Math.max(0, d));
}
}
const smooth = boxBlurMap(boxBlurMap(dark, n, DEHAZE_MAP_BLUR), n, DEHAZE_MAP_BLUR);
// The dark channel rides the red channel and every byte is opaque, so the
// image is its own premultiplied answer and needs no range the shader decodes.
const bytes = new Uint8Array(n * n * 4);
for (let i = 0; i < n * n; i++) {
const v = Math.min(255, Math.max(0, Math.round(smooth[i] * 255)));
bytes[i * 4] = v;
bytes[i * 4 + 1] = v;
bytes[i * 4 + 2] = v;
bytes[i * 4 + 3] = 255;
}
return Skia.Image.MakeImage(
{
width: n,
height: n,
colorType: Skia.ColorType.RGBA_8888,
alphaType: Skia.AlphaType.Premul,
},
bytes,
n * 4
);
} catch {
return null;
} finally {
disposeAll([paint, snap].filter(Boolean) as SkDisposable[]);
small.dispose();
}
}
// An image as a child shader — what every effect here wants instead of a paint.
// The caller owns the result (the renderer's `own`).
function imageShaderChild(image: any): any {
@@ -470,6 +698,7 @@ function replaceThrough(
let faces: { typeface: any; iconTypeface: any } | null = null;
let wallframeImage: any = null;
let oldFilmImage: any = null;
function loadFaces(assets: RenderAssets) {
if (faces) return faces;
@@ -542,6 +771,21 @@ function loadWallframe(assets: RenderAssets) {
return wallframeImage;
}
// Both films are the same sheet of distressed paper: `old-film` lays it down as
// it is, `old-film-portrait` turns it 90° CW, so the pair is one frame standing
// and one lying — the same two shapes the walls offer, in a sheet whose middle
// is a transparent window a couple of percent of paper wide around it.
const isOldFilm = (id: FrameId) => id === 'old-film' || id === 'old-film-portrait';
// A frame that carries its own opening: the aspect chip has nothing to say to
// it (both walls, both films, custom frames).
const hasOwnAspect = (id: FrameId) => isWallframe(id) || isOldFilm(id) || id === 'custom';
function loadOldFilm(assets: RenderAssets) {
if (oldFilmImage) return oldFilmImage;
oldFilmImage = Skia.Image.MakeImageFromEncoded(assets.oldFilm);
return oldFilmImage;
}
export async function renderPhoto(input: RenderInput): Promise<RenderResult | null> {
const { recipe, frameId, options } = input;
const owned: SkDisposable[] = [];
@@ -577,10 +821,10 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
const aspectWH = options?.aspect && options.aspect !== 'full' ? ASPECT_RATIO_W_H[options.aspect] : null;
const cropRect = options?.cropRect ?? null;
const ratioWH = cropRect ? null : options?.cropWH ?? aspectWH;
// A wall is an artwork with its own opening, so the aspect chip (ratioWH)
// has nothing to say there — but the visitor's own crop does, or a photo
// that was cropped and applied comes back whole behind the frame.
if (cropRect || (ratioWH && !isWallframe(frameId))) {
// A wall or a film is an artwork with its own opening, so the aspect chip
// (ratioWH) has nothing to say there — but the visitor's own crop does, or a
// photo that was cropped and applied comes back whole behind the frame.
if (cropRect || (ratioWH && !hasOwnAspect(frameId))) {
const curAspect = width / height;
let cropW = width;
let cropH = height;
@@ -646,6 +890,13 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
const matrixIsIdentity = matrix.every((v, i) => v === (i % 6 === 0 ? 1 : 0));
// 3b. Tone shader.
// TONE_BASE_RADIUS of the FRAME is the neighbourhood the ramp is drawn
// through, so the preview and the file look at the same size of one — this is
// the one place that knows how big the frame is. The base itself is a blurred
// copy of the tone shader's own child (blurredBase), not a ring of samples
// inside it: see TONE_BASE_RADIUS for why a ring was the mottle bug. A frame
// too small or a surface that will not come up leaves the sharp child as the
// base, which lands the base on the pixel and keeps the global ramp.
const tone = getToneUniforms(adjustments, recipe.baseFilter);
let toneShader: any = null;
// 3c. Cinema seasonal grade (cinema → tone → image).
@@ -687,7 +938,16 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
if (toneIsActive(tone)) {
const { toneEffect: effect } = effects();
if (effect) {
toneShader = own(effect.makeShaderWithChildren(toneUniformArray(tone), [baseShaderOf()]));
// The base is built HERE and not earlier: it draws the same shader the
// tone pass reads, and both have to see the pixels after the exposure.
const sigma = width * TONE_BASE_RADIUS * TONE_BASE_SIGMA;
const base = own(blurredBase(baseShaderOf, width, height, sigma));
toneShader = own(
effect.makeShaderWithChildren(toneUniformArray(tone), [
baseShaderOf(),
base ? own(imageShaderChild(base)) : baseShaderOf(),
])
);
}
}
if (cinemaOn) {
@@ -742,12 +1002,6 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
if (paintShader) paint.setShader(paintShader);
else if (!graded) paint.setColorFilter(colorFilter);
// 4. Denoise (blur only — CLARITY moved to its own pass below, see header).
if (adjustments.denoise > 0) {
const sigma = (adjustments.denoise / 10) * 0.6;
paint.setImageFilter(own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)));
}
// 5. Draw the graded photo.
if (paintShader) {
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), paint);
@@ -755,6 +1009,33 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
canvas.drawImage(skImage, 0, 0, paint);
}
// 4. NOISE REDUCTION — thay_doi_thong_so_giong_lightroom.md §4.1, the colour
// half of it (NR_SKSL). It runs HERE and not on the paint, because it needs
// the finished pixels as one child and a blurred copy of them as the other,
// and a `setImageFilter` on the draw can only offer the shader's input. The
// knob WAS that filter — one sigma over all three channels — so the pass that
// took a colour speckle out took the picture's edges with it. Only the chroma
// is blurred now; the luma is the frame's own, so a strand of hair comes back
// where it was whatever the knob says.
const denoiseKnob = adjustments.denoise ?? 0;
if (denoiseKnob > 0) {
const { nrEffect: denoiseEffect } = effects();
const sigma = (denoiseKnob / 10) * NR_CHROMA_SPAN * width;
// The blur is built before the redraw because the redraw overwrites the
// frame the reference is read out of (the same reason DEHAZE reads its dark
// channel and its light first).
const blurred = denoiseEffect ? own(blurredFrame(surface, width, height, sigma)) : null;
if (denoiseEffect && blurred) {
replaceThrough(canvas, surface, width, height, (snap) => {
const shader = denoiseEffect.makeShaderWithChildren(
[Math.min(1, denoiseKnob / 10)],
[own(imageShaderChild(snap)), own(imageShaderChild(blurred))]
);
return shader ? own(shader) : null;
});
}
}
// 4b. DEHAZE and CLARITY — the frame-wide half of the two docs
// (raw_parameter_processing... §3, ki_n_tr_c_multiple_passes... in full). Both
// compare a pixel against a blurred reference of its own surroundings, so the
@@ -778,22 +1059,64 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
return air;
};
// DEHAZE is signed: positive takes scattered light out, negative puts it
// back (DEHAZE_SKSL's own t moves either side of 1). Either way it reads the
// dark channel from the frame itself — a minimum over a patch, not the
// bilateral reference — so the pass needs no second child and the reference
// is built only for CLARITY and the masks that ask for it.
// back (the transmission it is handed moves either side of 1, DEHAZE_SKSL's
// own note). Its second child is not the bilateral reference CLARITY reads —
// the prior wants a MINIMUM over a patch, not an average — but the smoothed
// transmission built below, so the reference is still built only for CLARITY
// and the masks that ask for it.
//
// The knob is left to over-correct at the top of its range on purpose: a
// hazy sky goes white and a saturated colour beside a dark edge deepens.
// That is `(c - a)/t + a` with an airlight near 0.93 and a plain clamp, the
// arithmetic the doc asks for, and it is smooth across the frame — 6x zoom
// panels of a hazy frame at DEHAZE 100 show one wide gradient, no band
// repeating at any period. The bands that used to be drawn beside dark
// detail were the patch above, and hiding them behind a knee in the shader
// would soften the correction everywhere to answer a bug that is gone.
// ponytail: no knee; if the over-correction ever needs taming the one number
// that does it is DEHAZE_MAX_OMEGA.
const dehazeAmount = (adjustments.dehaze ?? 0) / 10;
// Through `effects()`, not the module variable: nothing above this pass has
// asked for DEHAZE, so on the first render the raw variable is still null and
// the knob did nothing until some later render happened to fill it in.
const { dehazeEffect: effect } = effects();
if (dehazeAmount !== 0 && dehazeEffect) {
replaceThrough(canvas, surface, width, height, (snap) => {
const a = airOf();
if (!a) return null;
const shader = dehazeEffect.makeShaderWithChildren(
dehazeUniformArray(a, dehazeAmount, dehazePatchStep(width)),
[own(imageShaderChild(snap))]
);
return shader ? own(shader) : null;
});
if (dehazeAmount !== 0 && effect) {
// The dark channel is read off the frame the pass is about to overwrite,
// like the light and for the same reason: after the pass the pixels are the
// corrected ones and the dark channel they would give is not the haze's.
const a = airOf();
const darkMap = a ? own(dehazeDarkChannel(surface, width, height, a)) : null;
if (a && darkMap) {
replaceThrough(canvas, surface, width, height, (snap) => {
const shader = dehazeEffect.makeShaderWithChildren(
dehazeUniformArray(a, dehazeAmount),
[
own(imageShaderChild(snap)),
own(
darkMap.makeShaderOptions(
Skia.TileMode.Clamp,
Skia.TileMode.Clamp,
Skia.FilterMode.Linear,
Skia.MipmapMode.None,
// A map this small only covers the frame if it is told to. With
// no matrix the image's own texels ARE the shader's coordinates,
// so `dark.eval` reads the map 1:1: the top-left 64 pixels of
// the frame get the real dark channel and every pixel past them
// clamps onto the map's last texel — one constant t over the
// whole photo, i.e. a global inversion instead of a dehaze.
// CanvasKit reads this matrix as the map's own pixels to the
// frame's (measured: `scaled(n/w)` clamps the same way, the
// reciprocal lands on the ideal ramp), hence frame over map,
// as a plain 3x3 in SkMatrix's own order.
[width / DEHAZE_MAP_SIZE, 0, 0, 0, height / DEHAZE_MAP_SIZE, 0, 0, 0, 1]
)
),
]
);
return shader ? own(shader) : null;
});
}
}
// CLARITY is one move in two directions: the frame against its own blurred
@@ -808,7 +1131,11 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
if (!reference) return null;
own(reference);
const shader = clarityBlendEffect.makeShaderWithChildren(
[(clarityKnob / 10) * (clarityKnob > 0 ? CLARITY_GAIN : 1)],
// Amount in the blend's own [-1, 1] units: the positive side's gain
// (CLARITY_DETAIL_GAIN) lives INSIDE the shader now, so the mask's
// CLARITY_GAIN — a different quantity on a different child — is not
// borrowed for this one and the two knobs stay independent.
[clarityKnob / 10],
[own(imageShaderChild(snap)), own(imageShaderChild(reference))]
);
return shader ? own(shader) : null;
@@ -1141,6 +1468,133 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
stampCanvas = cardCanvas;
stampW = outW;
stampH = outH;
} else if (isOldFilm(frameId)) {
// The sheet has the frame's SHAPE, like the walls: `old-film-portrait` is
// the PNG turned 90° CW, so the pair is one standing frame and one lying.
// The sheet's own 914x609 is far too small to hold a photo, so the frame
// is scaled UP to the photo instead of the photo down to the frame: the
// deckle stays a couple of percent of the print at any export size, which
// is also why the photo is cover-cropped into the whole sheet and not
// into a measured window — the sheet's edge is torn, so it has no straight
// sides to cut against; the photo simply runs under all of it.
flush(surface);
const photoImg = own(surface.makeImageSnapshot());
cardSource = photoImg;
const film = loadOldFilm(input.assets);
if (!film) {
console.error('Failed to decode old_film.png');
return null;
}
const portrait = frameId === 'old-film-portrait';
const sheetW = portrait ? film.height() : film.width();
const sheetH = portrait ? film.width() : film.height();
const scale = Math.max(width / sheetW, height / sheetH);
const outW = Math.round(sheetW * scale);
const outH = Math.round(sheetH * scale);
const cardSurface = createSurface(outW, outH);
if (!cardSurface) return null;
own(cardSurface);
const cardCanvas = cardSurface.getCanvas();
const plainPaint = own(Skia.Paint());
const cover = Math.max(outW / width, outH / height);
const coverW = outW / cover;
const coverH = outH / cover;
cardCanvas.drawImageRectOptions(
photoImg,
Skia.XYWHRect((width - coverW) / 2, (height - coverH) / 2, coverW, coverH),
Skia.XYWHRect(0, 0, outW, outH),
Skia.FilterMode.Linear,
Skia.MipmapMode.None,
plainPaint
);
cardCanvas.save();
if (portrait) {
// The wall-frame portrait's own turn: origin to the top-right corner,
// then 90° CW, so the sheet's width runs down the frame and the drawn
// rect is the frame's height by its width.
cardCanvas.translate(outW, 0);
cardCanvas.rotate(90, 0, 0);
}
cardCanvas.drawImageRectOptions(
film,
Skia.XYWHRect(0, 0, film.width(), film.height()),
portrait ? Skia.XYWHRect(0, 0, outH, outW) : Skia.XYWHRect(0, 0, outW, outH),
Skia.FilterMode.Linear,
Skia.MipmapMode.None,
plainPaint
);
cardCanvas.restore();
outputSurface = cardSurface;
stampCanvas = cardCanvas;
stampW = outW;
stampH = outH;
} else if (frameId === 'custom' && options?.customFrame) {
flush(surface);
const photoImg = own(surface.makeImageSnapshot());
cardSource = photoImg;
const pw = photoImg.width();
const ph = photoImg.height();
const customImg = own(Skia.Image.MakeImageFromEncoded(options.customFrame.bytes));
if (!customImg) {
console.error('Failed to decode custom frame');
return null;
}
const fw = customImg.width();
const fh = customImg.height();
const cardSurface = createSurface(fw, fh);
if (!cardSurface) return null;
own(cardSurface);
const cardCanvas = cardSurface.getCanvas();
const plainPaint = own(Skia.Paint());
const hole = options.customFrame.hole;
const holeX = hole.x * fw;
const holeY = hole.y * fh;
const holeW = hole.w * fw;
const holeH = hole.h * fh;
const holeCenterX = holeX + holeW / 2;
const holeCenterY = holeY + holeH / 2;
// Base cover scale to fill the transparent hole
const baseScale = Math.max(holeW / pw, holeH / ph);
const zoom = options.customFrame.scale ?? 1.0;
const finalScale = baseScale * zoom;
const drawW = pw * finalScale;
const drawH = ph * finalScale;
const panX = options.customFrame.panX ?? 0;
const panY = options.customFrame.panY ?? 0;
const drawX = holeCenterX + panX - drawW / 2;
const drawY = holeCenterY + panY - drawH / 2;
// 1. Draw photo underneath
cardCanvas.drawImageRectOptions(
photoImg,
Skia.XYWHRect(0, 0, pw, ph),
Skia.XYWHRect(drawX, drawY, drawW, drawH),
Skia.FilterMode.Linear,
Skia.MipmapMode.None,
plainPaint
);
// 2. Draw custom frame on top so transparent area displays the photo
cardCanvas.drawImageRectOptions(
customImg,
Skia.XYWHRect(0, 0, fw, fh),
Skia.XYWHRect(0, 0, fw, fh),
Skia.FilterMode.Linear,
Skia.MipmapMode.None,
plainPaint
);
outputSurface = cardSurface;
stampCanvas = cardCanvas;
stampW = fw;
stampH = fh;
} else {
drawFrameOnCanvas(canvas, width, height, frameId);
}
+88
View File
@@ -0,0 +1,88 @@
// HEIC → JPEG on the client, the way rawDevelop.ts does it for a camera RAW: the
// file is opened by the studio as the picture it holds, so a phone's own file
// needs no converter in the middle either.
//
// Unlike a RAW there is nothing to fit: a HEIC is already a rendered 8-bit sRGB
// image that the phone made, not sensor data with the camera's picture style to
// be matched, so this decode is a decode — no curve, no matrix, no white
// balance, and nothing here touches the RAW develop's colour.
//
// The reading itself is the worker's (see heicWorker.ts): this file is the queue
// in front of it, one request at a time, so a folder being read does not start
// four wasm decoders.
import type { HeicAsk, HeicReply } from './heicWorker';
// What a picker is told to offer beside the images it knows: iOS file names are
// the only sign of the format, and the MIME type for one is not registered
// everywhere a file dialog reads.
export const HEIC_ACCEPT = '.heic,.heif';
export function isHeicName(name: string): boolean {
return /\.(heic|heif)$/i.test(name);
}
// The answer a request that never got one resolves to: no JPEG, no error worth
// printing — the caller has the same nothing in hand as it has for a frame whose
// own preview will not decode.
const NOTHING: HeicReply = { id: 0 };
let worker: Worker | null = null;
let seq = 0;
const waiting = new Map<number, (reply: HeicReply) => void>();
function answer(reply: HeicReply): void {
const done = waiting.get(reply.id);
waiting.delete(reply.id);
done?.(reply);
}
// The one worker, made when the first HEIC is asked for and kept for the visit.
// A worker that dies takes its pending requests with it rather than leaving a
// reader on a promise that will never answer, and the next file gets a new one.
function lane(): Worker | null {
if (worker) return worker;
try {
const made = new Worker(new URL('./heicWorker.ts', import.meta.url), { type: 'module' });
made.onmessage = (event: MessageEvent<HeicReply>) => answer(event.data);
made.onerror = () => {
for (const id of [...waiting.keys()]) answer({ ...NOTHING, id });
made.terminate();
if (worker === made) worker = null;
};
worker = made;
} catch {
return null;
}
return worker;
}
function ask(bytes: Uint8Array, thumb: boolean, max: number): Promise<HeicReply> {
const target = lane();
if (!target) return Promise.resolve(NOTHING);
const id = ++seq;
return new Promise((resolve) => {
waiting.set(id, resolve);
// A copy, because the bytes are read again by whoever asked: the catalogue
// reads the frame's own date and position off the same slice it just asked
// for a tile from.
const ask: HeicAsk = { id, bytes: bytes.slice(), thumb, max };
target.postMessage(ask, [ask.bytes.buffer]);
});
}
// The picture a frame is, as a JPEG — what the studio opens and what a develop
// hands it for a RAW. `max` caps the long edge; 0 keeps the file's own size.
export async function heicToJpeg(bytes: Uint8Array, max = 0): Promise<Uint8Array> {
const reply = await ask(bytes, false, max);
if (!reply.jpeg) throw new Error(reply.error ?? 'heic');
return reply.jpeg;
}
// The phone's own small copy of the frame, for the catalogue's tiles: the item it
// keeps for exactly this, decoded in milliseconds where the frame is a second.
// Null when the file carries no such item, which is a frame with no tile rather
// than a frame that failed.
export async function heicThumbnail(bytes: Uint8Array): Promise<Uint8Array | null> {
const reply = await ask(bytes, true, 0);
return reply.jpeg ?? null;
}
+182
View File
@@ -0,0 +1,182 @@
// The HEIC decode, off the main thread.
//
// This is the one reading of a phone's file the app has to do in software: no
// browser engine on this platform decodes HEVC stills for `createImageBitmap`
// (Chromium answers `InvalidStateError`, and its WebCodecs will not take an hvc1
// configuration either), and a HEIC carries no JPEG preview to lift the way a
// camera RAW does — the frame inside it is one HEVC image, in grid tiles. So the
// file is decoded here, by libheif compiled to wasm, and what leaves this worker
// is a JPEG — the same thing `developRaw` and `rawThumbnail` hand their callers.
// Nothing downstream of this file knows which of the two it has.
//
// It runs in a worker for the reason the RAW develop runs in its own: this is a
// second of wasm for a 12MP frame (measured 1065ms on an iPhone 8 Plus file,
// 4032x3024), and a tab that stops painting for a second while it opens a folder
// is not an app. One worker is also one decoder — libheif is single-threaded and
// this heap is not shared — so the requests queue here instead of racing.
//
// A tile does not pay that second: a phone writes a small HEVC thumbnail of the
// frame as an item of its own (`thmb` on an iPhone), 320x240 on that same file,
// and it decodes in 11ms. It is not a top-level image, so libheif's own reader
// never returns it and the container is walked by hand below. A file with no
// such item gets no tile — the row still lists the frame by name, which is what
// a DNG with no preview gets too.
import createHeif, { type HeifImage, type HeifImageHandle, type HeifModule } from 'libheif-js/libheif-wasm/libheif-bundle.mjs';
// The develop's JPEG: the studio works on what a RAW develop gives it, so this
// is full size and encoded to be worked on rather than to be looked at.
const QUALITY = 0.92;
export interface HeicAsk {
id: number;
// The file — whole for a develop, the head of it for a tile: the thumbnail is
// in the first kilobytes (measured: 64KB is enough on that same frame, so the
// 256KB the catalogue already reads for a header covers it four times over).
bytes: Uint8Array;
// The file's own thumbnail item when set, the frame itself otherwise.
thumb: boolean;
// The long edge to encode at; 0 leaves the decoded size alone.
max: number;
}
export interface HeicReply {
id: number;
jpeg?: Uint8Array;
error?: string;
}
// One module per worker, made once and kept: it carries a wasm heap, and the
// second HEIC of a roll must not pay for the first one's instantiation.
let loading: Promise<HeifModule> | null = null;
const heif = (): Promise<HeifModule> => (loading ??= createHeif());
interface Rgba {
data: Uint8ClampedArray;
width: number;
height: number;
}
// libheif's own display call, wrapped: it answers on a later turn of the event
// loop, so the promise is what makes the buffer readable, and the handle has to
// outlive the call that reads it — every caller here frees the container after
// this has resolved, never before.
function pixels(image: HeifImage): Promise<Rgba | null> {
const width = image.get_width();
const height = image.get_height();
if (!width || !height) return Promise.resolve(null);
const data = new Uint8ClampedArray(width * height * 4);
return new Promise((resolve) =>
image.display({ data, width, height }, (decoded) => {
image.free();
resolve(decoded ? { data, width, height } : null);
})
);
}
// Whether a call came back with an error. The status these calls carry is
// emscripten's enum object, not a number — a successful one is `{value: 0}`,
// which is truthy — so the value inside it is what says yes or no. A handle that
// came back well has no `code` at all.
function failed(result: { code?: { value: number } }): boolean {
return !!result.code?.value;
}
// The small HEVC image a phone keeps of its own frame. Its item id is reached
// through the primary image's references, then the container is asked for a
// handle to that id — the same high-level call the top-level reader uses, so the
// colour conversion and the frame's own transforms come out of libheif either way.
async function thumbnailItem(m: HeifModule, bytes: Uint8Array): Promise<Rgba | null> {
const ctx = m.heif_context_alloc();
try {
if (failed(m.heif_context_read_from_memory(ctx, bytes))) return null;
const primary = m.heif_context_get_primary_image_handle(ctx);
if (failed(primary)) return null;
let id: number;
try {
const count = m._heif_image_handle_get_number_of_thumbnails(primary.$$.ptr);
if (!count) return null;
const list = m._malloc(4 * count);
try {
m._heif_image_handle_get_list_of_thumbnail_IDs(primary.$$.ptr, list, count);
id = m.HEAPU32[list >> 2];
} finally {
m._free(list);
}
} finally {
m.heif_image_handle_release(primary);
}
const handle: HeifImageHandle = m.heif_js_context_get_image_handle(ctx, id);
if (failed(handle)) return null;
return await pixels(new m.HeifImage(handle));
} catch {
return null;
} finally {
m.heif_context_free(ctx);
}
}
// The frame itself, which is the primary image of the container.
async function primaryImage(m: HeifModule, bytes: Uint8Array): Promise<Rgba | null> {
const images = new m.HeifDecoder().decode(bytes);
const image = images.find((i) => i.is_primary()) ?? images[0];
return image ? pixels(image) : null;
}
// The pixels as a JPEG, at most `max` on the long edge. The encode happens here
// rather than on the page for the usual reason: a 12MP frame is a 48MB readback
// and a canvas large enough to hold it, and neither belongs on the thread that
// paints.
async function encode(rgba: Rgba, max: number): Promise<Uint8Array> {
const scale = max > 0 ? Math.min(1, max / Math.max(rgba.width, rgba.height)) : 1;
const width = Math.max(1, Math.round(rgba.width * scale));
const height = Math.max(1, Math.round(rgba.height * scale));
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('no 2d context');
const imgData = new ImageData(rgba.data as Uint8ClampedArray<ArrayBuffer>, rgba.width, rgba.height);
try {
if (scale === 1) {
ctx.putImageData(imgData, 0, 0);
} else {
const bitmap = await createImageBitmap(imgData);
ctx.drawImage(bitmap, 0, 0, width, height);
bitmap.close();
}
} catch {
const fullCanvas = new OffscreenCanvas(rgba.width, rgba.height);
const fullCtx = fullCanvas.getContext('2d');
if (fullCtx) {
fullCtx.putImageData(imgData, 0, 0);
ctx.drawImage(fullCanvas, 0, 0, width, height);
}
}
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: QUALITY });
return new Uint8Array(await blob.arrayBuffer());
}
// The worker's own surface. `self` is typed as the page's window by the DOM lib
// the rest of the app compiles against, so the two calls this file makes are
// named here instead of pulling a second, conflicting lib in.
const scope = self as unknown as {
onmessage: ((event: MessageEvent<HeicAsk>) => void) | null;
postMessage: (message: HeicReply, transfer?: Transferable[]) => void;
};
scope.onmessage = (event) => {
const { id, bytes, thumb, max } = event.data;
void (async (): Promise<Rgba | null> => {
const m = await heif();
return thumb ? thumbnailItem(m, bytes) : primaryImage(m, bytes);
})()
.then(async (rgba) => {
if (!rgba) throw new Error('no image');
const jpeg = await encode(rgba, max);
scope.postMessage({ id, jpeg }, [jpeg.buffer]);
})
.catch((err: unknown) => scope.postMessage({ id, error: err instanceof Error ? err.message : String(err) }));
};
+96 -4
View File
@@ -151,11 +151,31 @@ async function shrinkOrKeep(bytes: Uint8Array, maxDim: number, maxBytes: number)
// hands it back as a Date in ours — which is what a camera prints on a stamp, and
// what someone opening a 2019 photo today expects to read under the coordinates.
// Null when the file carries no date at all; the caller falls back to its clock.
// `exifr` keeps a private cache of the parsed options it is handed, keyed by the
// options object itself, and every entry in it lives as long as the page. A
// fresh literal per frame is therefore a frame's worth of tag sets held forever:
// reading a roll of a hundred thousand frames is what fills the tab. So the
// options are written once here and the same object is handed over every time —
// which is also what the cache is for.
const DATE_OPTIONS = { pick: ['DateTimeOriginal', 'CreateDate', 'ModifyDate'] };
const SPEC_OPTIONS = {
pick: [
'Make',
'Model',
'ISO',
'ISOSpeedRatings',
'FocalLength',
'FNumber',
'ExposureTime',
'ExifImageWidth',
'ExifImageHeight',
],
};
const ISO_OPTIONS = { pick: ['ISO', 'ISOSpeedRatings'] };
export async function readCapturedAt(bytes: Uint8Array): Promise<number | null> {
try {
const tags = (await exifr.parse(bytes, {
pick: ['DateTimeOriginal', 'CreateDate', 'ModifyDate'],
})) as Record<string, unknown> | undefined;
const tags = (await exifr.parse(bytes, DATE_OPTIONS)) as Record<string, unknown> | undefined;
for (const tag of ['DateTimeOriginal', 'CreateDate', 'ModifyDate']) {
const value = tags?.[tag];
if (value instanceof Date && Number.isFinite(value.getTime())) return value.getTime();
@@ -166,6 +186,78 @@ export async function readCapturedAt(bytes: Uint8Array): Promise<number | null>
return null;
}
// What the camera recorded about the shutter, the glass and the frame itself —
// the numbers a photographer reads first. All of them sit in the EXIF segment at
// the front of the file, so this costs the same few hundred kilobytes the date
// does. Each one is null where the file does not carry it, which is what a
// screenshot, a scan or a Fuji RAF answers: `exifr` reads the formats it knows
// and nothing about a file it does not know is invented here.
export interface ShotSpecs {
camera: string | null;
iso: number | null;
// A focal length in millimetres, an f-number, and an exposure in seconds.
focal: number | null;
aperture: number | null;
exposure: number | null;
// The frame's own size, which only a JPEG states outright. A RAW leaves it to
// the decoder, and half a number is worse than none.
width: number | null;
height: number | null;
}
export async function readSpecs(bytes: Uint8Array): Promise<ShotSpecs | null> {
try {
const tags = (await exifr.parse(bytes, SPEC_OPTIONS)) as Record<string, unknown> | undefined;
if (!tags) return null;
const num = (v: unknown): number | null => {
const n = Number(Array.isArray(v) ? v[0] : v);
return Number.isFinite(n) && n > 0 ? n : null;
};
const make = tags.Make ? String(tags.Make).trim() : '';
const model = tags.Model ? String(tags.Model).trim() : '';
let camera: string | null = null;
if (model) {
camera = make && !model.toLowerCase().startsWith(make.toLowerCase()) ? `${make} ${model}` : model;
} else if (make) {
camera = make;
}
return {
camera,
iso: num(tags.ISO ?? tags.ISOSpeedRatings),
focal: num(tags.FocalLength),
aperture: num(tags.FNumber),
exposure: num(tags.ExposureTime),
// Null together: a frame with one edge and not the other has no size.
width: tags.ExifImageWidth && tags.ExifImageHeight ? num(tags.ExifImageWidth) : null,
height: tags.ExifImageWidth && tags.ExifImageHeight ? num(tags.ExifImageHeight) : null,
};
} catch {
return null;
}
}
// The rounded number as it is printed: one decimal, and none where there is
// none — `f/2.8` rather than `f/2.8000000000000003`.
const round = (n: number) => String(Math.round(n * 10) / 10);
// Those numbers as the line under a frame. A shutter faster than a second is the
// fraction a camera prints (`1/320s`), and everything the file does not have is
// left out rather than stood in for — an empty line is what a file with no EXIF
// gets, and the caller prints nothing.
export function specsLine(specs: ShotSpecs | null, place?: string | null, dims?: { w: number; h: number } | null): string {
if (!specs && !place && !dims) return '';
const parts = [
specs?.camera ? specs.camera : '',
specs?.aperture ? `f/${round(specs.aperture)}` : '',
specs?.exposure ? (specs.exposure < 1 ? `1/${Math.round(1 / specs.exposure)}s` : `${round(specs.exposure)}s`) : '',
specs?.iso ? `ISO ${Math.round(specs.iso)}` : '',
specs?.focal ? `${round(specs.focal)}mm` : '',
dims ? `${dims.w} × ${dims.h}` : (specs?.width && specs?.height ? `${specs.width} × ${specs.height}` : ''),
place ? place : '',
];
return parts.filter(Boolean).join(' · ');
}
// EXIF GPS of the loaded photo, in the shape the renderer + EXIF writer expect.
// Returns null when the photo has none — the caller then offers manual entry.
export async function readGps(bytes: Uint8Array): Promise<GPSInfo | null> {
@@ -189,7 +281,7 @@ export async function readGps(bytes: Uint8Array): Promise<GPSInfo | null> {
// the caller then prints "AUTO" instead of inventing a number.
export async function readIso(bytes: Uint8Array): Promise<number | null> {
try {
const tags = (await exifr.parse(bytes, { pick: ['ISO', 'ISOSpeedRatings'] })) as
const tags = (await exifr.parse(bytes, ISO_OPTIONS)) as
| { ISO?: unknown; ISOSpeedRatings?: unknown }
| undefined;
const raw = tags?.ISO ?? tags?.ISOSpeedRatings;
+663
View File
@@ -0,0 +1,663 @@
// Immich, as a second photo source beside the folders on disk — the page's half.
//
// Nothing here talks to Immich: the key never reaches the browser, and the
// address never leaves the backend, so every call below is one of the routes in
// `backend/src/immich.ts` on this page's own origin. What this file does is turn
// a server's answer into the two shapes the LIBRARY already knows — a row in the
// `photos` store and a name in the column — so the tree, the wall and the strip
// draw a server's frames with the code they already had for a folder's.
import {
breathe,
missingThumbs,
normPath,
prunePhotos,
putFolder,
putPhotoThumb,
putPhotos,
putPhotosFast,
remoteUrl,
saveWalkedDirs,
type LibraryDir,
type LibraryFolder,
type LibraryPhoto,
} from './library';
// Immich answers 200 items at most for one page of a search (the backend caps it
// there), so this is the size the sync loops at: one round trip per two hundred
// frames rather than one per frame.
const PAGE = 200;
// How many times one page of a server's frames is asked for again before the
// reading gives up on it. A page in flight is a call to a box the reader's own
// network may be between, and the second answer is usually the first answer.
const ASSET_RETRIES = 2;
// The nodes one reading is walking through. The screen asks for a sync when it
// opens and whenever the node it shows changes, and two readings of one node at
// the same time are two readings asking the server for the same pages and writing
// one place over the other: the second is turned away, and the first keeps the
// place for both.
const walking = new Set<string>();
// Tiles are drawn after the rows have landed rather than before: the column and
// the count are what the reader sees first, and a tile is the wall's own business.
const TILE_LANES = 3;
export interface ImmichAlbum {
id: string;
albumName: string;
assetCount: number;
coverId: string | null;
shared: boolean;
}
export interface ImmichAsset {
id: string;
name: string;
takenAt: string | null;
width: number | null;
height: number | null;
size: number | null;
}
export interface ImmichServer {
id: string;
name: string;
url: string;
type: 'api' | 'share';
version: string | null;
canDownload: boolean | null;
albums: string[];
keyMasked: string;
}
// The three refusals the dialog can say something about, plus the one it can only
// say as a shape: the key is wrong or too narrow, the box cannot be reached, it
// answered with something unusable, or what was typed is not an address and a key.
export type ImmichError = 'auth' | 'unreachable' | 'server' | 'invalid';
export type ImmichProbe =
| { ok: true; version: string | null; albums: ImmichAlbum[]; canDownload: boolean | null }
| { ok: false; error: ImmichError };
export type ImmichSaved = { ok: true; server: ImmichServer } | { ok: false; error: ImmichError };
// One place reads a JSON answer, so a route that answered with a page of HTML —
// a proxy in the way, a box that is not Immich — is a refusal rather than a crash
// somewhere further in.
async function json<T>(res: Response): Promise<T | null> {
try {
return (await res.json()) as T;
} catch {
return null;
}
}
async function get<T>(path: string): Promise<T | null> {
try {
return await json<T>(await fetch(path));
} catch {
return null;
}
}
// The saved servers. One call per session: the tree has to draw on the first
// paint, and a server list that is asked for on every render is a round trip per
// render.
let configOnce: Promise<{ servers: ImmichServer[] }> | null = null;
export function immichConfig(fresh = false): Promise<{ servers: ImmichServer[] }> {
if (fresh || !configOnce) {
configOnce = get<{ servers?: ImmichServer[] }>('/api/immich/config').then((body) => ({
servers: body?.servers ?? [],
}));
}
return configOnce;
}
export function forgetImmichConfig(): void {
configOnce = null;
}
// "Check this key" — nothing is stored, so a typo never becomes a row.
export async function probeImmich(url: string, key: string, type: 'api' | 'share'): Promise<ImmichProbe> {
try {
const res = await fetch('/api/immich/probe', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url, key, type }),
});
const body = await json<ImmichProbe>(res);
return body ?? { ok: false, error: 'server' };
} catch {
return { ok: false, error: 'unreachable' };
}
}
// Add a server, or change one. The backend probes before it stores, so a server
// that comes back saved is one that answered — `albums` is the ticked set, sent
// on the same call that saves the key so a fresh server is never briefly "all".
export async function saveImmichServer(input: {
id?: string;
name?: string;
url?: string;
key?: string;
type?: 'api' | 'share';
albums?: string[];
}): Promise<ImmichSaved> {
try {
const res = await fetch('/api/immich/config', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
});
const body = await json<ImmichSaved>(res);
if (body?.ok) forgetImmichConfig();
return body ?? { ok: false, error: 'server' };
} catch {
return { ok: false, error: 'unreachable' };
}
}
export async function forgetImmichServer(id: string): Promise<boolean> {
try {
const res = await fetch(`/api/immich/config?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.ok) forgetImmichConfig();
return res.ok;
} catch {
return false;
}
}
// Upload an edited photo to Immich Cloud
export async function uploadToImmich(
bytes: Uint8Array,
name: string,
serverId?: string,
albumId?: string
): Promise<{ ok: boolean; id?: string; error?: string }> {
try {
const params = new URLSearchParams();
if (serverId) params.set('server', serverId);
if (name) params.set('name', name);
if (albumId) params.set('album', albumId);
const res = await fetch(`/api/immich/upload?${params.toString()}`, {
method: 'POST',
headers: {
'content-type': 'image/jpeg',
},
body: bytes as BodyInit,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
return { ok: false, error: (err as any)?.error || (err as any)?.details || `HTTP ${res.status}` };
}
const data = await res.json().catch(() => ({}));
return { ok: true, id: (data as any)?.id };
} catch (err: any) {
return { ok: false, error: err?.message || String(err) };
}
}
// The albums this key can see, asked for live: an album added on Immich has to be
// there the next time the dialog is opened, so nothing here is cached.
export async function listImmichAlbums(server: string): Promise<ImmichAlbum[]> {
const body = await get<{ ok?: boolean; albums?: ImmichAlbum[] }>(
`/api/immich/albums?id=${encodeURIComponent(server)}`
);
return body?.albums ?? [];
}
export async function saveImmichAlbums(server: string, selected: string[]): Promise<string[]> {
try {
const res = await fetch('/api/immich/albums', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: server, selected }),
});
const body = await json<{ albums?: string[] }>(res);
return body?.albums ?? [];
} catch {
return [];
}
}
// One page of a server's frames. `album` is an Immich id or null for everything
// the key can see — the album field's empty state.
export async function listAssets(
server: string,
album: string | null,
page: number,
search = '',
takenBefore?: string | null,
takenAfter?: string | null
): Promise<{ items: ImmichAsset[]; hasMore: boolean; ok: boolean }> {
const query = new URLSearchParams({ server, page: String(page), size: String(PAGE) });
if (album) query.set('album', album);
if (search) query.set('q', search);
if (takenBefore) query.set('takenBefore', takenBefore);
if (takenAfter) query.set('takenAfter', takenAfter);
// A page that could not be read is not a page with nothing on it. Both are one
// shape to a loop that counts items, and the loop that takes the one for the
// other reads the end of the roll at the first timeout and drops every row it
// had not met yet. `ok` is the word that says which of the two this was, and an
// unanswered page is asked for again here rather than in every caller.
for (let attempt = 0; ; attempt++) {
try {
const res = await fetch(`/api/immich/assets?${query.toString()}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await json<{ items?: ImmichAsset[]; hasMore?: boolean }>(res);
if (!body) throw new Error('unreadable answer');
return { items: body.items ?? [], hasMore: body.hasMore === true, ok: true };
} catch {
if (attempt >= ASSET_RETRIES) return { items: [], hasMore: false, ok: false };
await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)));
}
}
}
// Fetch timeline buckets (months/years) for an Immich server in one fast call
export async function fetchImmichBuckets(
server: string,
size: 'YEAR' | 'MONTH' = 'MONTH'
): Promise<{ timeBucket: string; count: number }[]> {
const query = new URLSearchParams({ server, size });
const body = await get<{ buckets?: { timeBucket: string; count: number }[] }>(
`/api/immich/buckets?${query.toString()}`
);
return Array.isArray(body?.buckets) ? body.buckets : [];
}
// What the tree paints, and the name an album's frames are filed under. An album
// with no name — a key too narrow to read one, an album the reader never titled —
// is known by the first of its id, which is what the plan asks for.
function albumLabels(albums: ImmichAlbum[]): Map<string, string> {
const taken = new Set<string>();
const out = new Map<string, string>();
for (const album of albums) {
let label = album.albumName.trim() || album.id.slice(0, 8);
// Two albums may carry one name — Immich allows it — and a column is drawn
// off the path, so a second of that name would be a row the first one already
// is. The uuid is what tells them apart, and eight of it is enough to read.
if (taken.has(label)) label = `${label} ~${album.id.slice(0, 8)}`;
taken.add(label);
out.set(album.id, label);
}
return out;
}
// The `~` suffix a frame takes when an album holds two files of one name: the id
// is the name, so one of them has to move rather than one of them disappear.
function frameName(name: string, id: string, taken: Set<string>): string {
if (!taken.has(name)) return name;
return `${name} ~${id.slice(0, 8)}`;
}
export interface ImmichSyncResult {
total: number;
albums: number;
// False while the node is still being read — the answer above comes back at the
// foreground window, and the rest of it is read behind it. The screen says so
// rather than a number that would have the reader believe the node holds six
// hundred frames.
done: boolean;
// The album rows this read named, spelled the way the column spells a path. The
// screen puts these in the place of the node's old ones: an album the reader
// unticked is a row that has to go, and a record that is only ever added to
// would keep drawing it.
dirs: LibraryDir[];
}
export type ImmichSyncCallback = (batch: LibraryPhoto[], progress: { loaded: number; hasMore: boolean }) => void;
// Read a server's albums into the catalogue: the rows and the column first, so
// the count and the tree are right the moment this returns, and the tiles after,
// a lane at a time, so a reader who opens the node while it is still fetching
// sees frames rather than a blank wall.
//
// Streams batches to IndexedDB and UI immediately (in < 200ms per batch).
// Resolves the foreground pass in 3 pages (up to 600 newest photos) so the UI
// unblocks instantly, and continues syncing historical photos in the background.
export async function syncImmich(
folder: LibraryFolder,
onBatch?: ImmichSyncCallback
): Promise<ImmichSyncResult> {
const server = folder.server ?? '';
const search = folder.kind === 'search' ? (folder.query ?? '').trim() : '';
const chosen = folder.albums ?? [];
const root = normPath(folder.name);
const rows: LibraryPhoto[] = [];
const dirs: LibraryDir[] = [];
const counts: Record<string, number> = {};
// The frames this reading has already filed. The keyset read stops on a frame's
// own time and `takenBefore` is inclusive, so every page opens on the frame the
// page before it ended with: filed as it came, that frame is a row the wall
// draws twice. `frameName` answers the name a second *file* takes; the id is
// what says there is no second file.
const seen = new Set<string>();
// 1. ALWAYS read ALL photos of the Immich API (filed under root of folder: dir = '')
// Keyset pagination with takenBefore prevents slow SQL offsets and timeouts!
const rootTaken = new Set<string>();
// Where the reading before this one stopped, when it did not reach the end. A
// node of a hundred thousand frames is not one visit's to read, and a place only
// ever held in memory is a place every reload began without: the whole of what
// the catalogue was given was then the newest three pages of it, once a visit,
// for good. The node's own record keeps the place instead — the frame it stopped
// on, and the page within that frame's own instant.
const resume = folder.cursor && !folder.cursor.done ? folder.cursor : null;
let lastTakenBefore: string | null = resume?.before ?? null;
let page = resume?.page ?? 1;
let pages = 0;
let rootCount = 0;
let complete = false;
let failed = false;
const FOREGROUND_PAGES = 3; // The newest three pages answer the call so the screen unblocks
let hasMoreRemaining = false;
// The place, written down where the visit after this one reads it. Every page
// rather than every minute: a page is two hundred frames, and a visit may end
// at any of them.
const remember = async (before: string | null, at: number, done: boolean) => {
try {
await putFolder({ ...folder, cursor: { before, page: at, done } });
} catch {}
};
// Where a page leaves the reading. The bound is a frame's own instant and the
// call that asks for it keeps the frame it stopped on — `takenBefore` is
// inclusive — so a page whose oldest frame is older than the bound has moved it,
// and the page after it starts at that very frame. A page that is one instant
// from end to end cannot move it at all: Immich keeps a frame's time to the
// millisecond, and a roll imported in one go is hundreds of frames at the same
// one, which is the whole of the oldest days of this library. Asked for with the
// bound alone, that page is the same page for ever — the reading never ends, and
// the frames under that instant are never read. So the rest of the instant is
// walked by offset, and the bound is left where it is until the pages under it
// run out.
// ponytail: that offset walk is over everything older than the bound, and a deep
// offset is a slow answer — an Immich that offers a cursor for it, or a page
// asked per instant out of the timeline buckets, is the way out of it if a roll
// of thousands at one instant ever sits in the middle of a library.
const step = (items: ImmichAsset[]): void => {
const at = items[items.length - 1]?.takenAt ?? null;
if (page === 1 && at && at !== lastTakenBefore) lastTakenBefore = at;
else page++;
};
for (;;) {
const { items, hasMore, ok } = await listAssets(server, null, page, search, lastTakenBefore);
// A page that could not be read is not the end of the node, and only a
// reading that met the end of it may say what the node no longer holds.
if (!ok) {
failed = true;
break;
}
const batchRows: LibraryPhoto[] = [];
for (const item of items) {
if (!item.id || !item.name || seen.has(item.id)) continue;
seen.add(item.id);
const name = frameName(item.name, item.id, rootTaken);
rootTaken.add(item.name);
rootTaken.add(name);
const row: LibraryPhoto = {
id: [root, name].join('/'),
folder: folder.name,
dir: '',
name,
server,
remoteId: item.id,
taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(),
size: item.size ?? 0,
addedAt: Date.now(),
};
rows.push(row);
batchRows.push(row);
rootCount++;
}
// Save batch to IndexedDB and push to UI immediately
if (batchRows.length) {
await putPhotosFast(batchRows);
onBatch?.(batchRows, { loaded: rootCount, hasMore });
}
pages++;
if (!hasMore || !items.length) {
complete = true;
break;
}
step(items);
await remember(lastTakenBefore, page, false);
// After foreground window, resolve foreground sync promptly so UI is responsive
if (pages >= FOREGROUND_PAGES) {
hasMoreRemaining = true;
break;
}
}
counts[root] = rootCount;
// 2. ALBUMS of Immich ONLY display in the ALBUM section if the user chooses specific albums (chosen.length > 0).
if (!search && chosen.length > 0) {
const albums = await listImmichAlbums(server);
const labels = albumLabels(albums);
const chosenAlbums = albums.filter((a) => chosen.includes(a.id));
for (const album of chosenAlbums) {
const label = labels.get(album.id) ?? album.id.slice(0, 8);
const albumTaken = new Set<string>();
let albPage = 1;
let albCount = 0;
for (;;) {
const { items, hasMore, ok } = await listAssets(server, album.id, albPage, search);
// An album that could not be read is one this reading has no word on: the
// rows it holds are left where they are, and nothing below is pruned
// against a reading that did not meet them.
if (!ok) {
failed = true;
break;
}
const albBatch: LibraryPhoto[] = [];
for (const item of items) {
if (!item.id || !item.name) continue;
const name = frameName(item.name, item.id, albumTaken);
albumTaken.add(item.name);
albumTaken.add(name);
const row: LibraryPhoto = {
id: [root, label, name].join('/'),
folder: folder.name,
dir: label,
name,
server,
remoteId: item.id,
taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(),
size: item.size ?? 0,
addedAt: Date.now(),
};
rows.push(row);
albBatch.push(row);
albCount++;
}
if (albBatch.length) {
await putPhotosFast(albBatch);
onBatch?.(albBatch, { loaded: rootCount + albCount, hasMore });
}
if (!hasMore || !items.length) break;
albPage++;
}
dirs.push({ id: `${root}/${label}`, folder: folder.name, rel: label });
counts[`${root}/${label}`] = albCount;
}
}
// The record carries the reading's whole word or none of it: only a reading
// that met the end of the node may say what the node holds, because a record
// that says otherwise is the record the column reads its noughts off. A reading
// stopped at the foreground window, or cut short by a page that would not come
// back, folds its half into what was already there instead.
await saveWalkedDirs(root, dirs, counts, complete && !failed);
// A row the reading did not meet is dropped only against a reading that met all
// of them — and only one that began at the top. A reading that carried on from
// where the last one stopped holds the newest pages in its own list and no older
// row in it at all, and pruned to that list it would take back every frame it
// was picking up.
// ponytail: a resumed reading therefore stops one prune short — a frame dropped
// on the server keeps its row until some visit reads the node from the top. Read
// `met` out of the catalogue rather than out of `rows` if that ever shows.
if (complete && !failed && !resume) {
const met = new Set(rows.map((r) => (r.dir ? `${normPath(r.dir)}/${r.name}` : r.name).toLowerCase()));
await prunePhotos(root, '', met);
}
// The rest of the node, a page at a time, behind the answer above. Its place is
// the record's exactly as the foreground's is, so a visit that ends in the
// middle of it — a reload, a closed tab, a lid shut — costs the page in flight
// and nothing else.
if ((hasMoreRemaining || failed) && !walking.has(root)) {
walking.add(root);
void (async () => {
let bgPage = page;
let bgLastTakenBefore = lastTakenBefore;
let reachedEnd = false;
try {
for (;;) {
const { items, hasMore: bgMore, ok } = await listAssets(
server,
null,
bgPage,
search,
bgLastTakenBefore
);
// A page that could not be read leaves the place where the last page
// that could left it: the visit after this one asks for it again.
if (!ok) break;
if (!items.length) {
reachedEnd = true;
break;
}
const bgBatchRows: LibraryPhoto[] = [];
for (const item of items) {
if (!item.id || !item.name || seen.has(item.id)) continue;
seen.add(item.id);
const name = frameName(item.name, item.id, rootTaken);
rootTaken.add(item.name);
rootTaken.add(name);
bgBatchRows.push({
id: [root, name].join('/'),
folder: folder.name,
dir: '',
name,
server,
remoteId: item.id,
taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(),
size: item.size ?? 0,
addedAt: Date.now(),
});
rootCount++;
}
if (bgBatchRows.length) {
await putPhotosFast(bgBatchRows);
onBatch?.(bgBatchRows, { loaded: rootCount, hasMore: bgMore });
}
if (!bgMore) {
reachedEnd = true;
break;
}
const at = items[items.length - 1]?.takenAt ?? null;
if (bgPage === 1 && at && at !== bgLastTakenBefore) bgLastTakenBefore = at;
else bgPage++;
await remember(bgLastTakenBefore, bgPage, false);
await breathe();
}
counts[root] = rootCount;
await saveWalkedDirs(root, dirs, counts, reachedEnd);
if (reachedEnd) {
// The node is read through: the place is not a place any more, and the
// reading that begins next begins at the top — which is where a frame
// added since would be.
await remember(null, 1, true);
if (!resume) {
const met = new Set(rows.map((r) => (r.dir ? `${normPath(r.dir)}/${r.name}` : r.name).toLowerCase()));
await prunePhotos(root, '', met);
}
}
} finally {
walking.delete(root);
}
})();
}
return { total: rootCount, albums: dirs.length, dirs, done: complete && !failed };
}
// The name a server's node takes: the first one is `Immich`, and a second is
// numbered, because the name is the store's key and two keys cannot be one.
export function immichFolderName(taken: Iterable<string>): string {
const used = new Set(taken);
let n = 1;
while (used.has(n === 1 ? 'Immich' : `Immich ${n}`)) n++;
return n === 1 ? 'Immich' : `Immich ${n}`;
}
// What the column paints for that node: the source and the name its owner gave it.
export function immichFolderLabel(serverName: string): string {
return `Immich · ${serverName}`;
}
// Put a saved server in the column as a folder. The row is the page's own — a
// name, the server it reads, and the albums it keeps — and `taken` is what it must
// not collide with, because the name is the store's key and two keys cannot be
// one. The name is `Immich`, `Immich 2`, …; the label is what the tree paints.
// The name a search node takes. The query is in it because the name is what the
// node is: a column of `Immich · …` rows would say nothing about which search is
// which, and the name is the store's key, so the same words are already the same
// node. Only a clash with something that is not this query's own node — a disk
// folder someone called the same — moves it on to a number.
export function immichSearchName(q: string, taken: Iterable<string>): string {
const used = new Set(taken);
const base = `Immich · ${q}`;
let name = base;
for (let n = 2; used.has(name); n++) name = `${base} ${n}`;
return name;
}
// Put a search in the column as a node of its own. The same words are the same
// node read again rather than one more row for every time they are typed: the
// frames are keyed by it, and a node per attempt would be a column of dead ends.
export async function searchImmichFolder(
server: ImmichServer,
query: string,
current: LibraryFolder[]
): Promise<LibraryFolder> {
// Bounded the way the backend bounds it, so the name and the query are the same
// words: a row whose name is longer than the query that made it is a row nobody
// can match up again.
const q = query.trim().slice(0, 100);
const found = current.find((f) => f.kind === 'search' && f.server === server.id && f.query === q);
if (found) return found;
const folder: LibraryFolder = {
name: immichSearchName(q, current.map((f) => f.name)),
label: `Immich · ${server.name} · “${q}”`,
source: 'immich',
server: server.id,
kind: 'search',
query: q,
};
await putFolder(folder);
return folder;
}
export async function addImmichFolder(server: ImmichServer, taken: Iterable<string>): Promise<LibraryFolder> {
const folder: LibraryFolder = {
name: immichFolderName(taken),
label: immichFolderLabel(server.name),
source: 'immich',
server: server.id,
albums: server.albums,
kind: server.type === 'share' ? 'share' : 'album',
};
await putFolder(folder);
return folder;
}
File diff suppressed because it is too large Load Diff
+420
View File
@@ -0,0 +1,420 @@
// The catalogue's own copy, kept in a folder on the visitor's own disk. The
// catalogue lives in the browser's storage, which is the one place a cleared
// profile, a new machine or a reinstall takes away — and the frames themselves
// are not in it, so what is lost is the reading: every tile, every star, every
// recipe. This writes that reading out to a folder the visitor picks, and reads
// it back into a catalogue that has nothing.
//
// The folder holds one file of rows — thumbnail, star, recipe, the frame's size
// and write time, and no handle, which is not a thing that can be written down —
// and the tiles beside it, one file per frame under `thumbs/<frame id>` — a
// component the browser refuses to write spelled out as `%XX` (see `spell`) so
// the tiles of such a folder land too — so the
// folder mirrors the library exactly and a tile can be looked at, copied or
// rsynced by hand. A frame that is being restored is matched to a frame on the
// disk by that size and write time, so putting a catalogue back costs the tiles
// and not one frame read.
//
// ponytail: no incremental tree walk, no deletes. A tile is written when the
// frame is new or its tile changed, and one left behind by a frame that was
// dropped from the library stays there — it is a few dozen kilobytes in a folder
// the visitor owns. Add a sweep when a library that is edited down to a fraction
// of its size makes the leftovers worth the walk.
import type { LibraryEdit, LibraryFolder } from './library';
import {
ensurePermission,
forgetWalkedDirs,
getPhotoThumb,
listEdits,
listFolders,
listPhotos,
normPath,
renameFolder,
restoreCatalogue,
setBackupTileFetcher,
type CatalogueRow,
} from './library';
const DB_NAME = 'recipescam-backup';
// Two stores of its own, not the library's: the library opens without a version
// on purpose (see `openDb` there), and a version it has to reach for is one a
// second tab can block. This one is nothing but a handle and a list, so it can
// have a version and be upgraded.
const WHERE = 'where';
const WRITTEN = 'written';
const DB_VERSION = 1;
const CATALOGUE = 'recipescam-catalogue.json';
const THUMBS = 'thumbs';
// The catalogue is written whole however little changed: it is one file, and a
// row that was left behind in it is a frame that will not come back.
const VERSION = 1;
export async function readBackupTile(id: string): Promise<Blob | null> {
try {
const root = await folderHandle();
if (!root) return null;
// A frame id is `folder/dir/name`; a Windows-written one can say `\`, so it is
// spelled the same way here as everywhere else before it is cut.
const parts = normPath(id).split('/');
const name = parts.pop() as string;
const dir = await at(root, [THUMBS, ...parts].join('/'));
const file = await dir.getFileHandle(spell(name));
return new Blob([await (await file.getFile()).arrayBuffer()], { type: 'image/jpeg' });
} catch {
return null;
}
}
// Automatically register as lazy backup tile fetcher for library.ts
setBackupTileFetcher(readBackupTile);
interface Meta {
id: 'meta';
at: number;
photos: number;
wrote: number;
}
export interface BackupStatus {
// The folder's own name, or null when the visitor has not chosen one.
folder: string | null;
// Whether the browser has let the page write to it. It comes back `prompt`
// after a restart — a permission outlives the tab only while the tab does, and
// Chromium will not hand it back without a click — which is why the screen
// says so rather than failing silently.
granted: boolean;
at: number;
photos: number;
wrote: number;
}
export interface BackupProgress {
done: number;
total: number;
}
let dbPromise: Promise<IDBDatabase> | null = null;
function openDb(): Promise<IDBDatabase> {
dbPromise ??= new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(WHERE)) db.createObjectStore(WHERE, { keyPath: 'id' });
if (!db.objectStoreNames.contains(WRITTEN)) db.createObjectStore(WRITTEN, { keyPath: 'id' });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return dbPromise;
}
function ask<T>(store: string, mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest): Promise<T> {
return openDb().then(
(db) =>
new Promise<T>((resolve, reject) => {
const req = run(db.transaction(store, mode).objectStore(store));
req.onsuccess = () => resolve(req.result as T);
req.onerror = () => reject(req.error);
})
);
}
async function keep(store: string, rows: object[]): Promise<void> {
if (!rows.length) return;
const db = await openDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(store, 'readwrite');
const s = tx.objectStore(store);
for (const row of rows) s.put(row);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function folderHandle(): Promise<FileSystemDirectoryHandle | null> {
try {
const row = await ask<{ id: string; handle: FileSystemDirectoryHandle } | undefined>(WHERE, 'readonly', (s) =>
s.get('folder')
);
return row?.handle ?? null;
} catch {
return null;
}
}
// The permission is asked for silently and never granted from here: a prompt
// without a click behind it is a prompt the browser may refuse outright, and the
// buttons on the screen are where the click is.
async function granted(handle: FileSystemHandle, write: boolean): Promise<boolean> {
if (!handle) return false;
const askable = handle as FileSystemHandle & {
queryPermission?: (o: { mode: string }) => Promise<PermissionState>;
};
try {
return (await askable.queryPermission?.({ mode: write ? 'readwrite' : 'read' })) === 'granted';
} catch {
return false;
}
}
export async function backupStatus(): Promise<BackupStatus> {
const handle = await folderHandle();
const meta = await ask<Meta | undefined>(WHERE, 'readonly', (s) => s.get('meta')).catch(() => undefined);
return {
folder: handle?.name ?? null,
granted: handle ? await granted(handle, true) : false,
at: meta?.at ?? 0,
photos: meta?.photos ?? 0,
wrote: meta?.wrote ?? 0,
};
}
// The catalogue's own header, read out of the file and not out of this browser's
// memory of it. A folder the reader is pointing at may be a copy carried over on
// a stick, written by another machine, at another time: it is its own catalogue
// that says which backup it is, and its own count and date that the screen has
// to show before anything is written to it or read from it.
async function head(root: FileSystemDirectoryHandle): Promise<Meta | null> {
try {
const text = await (await (await root.getFileHandle(CATALOGUE)).getFile()).text();
const cat = JSON.parse(text) as { at?: number; photos?: unknown[] };
return { id: 'meta', at: cat.at ?? 0, photos: (cat.photos ?? []).length, wrote: 0 };
} catch {
return null;
}
}
// The picker's own dialog, the same way a folder of frames is picked — a second
// `id` so the browser remembers this folder apart from the library's. It is the
// only way a folder is ever chosen, and the only thing that hands a remembered
// one its permission back: the browser takes that away when the tab closes, and
// a permission asked for without a picker behind it is one it may refuse.
export async function pickBackupFolder(): Promise<string> {
if (!('showDirectoryPicker' in window)) throw new Error('no-folder-picker');
const handle = await (
window as unknown as {
showDirectoryPicker: (o?: unknown) => Promise<FileSystemDirectoryHandle>;
}
).showDirectoryPicker({ mode: 'readwrite', id: 'recipescam-backup' });
const before = await folderHandle();
await keep(WHERE, [{ id: 'folder', handle }]);
// A folder that is not the one the last run wrote into starts empty, and the
// list of tiles already written is about that folder — kept, it would say every
// tile is there and the new folder would come out with none of them.
if (before?.name !== handle.name) {
await ask(WRITTEN, 'readwrite', (s) => s.clear());
// And the row goes back to saying what this folder holds rather than what
// the last one did: a count and a date from the folder next door is a count
// and a date about a backup the reader is not looking at.
const meta = await head(handle);
await ask<Meta | undefined>(WHERE, 'readwrite', (s) => (meta ? s.put(meta) : s.delete('meta')));
}
return handle.name;
}
// A handle comes back from IndexedDB without its permission, so every write goes
// through this: the visitor's click is the one thing that can hand it back.
async function writable(): Promise<FileSystemDirectoryHandle> {
const handle = await folderHandle();
if (!handle) throw new Error('no-backup-folder');
if (!(await ensurePermission(handle, true))) throw new Error('no-backup-permission');
return handle;
}
// Chrome will not spell a name it will not put on a disk: no `* ? " < > | : \ /`,
// no control or format character, no space, dot or tilde at either end, no
// `.lnk` `.scf` `.url`, and it has to be UTF-8 — the whole of what Chromium's
// `IsSafePathComponent` refuses, and every one of those is a name a camera or a
// person is free to give a folder. The library is read through a handle, which
// lets every one of them through, so the refusal only ever surfaces here, on the
// way out: `getDirectoryHandle` says `Name is not allowed` and the whole backup
// stops. This spells such a name instead — the offending character as `%XX` of
// its UTF-8 bytes — and it is the same function on the way back in, so a tile
// under a folder called `2024 : ảnh.` still comes home. A name that was already
// safe is handed back untouched, which is what keeps the folder a person can
// read, copy or rsync by hand.
const UNSAFE = /[<>:"|?*\\/]|\p{Cc}|\p{Cf}|[\uFDD0-\uFDEF\uFFFE\uFFFF]/u;
const EDGE = /^[ .~]|[ .~]$/;
const SHORTCUT = /\.(lnk|scf|url)$/i;
const hex = (ch: string) =>
[...new TextEncoder().encode(ch)].map((b) => `%${b.toString(16).toUpperCase().padStart(2, '0')}`).join('');
function spell(name: string): string {
// `%` spells here even though a disk takes it: a name that holds one would
// otherwise spell the same as a name whose character was spelled out, and the
// two tiles would land on each other's file.
if (
name &&
name !== '.' &&
name !== '..' &&
!name.includes('%') &&
!UNSAFE.test(name) &&
!EDGE.test(name) &&
!SHORTCUT.test(name)
) {
return name;
}
const chars = [...name];
return chars
.map((ch, i) => {
const edge = (i === 0 || i === chars.length - 1) && /[ .~]/.test(ch);
return ch === '%' || edge || UNSAFE.test(ch) ? hex(ch) : ch;
})
.join('')
.replace(SHORTCUT, (m) => `%2E${m.slice(1)}`);
}
// The directories are asked for by the path in the frame id, and the id of every
// frame in a folder starts with that folder's: a cache of what has been opened
// turns a folder of 220 000 frames into a walk of the folders it is made of.
const opened = new WeakMap<FileSystemDirectoryHandle, Map<string, FileSystemDirectoryHandle>>();
async function at(root: FileSystemDirectoryHandle, rel: string, create = false): Promise<FileSystemDirectoryHandle> {
let cache = opened.get(root);
if (!cache) opened.set(root, (cache = new Map()));
let node = root;
let path = '';
// Both separators: a path handed in from a Windows-written id may say `\`.
for (const part of rel.split(/[\\/]/)) {
if (!part) continue;
const name = spell(part);
path = path ? `${path}/${name}` : name;
const had = cache.get(path);
if (had) {
node = had;
continue;
}
node = await node.getDirectoryHandle(name, { create });
cache.set(path, node);
}
return node;
}
async function put(root: FileSystemDirectoryHandle, rel: string, blob: Blob): Promise<void> {
const cut = rel.lastIndexOf('/');
const dir = cut < 0 ? root : await at(root, rel.slice(0, cut), true);
const file = await dir.getFileHandle(spell(cut < 0 ? rel : rel.slice(cut + 1)), { create: true });
// One write at a time and closed at once: a writable that is left open is a
// file that is not on the disk yet, and a backup that is interrupted is one the
// next run has to write again.
const writer = await file.createWritable();
await writer.write(blob);
await writer.close();
}
// What the last run left in the folder: the tile's own size, per frame. A tile
// that has not changed is not written again, which is the whole of what makes a
// second run cheap.
async function already(): Promise<Map<string, number>> {
const rows = await ask<{ id: string; size: number }[]>(WRITTEN, 'readonly', (s) => s.getAll()).catch(() => []);
return new Map(rows.map((r) => [r.id, r.size]));
}
export interface BackupResult extends BackupStatus {
// The tiles this run actually wrote, and the frames it found.
written: number;
}
export async function backupNow(onProgress?: (p: BackupProgress) => void): Promise<BackupResult> {
const root = await writable();
const [all, edits, allFolders] = await Promise.all([listPhotos(), listEdits(), listFolders()]);
// The copy is the disk's: a node that is really an Immich server and the frames
// read over the network from it are not on this machine, so they are not part of
// what a folder on this machine can hold. The catalogue still keeps them — see
// `immich.ts`, which reads them again on the next visit.
const photos = all.filter((p) => !p.remoteId);
const folders = allFolders.filter((f) => !f.source);
const had = await already();
const at0 = Date.now();
const cat = {
version: VERSION,
at: at0,
folders: folders.map(({ name, label }) => ({ name, label })),
// The two things a file cannot hold: the handle, and the tile, which is
// written beside this as a file of its own.
photos: photos.map(({ handle, thumb, ...row }) => row) as CatalogueRow[],
edits,
};
await put(root, CATALOGUE, new Blob([JSON.stringify(cat)], { type: 'application/json' }));
let written = 0;
let skipped = 0;
let done = 0;
const rows: { id: string; size: number }[] = [];
for (const photo of photos) {
if (++done % 64 === 0) onProgress?.({ done, total: photos.length });
const blob = photo.thumb ?? (await getPhotoThumb(photo.id));
if (!blob) continue;
if (had.get(photo.id) === blob.size) continue;
const parts = normPath(photo.id).split('/');
parts.pop();
const rel = [THUMBS, ...parts, photo.name].join('/');
try {
await put(root, rel, blob);
} catch (err) {
if (skipped < 5) console.warn('backup: could not write', rel, err);
skipped++;
continue;
}
rows.push({ id: photo.id, size: blob.size });
written++;
if (rows.length >= 200) await keep(WRITTEN, rows.splice(0, rows.length));
}
await keep(WRITTEN, rows);
if (skipped) console.warn(`backup: ${skipped} of ${photos.length} tiles skipped`);
onProgress?.({ done: photos.length, total: photos.length });
const meta: Meta = { id: 'meta', at: at0, photos: photos.length, wrote: written };
await keep(WHERE, [meta]);
return { folder: root.name, granted: true, at: at0, photos: photos.length, wrote: written, written };
}
export interface RestoreResult {
photos: number;
at: number;
}
// Put a catalogue back. Nothing is deleted and nothing that is already there is
// spared: the backup is the reading, and a frame it knows about is a frame the
// catalogue should know about too. Folders are not in it — a folder is a handle,
// and a handle only comes from the visitor's own picker — so the frames come back
// without one and the folders are picked again afterwards, which is also what
// gives every frame its handle back without reading a byte of it.
export async function restoreNow(onProgress?: (p: BackupProgress) => void): Promise<RestoreResult> {
const root = await folderHandle();
if (!root) throw new Error('no-backup-folder');
if (!(await ensurePermission(root, false))) throw new Error('no-backup-permission');
const text = await (await (await root.getFileHandle(CATALOGUE)).getFile()).text();
const cat = JSON.parse(text) as {
at?: number;
folders?: { name: string; label?: string }[];
photos: CatalogueRow[];
edits: LibraryEdit[];
};
// The folders this catalogue still has keep the names the backup remembers:
// a rename is the reader's own, and it is not in the frame's file anywhere.
const here = new Map<string, LibraryFolder>((await listFolders()).map((f) => [f.name, f]));
for (const f of cat.folders ?? []) {
const folder = here.get(f.name);
if (folder && f.label && folder.label !== f.label) await renameFolder(folder, f.label);
}
const photoList = cat.photos ?? [];
const editList = cat.edits ?? [];
const total = photoList.length;
// Instant catalog metadata & edits restoration (< 100ms)
const photos = await restoreCatalogue(photoList, editList);
// And the walks are forgotten with it: the numbers a walk wrote down are off a
// disk this backup does not describe, and a column drawn under them would say a
// folder holds what it held the last time the reader looked at it.
await forgetWalkedDirs();
// Activate backup tile fetcher for lazy rendering on screen
setBackupTileFetcher(readBackupTile);
onProgress?.({ done: total, total });
return { photos, at: cat.at ?? 0 };
}
+14 -182
View File
@@ -1,186 +1,18 @@
// The colour the camera itself wrote into the file.
// The grid the frame's own rendering is read on.
//
// LibRaw hands back the sensor's own data and nothing about how the body chose
// to render it: the white balance it locked, its picture style, its tone curve.
// The file carries the answer anyway — the preview saved inside it, which is the
// frame the photographer saw and the one a desktop viewer shows. So the develop
// is matched to that preview: develop a grid of the frame, take the same grid out
// of the embedded preview, and least-squares the 3x3 that takes the first to the
// second — then, on what that 3x3 leaves, the tone curve a matrix cannot hold.
// This file held the fit that used to relate a develop to the preview the camera
// wrote into the file: a grid out of each, a cubic a channel between them, and a
// 3x3 least squared ahead of that curve. None of it is the camera's rendering —
// a picture style is a colour transform with a curve in it — and the best a
// per-channel cubic could reach on the Fujifilm RAF this was reported on was
// dE00 6.04 against the file's own preview.
//
// Measured on the A5100 frame the studio was reported on (dE00 against the
// camera's own 24MP JPEG, blocks of a 64x64 grid): 12.0 as the develop left it,
// 9.2 with this fit drawn in linear light, 6.4 painted through the 8-bit colour
// filter this used to be, 6.6 with the 3x3 alone in float, 4.4 with the tone
// curve under it. What is left either way is the camera's own local rendering —
// its sharpening, the detail a 1.7MP preview never had — and no per-pixel
// transform reaches that.
// What replaced it is not a fit: the develop carries the difference itself, at
// the preview's own resolution, and adds it back on the value it left (see
// previewResidual in rawDevelop.ts). dE00 0.54 on that frame, 0.00 on every body
// that writes a full-size preview.
//
// The fit is on the sRGB-encoded values, exposure included — matrix and curve
// alike: the reference is the file's own frame, so matching its brightness is
// part of matching its colour. Fitted in that encoded space, not in linear light,
// because a least squares in linear light weighs the highlights and comes back
// with the midtones wrong — the same frames scored dE00 6.4 fitted encoded
// against 9.2 fitted linear. Any body, any picture style, and no fitted table to
// go stale — a preview is the one profile every RAW already carries.
// Row-major 3x3, on the sRGB-encoded develop: what the shader applies to a pixel.
export type Mat3 = readonly [
number, number, number,
number, number, number,
number, number, number,
];
// The grid the fit reads, and the size of the preview map handed to it.
// What is left here is the grid both the balance read and the develop's own maps
// are taken on, which is still the size the preview is reduced to when something
// only needs a statistic of the frame.
export const MATCH_GRID = 64;
// A block at or past this has nothing left to say about a rendering.
const CLIP = 0.97;
const FLOOR = 0.03;
// The per-file colour fit: a 3x3, then the tone curve a 3x3 cannot hold — one
// cubic a channel, on the encoded value the 3x3 leaves. `tone` is that curve in
// the shader's own order: r a b c d, then g, then b.
export interface Match {
m: Mat3;
tone: Float32Array;
}
// What the second stage does when the frame has no curve the fit can trust.
export const FLAT_TONE = new Float32Array([0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0]);
// Gaussian elimination on the one system this file fits: a channel is a row of
// three, for the 3x3 and again for the 3x3's own error.
function solve(C: number[][], d: number[]): number[] {
const n = 3;
const a = Array.from({ length: n }, (_, i) => [...C[i], d[i]]);
for (const row of a) for (let c = 0; c < n; c++) row[c] += 1e-9;
for (let c = 0; c < n; c++) {
let p = c;
for (let r = c + 1; r < n; r++) if (Math.abs(a[r][c]) > Math.abs(a[p][c])) p = r;
[a[c], a[p]] = [a[p], a[c]];
for (let r = 0; r < n; r++) {
if (r === c) continue;
const f = a[r][c] / a[c][c];
for (let k = c; k <= n; k++) a[r][k] -= f * a[c][k];
}
}
return a.map((_, i) => a[i][n] / a[i][i]);
}
// The curve a channel needs to reach the camera's, least squared: what the 3x3
// left for that channel, a cubic against the block the camera wrote there.
//
// The cubic is pinned at white, not free. Fitted free it slid its far end down to
// meet the blocks it wanted — 0.96 on red — and every blown highlight the develop
// had carried up to the white level came out off-white instead: a blown pixel
// (1, 1, 1) into the fit left at 0.82 / 0.97 / 0.93, and 0.0% of the frame at
// pure white against 2.3% before and 3.3% in the preview. So the fit solves the
// three free terms of `tone = x^3 + a(1 - x^3) + b(x - x^3) + c(x^2 - x^3)`, whose
// terms all vanish at 1 and which costs the fit almost nothing (rms 0.066 on red
// against the free cubic's 0.063, and 0.076 on blue against 0.076, on the frame
// this was measured on). Scaling the free curve onto its own end instead moved
// the midtones by the whole 4% the end was short, and pinning both ends moved them
// by 12% (rms 0.21 on green).
function basis(i: number, x: number): number {
const x3 = x * x * x;
return i === 0 ? 1 - x3 : i === 1 ? x - x3 : x * x - x3;
}
function fitTone(blocks: { t: number[]; q: number[] }[]): Float32Array {
const out = new Float32Array(12);
for (let c = 0; c < 3; c++) {
const A = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
const d = [0, 0, 0];
for (const { t, q } of blocks) {
const x = q[c], b = [basis(0, x), basis(1, x), basis(2, x)];
for (let p = 0; p < 3; p++) {
for (let k = 0; k < 3; k++) A[p][k] += b[p] * b[k];
d[p] += b[p] * (t[c] - x * x * x);
}
}
const [a, b, cc] = solve(A, d);
out.set([a, b, cc, 1 - a - b - cc], c * 4);
}
return out;
}
// A curve that comes back folded, or wandered far from the value it was handed,
// is a fit off blocks that fought each other, not a rendering: the caller keeps
// the 3x3 alone rather than a look nobody's camera has. The floor sits below the
// zero a curve is allowed to reach — the develop clamps there anyway — and the
// slope is only asked not to turn back.
function curveIsSane(tone: Float32Array): boolean {
for (const v of tone) if (!Number.isFinite(v) || Math.abs(v) > 4) return false;
for (let c = 0; c < 3; c++) {
const [a, b, cc, dd] = [tone[c * 4], tone[c * 4 + 1], tone[c * 4 + 2], tone[c * 4 + 3]];
let prev = a;
for (let i = 1; i <= 16; i++) {
const x = i / 16;
const y = a + x * (b + x * (cc + x * dd));
if (y < prev - 0.12 || y < -0.15 || y > 1.15) return false;
prev = y;
}
}
return true;
}
// `dev` and `ref` are n x n RGBA maps of the same frame, one byte per channel.
// The 3x3 comes back on those encoded values — the space the caller draws it in.
// Null when the two cannot be related — too few blocks carry colour, or the
// solution runs away — and the caller develops the frame as the sensor left it.
export function fitMatch(dev: Uint8Array, ref: Uint8Array, n = MATCH_GRID): Match | null {
const C = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
const d = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
const blocks: { e: number[]; t: number[] }[] = [];
let used = 0;
for (let i = 0; i < n * n; i++) {
const e = [dev[i * 4] / 255, dev[i * 4 + 1] / 255, dev[i * 4 + 2] / 255];
const t = [ref[i * 4] / 255, ref[i * 4 + 1] / 255, ref[i * 4 + 2] / 255];
// A block the camera clipped has no colour left to copy, one at the floor is
// only the pedestal, and one the develop clipped has lost the ratio between
// its channels — none of them say anything about the rendering.
if (t[0] > CLIP && t[1] > CLIP && t[2] > CLIP) continue;
if (e[0] > CLIP || e[1] > CLIP || e[2] > CLIP) continue;
if (Math.max(t[0], t[1], t[2]) < FLOOR) continue;
used++;
blocks.push({ e, t });
for (let x = 0; x < 3; x++) {
for (let y = 0; y < 3; y++) C[x][y] += e[x] * e[y];
for (let c = 0; c < 3; c++) d[x][c] += e[c] * t[x];
}
}
// A fit off a handful of blocks is a fit off noise.
if (used < (n * n) / 8) return null;
// Ridge towards identity, scaled to the data. The three channels of a develop
// are near-collinear — a camera's own rendering of a neutral scene is close to
// neutral — so the plain normal equations come back singular-ish, and the frame's
// own noise decides a matrix whose diagonal lands at or below zero. λ of 1e-3 of
// the data's scale lifts that back to a definite system and moves the fitted
// matrix only in the third decimal (measured: 0.001 kept the tone the same to
// 0.3% where 0.1 began flattening it).
const lambda = 1e-3 * ((C[0][0] + C[1][1] + C[2][2]) / 3);
for (let r = 0; r < 3; r++) {
C[r][r] += lambda;
d[r][r] += lambda;
}
const m = [...solve(C, d[0]), ...solve(C, d[1]), ...solve(C, d[2])];
// A row that ran off is a degenerate fit, not a look. A diagonal element at or
// below zero is not one: what these matrices hold is the difference between two
// near-identical channels (X-T3 -0.04, X100V -1.67 on a row that carries +1.05
// and -1.67), and refusing those refused the whole fit — those bodies opened
// byte-identical to an unfitted develop.
for (const v of m) if (!Number.isFinite(v) || Math.abs(v) > 4) return null;
// The 3x3 alone cannot bend a channel — it can only scale it — and what is left
// between the two frames is mostly exactly that bend. So the residual after the
// 3x3 is fitted a cubic a channel, on the value the 3x3 leaves.
const q = blocks.map(({ e, t }) => {
const v = [
m[0] * e[0] + m[1] * e[1] + m[2] * e[2],
m[3] * e[0] + m[4] * e[1] + m[5] * e[2],
m[6] * e[0] + m[7] * e[1] + m[8] * e[2],
];
return { t, q: v.map((x) => Math.min(1, Math.max(0, x))) };
});
const tone = fitTone(q);
return { m: m as unknown as Mat3, tone: curveIsSane(tone) ? tone : FLAT_TONE };
}
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
// Walking a picked folder is the one part of the catalogue that can run for
// minutes: a roll is dated folders inside dated folders, and a library can hold
// thousands. The walk goes from the parent down, one layer at a time — the picked
// folder and its own frames, then every folder one level down with theirs, then
// the level below that — so the names in the column and the frames near the top
// are on screen within the first pass. A folder the reader clicks is opened
// straight away and put in front of the queue, so the branch in front of them is
// read before the layer it sits in would have come round. A pass covers
// `DIRS_PER_PASS` directories and then hands the thread back, which is what keeps
// the page answering while a scan runs.
//
// Pure on purpose: it only reads `values()` and `getDirectoryHandle()` off a
// directory handle, so a check can walk a stand-in tree without a browser
// (scripts/roll-walk-check.mjs).
export interface WalkFile {
handle: FileSystemFileHandle;
rel: string;
}
interface Pending {
dir: FileSystemDirectoryHandle;
rel: string;
}
// What the walk is: the folder everything is measured from, the directories still
// to read, and the ones already read. `rel` is how a folder is spelled here — ''
// for the picked one, `2026/04/` under it, always with the trailing slash.
export interface Walk {
root: FileSystemDirectoryHandle;
pending: Pending[];
walked: Set<string>;
}
// Directories one pass reads before it yields: small enough that a click is
// answered within a second, large enough that a folder of a few hundred is one
// pass rather than a hundred.
export const DIRS_PER_PASS = 200;
// Folders a roll drags along that are never the reader's photographs. A leading
// `.` or `@` is the `.thumbnails` a camera writes, `.git`, `.Trash`, and the
// `@eaDir` a Synology puts beside every frame; the spelled-out names are the
// recycle bins and system folders of Windows, Synology and Linux volumes, which
// hold deleted or machine-written files rather than frames. Matching is
// case-insensitive because the same folder is `$RECYCLE.BIN` on one volume and
// `$Recycle.Bin` on the next.
export const SYSTEM_DIR = /^[.@]|^\$RECYCLE\.BIN$|^RECYCLER$|^System Volume Information$|^#recycle$|^lost\+found$/i;
// The folder a path names, asked from the root one segment at a time. A queue
// that was written down as paths comes back as handles this way, and a path that
// will not open answers null rather than a folder that is not there.
export async function openAt(root: FileSystemDirectoryHandle, rel: string): Promise<FileSystemDirectoryHandle | null> {
let dir = root;
try {
// ponytail: a path written on Windows can arrive with `\`, so both separators
// split it; a POSIX name holding a literal `\` is the price (normPath pays it already).
for (const part of rel.split(/[\\/]/).filter(Boolean)) dir = await dir.getDirectoryHandle(part);
} catch {
return null;
}
return dir;
}
// The folder the reader clicked goes first. When it is not in the queue the walk
// has not reached it yet — its row is on screen because the parent pass named it
// — so it is opened from the picked folder and read next. A folder already read,
// or one that will not open, changes nothing.
export async function jumpQueue(walk: Walk, rel: string | null): Promise<void> {
if (!rel || walk.walked.has(rel)) return;
const at = walk.pending.findIndex((p) => p.rel === rel);
if (at >= 0) {
if (at > 0) walk.pending.unshift(walk.pending.splice(at, 1)[0]);
return;
}
const dir = await openAt(walk.root, rel);
if (!dir) return;
// ponytail: the reader's folder jumps the whole queue, ancestors included —
// the folders between it and the picked one are read when their layer comes.
walk.pending.unshift({ dir, rel });
}
// One pass over the queue. The queue is the walk's own state: what is behind has
// been read, what is ahead is in the order it was found — parents before
// children, shallower layers before deeper ones. `seen` collects the folders this
// pass found, which is what the column draws before they hold a frame. Returns
// true when nothing is left to walk.
export async function walkPass(
walk: Walk,
out: WalkFile[],
seen: string[],
keep: (name: string) => boolean,
jump: () => string | null,
onDir?: (rel: string) => void,
onFile?: (file: WalkFile) => void,
onFail?: (rel: string) => void
): Promise<boolean> {
for (let i = 0; i < DIRS_PER_PASS && walk.pending.length; i++) {
await jumpQueue(walk, jump());
const here = walk.pending.shift()!;
onDir?.(here.rel);
// A folder opened by a click is still in the queue its parent filled: it is
// read once, in the place the click put it.
if (walk.walked.has(here.rel)) continue;
walk.walked.add(here.rel);
try {
const baseRel = here.rel ? (here.rel.endsWith('/') ? here.rel : `${here.rel}/`) : '';
for await (const entry of here.dir.values()) {
if (SYSTEM_DIR.test(entry.name)) continue;
if (entry.kind === 'directory') {
const rel = `${baseRel}${entry.name}/`;
walk.pending.push({ dir: entry as FileSystemDirectoryHandle, rel });
seen.push(rel);
} else if (keep(entry.name)) {
const file: WalkFile = { handle: entry as FileSystemFileHandle, rel: `${baseRel}${entry.name}` };
out.push(file);
onFile?.(file);
}
}
} catch {
// A subfolder that will not open is skipped; the rest of the roll stands.
// It is also a folder this walk has no word on — what it holds was never
// listed, so a caller about to take the walk's word for what the disk
// holds is told here that this folder is not part of it.
onFail?.(here.rel);
}
}
return walk.pending.length === 0;
}
+1
View File
@@ -15,6 +15,7 @@ export interface StudioState {
recipe?: Recipe;
simId?: string | null;
frameId?: FrameId;
framePanX?: number;
crop?: CropRatio;
cropRect?: CropRect;
// false = the frame is on screen but not accepted yet (still draggable).
+72
View File
@@ -0,0 +1,72 @@
import { download } from './imageOps';
// Posting the export to a social site. A page cannot reach into another
// origin's composer: the cookie is the browser's to send, never ours to read,
// and the window we open has no DOM we may touch — which is the same rule that
// keeps any other site from posting as the visitor. So there is no way to drop
// the picture into the site's own picker from here. Two things do work, and
// this takes the best one the browser offers:
//
// sheet on a phone the OS share sheet opens and the target app —
// Facebook, Instagram, Reddit — comes up with the picture already
// in its composer. Only the caption is left to type.
// clipboard the desktop path: the picture goes on the clipboard and the site
// opens beside it, so one paste (Ctrl+V) puts it in the composer.
// download no share sheet and no clipboard — an http origin is not a secure
// context, so the API is not even there: the file is handed over the
// old way and the site opens beside it.
//
// ponytail: the four sites are listed here rather than in a config file. Add a
// network when someone asks for one; these three paths serve any site alike.
export const SHARE_SITES = {
facebook: 'https://www.facebook.com/',
instagram: 'https://www.instagram.com/',
reddit: 'https://www.reddit.com/submit',
x: 'https://x.com/compose/post',
} as const;
export type ShareSite = keyof typeof SHARE_SITES;
export type SharePath = 'sheet' | 'clipboard' | 'download';
// The clipboard takes PNG in every engine — Chromium silently refuses a JPEG —
// so the file is drawn to a canvas once. No resample: the bytes handed in are
// already the size the visitor chose.
async function pngOf(file: File): Promise<Blob> {
const bitmap = await createImageBitmap(file);
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('no 2d context');
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
return await new Promise<Blob>((resolve, reject) =>
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png')
);
}
export async function sharePhoto(bytes: Uint8Array, filename: string, site: ShareSite): Promise<SharePath> {
const file = new File([bytes as BlobPart], filename, { type: 'image/jpeg' });
// A phone is the only place the picture reaches the target app on its own:
// the sheet hands the file over and that app owns its own composer.
if (navigator.canShare?.({ files: [file] })) {
try {
await navigator.share({ files: [file] });
} catch (err) {
// The sheet was closed: a "not now", not a failure, and nothing else opens
// on top of the choice the visitor just made.
if (!(err instanceof DOMException && err.name === 'AbortError')) throw err;
}
return 'sheet';
}
try {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': await pngOf(file) })]);
window.open(SHARE_SITES[site], '_blank', 'noopener');
return 'clipboard';
} catch {
window.open(SHARE_SITES[site], '_blank', 'noopener');
download(bytes, filename);
return 'download';
}
}
+10 -1
View File
@@ -102,7 +102,16 @@ function rasterSurface(w: number, h: number) {
let grContext: any = null;
function ensureGrContext(): any {
if (grContext) return grContext;
if (grContext) {
if (typeof grContext.isContextLost === 'function' && grContext.isContextLost()) {
try {
if (typeof grContext.delete === 'function') grContext.delete();
} catch {}
grContext = null;
} else {
return grContext;
}
}
if (typeof OffscreenCanvas === 'undefined') return null;
try {
grContext = CK.MakeWebGLContext(CK.GetWebGLContext(new OffscreenCanvas(8, 8)));
+53 -4
View File
@@ -22,6 +22,10 @@ const MODEL_URL = '/models/realesr-general-x4v3.onnx';
const MODEL_F16_URL = '/models/realesr-general-x4v3-f16.onnx';
// The wasm dir, not the file: the runtime picks its own name inside it.
const WASM_DIR = '/wasm/ort/';
// What the export menu warms, named so the warming costs no runtime: the script
// the runtime hands its threads, the binary they all load, and the two copies of
// the model. See `preloadSuperRes`.
const RUNTIME_FILES = ['ort-wasm-simd-threaded.jsep.mjs', 'ort-wasm-simd-threaded.jsep.wasm'];
// The model's own factor is fixed at 4; the destination is whatever the export
// asked for, so each finished tile is drawn to its place in the output rather
// than assembled at 4x and shrunk (which would cost the memory of both).
@@ -86,8 +90,14 @@ function load(): Promise<Loaded> {
// check is for the case the headers ever go missing: asking for more than
// one thread without isolation makes the runtime throw instead of falling
// back, and a slow export beats a broken one.
//
// Four, not the processor's whole width. Every thread is a Web Worker of
// its own holding a copy of that 27MB binary, and on a laptop the memory
// and the bandwidth are both spent well before the eighth one: the tile is
// 256 pixels square, and the threads that matter are here. A machine with
// fewer cores asks for fewer.
ort.env.wasm.numThreads = self.crossOriginIsolated
? Math.min(8, navigator.hardwareConcurrency || 1)
? Math.min(4, navigator.hardwareConcurrency || 1)
: 1;
// A laptop with two GPUs would otherwise hand this to the one built into
// the processor. The model is the export, so ask for the fast one.
@@ -117,10 +127,49 @@ function load(): Promise<Loaded> {
// Start fetching the 32MB of runtime and model before the export that needs
// them, so the visitor waits for the pixels and not for the download. Called
// when the export menu opens — the only door onto an upscale — and ignored
// afterwards: every later call finds `load()` already in flight.
// when the export menu opens — the only door onto an upscale.
//
// The bytes, and only the bytes: they are fetched by name rather than imported,
// because importing the runtime is what starts its thread pool — one Web Worker
// per thread, one copy of the 27MB binary each — and a pool that then belongs to
// the page until it dies, whether or not an export is ever asked for. Opening a
// menu is not the work. What the menu is worth is the download, and the download
// is what the browser's cache keeps: the export's own `import` then pays a second
// of instantiation it cannot avoid anyway, and finds the files already on disk.
export function preloadSuperRes(): void {
void load().catch(() => {});
for (const file of [...RUNTIME_FILES.map((f) => WASM_DIR + f), MODEL_URL, MODEL_F16_URL]) {
void fetch(file).catch(() => {});
}
}
// The page is being put away — closed, reloaded, restored from the back/forward
// cache — and this renderer is holding the session's own buffers and a thread
// pool with it. Hand the session back rather than leaving the browser to reap it
// with the page: a machine short of memory at the moment the studio closes is a
// machine that hangs on the studio opening again, which is the one symptom this
// can answer for.
//
// The pool is the runtime's and goes with the page; the session is ours. `loaded`
// goes with it, so a page that comes back loads the runtime again instead of
// calling into a released session — the files are cached, so that is a second.
export async function releaseSuperRes(): Promise<void> {
const pending = loaded;
loaded = null;
if (!pending) return;
try {
await (await pending).session.release();
} catch {
// A load that failed, or a runtime too old to have `release`: nothing to
// give back, and nothing to say about it either.
}
}
// The last moment the studio is on screen. Nothing else in this module listens
// for anything, so the listener goes with the one resource that needs it.
if (typeof addEventListener === 'function') {
addEventListener('pagehide', () => {
void releaseSuperRes();
});
}
// The photo's bytes, enlarged so its longest edge is `targetLongest`. The caller
+495
View File
@@ -0,0 +1,495 @@
// TIFF → RGBA, for the catalogue's tiles.
//
// A `.tif` in an album had no tile, and none of the three decoders the app
// already carries will make one. Measured on a baseline 5472x3648 scan written
// by vips: `createImageBitmap` answers InvalidStateError, CanvasKit's
// `MakeImageFromEncoded` answers null — so `tiffThumbnail` never returned
// anything for a TIFF at all — and LibRaw opens the file only to report a 0x0
// frame with no thumbnail inside it.
//
// So the frame is read here instead: the header, the strips or tiles with their
// compression, the photometric, and one row in `step` out of the image. A 640px
// tile shows no more of a 5472px row than that, which is why the full-size row
// is never built — a scan album is hundreds of megabytes a frame and the tile
// is the only thing anyone asked for.
//
// What is covered: little and big endian, strips and tiles, chunky (planar 1),
// 1/8/16-bit, grey / RGB / palette, compression none, LZW (5), PackBits
// (32773) and both JPEG-in-TIFF flavours (6 and 7, through CanvasKit, which is
// the one thing in here that does decode a JPEG). YCbCr outside a JPEG and CMYK
// are not decoded and answer null — the row still lists the frame by name.
import { Skia } from './skiaShim';
export interface TiffFrame {
width: number;
height: number;
rgba: Uint8Array;
}
// An IFD longer than this is not a camera file, it is something reading one.
const MAX_ENTRIES = 4096;
interface Entry {
type: number;
count: number;
/** Where the value's bytes are, inline in the entry or out at an offset. */
at: number;
}
interface Chunk {
/** Position in the image this chunk's pixels start at. */
x0: number;
y0: number;
/** Width of a stored row, which a tile pads out to its own tile width. */
stride: number;
width: number;
height: number;
offset: number;
length: number;
/** Set for the two JPEG flavours: the stream itself, tables already joined. */
jpeg?: Uint8Array;
}
interface Samples {
kind: 'samples';
width: number;
height: number;
spp: number;
depth: number;
data: Uint8Array | Uint16Array;
}
interface Rgba {
kind: 'rgba';
width: number;
height: number;
data: Uint8Array;
}
type Decoded = Samples | Rgba;
export function decodeTiff(bytes: Uint8Array, maxSide: number): TiffFrame | null {
if (bytes.length < 8) return null;
const little = bytes[0] === 0x49 && bytes[1] === 0x49;
if (!little && !(bytes[0] === 0x4d && bytes[1] === 0x4d)) return null;
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const u16 = (at: number) => view.getUint16(at, little);
const u32 = (at: number) => view.getUint32(at, little);
if (u16(2) !== 42) return null;
const valueSize = (type: number) => {
if (type === 1 || type === 2 || type === 6 || type === 7) return 1;
if (type === 3 || type === 8) return 2;
if (type === 4 || type === 9 || type === 11) return 4;
if (type === 5 || type === 10 || type === 12) return 8;
return 0;
};
const readIfd = (offset: number): Map<number, Entry> | null => {
if (offset < 8 || offset + 2 > bytes.length) return null;
const count = u16(offset);
if (count > MAX_ENTRIES || offset + 2 + count * 12 > bytes.length) return null;
const tags = new Map<number, Entry>();
for (let i = 0; i < count; i++) {
const entry = offset + 2 + i * 12;
const type = u16(entry + 2);
const n = u32(entry + 4);
const size = valueSize(type) * n;
if (!size || n > 1 << 22) continue;
const at = size <= 4 ? entry + 8 : u32(entry + 8);
if (at < 0 || at + size > bytes.length) continue;
tags.set(u16(entry), { type, count: n, at });
}
return tags;
};
const num = (tags: Map<number, Entry>, tag: number, fallback = 0) => {
const entry = tags.get(tag);
if (!entry) return fallback;
if (entry.type === 3) return u16(entry.at);
if (entry.type === 4 || entry.type === 9) return u32(entry.at);
if (entry.type === 1 || entry.type === 6 || entry.type === 7) return bytes[entry.at];
return fallback;
};
const nums = (tags: Map<number, Entry>, tag: number) => {
const entry = tags.get(tag);
if (!entry) return [];
const out: number[] = [];
for (let i = 0; i < entry.count; i++) {
if (entry.type === 3) out.push(u16(entry.at + i * 2));
else if (entry.type === 4) out.push(u32(entry.at + i * 4));
else out.push(bytes[entry.at + i]);
}
return out;
};
const tags = readIfd(u32(4));
if (!tags) return null;
const width = num(tags, 256);
const height = num(tags, 257);
if (width < 1 || height < 1) return null;
const compression = num(tags, 259, 1);
const photometric = num(tags, 262, 2);
const spp = num(tags, 277, 1);
if (num(tags, 284, 1) !== 1) return null;
if (spp < 1 || spp > 4) return null;
if (photometric === 5) return null;
if (photometric === 6 && compression !== 6 && compression !== 7) return null;
if (photometric === 2 && spp < 3) return null;
let depths = nums(tags, 258);
if (!depths.length) depths = [1];
if (depths.length === 1 && spp > 1) depths = new Array(spp).fill(depths[0]);
if (depths.length !== spp) return null;
const depth = depths[0];
if (depths.some((d) => d !== depth)) return null;
if (depth !== 1 && depth !== 8 && depth !== 16) return null;
const predictor = num(tags, 317, 1);
if (predictor !== 1 && predictor !== 2) return null;
const tileWidth = num(tags, 322);
const tileHeight = num(tags, 323);
const tiled = tileWidth > 0 && tileHeight > 0 && tags.has(324);
const rowsPerStrip = Math.max(1, num(tags, 278, height) || height);
const chunks = new Map<number, Chunk>();
const tables = tags.get(347);
const jpegTables = tables ? bytes.subarray(tables.at, tables.at + tables.count) : null;
if (compression === 6) {
const offsets = nums(tags, 273);
const lengths = nums(tags, 279);
const offset = num(tags, 513, offsets[0] ?? -1);
const length = num(tags, 514, lengths[0] ?? 0);
if (offset < 0 || offset + length > bytes.length || !length) return null;
chunks.set(0, {
x0: 0, y0: 0, stride: width, width, height, offset, length,
jpeg: bytes.subarray(offset, offset + length),
});
} else if (tiled) {
const offsets = nums(tags, 324);
const lengths = nums(tags, 325);
const across = Math.ceil(width / tileWidth);
const down = Math.ceil(height / tileHeight);
for (let ty = 0; ty < down; ty++) {
for (let tx = 0; tx < across; tx++) {
const key = ty * across + tx;
const offset = offsets[key];
const length = lengths[key];
if (offset == null || !length || offset + length > bytes.length) continue;
const x0 = tx * tileWidth;
const y0 = ty * tileHeight;
const chunk: Chunk = {
x0, y0, stride: tileWidth,
width: Math.min(tileWidth, width - x0),
height: Math.min(tileHeight, height - y0),
offset, length,
};
if (compression === 7) {
const scan = bytes.subarray(offset, offset + length);
chunk.jpeg = jpegTables ? jpegWithTables(jpegTables, scan) : scan;
}
chunks.set(key, chunk);
}
}
} else {
const offsets = nums(tags, 273);
const lengths = nums(tags, 279);
for (let i = 0; i < offsets.length; i++) {
const y0 = i * rowsPerStrip;
if (y0 >= height) break;
const offset = offsets[i];
const length = lengths[i] ?? 0;
if (offset < 0 || !length || offset + length > bytes.length) continue;
const chunk: Chunk = {
x0: 0, y0, stride: width, width,
height: Math.min(rowsPerStrip, height - y0),
offset, length,
};
if (compression === 7) {
const scan = bytes.subarray(offset, offset + length);
chunk.jpeg = jpegTables ? jpegWithTables(jpegTables, scan) : scan;
}
chunks.set(i, chunk);
}
}
if (!chunks.size) return null;
const keyOf = tiled
? (sx: number, sy: number) => Math.floor(sy / tileHeight) * Math.ceil(width / tileWidth) + Math.floor(sx / tileWidth)
: (_sx: number, sy: number) => Math.floor(sy / rowsPerStrip);
const value = (s: Samples, i: number) => {
if (i < 0 || i >= s.data.length) return 0;
const raw = s.data[i];
return s.depth === 16 ? raw >> 8 : raw;
};
const decode = (chunk: Chunk): Decoded | null => {
if (chunk.jpeg) {
const image = Skia.Image.MakeImageFromEncoded(chunk.jpeg);
if (!image) return null;
try {
const w = image.width();
const h = image.height();
if (!w || !h) return null;
// CanvasKit's image `readPixels` only takes its own buffer: handed one of
// ours it throws, and the frame came back black to a check on its return
// value (measured on a 5472x128 strip of a JPEG-compressed scan).
const px = image.readPixels(0, 0, {
width: w,
height: h,
colorType: Skia.ColorType.RGBA_8888,
alphaType: Skia.AlphaType.Unpremul,
colorSpace: Skia.ColorSpace.SRGB,
});
if (!px) return null;
return {
kind: 'rgba',
width: w,
height: h,
data: new Uint8Array(px.buffer, px.byteOffset, px.byteLength),
};
} finally {
image.delete();
}
}
const stride = Math.ceil((chunk.stride * spp * depth) / 8);
const want = stride * chunk.height;
let raw: Uint8Array;
if (compression === 1) raw = bytes.subarray(chunk.offset, Math.min(bytes.length, chunk.offset + want));
else if (compression === 5) raw = lzwDecode(bytes.subarray(chunk.offset, chunk.offset + chunk.length), want);
else if (compression === 32773) raw = packBits(bytes.subarray(chunk.offset, chunk.offset + chunk.length), want);
else return null;
if (raw.length < want) return null;
if (depth === 1) {
const data = new Uint8Array(chunk.height * chunk.width * spp);
for (let y = 0; y < chunk.height; y++) {
for (let x = 0; x < chunk.width; x++) {
const at = y * stride + (x >> 3);
const bit = at < raw.length ? (raw[at] >> (7 - (x & 7))) & 1 : 0;
data[y * chunk.width * spp + x] = bit * 255;
}
}
return { kind: 'samples', width: chunk.width, height: chunk.height, spp, depth: 1, data };
}
if (depth === 16) {
const data = new Uint16Array(chunk.height * chunk.width * spp);
for (let i = 0; i < data.length; i++) {
const at = i * 2;
data[i] = little ? raw[at] | (raw[at + 1] << 8) : (raw[at] << 8) | raw[at + 1];
}
if (predictor === 2) differencing(data, chunk, spp);
return { kind: 'samples', width: chunk.width, height: chunk.height, spp, depth: 16, data };
}
const data = new Uint8Array(want).fill(0);
data.set(raw.subarray(0, want));
if (predictor === 2) differencing(data, chunk, spp);
return { kind: 'samples', width: chunk.width, height: chunk.height, spp, depth: 8, data };
};
const scale = Math.min(1, maxSide / Math.max(width, height));
const outW = Math.max(1, Math.round(width * scale));
const outH = Math.max(1, Math.round(height * scale));
const out = new Uint8Array(outW * outH * 4);
const palette = photometric === 3 ? nums(tags, 320) : null;
const half = palette ? palette.length / 3 : 0;
let cachedKey = -1;
let cached: Decoded | null = null;
for (let oy = 0; oy < outH; oy++) {
const sy = Math.min(height - 1, Math.floor((oy * height) / outH));
for (let ox = 0; ox < outW; ox++) {
const sx = Math.min(width - 1, Math.floor((ox * width) / outW));
const key = keyOf(sx, sy);
if (key !== cachedKey) {
cachedKey = key;
const chunk = chunks.get(key);
cached = chunk ? decode(chunk) : null;
}
const chunk = chunks.get(key);
if (!cached || !chunk) continue;
const chunkX = sx - chunk.x0;
const chunkY = sy - chunk.y0;
if (chunkX < 0 || chunkY < 0 || chunkX >= chunk.width || chunkY >= chunk.height) continue;
const at = (oy * outW + ox) * 4;
let r: number;
let g: number;
let b: number;
if (cached.kind === 'rgba') {
const i = (chunkY * cached.width + chunkX) * 4;
if (i + 3 >= cached.data.length) continue;
r = cached.data[i];
g = cached.data[i + 1];
b = cached.data[i + 2];
} else {
const i = chunkY * cached.width * spp + chunkX * spp;
if (palette && half) {
const index = value(cached, i);
r = (palette[index] ?? 0) >> 8;
g = (palette[half + index] ?? 0) >> 8;
b = (palette[2 * half + index] ?? 0) >> 8;
} else if (photometric === 2 || photometric === 6) {
r = value(cached, i);
g = value(cached, i + 1);
b = value(cached, i + 2);
} else {
const gray = value(cached, i);
r = g = b = photometric === 0 ? 255 - gray : gray;
}
}
out[at] = r;
out[at + 1] = g;
out[at + 2] = b;
out[at + 3] = 255;
}
}
return { width: outW, height: outH, rgba: out };
}
// Horizontal differencing, undone in place: every sample is the one before it
// plus the difference stored. Bounded by the type, which is the point — a 16-bit
// sample wrapped at 65536 and an 8-bit one at 256, and the file wrapped them the
// same way.
function differencing(data: Uint8Array | Uint16Array, chunk: Chunk, spp: number): void {
const row = chunk.width * spp;
for (let y = 0; y < chunk.height; y++) {
const base = y * row;
for (let i = spp; i < row; i++) data[base + i] += data[base + i - spp];
}
}
// A JPEG-compressed strip carries the frame's own tables — the whole stream
// minus the entropy-coded scan — and the strip is the scan. Joining the two is
// the only way either decodes: a strip on its own is missing its tables, and the
// tables on their own are not a picture.
function jpegWithTables(tables: Uint8Array, strip: Uint8Array): Uint8Array {
const tableFrom = tables[0] === 0xff && tables[1] === 0xd8 ? 2 : 0;
const tableTo = tables.length > 4 && tables[tables.length - 2] === 0xff && tables[tables.length - 1] === 0xd9
? tables.length - 2
: tables.length;
const scanFrom = strip[0] === 0xff && strip[1] === 0xd8 ? 2 : 0;
const complete = strip.length > 4 && strip[strip.length - 2] === 0xff && strip[strip.length - 1] === 0xd9;
const body = tableTo - tableFrom;
const out = new Uint8Array(2 + body + (strip.length - scanFrom) + (complete ? 0 : 2));
out[0] = 0xff;
out[1] = 0xd8;
out.set(tables.subarray(tableFrom, tableTo), 2);
out.set(strip.subarray(scanFrom), 2 + body);
if (!complete) {
out[out.length - 2] = 0xff;
out[out.length - 1] = 0xd9;
}
return out;
}
// TIFF's LZW: MSB-first codes, 9 to 12 bits, and the code width steps up one
// code early — at 511, not 512 — which is the quirk every other implementation
// trips on.
function lzwDecode(src: Uint8Array, expected: number): Uint8Array {
const out = new Uint8Array(expected);
const prefix = new Int32Array(4096);
const suffix = new Uint8Array(4096);
const stack = new Uint8Array(4096);
let taken = 0;
let read = 0;
let bitCount = 0;
let bitBuffer = 0;
let next = 258;
let codeSize = 9;
let previous = -1;
let first = 0;
const code = () => {
while (bitCount < codeSize) {
if (read >= src.length) return -1;
bitBuffer = (bitBuffer << 8) | src[read++];
bitCount += 8;
}
bitCount -= codeSize;
return (bitBuffer >> bitCount) & ((1 << codeSize) - 1);
};
const emit = (at: number) => {
let depth = 0;
let walk = at;
while (walk >= 256) {
if (depth >= 4096 || prefix[walk] < 0) return;
stack[depth++] = suffix[walk];
walk = prefix[walk];
}
stack[depth++] = walk;
first = walk;
while (depth > 0) {
if (taken >= out.length) return;
out[taken++] = stack[--depth];
}
};
let current = code();
while (current >= 0 && current !== 257) {
if (current === 256) {
next = 258;
codeSize = 9;
previous = -1;
current = code();
continue;
}
if (previous < 0) {
if (current > 255) return out;
emit(current);
} else if (current < next) {
emit(current);
if (next < 4096) {
prefix[next] = previous;
suffix[next] = first;
next++;
}
} else {
if (next >= 4096) return out;
prefix[next] = previous;
suffix[next] = first;
emit(next);
next++;
}
previous = current;
if (next === (1 << codeSize) - 1 && codeSize < 12) codeSize++;
current = code();
}
return out;
}
function packBits(src: Uint8Array, expected: number): Uint8Array {
const out = new Uint8Array(expected);
let taken = 0;
let read = 0;
while (read < src.length && taken < expected) {
let run = src[read++];
if (run > 127) run -= 256;
if (run >= 0) {
const end = Math.min(src.length, read + run + 1);
while (read < end && taken < expected) out[taken++] = src[read++];
read = end;
} else if (run !== -128) {
const value = src[read++];
const times = Math.min(1 - run, expected - taken);
for (let i = 0; i < times; i++) out[taken++] = value;
}
}
return out;
}
+16 -7
View File
@@ -25,6 +25,21 @@ interface I18nValue {
const Ctx = createContext<I18nValue | null>(null);
function translate(lang: Lang, key: MsgKey, params?: Record<string, string | number>): string {
let text: string = DICTS[lang][key] ?? vi[key] ?? key;
if (params) {
for (const [name, v] of Object.entries(params)) text = text.replace(`{${name}}`, String(v));
}
return text;
}
// The same lookup, for the code outside React that has to write a line the
// screen will show — the held write in api.ts, which throws its sentence out of
// a catch block. It reads the language the way the provider does.
export function t(key: MsgKey, params?: Record<string, string | number>): string {
return translate(readLang(), key, params);
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Lang>(readLang);
@@ -40,13 +55,7 @@ export function I18nProvider({ children }: { children: ReactNode }) {
const value = useMemo<I18nValue>(() => ({
lang,
setLang,
t: (key, params) => {
let text: string = DICTS[lang][key] ?? vi[key] ?? key;
if (params) {
for (const [name, v] of Object.entries(params)) text = text.replace(`{${name}}`, String(v));
}
return text;
},
t: (key, params) => translate(lang, key, params),
}), [lang, setLang]);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
+248 -12
View File
@@ -10,9 +10,18 @@ export const en: Dict = {
'nav.account': 'Account',
'nav.theme': 'Theme',
'nav.language': 'Language',
// The phone's way back from a tab's strip to the rail that opened it.
'nav.tabs': 'TABS',
'nav.back': 'Back to home',
'nav.guest': 'Guest',
// The install offer (see vi.ts).
'install.title': 'Install RecipesCam',
'install.body': 'Add it to your home screen to open it faster — smoother, offline, full screen like an app.',
'install.yes': 'INSTALL',
'install.no': 'NO THANKS',
'install.ios': 'Tap the Share button in the toolbar, then choose “Add to Home Screen”.',
'act.undo': 'UNDO',
'act.redo': 'REDO',
'act.reset': 'RESET',
@@ -27,13 +36,13 @@ export const en: Dict = {
'drop.clear': 'Clear',
'drop.peeking': 'SHOWING THE ORIGINAL',
'drop.peekHint': 'Hold right-click or the \\ key to compare with the original',
'drop.compare': 'Compare with original picture',
'drop.keep': 'SAVE ORIGINAL',
'drop.compare': 'Compare',
'drop.keep': 'Save original',
'drop.keepHint': 'Save the untouched photo to the device photo library. A browser that cannot share files downloads it instead.',
'drop.demoLimit': 'Demo build: exports are capped at {px}px and carry a watermark.',
// The live viewfinder (see vi.ts for what the status line is for).
'cam.open': 'OPEN CAMERA',
'cam.open': 'Open camera',
'cam.shot': 'SHOOT',
'cam.flip': 'Switch front/back camera',
'cam.starting': 'Opening the camera…',
@@ -43,7 +52,7 @@ export const en: Dict = {
'cam.fail': 'The camera did not open.',
'cam.shotFail': 'The shot failed — try again.',
'hist.title': 'HISTOGRAM',
'hist.title': 'Histogram',
'hist.drag': 'Drag to move',
'hist.hide': 'Hide the histogram',
@@ -51,8 +60,7 @@ export const en: Dict = {
'tab.favorited': 'FAVORITED',
'tab.photos': 'MY PHOTOS',
'tab.light': 'LIGHT',
'tab.wb': 'WB',
'tab.fx': 'FX',
'tab.tools': 'TOOLS',
'tab.hsl': 'HSL',
'hsl.hint': 'PRESS PICK, THEN SAMPLE A COLOUR ON THE PHOTO',
'tab.frame': 'FRAME',
@@ -85,6 +93,21 @@ export const en: Dict = {
'frame.cinematic': 'CINEMATIC',
'frame.wall': 'WALL FRAME PORTRAIT',
'frame.wallLand': 'WALL FRAME LANDSCAPE',
'frame.oldFilm': 'OLD FILM LANDSCAPE',
'frame.oldFilmPort': 'OLD FILM PORTRAIT',
'frame.custom': 'CUSTOM FRAME',
'frame.import': '+ IMPORT FRAME',
'frame.customTitle': 'Custom Transparent Frame',
'frame.zoom': 'Zoom / Scale',
'frame.panX': 'Pan X',
'frame.panY': 'Pan Y',
'frame.fit': 'Fit Window',
'frame.fill': 'Fill Window',
'frame.center': 'Center',
'frame.replace': 'Change Frame',
'frame.remove': 'Remove Frame',
'frame.dragHint': 'Drag on photo to pan • Scroll wheel to zoom',
'frame.analyzing': 'Detecting frame transparency...',
'crop.none': 'NO CROP',
'crop.1:1': '1:1',
@@ -131,7 +154,7 @@ export const en: Dict = {
'auth.loggedInAs': 'Signed in as {email}',
'auth.verifyTitle': 'Verify your email',
'auth.verifyBody': 'We sent a six-digit code to {email}. Type it below — or open the link in the mail — to unlock the PRO features.',
'auth.verifyProHint': 'Until then the account works exactly like a guest: exports stay 2048px with a watermark, and nothing can be saved.',
'auth.verifyProHint': 'Until then the account stays on the basic tier: recipes, saved photos (12 max), and an export inside the long edge of the photo being edited. PRO features (HSL, TOOLS, RAW, exports past it) need the account to be activated.',
'auth.verifySent': 'A new code has been sent.',
'auth.codePlaceholder': 'The six digits from the mail',
'auth.verifyBtn': 'VERIFY',
@@ -139,11 +162,19 @@ export const en: Dict = {
'auth.codeLocked': 'Too many wrong tries — ask for a new code by email.',
'auth.signupVerifyHint': 'After signing up we email you a six-digit code — type it in to unlock the PRO features.',
// The PRO gate. Signed in but unproven is served as a guest, so the studio
// needs one place that says why and one way to ask for the letter again.
'pro.verifyChip': 'VERIFY EMAIL TO USE PRO',
// The trial notice under the header: which features are being tried out, on
// whom, and until when.
'notice.trial': 'Some features are on trial for registered users until 1 December; after that they open only in the Android app.',
// The PRO gate. A signed-in account the operator has not activated has the
// basic tier, so the studio needs one place that says what that means (the
// bar), one reason per locked tab (the chips), and one answer for a click on
// either of them.
'pro.hslChip': 'HSL IS A PRO FEATURE',
'pro.unverifiedBar': 'Account {email} is not verified — it works like a guest.',
'pro.fixChip': 'FIX IS A PRO FEATURE',
'pro.gradientChip': 'GRADIENT MASK IS A PRO FEATURE',
'pro.basicBar': 'Account {email} is on the basic tier — PRO features need an account the operator has activated.',
'pro.locked': 'PRO feature — this account has not been activated. Ask the operator to tick "Activated Pro".',
'pro.resend': 'RESEND VERIFICATION EMAIL',
'pro.resent': 'SENT',
@@ -200,7 +231,10 @@ export const en: Dict = {
'export.4k': 'EXPORT 4K (4096px)',
'export.customLabel': 'Longest edge (px)',
'export.custom': 'EXPORT',
'export.cap': 'Guest exports are capped at {px}px.',
'export.cap': 'This account can export up to {px}px.',
'export.share': 'Share to',
'export.shareHint':
"On a phone the network's own app opens with the picture already in its composer. On a desktop the picture goes to the clipboard — paste it (Ctrl+V) into the composer that opened; where pasting is refused, the file downloads for you to drop in.",
'photo.uploading': 'UPLOADING…',
@@ -218,9 +252,184 @@ export const en: Dict = {
'photos.failed': 'That did not work: {msg}',
'photos.noLabels': 'No strip labels yet',
'lib.title': 'LIBRARY',
'lib.locked': 'The library is for accounts. Log in to browse your photo folders.',
// The LIBRARY button while there is no account: the link is not offered, and
// this is what says why.
'lib.needAccount': 'The library is for accounts — log in to open it.',
'lib.hint':
'Pick a photo folder on this machine. Nothing is uploaded — the page only remembers where the folder is and keeps one thumbnail per frame.',
'lib.add': 'ADD FOLDER',
'lib.stop': 'STOP SCAN',
'lib.rename': 'RENAME FOLDER',
'lib.renameAsk': 'Name shown for this folder (the folder on the disk is not renamed):',
'lib.update': 'UPDATE...',
'lib.rescan': 'RESCAN...',
'lib.makeThumbs': 'MAKE THUMBS',
'lib.thumbsMade': 'Made {n} thumbnails for {folder}.',
'lib.resume': 'KEEP READING {folder}',
'lib.resumeHint': 'The last reading of this folder was cut off. Press to read it on from where it stopped.',
'lib.remove': 'REMOVE',
'lib.reconnect': 'GRANT ACCESS AGAIN',
'lib.nodeHint': 'Right-click to rename, update, rescan, make the missing thumbnails, collapse or remove the folder.',
'lib.collapseAll': 'COLLAPSE ALL',
'lib.collapse': 'COLLAPSE',
'lib.colHint': 'Right-click the empty part of this column to add a folder.',
'lib.colWidth': 'Drag to set the width of the folder column (arrow keys when focused)',
'lib.viewPreview': 'SINGLE',
'lib.viewGrid': 'ALL THUMBS',
'lib.subsShow': 'SHOW SUBFOLDERS',
'lib.subsHide': 'THIS FOLDER ONLY',
'lib.subsHint': 'Frames in subfolders: the whole branch under the open folder, or only what that folder holds itself.',
'lib.open': 'Open in the studio',
'lib.openExplorer': 'Open in window explorer',
'lib.openLocation': 'Open at location',
'lib.openStudio': 'Open in studio',
'lib.mergePanorama': 'Panorama Merge…',
'lib.mergeHdr': 'HDR Merge (Anti-ghosting)…',
'lib.copiedPath': 'Copied location to clipboard: {path}',
'lib.edited': 'edited',
'lib.reset': 'Reset to default',
'lib.size': '{mb} MB',
'lib.count': '{n} photos',
// What the thumbnail view is read through. The score a frame is given is the
// reader's own, so the words say so: "stars and up" is a floor, not a target.
'lib.sort': 'SORT',
'lib.sortNewest': 'Newest first',
'lib.sortOldest': 'Oldest first',
'lib.sortStars': 'Most stars',
'lib.sortDateTaken': 'Date taken',
'lib.sortDateEdited': 'Date edited',
'lib.sortFileType': 'File type',
'lib.sortName': 'File name',
'lib.sortAsc': 'Ascending',
'lib.sortDesc': 'Descending',
'lib.rating': 'RATING',
'lib.ratingAll': 'Any rating',
'lib.ratingAtLeast': '{n}★ and up',
'lib.search.label': 'Search Immich',
'lib.search.placeholder': 'Name or tag…',
'lib.search.hint': 'Immich: type a file name or a tag, then Enter — the answer becomes a node of its own in the tree.',
'lib.search.noServer': 'No Immich server yet — add one first.',
'lib.starHint': 'Give this frame {n} stars',
'lib.rotateLeft': 'Turn this frame left',
'lib.rotateRight': 'Turn this frame right',
'lib.year': 'YEAR',
'lib.yearAll': 'Any year',
'lib.hourFrom': 'Shot from',
'lib.hourFromAny': 'Any hour',
'lib.hourTo': 'Shot until',
'lib.hourToAny': 'Any hour',
'lib.scanning': 'Scanning {done}/{total} — {added} new…',
'lib.scanned': 'Scanned {folder}: {added}/{total} new frames.',
'lib.empty': 'Nothing in this folder yet.',
'lib.noFolders': 'No folder yet. Hit ADD FOLDER and pick a photo folder on this machine.',
'lib.blocked':
'Another tab of this app is holding the library open from an older build. Close that tab, or reload it, and this one picks the library up.',
'lib.missing': 'Could not open {name} — the file has been moved or deleted.',
'lib.offline':
'Could not open {name} — the photo folder is not connected. Plug the drive back in and try again.',
'lib.failed': 'That folder could not be read.',
'lib.unsupported':
'This browser will not let a page read a folder from the disk (Chrome, Edge, Opera and Brave will). Photos still edit fine — drag and drop them into the studio.',
// The catalogue's own copy: the browser's storage is the one place a cleared
// profile takes the reading away from, and these two are what puts it back.
'lib.backup': 'BACK UP',
'lib.restore': 'RESTORE',
'lib.backupNone': 'No backup folder yet — press to pick one and write the catalogue into it.',
'lib.backupAt': 'Backup: {folder} · {n} frames · {time}',
'lib.backupBlocked': 'The browser has not let this page write to {folder}. Press BACK UP and allow it.',
'lib.backupPick': 'Pick the folder the catalogue is written into and read back from — the folder name is the backup name.',
'lib.backupRun': 'Backing up {done}/{total}…',
'lib.backupDone': 'Backed up {n} frames, {w} tiles written.',
'lib.backupFailed': 'The backup folder could not be written to ({why}).',
'lib.restoreConfirm': 'Restore {n} frames from {folder}, written {time}? Frames the library already holds are left as they are.',
'lib.restoreRun': 'Restoring {done}/{total}…',
'lib.restoreDone': 'Restored {n} frames. Add the folder again so every frame gets its file back.',
'lib.restoreNothing': 'There is no catalogue of its own in {folder} — pick the folder a backup was written into.',
'lib.restoreFailed': 'The backup folder could not be read.',
// The repair (Library.tsx, admin only): every roll read back to the disk, so
// the catalogue and the disk say the same thing again.
'lib.repair': 'REPAIR DATA',
'lib.repairConfirm': 'Read all {n} folders from the top so the library matches the disk? Frames that are gone will be dropped from the catalogue.',
'lib.repairRun': 'Repairing the library ({n} folders)…',
'lib.repairDone': 'Library repaired: {n} folders read back.',
// Immich (src/engine/immich.ts, src/ui/ImmichDialog.tsx, Library.tsx): one more
// source beside the folders on disk — a server the account keeps, read through
// the backend's own routes. The key never reaches this page.
'lib.immich.add': 'ADD IMMICH',
'lib.immich.title': 'ADD AN IMMICH SERVER',
'lib.immich.albumsChoose': 'CHOOSE ALBUMS',
'lib.immich.name': 'Name shown',
'lib.immich.nameHint': 'Leave blank to use the hostname',
'lib.immich.url': 'Server address',
'lib.immich.key': 'API key',
'lib.immich.keyShare': 'This is a share link, not an API key',
'lib.immich.keyCheck': 'CHECK THE KEY',
'lib.immich.checking': 'CHECKING…',
'lib.immich.perms':
'The API key needs all six read permissions: album.read, album.statistics, asset.read, asset.view, asset.download and asset.statistics.',
'lib.immich.ok': 'The key works.',
'lib.immich.albumsPick': 'Tick the albums to keep in the library. Ticking none means all of them.',
'lib.immich.albumIds': 'Or paste album IDs',
'lib.immich.albumIdsHint': 'One ID per line, or a whole …/albums/<id> address',
'lib.immich.badIds': '{n} line(s) with no album ID in them were skipped.',
'lib.immich.unknownAlbum': 'This key sees no album {id}.',
'lib.immich.newKey': 'New key (blank to keep the current one)',
'lib.immich.noDownload': 'This key cannot download originals — frames can be viewed and graded from their previews only.',
'lib.immich.sync': 'READ AGAIN',
'lib.immich.disconnect': 'DISCONNECT',
'lib.immich.keep': 'REMOVE FROM LIBRARY',
'lib.immich.failed': 'This server could not be read.',
'lib.immich.auth': 'The key is wrong or too narrow.',
'lib.immich.unreachable': 'That address could not be reached.',
'lib.immich.serverErr': 'The server answered with something unusable.',
'lib.immich.invalid': 'An address and a key are both needed.',
'lib.immich.empty': 'This key sees no album.',
'lib.immich.added': 'Read {n} frames from {folder}.',
'lib.immich.reading': 'Reading {folder}, still reading…',
'lib.immich.hint': 'Right-click to read again, choose albums, disconnect or remove it from the library.',
'lib.immich.links': 'Saved cloud links',
'lib.immich.dup': 'same address — the library is read twice',
'lib.immich.linkForget': 'REMOVE THE LINK',
'immich.upload': 'Upload to Immich',
'immich.uploadTitle': 'Upload edited photo to Immich Cloud',
'immich.uploading': 'Uploading…',
'immich.uploadSuccess': 'Successfully uploaded photo to Immich Cloud!',
'immich.uploadFailed': 'Failed to upload to Immich Cloud: {msg}',
'immich.uploadToCloud': 'Upload to Immich Cloud',
'immich.noServer': 'No Immich Cloud server connected',
// Mobile Cloud Library
'lib.mobile.photos': 'Photos',
'lib.mobile.search': 'Search',
'lib.mobile.albums': 'Albums',
'lib.mobile.library': 'Library',
'lib.mobile.allAlbums': 'All albums',
'lib.mobile.allPhotos': 'All photos',
'lib.mobile.addImmich': 'ADD IMMICH',
'lib.mobile.addImmichCloud': 'ADD IMMICH CLOUD SERVER',
'lib.mobile.cloudDesc': 'Mobile browsers do not support local file system management. Cloud library allows you to browse and edit photos directly from your Immich Cloud.',
'lib.mobile.servers': 'CLOUD SERVERS',
'lib.mobile.noServers': 'No Immich Cloud servers connected yet.',
'lib.mobile.noPhotos': 'No photos in this cloud library yet.',
'lib.mobile.noAlbums': 'No albums found.',
'lib.mobile.connected': 'Connected',
'lib.mobile.scroll': 'scroll',
'lib.mobile.openStudio': 'OPEN IN STUDIO',
'lib.mobile.preview': 'Preview',
'lib.mobile.details': 'Details',
'lib.mobile.rescan': 'RELOAD',
'lib.mobile.chooseAlbums': 'CHOOSE ALBUMS',
'lib.mobile.disconnect': 'DISCONNECT',
'lib.mobile.searchPlaceholder': 'Search on Immich (filename, tag)...',
'nav.admin': 'Admin',
'nav.photos': 'My photos',
'nav.profile': 'Profile',
'nav.library': 'LIBRARY',
'nav.studio': 'Studio',
'adm.title': 'Strip moderation',
'adm.subtitle': 'Photos contributed by users. Delete one to pull it off the landing page.',
@@ -266,6 +475,7 @@ export const en: Dict = {
'adm.userName': 'Name',
'adm.userEmail': 'Email',
'adm.userRole': 'Role',
'adm.userPro': 'Activated Pro',
'adm.userPhotos': 'Photos',
'adm.userJoined': 'Joined',
'adm.roleAdmin': 'Admin',
@@ -329,6 +539,32 @@ export const en: Dict = {
'err.generic': 'Something went wrong: {msg}',
'err.offline': 'Could not reach the server.',
// The offline bar (src/ui/OfflineBar.tsx).
'offline.notice': 'No server — showing the copy saved on this machine.',
// A write the server never got. It is held, not lost; the sentence rides
// `err.generic`'s `{msg}`, so it reads as the second half of that one, and the
// count is the bar's own line for the queue (`offline.pending`).
'offline.queued': 'the server is out of reach, so this change is saved here and sent when it is back.',
'offline.pending': 'Not sent yet: {n}',
// A folder the reader pointed at that is already in the library.
'lib.dupFolder': '"{folder}" is already in the library.',
// A repair the reader cut short, told apart from one that ran to its end.
'lib.repairHalt': 'Repair stopped: {n} folders read back before it was stopped.',
// The crash screen (src/ui/ErrorBoundary.tsx).
'crash.title': 'The app hit an error',
'crash.body': 'Something went wrong. The details are below — send them along if it keeps happening.',
'crash.reload': 'Reload',
// The one-window guard (src/ui/SingleTab.tsx).
'tab.title': 'RecipesCam is open in another tab',
'tab.body': 'The app runs one tab at a time. Use that one, or move it to this tab.',
'tab.here': 'Use this tab',
'tab.close': 'Close this tab',
'tab.taken': 'Another tab just opened RecipesCam and took over.',
'tab.retake': 'Take it back',
'meta.exported': 'Downloaded {name} ({kb}KB, 300 ppi)',
};
+249 -12
View File
@@ -13,13 +13,24 @@ export const vi = {
'nav.account': 'Tài khoản',
'nav.theme': 'Giao diện',
'nav.language': 'Ngôn ngữ',
'nav.tabs': 'TAB',
'nav.back': 'Về trang chủ',
'nav.admin': 'Quản trị',
'nav.photos': 'Ảnh của tôi',
'nav.profile': 'Hồ sơ',
'nav.library': 'THƯ VIỆN',
'nav.studio': 'Studio',
'nav.guest': 'Khách',
// The install offer (src/ui/InstallPrompt.tsx). The only dialog that asks for
// something the visitor did not come for, so the copy sells what they get out of
// it and the refusal is one word away.
'install.title': 'Cài RecipesCam lên màn hình chính',
'install.body': 'Cài lên màn hình chính để mở nhanh hơn — mượt mà hơn, offline, toàn màn hình như một app.',
'install.yes': 'ĐỒNG Ý',
'install.no': 'KHÔNG CẢM ƠN',
'install.ios': 'Bấm nút Chia sẻ trên thanh địa chỉ, rồi chọn "Thêm vào màn hình chính".',
'act.undo': 'HOÀN TÁC',
'act.redo': 'LÀM LẠI',
'act.reset': 'ĐẶT LẠI',
@@ -34,15 +45,15 @@ export const vi = {
'drop.clear': 'Xoá ảnh',
'drop.peeking': 'ĐANG XEM ẢNH GỐC',
'drop.peekHint': 'Giữ chuột phải hoặc phím \\ để so ảnh gốc',
'drop.compare': 'So với ảnh gốc',
'drop.keep': 'LƯU ẢNH GỐC',
'drop.compare': 'So sánh',
'drop.keep': 'Lưu ảnh gốc',
'drop.keepHint': 'Lưu nguyên ảnh gốc chưa chỉnh vào thư viện ảnh của máy. Trình duyệt không chia sẻ được tệp thì ảnh sẽ được tải về.',
'drop.demoLimit': 'Bản demo: ảnh xuất ra tối đa {px}px và có watermark.',
// The live viewfinder. The status line is the one fact the user cannot see
// for themselves: the preview runs at its own resolution, and how fast the
// device grades it is what tells them the phone is keeping up.
'cam.open': 'MỞ CAMERA',
'cam.open': 'Mở camera',
'cam.shot': 'CHỤP',
'cam.flip': 'Đổi camera trước/sau',
'cam.starting': 'Đang mở camera…',
@@ -53,7 +64,7 @@ export const vi = {
'cam.shotFail': 'Chụp không thành công — thử lại.',
// The histogram overlay. 'HISTOGRAM' is the word Vietnamese photographers use.
'hist.title': 'HISTOGRAM',
'hist.title': 'Histogram',
'hist.drag': 'Kéo để di chuyển',
'hist.hide': 'Ẩn histogram',
@@ -61,8 +72,7 @@ export const vi = {
'tab.favorited': 'FAVORITED',
'tab.photos': 'ẢNH CỦA TÔI',
'tab.light': 'LIGHT',
'tab.wb': 'WB',
'tab.fx': 'FX',
'tab.tools': 'TOOLS',
'tab.hsl': 'HSL',
'hsl.hint': 'BẤM PICK RỒI CHỌN MỘT MÀU TRÊN ẢNH',
'tab.frame': 'FRAME',
@@ -95,6 +105,21 @@ export const vi = {
'frame.cinematic': 'ĐIỆN ẢNH',
'frame.wall': 'WALL FRAME PORTRAIT',
'frame.wallLand': 'WALL FRAME LANDSCAPE',
'frame.oldFilm': 'OLD FILM LANDSCAPE',
'frame.oldFilmPort': 'OLD FILM PORTRAIT',
'frame.custom': 'KHUNG TỰ CHỌN',
'frame.import': '+ THÊM KHUNG',
'frame.customTitle': 'Khung tự thêm (Transparent Frame)',
'frame.zoom': 'Thu phóng',
'frame.panX': 'Dịch ngang',
'frame.panY': 'Dịch dọc',
'frame.fit': 'Vừa khung',
'frame.fill': 'Lấp đầy',
'frame.center': 'Căn giữa',
'frame.replace': 'Đổi khung',
'frame.remove': 'Bỏ khung',
'frame.dragHint': 'Kéo trên ảnh để di chuyển • Cuộn chuột để phóng to/thu nhỏ',
'frame.analyzing': 'Đang phân tích vùng trong suốt của khung...',
'crop.none': 'KHÔNG CẮT',
'crop.1:1': '1:1',
@@ -141,7 +166,7 @@ export const vi = {
'auth.loggedInAs': 'Đã đăng nhập: {email}',
'auth.verifyTitle': 'Xác thực email',
'auth.verifyBody': 'Đã gửi mã xác thực 6 số tới {email}. Nhập mã vào ô dưới đây — hoặc mở liên kết trong email — để dùng được các tính năng PRO.',
'auth.verifyProHint': 'Khi chưa xác thực, tài khoản dùng y như khách: ảnh xuất ra tối đa 2048px kèm watermark và không lưu được gì.',
'auth.verifyProHint': 'Khi chưa xác thực, tài khoản vẫn ở bản cơ bản: lưu công thức, lưu ảnh (tối đa 12) và xuất ảnh trong giới hạn cạnh dài của ảnh đang chỉnh sửa. Các tính năng PRO (HSL, TOOLS, RAW, xuất lớn hơn) cần tài khoản được kích hoạt.',
'auth.verifySent': 'Đã gửi lại mã xác thực.',
'auth.codePlaceholder': 'Mã 6 số trong email',
'auth.verifyBtn': 'XÁC THỰC',
@@ -149,11 +174,19 @@ export const vi = {
'auth.codeLocked': 'Sai quá nhiều lần — hãy gửi lại email để nhận mã mới.',
'auth.signupVerifyHint': 'Sau khi đăng ký, hệ thống gửi một mã xác thực 6 số tới email — nhập mã để bật các tính năng PRO.',
// The PRO gate. Signed in but unproven is served as a guest, so the studio
// needs one place that says why and one way to ask for the letter again.
'pro.verifyChip': 'XÁC THỰC EMAIL ĐỂ DÙNG PRO',
// The trial notice under the header: which features are being tried out, on
// whom, and until when.
'notice.trial': 'Một vài tính năng sẽ được sử dụng thử với người đã đăng ký đến ngày 1/12, sau đó chỉ mở các tính năng này trên ứng dụng android',
// The PRO gate. A signed-in account the operator has not activated has the
// basic tier, so the studio needs one place that says what that means (the
// bar), one reason per locked tab (the chips), and one answer for a click on
// either of them.
'pro.hslChip': 'HSL LÀ TÍNH NĂNG PRO',
'pro.unverifiedBar': 'Tài khoản {email} chưa xác thực email — đang dùng như khách.',
'pro.fixChip': 'FIX LÀ TÍNH NĂNG PRO',
'pro.gradientChip': 'GRADIENT MASK LÀ TÍNH NĂNG PRO',
'pro.basicBar': 'Tài khoản {email} đang ở bản cơ bản — tính năng PRO cần tài khoản được kích hoạt.',
'pro.locked': 'Tính năng PRO — tài khoản chưa được kích hoạt. Nhờ quản trị viên tích "Kích hoạt PRO".',
'pro.resend': 'GỬI LẠI EMAIL XÁC THỰC',
'pro.resent': 'ĐÃ GỬI',
@@ -210,7 +243,10 @@ export const vi = {
'export.4k': 'XUẤT 4K (4096px)',
'export.customLabel': 'Cạnh dài nhất (px)',
'export.custom': 'XUẤT',
'export.cap': 'Bản khách giới hạn ở {px}px.',
'export.cap': 'Tài khoản này xuất tối đa {px}px.',
'export.share': 'Chia sẻ lên',
'export.shareHint':
'Điện thoại: app của mạng mở ra với ảnh đã nằm trong ô đăng. Máy tính: ảnh được copy vào clipboard, dán (Ctrl+V) vào ô đăng vừa mở; nơi không cho dán thì ảnh được tải về để bạn kéo vào.',
'photo.uploading': 'ĐANG TẢI…',
@@ -228,6 +264,180 @@ export const vi = {
'photos.failed': 'Không thực hiện được: {msg}',
'photos.noLabels': 'Chưa có nhãn dải phim',
'lib.title': 'THƯ VIỆN',
'lib.locked': 'Thư viện dành cho tài khoản. Đăng nhập để duyệt các thư mục ảnh của bạn.',
// Nút LIBRARY khi chưa có tài khoản: liên kết không được mời, và đây là lý do.
'lib.needAccount': 'Thư viện dành cho tài khoản — hãy đăng nhập để mở.',
'lib.hint':
'Chọn thư mục ảnh trên máy. Ảnh không được tải lên đâu cả — trang chỉ nhớ đường dẫn và giữ một ảnh thu nhỏ cho mỗi tấm.',
'lib.add': 'THÊM THƯ MỤC',
'lib.stop': 'DỪNG QUÉT',
'lib.rename': 'ĐỔI TÊN THƯ MỤC',
'lib.renameAsk': 'Tên hiển thị cho thư mục này (thư mục trên máy không bị đổi tên):',
'lib.update': 'CẬP NHẬT...',
'lib.rescan': 'QUÉT LẠI...',
'lib.makeThumbs': 'TẠO ẢNH THU NHỎ',
'lib.thumbsMade': 'Đã tạo {n} ảnh thu nhỏ cho {folder}.',
'lib.resume': 'ĐỌC TIẾP {folder}',
'lib.resumeHint': 'Lượt đọc thư mục này bị cắt ngang. Bấm để đọc tiếp từ chỗ đã dừng.',
'lib.remove': 'BỎ',
'lib.reconnect': 'CẤP LẠI QUYỀN',
'lib.nodeHint': 'Chuột phải để đổi tên, cập nhật, quét lại, tạo ảnh thu nhỏ còn thiếu, thu gọn hay bỏ thư mục.',
'lib.collapseAll': 'THU GỌN TẤT CẢ',
'lib.collapse': 'THU GỌN',
'lib.colHint': 'Chuột phải vào vùng trống của cột này để thêm thư mục.',
'lib.colWidth': 'Kéo để chỉnh độ rộng cột thư mục (mũi tên trái/phải khi đang chọn)',
'lib.viewPreview': 'MỘT ẢNH',
'lib.viewGrid': 'TOÀN BỘ',
'lib.subsShow': 'HIỆN CẢ THƯ MỤC CON',
'lib.subsHide': 'CHỈ THƯ MỤC ĐANG CHỌN',
'lib.subsHint':
'Ảnh trong thư mục con: hiện cả nhánh dưới thư mục đang chọn, hoặc chỉ ảnh của chính thư mục đó.',
'lib.open': 'Mở trong studio',
'lib.openExplorer': 'Open in window explorer',
'lib.openLocation': 'Open at location',
'lib.openStudio': 'Mở trong studio',
'lib.mergePanorama': 'Ghép Panorama…',
'lib.mergeHdr': 'Ghép HDR (Chống nhòe)…',
'lib.copiedPath': 'Đã sao chép đường dẫn: {path}',
'lib.edited': 'đã chỉnh',
'lib.reset': 'Đặt lại ảnh về mặc định',
'lib.size': '{mb} MB',
'lib.count': '{n} ảnh',
// Cách đọc bày ảnh: điểm sao là của người xem, nên chữ nói đúng thế — "từ n
// sao" là mức sàn, không phải mức để nhắm tới.
'lib.sort': 'SẮP XẾP',
'lib.sortNewest': 'Mới chụp trước',
'lib.sortOldest': 'Cũ nhất trước',
'lib.sortStars': 'Nhiều sao nhất',
'lib.sortDateTaken': 'Ngày chụp',
'lib.sortDateEdited': 'Ngày sửa/lưu',
'lib.sortFileType': 'Loại tệp',
'lib.sortName': 'Tên tệp',
'lib.sortAsc': 'Tăng dần',
'lib.sortDesc': 'Giảm dần',
'lib.rating': 'XẾP HẠNG',
'lib.ratingAll': 'Mọi mức sao',
'lib.ratingAtLeast': 'Từ {n} sao',
'lib.search.label': 'Tìm trên Immich',
'lib.search.placeholder': 'Tên ảnh hoặc tag…',
'lib.search.hint': 'Immich: gõ tên ảnh hoặc tên tag rồi Enter — kết quả thành một mục riêng trong cây.',
'lib.search.noServer': 'Chưa có máy chủ Immich nào — thêm một cái trước.',
'lib.starHint': 'Cho ảnh này {n} sao',
'lib.rotateLeft': 'Xoay ảnh sang trái',
'lib.rotateRight': 'Xoay ảnh sang phải',
'lib.year': 'NĂM',
'lib.yearAll': 'Mọi năm',
'lib.hourFrom': 'Chụp từ',
'lib.hourFromAny': 'Mọi giờ',
'lib.hourTo': 'Chụp đến',
'lib.hourToAny': 'Mọi giờ',
'lib.scanning': 'Đang quét {done}/{total} — thêm {added}…',
'lib.scanned': 'Đã quét {folder}: {added}/{total} ảnh mới.',
'lib.empty': 'Chưa có ảnh nào trong thư mục này.',
'lib.noFolders': 'Chưa có thư mục nào. Bấm THÊM THƯ MỤC để chọn một thư mục ảnh trên máy.',
'lib.blocked':
'Một tab khác của ứng dụng đang giữ thư viện ở bản cũ hơn. Hãy đóng tab đó, hoặc tải lại nó, để tab này mở được thư viện.',
'lib.missing': 'Không mở được {name} — tệp đã bị di chuyển hoặc xoá.',
'lib.offline':
'Không mở được {name} — thư mục ảnh chưa kết nối. Cắm lại ổ đĩa rồi thử lại.',
'lib.failed': 'Không đọc được thư mục này.',
'lib.unsupported':
'Trình duyệt này không cho phép app đọc thư mục trên máy (cần Chrome, Edge, Opera hoặc Brave). Ảnh vẫn chỉnh được bình thường bằng cách kéo & thả vào studio.',
// Bản sao của danh mục: bộ nhớ trình duyệt là chỗ duy nhất mà một profile bị
// xoá lấy mất phần đã đọc, và hai nút này là cách lấy lại.
'lib.backup': 'SAO LƯU',
'lib.restore': 'PHỤC HỒI',
'lib.backupNone': 'Chưa có thư mục sao lưu — bấm để chọn một thư mục và ghi danh mục vào đó.',
'lib.backupAt': 'Sao lưu: {folder} · {n} ảnh · {time}',
'lib.backupBlocked': 'Trình duyệt chưa cho trang ghi vào {folder}. Bấm SAO LƯU và cho phép.',
'lib.backupPick': 'Chọn thư mục mà danh mục được ghi vào và đọc lại từ đó — tên thư mục chính là tên bản sao lưu.',
'lib.backupRun': 'Đang sao lưu {done}/{total}…',
'lib.backupDone': 'Đã sao lưu {n} ảnh, ghi {w} thumbnail.',
'lib.backupFailed': 'Không ghi được vào thư mục sao lưu ({why}).',
'lib.restoreConfirm': 'Phục hồi {n} ảnh từ {folder}, ghi lúc {time}? Những ảnh thư viện đã có sẽ được giữ nguyên.',
'lib.restoreRun': 'Đang phục hồi {done}/{total}…',
'lib.restoreDone': 'Đã phục hồi {n} ảnh. Thêm lại thư mục ảnh để mỗi ảnh lấy lại được tệp của nó.',
'lib.restoreNothing': 'Thư mục {folder} không có danh mục sao lưu nào — hãy chọn đúng thư mục đã ghi sao lưu.',
'lib.restoreFailed': 'Không đọc được thư mục sao lưu.',
// Sửa dữ liệu (Library.tsx, chỉ quản trị): đọc lại từng cuộn ảnh về đúng đĩa,
// để danh mục và đĩa nói cùng một điều.
'lib.repair': 'SỬA DỮ LIỆU',
'lib.repairConfirm': 'Đọc lại cả {n} thư mục từ đầu để thư viện khớp với đĩa? Ảnh đã mất sẽ bị gỡ khỏi danh mục.',
'lib.repairRun': 'Đang sửa dữ liệu thư viện ({n} thư mục)…',
'lib.repairDone': 'Đã sửa dữ liệu thư viện: đọc lại {n} thư mục.',
// Immich (src/engine/immich.ts, src/ui/ImmichDialog.tsx, Library.tsx): một
// nguồn ảnh nữa bên cạnh các thư mục trên máy — một máy chủ mà tài khoản đã
// lưu, đọc qua các route của backend. Khoá không bao giờ xuống trang này.
'lib.immich.add': 'THÊM IMMICH',
'lib.immich.title': 'THÊM MÁY CHỦ IMMICH',
'lib.immich.albumsChoose': 'CHỌN ALBUM',
'lib.immich.name': 'Tên hiển thị',
'lib.immich.nameHint': 'Bỏ trống để lấy tên miền',
'lib.immich.url': 'Địa chỉ máy chủ',
'lib.immich.key': 'Khoá API',
'lib.immich.keyShare': 'Đây là link chia sẻ, không phải API key',
'lib.immich.keyCheck': 'KIỂM TRA KHOÁ',
'lib.immich.checking': 'ĐANG KIỂM TRA…',
'lib.immich.perms':
'Khoá API cần đủ 6 quyền đọc: album.read, album.statistics, asset.read, asset.view, asset.download và asset.statistics.',
'lib.immich.ok': 'Khoá dùng được.',
'lib.immich.albumsPick': 'Tick những album đưa vào thư viện. Không tick gì nghĩa là lấy tất cả.',
'lib.immich.albumIds': 'Hoặc dán ID album',
'lib.immich.albumIdsHint': 'Mỗi dòng một ID, hoặc dán cả đường dẫn …/albums/<id>',
'lib.immich.badIds': 'Bỏ qua {n} dòng không có ID album.',
'lib.immich.unknownAlbum': 'Khoá này không thấy album {id}.',
'lib.immich.newKey': 'Khoá mới (bỏ trống nếu giữ nguyên)',
'lib.immich.noDownload': 'Khoá này không có quyền tải ảnh gốc — chỉ xem và chỉnh được bản xem trước.',
'lib.immich.sync': 'ĐỌC LẠI',
'lib.immich.disconnect': 'NGẮT KẾT NỐI',
'lib.immich.keep': 'XOÁ KHỎI THƯ VIỆN',
'lib.immich.failed': 'Không đọc được máy chủ này.',
'lib.immich.auth': 'Khoá sai hoặc thiếu quyền đọc.',
'lib.immich.unreachable': 'Không kết nối được tới địa chỉ này.',
'lib.immich.serverErr': 'Máy chủ trả lời không đọc được.',
'lib.immich.invalid': 'Cần có địa chỉ và khoá.',
'lib.immich.empty': 'Khoá này không thấy album nào.',
'lib.immich.added': 'Đã đọc {n} ảnh từ {folder}.',
'lib.immich.reading': 'Đang đọc {folder}, còn đọc tiếp…',
'lib.immich.hint': 'Chuột phải để đọc lại, chọn album, ngắt kết nối hoặc xoá khỏi thư viện.',
'lib.immich.links': 'Liên kết cloud đã lưu',
'lib.immich.dup': 'trùng địa chỉ — thư viện bị đọc hai lần',
'lib.immich.linkForget': 'XOÁ LIÊN KẾT',
'immich.upload': 'Lưu lên Immich',
'immich.uploadTitle': 'Tải ảnh đã sửa lên Immich Cloud',
'immich.uploading': 'Đang tải lên…',
'immich.uploadSuccess': 'Đã tải ảnh lên Immich Cloud thành công!',
'immich.uploadFailed': 'Tải lên Immich Cloud thất bại: {msg}',
'immich.uploadToCloud': 'Tải lên Immich Cloud',
'immich.noServer': 'Chưa kết nối máy chủ Immich Cloud',
// Mobile Cloud Library
'lib.mobile.photos': 'Ảnh',
'lib.mobile.search': 'Tìm kiếm',
'lib.mobile.albums': 'Album',
'lib.mobile.library': 'Thư viện',
'lib.mobile.allAlbums': 'Tất cả album',
'lib.mobile.allPhotos': 'Tất cả ảnh',
'lib.mobile.addImmich': 'THÊM IMMICH',
'lib.mobile.addImmichCloud': 'THÊM MÁY CHỦ IMMICH CLOUD',
'lib.mobile.cloudDesc': 'Trên mobile không hỗ trợ quản lý thư mục máy tính cục bộ. Thư viện cloud giúp bạn mở và chỉnh sửa trực tiếp các kho ảnh Immich Cloud.',
'lib.mobile.servers': 'MÁY CHỦ CLOUD',
'lib.mobile.noServers': 'Chưa có máy chủ Immich Cloud nào được kết nối.',
'lib.mobile.noPhotos': 'Chưa có ảnh nào trong thư mục cloud.',
'lib.mobile.noAlbums': 'Chưa có album nào.',
'lib.mobile.connected': 'Đã kết nối',
'lib.mobile.scroll': 'scroll',
'lib.mobile.openStudio': 'MỞ TRONG STUDIO',
'lib.mobile.preview': 'Xem ảnh',
'lib.mobile.details': 'Chi tiết',
'lib.mobile.rescan': 'ĐỌC LẠI',
'lib.mobile.chooseAlbums': 'CHỌN ALBUM',
'lib.mobile.disconnect': 'NGẮT KẾT NỐI',
'lib.mobile.searchPlaceholder': 'Tìm kiếm trên Immich (tên ảnh, tag)...',
'adm.title': 'Quản trị dải phim',
'adm.subtitle': 'Ảnh do người dùng đóng góp. Xoá một ảnh để gỡ nó khỏi trang chủ.',
'adm.upload': 'THÊM ẢNH',
@@ -272,6 +482,7 @@ export const vi = {
'adm.userName': 'Tên',
'adm.userEmail': 'Email',
'adm.userRole': 'Quyền',
'adm.userPro': 'Kích hoạt PRO',
'adm.userPhotos': 'Số ảnh',
'adm.userJoined': 'Ngày tạo',
'adm.roleAdmin': 'Quản trị',
@@ -337,6 +548,32 @@ export const vi = {
'err.generic': 'Có lỗi xảy ra: {msg}',
'err.offline': 'Không gọi được máy chủ.',
// The offline bar (src/ui/OfflineBar.tsx). Says both halves: the server is
// gone, and what is on the screen is the copy this machine kept.
'offline.notice': 'Mất kết nối máy chủ — đang xem bản đã lưu trên máy.',
'offline.queued': 'máy chủ không tới được, nên thay đổi này đã lưu trên máy và sẽ gửi khi kết nối lại.',
'offline.pending': 'Chưa gửi được: {n}',
// A folder the reader pointed at that is already in the library. The picker
// gives no name back to compare by eye, so the note names the one that is there.
'lib.dupFolder': 'Thư mục "{folder}" đã có trong thư viện.',
// A repair the reader cut short, told apart from one that ran to its end.
'lib.repairHalt': 'Đã dừng sửa dữ liệu: đọc lại {n} thư mục trước khi dừng.',
// The crash screen (src/ui/ErrorBoundary.tsx). Rare, and the only thing a
// report of a blank page will have to carry.
'crash.title': 'Ứng dụng gặp lỗi',
'crash.body': 'Đã có lỗi xảy ra. Chi tiết ở dưới — gửi lại nếu còn tiếp diễn.',
'crash.reload': 'Tải lại',
// The one-window guard (src/ui/SingleTab.tsx).
'tab.title': 'RecipesCam đang mở ở tab khác',
'tab.body': 'Ứng dụng chỉ chạy một tab một lúc. Dùng tab kia, hoặc chuyển sang tab này.',
'tab.here': 'Dùng tab này',
'tab.close': 'Đóng tab này',
'tab.taken': 'Tab khác vừa mở RecipesCam và tiếp quản.',
'tab.retake': 'Lấy lại',
'meta.exported': 'Đã tải {name} ({kb}KB, 300 ppi)',
} as const;
+121 -17
View File
@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import './styles/fonts.css';
import './styles/tokens.css';
import './styles/app.css';
import './styles/landing.css';
import './styles/mobile-library.css';
import { ThemeProvider } from './theme/ThemeProvider';
import { I18nProvider } from './i18n/I18nProvider';
import { Landing } from './Landing';
@@ -10,23 +12,106 @@ import { Workspace } from './App';
import { Admin } from './Admin';
import { ProfilePage } from './ProfilePage';
import { PhotosPage } from './PhotosPage';
import { Library } from './Library';
import { go } from './nav';
import { ErrorBoundary } from './ui/ErrorBoundary';
import { OtherWindowNotice, TakenBar, useSingleWindow } from './ui/SingleTab';
import { InstallPrompt } from './ui/InstallPrompt';
import { OfflineBar } from './ui/OfflineBar';
import { installTracking } from './track';
// Five routes, no router: the landing page, the workspace, the strip moderation
// screen, the member's own profile and the member's photo folder. nginx serves
// index.html for all of them (SPA fallback), so this is a pathname check.
const path = window.location.pathname;
const page = path.startsWith('/app') ? (
<Workspace />
) : path.startsWith('/admin') ? (
<Admin />
) : path.startsWith('/photos') ? (
<PhotosPage />
) : path.startsWith('/profile') ? (
<ProfilePage />
) : (
<Landing />
);
// Six routes, no router: the landing page, the workspace, the strip moderation
// screen, the member's own profile, the member's photo folder and the local
// catalogue of folders on the visitor's own disk. nginx serves index.html for
// all of them (SPA fallback), so this is a pathname check.
//
// The pages that are not the two heavy screens: they read no catalogue and boot
// no renderer, so they are drawn straight from the route and nothing is kept.
function page(route: string) {
return route.startsWith('/admin') ? (
<Admin />
) : route.startsWith('/photos') ? (
<PhotosPage />
) : route.startsWith('/profile') ? (
<ProfilePage />
) : (
<Landing />
);
}
// The studio and the library are the two screens worth a boot: ~20MB of CanvasKit
// under one and the whole catalogue under the other. Both used to be mounted for
// every visit, which is the 20MB and the read on a page that was shown neither,
// and it is what a machine short of memory took for a crash. A route mounts its
// screen the first time it is asked for and stays mounted after that, so coming
// back from the studio is a screen opening rather than a page loading.
const heavy = (route: string) => (route.startsWith('/app') ? 'app' : route.startsWith('/library') ? 'library' : 'other');
function Shell() {
const [route, setRoute] = useState(() => window.location.pathname + window.location.search);
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const target = e.target as Element | null;
const link = target?.closest?.('a');
if (!(link instanceof HTMLAnchorElement)) return;
if (link.target || link.hasAttribute('download')) return;
const url = new URL(link.href, window.location.href);
const here = window.location.pathname + window.location.search;
if (url.origin !== window.location.origin || url.hash) return;
if (url.pathname + url.search === here) return;
e.preventDefault();
go(url.pathname + url.search);
};
const onPop = () => setRoute(window.location.pathname + window.location.search);
document.addEventListener('click', onClick);
window.addEventListener('popstate', onPop);
return () => {
document.removeEventListener('click', onClick);
window.removeEventListener('popstate', onPop);
};
}, []);
const kind = heavy(route);
const [seen, setSeen] = useState(() => new Set([kind]));
useEffect(() => {
setSeen((prev) => (prev.has(kind) ? prev : new Set(prev).add(kind)));
}, [kind]);
// One window at a time, decided before either heavy screen is mounted — see
// `useSingleWindow`. `null` is the question still being out: nothing is drawn
// for it, because drawing the app and then taking it away is worse than the
// fraction of a second the answer takes.
const { held, taken, take } = useSingleWindow(kind !== 'other');
const blocked = kind !== 'other' && held !== false;
return (
<>
{blocked ? (
held ? (
<OtherWindowNotice onUse={take} />
) : null
) : (
<>
{seen.has('app') ? (
<div style={{ display: kind === 'app' ? 'block' : 'none', height: '100%' }}>
<Workspace />
</div>
) : null}
{seen.has('library') ? (
<div style={{ display: kind === 'library' ? 'block' : 'none', height: '100%' }}>
<Library />
</div>
) : null}
{kind === 'other' ? page(route) : null}
</>
)}
{taken ? <TakenBar onTake={take} /> : null}
</>
);
}
const root = document.getElementById('root');
if (!root) throw new Error('#root missing');
@@ -35,9 +120,28 @@ if (!root) throw new Error('#root missing');
installTracking();
// No StrictMode: it double-invokes effects, which would load CanvasKit twice and
// run the render pipeline twice on every mount.
// run the render pipeline twice on every mount. The install offer hangs off the
// i18n provider because its copy comes from the dictionaries. The boundary is
// the whole app's: an error on the way to the screen used to be a white page.
createRoot(root).render(
<ThemeProvider>
<I18nProvider>{page}</I18nProvider>
<I18nProvider>
<ErrorBoundary>
<Shell />
</ErrorBoundary>
<InstallPrompt />
<OfflineBar />
</I18nProvider>
</ThemeProvider>
);
// Service worker, production only: in dev it would answer the browser with the
// last build's shell instead of vite's modules. Registered after `load` so its
// precache does not race the studio's own 20MB of wasm for the connection, and a
// browser that refuses it (private mode, a plain-http origin) just loses the
// offline shell — nothing else depends on it.
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
+12
View File
@@ -0,0 +1,12 @@
// Internal navigation, in one place. SPA in-place navigation preserves
// in-memory photo and folder caches when transitioning between Web Studio
// (/app) and Library (/library).
//
// `replace` is for the moves that are not steps: a screen sending a visitor back
// where they belong (the library, to a guest) must not leave itself as the page
// the Back button returns to, or the visitor is pushed off it on every press.
export function go(url: string, replace = false): void {
if (replace) history.replaceState(null, '', url);
else history.pushState(null, '', url);
window.dispatchEvent(new PopStateEvent('popstate'));
}
+118
View File
@@ -0,0 +1,118 @@
// Installability lives outside React. The browser fires `beforeinstallprompt` once,
// whenever it likes, and the event has to be held until a click spends it — so this
// is a module-level slot plus a subscriber list, and no store, hook or context.
//
// The state the visitor owns is the answer: a refusal, or an install. Remember either
// one, and the offer never comes back. A no is an answer, not a snooze.
type InstallEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
};
const DISMISSED = 'rc.install.dismissed.v1';
// The other answer: installed, and never asked again — including from a plain browser
// tab, which has no `standalone` to tell it so.
const INSTALLED = 'rc.install.installed.v1';
// The window event a control elsewhere in the app (the Library's own button, say)
// fires to pull the offer forward instead of waiting out the delay.
export const OFFER_EVENT = 'rc:install-offer';
let deferred: InstallEvent | null = null;
const listeners = new Set<() => void>();
const notify = () => {
for (const listener of listeners) listener();
};
// Registers `listener` for every change in installability and returns the
// unsubscribe — the shape `useEffect` already has.
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function canInstall(): boolean {
return deferred !== null && !dismissed() && !isInstalled();
}
// Spends the browser's prompt and reports the answer. Null means there was nothing
// to spend: the event never arrived, or this is a second call.
//
// Either answer is remembered, because either answer is the visitor's: yes is the app
// being installed, and the browser's own sheet being waved away is a no — the event is
// spent now, and it never fires again for this install.
export async function promptInstall(): Promise<'accepted' | 'dismissed' | null> {
const event = deferred;
if (!event) return null;
// Spent either way, so it is cleared before the await: a second prompt() on the
// same event rejects.
deferred = null;
notify();
await event.prompt();
const { outcome } = await event.userChoice;
if (outcome === 'accepted') markInstalled();
else dismissInstall();
return outcome;
}
// The installed app is its own window; a Safari tab that was added to the home
// screen keeps `standalone` on the navigator instead.
export function isStandalone(): boolean {
return (
window.matchMedia('(display-mode: standalone)').matches ||
(navigator as Navigator & { standalone?: boolean }).standalone === true
);
}
// Every browser on iOS is WebKit, but only Safari's own UI has "Add to Home Screen",
// and only there does the manual instruction make sense.
export function isIosSafari(): boolean {
const ua = navigator.userAgent;
const ios = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
return ios && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(ua);
}
export function dismissed(): boolean {
return localStorage.getItem(DISMISSED) === '1';
}
export function dismissInstall(): void {
localStorage.setItem(DISMISSED, '1');
notify();
}
// True in the installed window itself, and also in any browser tab that watched the
// install happen — that tab keeps running the site, and must not ask for the app it
// just installed.
export function isInstalled(): boolean {
return isStandalone() || localStorage.getItem(INSTALLED) === '1';
}
export function markInstalled(): void {
localStorage.setItem(INSTALLED, '1');
notify();
}
// Asks for the offer now.
export function offerInstall(): void {
window.dispatchEvent(new Event(OFFER_EVENT));
}
window.addEventListener('beforeinstallprompt', (event) => {
// Without this Chromium shows its own mini-infobar and the event is spent; the
// app's dialog is the one the visitor gets.
event.preventDefault();
deferred = event as InstallEvent;
notify();
});
// Fires once the app is installed, from our prompt or from the browser's own menu,
// and is the only signal that the offer is done.
window.addEventListener('appinstalled', () => {
deferred = null;
markInstalled();
});
+234
View File
@@ -0,0 +1,234 @@
// Whether the server can be reached, and what it last said.
//
// Two things the offline story needs and no more: a flag the screen can watch
// (the notice in ui/OfflineBar.tsx), and the last good answer to each read, so a
// reload with no server comes back on the data it was already showing instead of
// an empty page.
//
// The flag is set by the API layer, the one place that finds out: a fetch that
// never got an answer is a server that is gone, and the next one that answers is
// the server back. `navigator.onLine` is the browser's guess and is wrong exactly
// where it matters — a server down behind a working connection — so it only sets
// the flag one way, and never clears it.
//
// The last good answers live in a store of their own in IndexedDB, keyed by the
// route that gave them: the strip's listing is a few hundred kilobytes and
// localStorage's few megabytes hold the theme, the language and the library's
// view besides. They are this browser's, not this account's, so they are dropped
// whenever the account changes (see `forgetStale`, called from the auth calls in
// api.ts).
//
// The writes that found no server are held here too, in order, and sent the
// moment one answers — same storage, same account, same clearing. See `hold`.
import { keep, recall } from '../remember';
// The writes waiting for a server. A write the API layer carries is always a
// JSON string (the two that carry bytes — a photo, an avatar — never reach here,
// see `hold`), so this is a handful of small bodies in the same small
// localStorage the theme and the language already live in. A queue long enough
// to matter is a queue the visitor has walked away from, and a reload keeps it
// anyway.
const PENDING = 'rc.pending';
// A read is kept whole, whatever its size: the strip's listing carries thousands
// of rows and it is exactly the read an offline visit wants an answer to. The
// ceiling is only there so a body that is not a listing at all — a few megabytes
// of image someone sent through a JSON route — is not written into the store.
const CAP = 2_000_000;
let reachable = true;
const listeners = new Set<() => void>();
const notify = () => {
for (const listener of listeners) listener();
};
function set(next: boolean): void {
if (reachable === next) return;
reachable = next;
notify();
}
// The shape `useEffect` already has: an unsubscribe.
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function isReachable(): boolean {
return reachable;
}
// A request that never got an answer.
export function serverGone(): void {
set(false);
}
// Any answer at all — a 401 included: a server that refuses is a server that is
// there, and what the screen shows for it is its own business.
export function serverAnswered(): void {
set(true);
flush();
}
// One held write: the route, the verb, and the JSON body `call()` was handed.
// `body: null` is a write that carries none (`DELETE`).
export interface Held {
method: string;
path: string;
body: string | null;
}
function queue(): Held[] {
const raw = recall(PENDING);
if (raw === null) return [];
try {
const list = JSON.parse(raw) as Held[];
return Array.isArray(list) ? list : [];
} catch {
// A half-written queue is no queue.
return [];
}
}
function setQueue(list: Held[]): void {
keep(PENDING, JSON.stringify(list));
notify();
}
// How many writes the server has not seen yet — the count the bar shows.
export function heldCount(): number {
return queue().length;
}
// Hold a write the server did not answer. The same route twice is the later
// write in the earlier one's place: two edits of one recipe offline are one
// recipe to save, not two.
//
// Only what the API layer carries is held, and a file is only ever carried
// round it (a photo upload, an avatar) — those are not held at all: there is no
// trustworthy place for a few megabytes in this browser, and the uploader's own
// retry is the honest answer for a file.
export function hold(path: string, method: string, body: string | null): void {
const list = queue();
const at = list.findIndex((item) => item.path === path && item.method === method);
const one: Held = { method, path, body };
if (at === -1) list.push(one);
else list[at] = one;
setQueue(list);
}
let sending = false;
// A server answered: the writes it never got go now, oldest first. A refusal
// (4xx) is that server's own answer, so the entry goes — asking the same
// question forever is not a queue, it is a loop. No answer at all, or a server
// that broke (5xx, 429), stops the run and leaves the rest for the next answer.
export function flush(): void {
if (sending || !reachable) return;
const list = queue();
if (list.length === 0) return;
sending = true;
void (async () => {
for (const item of list) {
let res: Response;
try {
res = await fetch(`/api${item.path}`, {
method: item.method,
credentials: 'same-origin',
headers: item.body === null ? undefined : { 'content-type': 'application/json' },
body: item.body ?? undefined,
});
} catch {
// Gone again mid-run: what is left stays right where it is, in order.
serverGone();
break;
}
if (res.status >= 500 || res.status === 429) break;
setQueue(queue().filter((it) => !(it.path === item.path && it.method === item.method)));
}
sending = false;
})();
}
// The reads are a database of their own, not a store added to the library's: a
// tab left open holds the library at the version it opened it with, and a new
// store there would have every visit wait on that tab. Their own key is the
// route, their value the JSON string it answered with.
const DB_NAME = 'recipescam-offline';
const READS = 'reads';
let dbPromise: Promise<IDBDatabase> | null = null;
function openDb(): Promise<IDBDatabase> {
dbPromise ??= new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore(READS);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return dbPromise;
}
// One request per call, the shape the library's own `ask` has: a keyed get, put
// or clear is one transaction and nothing more.
function ask(mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest): Promise<unknown> {
return openDb().then(
(db) =>
new Promise((resolve, reject) => {
const req = run(db.transaction(READS, mode).objectStore(READS));
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
})
);
}
// What this route answered the last time a server was there, or null. Answered
// out of the store, so it is a promise where the old localStorage read was not;
// the one caller already awaits.
export async function stale<T>(path: string): Promise<T | null> {
let raw: unknown;
try {
raw = await ask('readonly', (s) => s.get(path));
} catch {
// No store to read, no answer to give — the caller's own empty state.
return null;
}
if (typeof raw !== 'string') return null;
try {
return JSON.parse(raw) as T;
} catch {
// A half-written value is no value.
return null;
}
}
// Written in the background: the answer on screen is not waiting on the disk.
export function keepStale(path: string, body: unknown): void {
let raw: string;
try {
raw = JSON.stringify(body);
} catch {
return;
}
if (raw.length > CAP) return;
void ask('readwrite', (s) => s.put(raw, path)).catch(() => {});
}
// The account changed: the answers held are the last account's, and the next one
// must not be shown them. Neither must it send its writes under the new name —
// they were made by whoever was signed in when they landed.
export function forgetStale(): void {
void ask('readwrite', (s) => s.clear()).catch(() => {});
try {
localStorage.removeItem(PENDING);
} catch {
// Private mode: there was nothing to drop.
}
notify();
}
// The browser going offline is the flag arriving early, and no more: it is never
// used to clear it, because a connection says nothing about the server behind it.
window.addEventListener('offline', serverGone);

Some files were not shown because too many files have changed in this diff Show More