753eea0d7b1c058e8a6bd00ac78985d6d5ee2e13
171 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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.
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
3aedd67ca0 | web: open the catalogue without a version another tab can block | ||
|
|
52732a17ee | web: name a roll's folders before reading their frames | ||
|
|
d0fddba1e8 | web: walk a roll layer by layer, and let the strip drop the branch | ||
|
|
bd206a8be9 | web: let a wheel tick walk the strip sideways | ||
|
|
2c78ff80e8 | web: fold a folder's rows without folding its frames | ||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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
|
||
|
|
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. |
||
|
|
e24ea9e71d |
web: open the stage on the neutral stock, not the CLASSIC NEGIPES preset
The boot recipe was DEFAULT_RECIPES[0], so every file that landed — NEF or JPEG — came in with exposure +2 (+0.5 EV), contrast +1, saturation -1, 6300K / tint +2, highlight -2, clarity +2, grain +4 and the classic-neg filter already on. On APACHAI-20160215-357.NEF that read as pink skin and blown highlights, and it made a correct decode look like a decode bug. Develop itself was fine: 179.0/168.2/171.2 against the embedded preview's 178.2/167.1/170.7. Boot is now BASE_RECIPE — no filter, all knobs default, the same look COMPARE splits against. A session left by the old boot holds that preset verbatim, which was never the visitor's pick, so it is dropped; anything the visitor actually touched moves the stored object off the exact fingerprint and is kept. Render bytes, same 1600x1068 view of the JPEG (R/G/B means): source file 170.7 / 159.1 / 163.1 before 194.7 / 178.0 / 177.2 after 169.5 / 158.5 / 162.0 NEF render stage: 202.4 / 186.3 / 185.4 -> 177.9 / 167.3 / 169.9 Output is byte-identical to the old neutral render (md5 a3932746...), while a clicked chip (classic-neg-default) and a tweaked session both still restore their own bytes. |
||
|
|
d1e9425b9c |
web: read a frame's white off its pile, not off its largest sample
sensorWhite hung its nine counts of window off the plane's largest sample. A hot pixel sits hundreds of counts above the level the sensor stops at, so on a frame that carries one the window held the stray alone, found no pile in it, and handed the develop the factor two instead of the frame's own level. Measured on a Panasonic DMC-LX10 RW2: one sample at 15993 and one at 14665 over a pile of 9,594,544 at 13855. The frame's level is 1.75x the fall-back, so the develop opened 0.81 of a stop bright, clipped the sky the sensor had held to flat, and the fit to the camera's preview could only pull the exposure back after the highlight detail was gone. Against the camera's own JPEG of the shot, mean |dL| 23.48 and dRGB +8.57,+0.90,+4.90 became 19.14 and +5.32,+1.89,+2.04, and the level itself 7908 (gain 8.2872) became 13874 (gain 4.7236) against the 13855 the plane piled at — 0.14%, 0.002 of a stop. The level is now the highest count the plane piled at, off a whole-plane histogram: `floor` counts is a pile, and the same cliff rule as before still has to hold over the count below it, so a smooth bright sky is left alone and a frame that has not clipped still falls back on the factor two. The same stray was in four of the ten bodies to hand. A Nikon _GDN0447.NEF read the fall-back 8190 where its plane piles at 13806 (gain 8.0018 -> 4.6022, 0.79 of a stop), a Fujifilm RAF 29696 where the documented level is 30993, and a Sony ARW 31742 against the 2.002x the body's ratio was measured at. Six were untouched, and a Canon CR2 develops byte-identical through the change — the window moves only on a frame whose largest sample is not its highest pile. scripts/white-level-check.mjs keeps the LX10 shape: a plane piled at 30995 with a stray above it has to answer 30995. |
||
|
|
e6c050581f |
web: make AUTO write the tone and the colour it reads off the frame
The AUTO chip measured the photo and wrote one knob, EV. It now writes the four
the measurement actually names, off the same binned ramp (ui/Histogram.tsx),
which is why it is one chip and not four: the means it needs are all in the
histogram the exposure answer already reads.
EV the mean luma, unchanged
HIGHLIGHT the top 1% (p99 > 0.9 pulls back), to -5 of the ruler at most
SHADOW the bottom 1% (p01 < 0.02 opens up), to +5
TEMPERATURE/TINT the gain that puts the three channel means on each other,
green as the anchor: gray-world on linearised means, then the
closest of 76 temperatures x 21 tints under the renderer's own
kelvinToRGB, so the pair cannot drift from what the ruler applies.
The ends rather than the average is what keeps a small blown window from
dragging the whole frame: a specular in the corner wants HIGHLIGHT, not a
flatter picture everywhere. Both ends stop at half the ruler, so the frame is
corrected and a hand can still finish the move; the knobs then report the
numbers AUTO chose, the way the EV knob does.
Each reading is a pure function of the ramp, so pressing AUTO twice lands on the
same recipe by construction, and a frame with a dead channel leaves the WB ruler
where it is rather than inventing a cast. ponytail: one linear ramp per end and
no scene analysis; add a curve, or weight by how much of the frame is clipped,
when AUTO starts overshooting a scene with a genuine specular in it.
scripts/auto-tone-check.mjs holds the three readings: the percentile walk, the
thresholds that leave a knob alone, and the scan landing back on the gain it was
asked for.
|
||
|
|
224ff0b935 |
web: open a RAW at the resolution of its sensor, not at the quarter of it
LibRaw's half-size demosaic was on. The Ricoh GR's own DNG (D0004128.DNG) developed to 3010x2012 while the JPEG written beside it in the same second is 6000x4000, and the Fuji's RAF to 3008x2007 against its own 6000x4000 -- the quarter was the flag, not the file. With `halfSize: false` the same develop returns 6020x4024 and it is the sensor's frame on every body tried: D0004128.DNG 6020x4024 IMGP6916.DNG 6028x4024 DSCF1701.RAF 6016x4014 _DSC0009.ARW 6024x4024 AFXT2721.RAF 6246x4170 Nikon-D850 NEF 6216x4136 _GDN0447.NEF 4284x2844 P1010607.RW2 3472x3472 5G4A9396.CR2 2880x1920 Nine files, 27s to 155s a develop on one core. Checked through the app itself, not only through LibRaw: photo-dims 6020x4024 on the DNG against 6000x4000 on the JPEG, both err none. The colour it opens with is now fitted per file to the preview the camera wrote into it (previewMatch.ts): a 3x3 over a block grid of the develop against the same grid of that preview, then one cubic a channel for what the 3x3 leaves. The offline per-body table this replaces (cameraMatch.ts) stopped matching the moment the path under it changed -- its rows no longer summed to 1 once the highlight knee landed ahead of it -- and a body with a row opened with a cast one without did not. The file's own preview does not age. The white level the gain carries is the frame's own plateau rather than `maximum` (sensorWhite.ts), a factor of 1.89 to 2.00 out; without it every frame opened a stop bright and a body that sat lower (X-Trans, 1.892) never reached the highlight desaturation at all. The desaturation gate reads the gain-lifted levels as well as the sensor's, which is the whole of the magenta: on a body whose cam_mul lifts red and blue (the GR's [2.64, 1, 1.73]) a blown sky crosses the white level at 0.38 of the raw range in red while green crosses at 1.0, so a gate read on the sensor's levels alone stayed shut across it. Measured in the app against the camera's own JPEG, mean dRGB over a 16x16 block grid: +1.20, -5.95, -6.11 with the sensor's clip alone, +0.21, +0.24, +0.47 with both, mean |dL| 21.5 against 10.3. The same grid on the Fuji comes back balanced (+4.7, +5.0, +3.6) and best aligned at offset 0,0. -HL is recovery and +HL is a lift, so they are different moves now: recovery is the doc's soft knee in linear light over the top half, which is the only term in the tone shader that is not a shift and the only one that can put detail back into a blown sky rather than merely darken it. The four checks pin the develop down where it can only run in a browser: raw-develop-check, preview-match-check, white-level-check, highlight-knee-check. |
||
|
|
b824308182 |
web: hand the RAW develop's plane to Skia as half, so the GPU keeps its shadows
An RGBA_F32 image with an sRGB tag comes back off the GPU backend sampled on a 1/255 grid; the same shader on a raster surface returns the floats untouched. The plane is raw/65535, so the shadows the black level is there to keep sit at 1e-3 and quantise to zero -- a 3010x2012 develop landed 41189 pixels under luma 2 with the dark end speckled blue/yellow, against none on the raster surface. A half is uploaded as float, so the plane stays exact either way. Rejects the earlier guess that the render target's colour space was to blame: gpu+rt-srgb and gpu+img-untagged came back byte-identical to gpu. scripts/half-check.mjs checks the conversion: the named encodings, and no plane value in a 14-bit sensor's range moving more than 4.8e-4 relative. |
||
|
|
610a274103 |
web: give a RAW the colour its own camera would have given it
LibRaw is deliberately kept out of white balance and tone here, so a RAW opened in the studio lands on the neutral demosaic — while the JPEG on the back of the camera carried the body's own rendering. cameraMatch.ts holds that difference as one 3x3 per body, fitted offline against the camera's own preview of the same frame and applied in the develop shader right after the sRGB encode. Measured on held-out blocks, mean CIEDE2000 against the camera preview: GR III 5.99 -> 4.28 X100V 8.19 -> 4.22 GR II 11.68 -> 9.73 X100S 7.41 -> 7.27 X-T3 9.03 -> 6.12 The matrix is fitted luma-preserving and the shader holds that exactly, so the profile moves colour and never exposure: a preview that came out dark stays dark, by design. What is left over is largely high-frequency (sharpening, noise reduction, demosaic) — the error keeps falling as the blocks grow. The fits are weak evidence on their own. Validation on colour charts came out poor: the daylight chart is an Adobe DNG Converter export that aligns to the body's own develop at only 0.785 correlation and gets worse with the profile applied, and the tungsten chart is a different illuminant entirely. The honest claims are the self-fit numbers above and that the X100S — whose cast was small to begin with — barely moves. Verified end to end through the real develop: an unfitted body (Sony ILME-FX30) develops byte-for-byte identically to before, and reading the matrix back out of each profiled develop recovers the fitted one. ponytail: one matrix per body, no tone curve and no 3D LUT (a curve on top measured 2% better and needs a spline plus array uniforms). The match is applied to the 8-bit band the develop already produces — give develop 16-bit output if a profile ever has to grade rather than match. |
||
|
|
432acba9c1 |
web: put the mask's column away with the tool, and keep a blown highlight's hue
The column of mask knobs belongs to an armed LINEAR or RADIAL shape, and it stayed on the stage after the hand had moved on: arm a shape, draw it, click another chip or another tab, and the strip of mask sliders was still there belonging to a tool that was no longer in hand. A capture-phase `pointerdown` on `window`, armed only while `maskTool` is set, now puts the tool down in the same gesture that reaches for something else. A press on the rail (the tabs) or on any chip except the shape's own two dismisses; a press inside the column is left alone, because the column's own chips handle their click themselves, and a press on the photo is left alone, because dragging on the photo is how the shape is drawn. It is the dismissal the STRAIGHTEN tool already had, one effect over, so the tab-switch effect needed no `setMaskTool(null)` of its own. A RAW's blown highlight came out magenta, and was measured before it was touched. `example-sony.ARW` through the lab (`rawblow.html`, the same camera white/black and the same `rgb_cam` the app's develop uses): white 16380, black 512, cam_mul green-normalised to (2.581, 1, 1.553). The pixels the sensor could not hold — 0.7% of the frame, raw max channel at or past 0.99 of the white level — average (0.775, 1.416, 1.108) in raw, and per channel 26.3% / 96.0% / 46.7% are at or over white: green is 1.4x the white level while red is still under it. The develop shader clamped every channel to 1.0 BEFORE the white-balance gains, and that one clamp is the whole cast. Green is the channel the gains are normalised to, so it stopped at 1.0, while red and blue — which need their 2.581 and 1.553 — were already past it and were carried over by the multiply. The blown area therefore left the matrix at (1.0, 0.53, 0.62) instead of at white: measured on the develop output, (254.4, 217.0, 242.9) — red and blue 38 above green, which is magenta. On the stage, pixels with red and blue over 235 and green under 225 in the same framing: 1191 with the clamp, 33 without it. The shader now only floors at zero, keeps the channel ratios through the matrix, and fades whatever ran past white towards white (`mix(rgb / mx, 1, 1 - 1/mx)`, the desaturate-to-white dcraw uses for the same problem). The blown area comes out (254.5, 253.9, 246.6): an overflow that stays bright and stops taking a hue, broken per channel at sd 8.7 / 28.0 / 26.4 today against 5.4 / 3.3 / 17.8 now. Whole-frame averages move by 0.008/0.278/0.140 of a level — the fade only touches pixels that were over white, which are the 0.7%. "cannot be rescued" is the second half of the same fact, and it is now a measurement rather than a hope: LIGHT's HIGHLIGHT row is a curve over what develop emitted, and while red and blue were pinned at 255 the curve had nothing to pull on. The fade leaves a compressed ramp there instead, which is what the row now pulls. LibRaw's own reconstruction modes are not the answer on this file: `-H` 1, 2 and 3 hand back byte-identical develop output to `-H` 0 (`pxAt65535` is 0 — the sensor never reached its 65535, only the camera's white level), so `SETTINGS.highlight` stays 0. What the app cannot do is keep the two stops above white, because the band still leaves develop as 8-bit JPEG; that ceiling is named at the point of the fade, for whoever needs RAW highlights recovered rather than merely correct. `npm run typecheck` and `npm run build` are clean (bundle `index-BJy_3HCz.js`). Probes: verify-mask-column (dev server, real photo, arm a shape and draw it, then reach for another chip and for LIGHT and FX — 8 checks, 8 pass: the column stands while the shape is armed, survives a press on the photo and on the shape's own kind chips, and goes on any other chip or tab), rawblow (the real ARW through the real develop maths in the page, before/after chains side by side, which is where the magenta and the reconstruction modes were measured), probe-raw-highlight (the app itself, ARW uploaded, blown pixels counted and the HIGHLIGHT row driven to both ends). |
||
|
|
9164bf3228 |
web: read DEHAZE off the dark channel, and let it run both ways
DEHAZE read its haze estimate out of the frame's own bilateral reference — the
patch AVERAGE — where the Dark Channel Prior asks for the patch MINIMUM. That
one word is the whole prior: `dark = min(min(r,g,b)/A)` over a neighbourhood
reads 0 for any patch that holds a shadow or a black frame line, so the
transmission stays at 1 and the patch is left alone, while the average of a
patch that holds a dark pixel is still bright, so every patch looked hazy. The
positive end therefore ground the frame down instead of taking haze out of it:
at +9 the mask moved its own middle band -0.2127 and the frame-wide row moved
the whole frame -0.2311, and the local contrast went the WRONG way (dhp -0.0060
on the mask, -0.0056 frame-wide) — a haze remover that lowers contrast is a haze
remover that is lowering everything.
The pass reads the dark channel from the image it is correcting, five by five
taps at DEHAZE_PATCH_STEP (0.625% of the frame's width per tap, a 2.5%-wide
patch — the DCP's own 15 pixels on a 600px frame, and the same fraction of a
4000px export) in DEHAZE_SKSL and in gradientMask's block, so the mask and the
frame-wide row are the same neighbourhood at every render size. Five by five
rather than fifteen by fifteen because 225 child reads per pixel is what
CLARITY_BLUR_SKSL already refused for a reference the prior does not need to be
that wide. The bilateral reference is now only what CLARITY compares against, so
DEHAZE no longer takes a second child at all.
DEHAZE is signed, which it was not: the knob was 0..10 and the export engine
skipped the pass unless the amount was above zero, so a negative value was a
slider the UI would not even offer. It is -10..+10 now, and the transmission
carries the sign — positive pushes t below 1 and `J = (I - A)/t + A` takes the
scattered light out, negative pushes it above 1 and the same expression scatters
light back in. That is the direction a photo shot through mist wants, and it
needs no second formula: one expression, both signs, the ceiling at
1 + DEHAZE_MAX_OMEGA.
CLARITY's negative side was the last place where a knob meant two different
things depending on where it was read: the frame-wide row softened with a mist
blur of its own radius (MakeBlur, sigma |c|/10*4) while a mask mixed toward the
bilateral reference the positive side reads — two neighbourhoods, two strengths,
one name. CLARITY_BLEND_SKSL now carries both directions of the one move (above
zero the doc's unsharp, below it the mix back toward the same reference, gain
1), so the frame-wide row and a mask's CLARITY are the same reference at the
same strength, and the frame-wide mist blur is gone.
Measured in one harness, one photo, one session, knob at +-9, before -> after,
mask phase and frame phase in the same run (the box is the mask's own middle
box for the mask, the stage's own box for the frame-wide row):
- FRAME DEHAZE +9: dmean -0.1680 -> -0.0751, dhp -0.0056 -> +0.0036, white
band -0.2156 -> -0.0522 — it darkens the haze and raises the contrast
instead of lowering both.
- FRAME DEHAZE -9: dmean +0.0469 (was not offered), dhp -0.0010 — the same
knob on the other side, and the frame gets hazier.
- MASK DEHAZE +9: dmean -0.1490 -> -0.0513, dhp -0.0060 -> +0.0039, white band
-0.1234 -> -0.0274, dark band -0.0595 -> -0.0075 — a mask's DEHAZE is now
the frame-wide move on the mask's own pixels (dhp +0.0039 against the
frame's +0.0036).
- MASK DEHAZE -9: dmean +0.0319, dhp -0.0013.
- FRAME CLARITY -9: dhp -0.0200 -> -0.0094, white band -0.1112 -> -0.0203, so
the frame-wide row no longer pays for its soften by flattening every white
in the frame; MASK CLARITY -9 is the same move (dhp -0.0150, white band
-0.0103) and the two now agree in direction, sign and rough magnitude at
-9. CLARITY +9 is untouched on both sides (+0.0335 mask, +0.0307 frame) and
every other knob's numbers are unchanged to within +-0.0005, which is the
run-to-run noise of the same harness.
`step` was the uniform's first name and SkSL refused the shader with it (a
builtin), which is how a whole DEHAZE row came back with all-zero deltas in the
first measurement after the change; `stepPx` is what compiles. `npm run
typecheck` and `npm run build` are clean, and the stage draws with no page error
(the only console error is the dev server's own `/api/events` 404).
Not ported: nothing. The phone's renderer has no gradient mask and no
atmospheric-light estimate to mirror; `shared/utils/toneShader.ts` and
`shared/utils/gradientMask.ts` are the web engine's own files.
Probes: measure-parity (both phases in one run, one photo, before and after —
the same harness the previous commit was scored with), measure-dehaze2 (the same
script with only DEHAZE in both phases, plus a console listener, which is how
the `step` uniform was caught), sim-dehaze-dcp (the offline simulation that
picked the min-patch over the average: clear frame +9, contrast 0.0248 -> 0.0292
against the average's 0.0248 -> 0.0235).
|
||
|
|
5f3257a4d8 |
web: make a mask's knobs the moves the frame-wide row of the same name makes
A gradient mask carried its own copy of the six tonal and spatial formulas, and
four of them had drifted from the columns of the same name. HIGHLIGHT was
inverted: the single shift `0.5 * (shadows*ms - highlights*mh)` put the knob's
`x` on the shadow mask and its `y` on the highlight mask, so turning HIGHLIGHT
up pulled the bright band DOWN and turning it down lifted it — measured at the
mask, +9 moved the top band -0.2295 and -9 moved it +0.0454, and the whole
frame's HIGHLIGHT row reads the other way. WHITE and BLACK were a flat gain on
the end each one owns (`c * (1 + 0.5*k*mh)`), which scales every pixel above the
midtone by the same fraction and so drags the near-whites into the greys rather
than leaving them white: a lowered WHITE took the top band down -0.2117 while
the middle band moved -0.0036, and a raised BLACK pushed the middle band up
+0.0195 for +0.0414 at the bottom — the lift went everywhere except where it was
asked for. CLARITY's negative side was the same unsharp as its positive side
with the sign flipped — `c + k*(c - blur)` with k negative — which is a soften
only in name: it sank the mask's whites (top band -0.0543 at -9) and left the
mask's own contrast where it was (dhp -0.0008), the opposite of what the knob is
named for.
DEHAZE ran after CLARITY, so a mask sharpened its haze and then tried to remove
it; frame-wide the two are the other way round, and for a reason.
The four now mean inside a mask what they mean on the whole frame, because
toneShader.ts is the reference the app's own rows are written from and the same
name on the same knob should not be two different moves. HIGHLIGHT and SHADOW
are TONE_SKSL's additive luma shifts — HIGHLIGHT weighted by the headroom it has
left (1 - t) so it cannot drag a blown white to grey, SHADOW by its own floor —
with the colour difference riding along at TONE_SKSL's damped gain so a lift
cannot collapse a colour. WHITE and BLACK are TONE_SKSL's per-channel point
moves, cubic in each channel's distance from the end it owns, so the toe and the
shoulder move and the midtones stay put. DEHAZE runs before CLARITY, the
frame-wide order. CLARITY's negative side is a real soften, `mix(c, blur, -k)`
toward the same bilateral reference its positive side works against. TONE_SKSL's
two smoothsteps (0.50..1.00 and 0.00..0.55) replace the mask shader's own pair,
and the soft masks are computed in float and narrowed once, the way the
frame-wide shader does it.
Measured in one harness, one photo, one session (the mask's own middle box, knob
at +-9, before -> after on the mask, with the frame-wide knob of the same name
as the reference it is now written from):
- HIGHLIGHT +9: top band -0.2295 -> +0.0298 (frame-wide +0.0547), dark band
0.0000 -> -0.0001 — the lift is a lift, and the inversion is gone.
HIGHLIGHT -9: +0.0454 -> -0.0385 (frame-wide -0.0674).
- WHITE -9: top band -0.2117 -> -0.0646 (frame-wide -0.0860) — a lowered
white stays a white instead of becoming a grey — while the middle band goes
-0.0036 -> -0.0150 (frame-wide -0.0139), which is the move a white ends up
making when it is a point move rather than a gain.
- BLACK +9: bottom band +0.0414 -> +0.0997 (frame-wide +0.1036) and the middle
+0.0195 -> +0.0347 (frame-wide +0.0402) — it goes to the toe it owns.
- CLARITY -9: dhp -0.0008 -> -0.0149 (frame-wide -0.0200) — negative CLARITY
softens now — and the top band -0.0543 -> -0.0113, so it no longer pays for
that soften by sinking the mask's whites. CLARITY +9 was already right
(+0.0333 both sides) and stays.
- DEHAZE, the one knob with nothing on the negative side: unchanged at -9
(-0.1490 both sides, the pass order was the only thing wrong with it), and
the mask and the frame-wide row now agree across the whole range
(1/3/5/7/9 at -0.0124/-0.0397/-0.0711/-0.1074/-0.1490 on the mask against
-0.0133/-0.0423/-0.0759/-0.1151/-0.1619 frame-wide), monotone.
DEHAZE's own numbers are therefore not a mask-only bug: an estimate of the haze
that reads a mask differently from the frame would be inside `atmosphericLight`
and `DEHAZE_MAX_OMEGA`, which both paths share, and changing either moves the
frame-wide DEHAZE column too — left as it is rather than changed under a mask
report.
Everything else about the mask is untouched: `dctrl` is +0.0000 on all six
knobs (a knob still moves the mask's own pixels and nothing outside it), the
shader compiles and the stage draws with no page error.
Not ported: nothing. `shared/utils/gradientMask.ts` is the web engine's own file
and the phone's renderer has no gradient mask to mirror.
Probes: measure-mask-knobs (the six knobs on a selected mask, before and after),
measure-frame-knobs (the same six frame-wide, the reference the mask is now
written from), measure-parity (both phases in one run so the two are the same
photo in the same session), png-parity-report (the before half of that run died
in its frame phase and left no log, so its already-captured mask frames are
re-read off the PNGs with the same box and the same bands), measure-dehaze-curve
(DEHAZE 1..9 on the mask against 1..9 frame-wide, for monotonicity and for the
pass order).
|
||
|
|
97bdf605e2 |
web: import the camera's RAW, and grade it like the phone
The studio took JPEG, PNG and HEIC and nothing else, so a photographer's own negatives never reached it. A RAW now loads the way any other file does — `isRawName` reads the extension off a 24-entry list, the file goes into OPFS under one slot (`current_image.raw`, beside `current_image.name`, so a reload finds it again) and `rawDevelop` runs it through LibRaw-wasm: half size, 16-bit output, camera white balance and the camera's own 3x3 matrix, in bands of 2M pixels so a 30MB file never holds a second copy of itself. `example-sony.ARW` (30.3MB) lands as a 3120x2084 picture, no page error. DEHAZE joins the FX tab, where Lightroom keeps it: a chip off the same PARAM_DEFS entry (`dehaze`, 0..10) so nothing new renders chips, and the pass is the dark channel prior — `atmosphericLight` reads A off a 32x32 draw of the photo, `DEHAZE_SKSL` takes omega up to 0.95 over a floor of 0.1 — measured at 71.8% of the stage's pixels moved between 0 and 10. The gradient mask grows the six knobs the phone's has: HIGHLIGHT, SHADOW, WHITE, BLACK, CLARITY and DEHAZE. The mask's falloff is a smoothstep rather than a line, and CLARITY/DEHAZE inside a mask get a blurred copy of the photo plus the air A as a second child of the mask shader — so a mask's clarity is clarity and not a flat brightness lift. The column shows all nine rulers; CLARITY 9 moves 42.2% of the stage, DEHAZE 9 moves 27.9%. CLARITY stops reading the whole photo per pixel: the single pass that sampled a 15x15 box 225 times is now the three passes the same math wants — 1x15, then 15x1, then a blend, `orig + (orig - B) * 3.2` — about 30 reads. Both signs work (77.4% of the stage moves at +10, 79.6% at -10), and the negative branch keeps its mist as it was. The pointer reviews a look before it is taken: resting on a PHOTO STYLE chip or a recipe chip lays that look on the photo while it stays there and gives it back the moment it leaves — byte-identical, measured on four of them (24.9%, 23.8%, 24.5%, 25.3% of the stage moves on, 0.00% off) — while the recipe, the UNDO stack and the session stay on the look the click left. A hovered look brings its colour alone: the masks, the dust spots and the mosaic of the photo being edited ride along, or a pointer crossing a chip row would rub them off. A PRO sim is left out, since a hover that showed its look would hand over what the click gates. Probes: e2e-raw-verify, e2e-dehaze-mask, e2e-mask-verify, e2e-clarity-verify, e2e-hover-preview2. |
||
|
|
178bc78bbb |
web: an About section on the landing, after Pricing
The nav shelf was a one-pager's anchor list ending at Pricing, and the About copy had nowhere to live. Adds About as the seventh entry — it feeds both the desktop shelf and the mobile sheet off the same array — and the matching `#about` section right behind Pricing, built from the section template the rest of the page uses, so it picks up the page's theme and language switches with no new CSS and no route of its own. The copy is the About RecipesCam brief as bilingual `Txt` pairs, kept beside the markup: vision, the Android app (highlights as steps), the website's three purposes, and the contact address. |
||
|
|
6814b055cb |
web: frame through the recipe, with the viewfinder where the stage was
The live view is the one place where the look is chosen before the picture exists, so the columns that hold it have to stay in reach while the camera is open. The viewfinder was a fixed black sheet over the whole app: the rail and every slider were behind it, and the only way to change the look was to close the camera, grade the file, and open it again. It now covers the stage and nothing else — the stage and the view share a slot (`.stage-slot`, position relative) and the view is absolutely placed inside it, z-index 5, black in either theme. Measured at 1600px: the rail and the columns are on screen, the feed takes 1347px of the 1600 and the stage's own 253px rail the rest. A slider moved with the camera open reaches the very next frame (the render loop reads the recipe per frame, so nothing had to be wired), a monochrome sim takes the live feed from sat 42.1 to 0, and the shutter hands the studio that same frame with the sim already on it. The preview render is skipped while the viewfinder is up (`|| shooting` in the guard, and in the effect's deps): it would run the same recipe through the same renderer as the frames the user is actually watching, and the live one wins. Opening it is PRO, like the HSL chips and the geotag — the frames are the paid ones. The button stays on the page for everyone and carries the badge, and the click runs the app's own `promptPro`: the account dialog for a guest, the verification panel for a signed-in account, `/api/auth/me` being what says which. A verified account gets the camera; a guest gets `.modal-backdrop` and no `camera-view` at all. Probes: cam-pro-gate (guest sees the badge, the click opens the dialog, no viewfinder; verified opens it), cam-live-edit (rail and columns survive the live view, feed covers the stage, the recipe reaches the live frames and the shot), cam-smoke, cam-renegotiate, cam-close-flip. |
||
|
|
cb1839e36b |
web: shoot through the live camera
The one path where the look is chosen before the picture exists: OPEN CAMERA grades the camera's own feed with the recipe in force, many times a second, and the shutter hands the studio the sensor's still under that same recipe. Preview and file differ in resolution only — the still is `takePhoto`'s own frame, not a copy of the small preview video, with `grabFrame` and a 2d copy of the element behind it for the browsers that ship no ImageCapture. The renderer gains two inputs for it: `sourceImage`, a picture the caller already decoded (re-encoding the camera's frame to JPEG only to decode it again would cost more than the whole render), and `drawTo`, which paints the finished picture instead of encoding it. One render is in flight at a time; a frame that arrives during one is dropped, so a slow device shows a lower frame rate rather than a queue of moments that have passed. The view flashed black on a phone. Setting width/height on a canvas resets its bitmap: measured on the preview, a resize leaves mean 0 until the next render lands, which on this box is 0.5s and on a phone more. The buffer was sized from every incoming frame, and a capture that renegotiates its resolution — which Chromium does when the page is too slow to consume its frames, and this pipeline runs ~2 fps at 720p under software GL — strobed black/picture at every switch. The buffer is now sized on the first frame and after that only when the frame's aspect changes: a same-aspect frame is scaled into it. Swapping a 1280x720 stream for a 640x360 one mid-view now leaves the buffer at 1280x720 with no black frame, and 640x360 renders at 6-13 fps instead of 2. The frames are read from a <video>, which is now IN the document (1px, behind the black backdrop) rather than detached: Safari draws blank frames from a detached video, which is the same black-between-pictures. It leaves the document with the view, and the tracks are stopped, so the camera light goes out. Probes: cam-smoke (feed painted, resolution, frame rate, a monochrome sim reaching the live frames, shutter into the studio, close, console clean), cam-renegotiate (no resize, no blank frame, status line on the frames), cam-close-flip (flip returns a picture; video gone on close). |
||
|
|
fd2d9935c1 |
web: filter the exports the model was only smearing
The upscaler ran for every enlargement, including the ones a plain resample wins: measured on a 2048px source cropped and blown back up it loses to lanczos on PSNR and SSIM at 2x and 3x, and its smoothness reads as plastic skin and lost texture next to it. From 4x — the model's own factor — it stops losing, so the threshold moves to 4 and the crop no longer drags a 2x export through it. The resamples it now carries never set imageSmoothingQuality, and the default 'low' point-samples: a 1px stripe comes out at full amplitude instead of the average of what it crossed. Both callers ask for 'high'. Probe on a 2400x1800 source exported at 4K: 299.9s -> 7.8s, correlation with the source's 1px/2px bands 0.89/0.95 -> 0.98/0.98, grain sd 25.4 -> 47.1 (a plain HQ resize of the same source keeps 16.1). |
||
|
|
a9030fc0c0 |
web: give the GPU path a half-precision upscaler
The GPU export now runs the same upscaler in half precision. The chip is handed 2.34MB of weights instead of 4.88MB, and where its shaders can multiply in fp16 it does twice the work per pass. `realesr-fp16.py` is the conversion, run on what `realesr-gpu.py` already wrote (the PReLU-rewritten model), never instead of it. onnxconverter-common's `keep_io_types` needed two of its own mistakes put right: - It rewrites the consumers of the graph input but misses the one that never goes through the network. This model adds a Resize of the ORIGINAL photo to the upsampler's output, that Resize reads the graph input directly, and the runtime refuses a graph whose final Add mixes fp32 and fp16. The consumer is rewired onto the cast that `keep_io_types` should have sent it through. - It also half-precisions Resize's `scales` — ONNX defines that input as float32 whatever the rest of the graph does, and a runtime that opens the file at all rejects the whole graph: "Type 'tensor(float16)' of input parameter (/Constant_output_0) of operator (Resize) is invalid", on the GPU as much as on the processor. The script widens it back and asserts it did. The tensor the app builds stays float32 and the model's two Cast nodes are its own edge, so nothing in superRes.ts or App.tsx has to know which copy it got: 205 nodes, 101 fp16 weights, io still float. `openSession` asks for the model only where the adapter advertises `shader-f16` — a provider without it emulates the type on the same file at the same speed, so the smaller download would be the only thing gained. The order is fp16 on the GPU, fp32 on the GPU, fp32 on the processor, each attempt falling through on its own failure. Measured on the rebuilt container (BASE=http://localhost:8090): - fp16 vs fp32 on a 128x128 tile, same graph: max abs diff 0.0025 (0.65/255), mean 0.00028, psnr 71.0dB. - sr-f16-chooser.cjs 4 PASS / 0 FAIL: on a forged adapter advertising `shader-f16`, the fp16 file is the FIRST model asked for; on one whose device refuses, the fp32 file is fetched for the processor and the 4K export still lands (7,555,377 bytes, 19.6s), no console errors. - superres-test.cjs 32 PASS / 0 FAIL, sr-crop-export.cjs 0 FAIL, web-smoke.cjs 0 FAIL, sr-model-probe.cjs 0 FAIL. - npx tsc --noEmit clean. ponytail: the speed of the fp16 path is NOT measured — this container has no WebGPU adapter (not even lavapipe/swiftshader, headed through xvfb), so every export here runs the wasm fallback. sr-model-probe.cjs on a machine with a GPU is what would show it. Also worth noting for the next person: in a browser with no working adapter, the runtime builds the device BEFORE it fetches the model, so no probe in a GPU-less container can observe which model was chosen — a stub whose device throws leaves the network silent. The chooser probe forges a device good enough to be accepted for exactly that reason. |
||
|
|
12113088c8 |
web: hand the upscaler the photo's own pixels
An export larger than the photo came back flat: the model was never shown the
finest detail the photo held. Before it ran, the source was drawn down to
`scale / 4` of its size — floored at half — and only then handed over, on the
reasoning that a four-for-one model reading `target / 4` invents exactly the
destination and a whole photo would waste three quarters of its output. That
holds for a perfect resampler; it is not what this one is. A 2400px photo going
to 4K was fed at 1200px, and detail finer than the feed's own pixel — 1px
stripes, skin, foliage, fabric — was averaged into flat grey before the model
ever saw it. The draw then had that grey to enlarge, and no model can put back
what it was never given.
The feed is now the bitmap itself, read once at its own size: `drawImage(bitmap,
0, 0)`, no intermediate scale, no floor. The model's four-for-one is spent in
the destination draw instead, which reduces to `scale` and keeps what the photo
actually held. That draw also stops defaulting to `low` — it is usually a
reduction by up to four, and `low` would keep one sample in four of what the
model has just drawn.
Measured against the same running stack, a 2400x1800 source exported at 4K with
bands of 1/2/4/8/16px stripes and a patch of per-pixel grain, each band scored
by how it correlates with the pattern the source held at the source's own pixel
pitch (`r` / on-minus-off swing), plus the grain's high-frequency energy:
p1 p2 grain sd secs
before -0.01 / -0.0 0.94 / 204.0 13.9 51.0
after 0.89 / 179.3 0.95 / 214.7 25.3 188.4
hqresize 0.96 / 83.2 0.99 / 125.9 16.1 —
The 1px band went from uncorrelated and flat to 0.89 — the finest detail the
photo has now reaches the file. Grain lands above the plain-resize reference
rather than below it, which is the model enlarging texture instead of a filter
smearing it.
ponytail: the whole photo per tile means 80 tiles for a 2400px source where 20
were enough, so the wasm path (no WebGPU in the test chromium) grew from 51s to
188s for that export. It is the price of the detail and it is paid once per
export, off the critical path; a device with WebGPU, or a smaller source, does
not pay it this way.
Verified on the rebuilt container (BASE=http://localhost:8090):
- sr-detail-probe.cjs, midtone source so the app's tone pipeline cannot clip
the very detail being measured (an earlier all-contrast version of it reported
"grain 0.00" for the model AND for a plain resize — it was measuring the clip)
- superres-test.cjs 32 PASS / 0 FAIL (export sizes, 4K tile seams clean)
- sr-crop-export.cjs 0 FAIL
- npx tsc --noEmit clean.
|
||
|
|
7e47a153b8 |
web: make EXPOSURE, EV and HIGHLIGHT mean what Lightroom means
A stop is a multiplier on light, so EXPOSURE and EV stop living in the sRGB colour matrix and get a linear-light pass of their own (EXPOSURE_SKSL: linearise, `C * 2^EV`, re-encode). The matrix keeps CONTRAST: a gain on encoded values is what made +1 EV land at x1.5 instead of x2. Measured on the neutral PROVIA sim: EV +1 = x2.011, EV +2 = x3.999, still unclipped at 239. The pass sits between the matrix and the tone shader, and the tone / cinema / curve / glow / halation children all sample through it, so HIGHLIGHT finally sees the value exposure produced instead of the one before it. Recovery keeps `L + strength * mask * (1 - L)` over `smoothstep(0.50,1.00,luma)`, and the colour comes back as `color * (luma_new / luma)`: a blown white stays white (255 -> 255 at -10, 255 at +10), a 0.8 grey loses 33 luma, the midtones beside it do not move. AUTO is the histogram the LIGHT tab already draws: weighted mean luminance (guard 0.001), target 0.48, `log2(0.48 / avg)` clamped to +-2.5 EV, handed to the same knob. A 0.251 grey asks for EV 0.9 and lands at mean 83.0 against the 83.3 predicted, idempotent on a second press. A stock's own bias rides the same pass (`SIM_EXPOSURE_BIAS_EV`, VIVID +0.25 EV) and cancels against the knob, so -1 EXPOSURE on VIVID returns the CLASSIC rendering (measured 0.4149 vs 0.4177). ponytail: the phone app's `src/utils/colorUtils.ts` keeps the old math, so the two copies have to move together; recipes saved before this commit (EXPOSURE 2, HIGHLIGHT +-1) render under the new stop semantics. Verified on the rebuilt container (BASE=http://localhost:8090): - web-exposure-probe.cjs 20 PASS / 0 FAIL (neutral 128 -> 128, EV +1 ratio 2.011, EV +2 ratio 3.999, EXPOSURE +10 ratio 5.62 / -10 ratio 0.172, AUTO EV 0.9, HIGHLIGHT -10 on a 204 grey 204 -> 171, white 255 -> 255, no console errors) - sim-exposure-test.cjs 9 PASS / 0 FAIL (classic 0.4149, vivid 0.4531, knob -1 returning 0.4177, bias 0.0382) - regression suite, 28 probes: mask 53/0, brush-edit 35/0, heal-idle 23/0, heal-zoom-drag 28/0, sims 31/0, sim-vivid 9/0, white-black 4/0, temp-swatch 33/0, tone-curve clean, compare 25/0, create 52/0, wb-preset 33/0, zoom 25/0, save-recent 25/0, web-smoke 9/0 (its export step was stale — EXPORT opens a size picker now). panel-test 4 FAIL, histogram-wb 1 FAIL, studio-save-hl and progate timeouts, landing-test 6 FAIL ($0.99 pricing) are pre-existing. - npx tsc --noEmit clean. |
||
|
|
d2115941c7 |
feat(admin): back the data up, and put it back, from the admin tool
A new BACKUP tab downloads the deployment's whole state — the SQLite file and both media folders, photos included — as one .tar.gz, and takes the same file back. That one artefact therefore does both jobs: the operator's backup and the data package that moves an install onto another box. The database is snapshotted through SQLite's own backup rather than copied, because the file is written to while the archive streams; the media folders are tarred straight off the volume, so no second copy of them is made. A restore replaces the data on disk and then exits — the container's restart policy brings the API back on the restored files, which is the only moment the open handle can be dropped. The state being replaced is tarred aside first, and the archive is checked for `..` entries before anything is unpacked. The API authenticates that route before it reads a byte, and nginx lets that one path past the body cap which holds everywhere else. |
||
|
|
9016ef038d | web: price the plan at $0.99 | ||
|
|
c70edce8c1 |
web: mirror the frame with H-FLIP and V-FLIP, and stamp a typed place
ROTATE gains the two mirrors: H-FLIP and V-FLIP toggle one at a time and stay on through the quarter turns and STRAIGHTEN, which makes them compose with every rotation the strip already offers. ROTATE's own RESET levels the whole frame, mirrors included. The flip itself lands last, in screen space, so a mirrored photo is what the eye sees rather than what the sensor saw; the pixels are copied axis-aligned, so there is nothing to resample. Session state carries the two flags, so a reopened photo comes back mirrored. Also fixes the stamp: a typed PLACE NAME with no GPS fix now prints on its own (latitude/longitude ride in as NaN), instead of the whole stamp and its box being skipped for want of coordinates. |
||
|
|
9d7a5beec2 |
web: say the pricing promise plainly on the landing page
The pricing head asked the reader to start free and go Pro when the grain
matters, which is our joke rather than their question. It now says what
the plans actually are: free to use, forever, and Pro only when they're
ready for more.
The Vietnamese line moves with it — "Miễn phí dùng mãi. Chỉ lên Pro khi
bạn cần nhiều hơn." — so the two languages still promise the same thing.
ponytail: one string on each side of the existing c({ en, vi }) pair, no
new copy key and no layout change.
Verified:
- npx tsc --noEmit clean.
- Against the rebuilt production bundle on :8090, a probe reads #pricing
h2 in both languages: EN "Free to use, forever. Upgrade to Pro only
when you're ready for more.", VI "Miễn phí dùng mãi. Chỉ lên Pro khi
bạn cần nhiều hơn.", VI overflow 0px — 3/3.
- landing-test, lp-probe, subnav-anchor-probe, pro-gate-test all rc=0
fails=0; screenshot of the section in both languages shows the head
wrapping over two lines above the plan cards, nothing clipped.
|
||
|
|
08a4570b2d |
web: put the brushes in a FIX strip and the shapes in a GRADIENT MASK one
The FX row had grown into a flat list where a brush and a filter and a shape sat side by side, and it lied about what the tools are: HEAL and MOSAIC only paint, LINEAR and RADIAL only make a mask. The row now carries FIX and GRADIENT MASK, in that order, before MONOCHROME, and each one opens its own strip holding the tools that belong to it — turning amber when it has a spot or a mask to show for itself, so the state still reads from the row without opening anything. The two brushes moved into the FIX strip with the header FIX above them, the two shapes into the GRADIENT MASK strip under the header GRADIENT MASK, and each CLEAR moved in with the tool it clears instead of sitting at the end of the row. Opening FIX still arms the brush the same way — the strip only changes where the chip lives, not what clicking it does. The mask column (shine, bearing, feather, and the rest) still hangs off the shape you pick, so it now stands right after the options column: the strip that brought it out comes first, then the column it belongs to. Nothing else in the column order moved. ponytail: the two strips ride the existing openGroup and toggleGroup, so a strip key is just a widened GroupKey rather than new state to keep in sync; the option-strip body itself stayed where it was, keeping the diff to the chips that moved. Verified: - npx tsc --noEmit clean. - Frontend probes against the built production bundle on :8090 and the dev server: mask-probe 53/0, brush-edit-probe 35/0, heal-idle-probe 23/0, heal-zoom-drag-probe 28/0, grain-controls 20/0, temp-swatch 33/0, and landing, pro-gate, award-column, otp-code, tone-curve, hsl-panel, chip-edge, chips-desk, slider-reset all PASS rc=0 fails=0. - Backend npm test 180/0. - panel-test keeps exactly its four pre-existing failures (rail labels, WB swatch); they reproduce on the commit before this one. |
||
|
|
cca6fc46f7 |
web: turn a mask by the turn the hand makes
A press anywhere on a mask's outline takes hold of it to turn it, and the turn it asked for was read as an absolute bearing: the direction from the shape's pin to wherever the finger now happened to be. The outline is a grip a hand lands on wherever it likes — a ramp's line runs the whole way across the photo — so the moment a press was set down on it the shape swung round to face that finger. A press on the edge of a ramp lying across the photo stood it upright on a ten-pixel move, and a shape already turned snapped to the angle of the press before it had been dragged at all. The turn is now what the hand turns: the change in bearing about the pin since the press, which on the first move is the press itself. A finger that lands on the outline and stays there leaves the angle exactly as it found it; one that carries the shape round by a quarter turn turns it by a quarter turn, whatever angle it was at to begin with. An ellipse adds that turn to the angle it stores, a ramp spins its two ends about its own middle, and both are measured in the photo's own pixels so the two bearings are the same kind of thing. ponytail: The turn is read against the previous pointer position the drag already keeps, so it costs one subtraction and no state. A pointer that leaves the photo mid-turn keeps the angle it stood at — the rule the brush already follows — and the next move on the picture resumes from the last position that was on it. Verified: tsc clean; mask-probe 52/0 on the dev server and again on 8090 — a press on a ramp's own edge that travels a hundredth of the photo's width leaves the ramp at the angle it was taken hold of at (ends 0.500 and 0.500), and on a shape already turned the same press keeps it upright (0.562,0.260 and 0.558,0.860); a quarter turn of the hand on the edge turns the ramp a quarter of the way round (ends 0.200 and 0.800, one pin, no second shape laid); the pin still takes the shape with the hand to where the hand went (0.560,0.560, no sideways throw); the rim of an ellipse still turns with the hand on it (1 pin); another chip still leaves 0 mask columns and the mask chip brings 1 back; DELETE still takes the chosen shape off the photo with its grade (64 -> 255, then back to 61). Against the code before this change the same probe fails 9, both angle checks among them — the ramp lands at 0.850,0.560 and 0.860,0.562, that is, wherever the finger was. brush-edit 33/0, heal-idle 23/0, heal-zoom-drag 28/0, landing/pro-gate/ award-column/otp-code/tone-curve/hsl-panel/grain-controls/chip-edge/chips-desk/ slider-reset/temp-swatch all ALL PASS, backend 180/0. |
||
|
|
1f6c97be62 |
web: let a mask's own edge turn it, and another chip put it down
A mask could only be taken hold of by its pin or by one of the handles, and the pin of a linear mask sits on the middle of its own line, so the press a hand aims at "that line" was the press that moved the shape. What it moved by was worse: a ramp is stored as the two points it falls between, and 'move' measured the hand against the shape's first point, so the first move of a drag put that end under the pointer and threw the rest of the ramp sideways by half its length — the shape landed somewhere off to the side of the hand instead of under it. The move now takes its delta from the pin the press landed on, which is what the press wanted, and which for an ellipse was already the centre it moved by. The drawn outline is the shape's own handle. A press on the line a ramp falls across — any of the three, anywhere along it — or on an ellipse's rim, turns the shape; the nodes that resize it sit on that same outline and are drawn over it, so a press on one of those is still a resize. That is the Lightroom gesture: the edge is what you drag to aim a gradient, and the ends and the axes are what you drag to lay it out again. Until now a press on the edge was a press on the layer, which read it as the start of the next shape and laid a second mask down while the first was being turned. The band is sixteen screen pixels wide and does not scale with the zoom, so a finger finds it at any size of photo. A mask is chosen by pressing it on the picture, so the keys the hand is on are DELETE, which is what the chip beside the photo already says. And the column the chosen mask puts up — its name, DELETE, and its knobs as rulers — belongs to the mask tool: another chip puts that tool down and takes the column with it, because a photo still armed to draw shapes is not what a hand reaching for a knob is asking for. The shape stays chosen and stays live in the render; the mask chip brings its column straight back. ponytail: Backspace is read as DELETE as well, since that is the key the label sits under on a Mac keyboard, and is the one shortcut this adds. A mask can still only be chosen while a mask tool is armed — the shapes answer the pointer through the layer that only exists then — so the column coming back with the mask chip is also the way back to a shape drawn a moment ago. Verified: tsc clean; mask-probe 50/0 on the dev server and again on 8090 — a ramp lands with its pin on its middle, dragging its own edge turns it (ends at 0.200 and 0.800, one pin, no second shape laid), the same drag on an ellipse's rim turns that (90deg -> -39deg, one pin), the pin takes the shape with the hand to where the hand went (0.560,0.560, no sideways throw), another chip leaves 0 mask columns and the mask chip brings 1 back, and DELETE takes the chosen shape off the photo with its grade (64 -> 255, then back to 61). brush-edit 33/0, heal-idle 23/0, heal-zoom-drag 28/0, landing/pro-gate/award-column/otp-code/tone-curve/ hsl-panel/grain-controls/chip-edge/chips-desk/slider-reset/temp-swatch all ALL PASS, backend 180/0. panel-test (4), histogram-wb (1) and studio-save-hl fail exactly as they do on the build before this one, on their own tabs. |