Commit Graph

737 Commits

Author SHA1 Message Date
3dtours 7e4bfd3da7 Merge branch origin/recipes-web into recipes-web 2026-10-16 12:34:06 +07:00
3dtours a70037970b chore(docker): stamp the image bundle with 4df6ba7 2026-10-16 12:32:40 +07:00
3dtours 4df6ba7ccc fix(ui): expand develop sidebar width and fix ruler track and chip label wrapping in desktop STUDIO 2026-10-16 12:29:36 +07:00
3dtours f96a60f416 fix(heal): apply HEAL directly to base image before exposure grading and use fast texture synthesis on mobile to prevent iOS reload 2026-10-16 12:04:18 +07:00
3dtours d579a67ed9 fix(heal): restore direct single-threaded WASM inpainting on mobile, remove worker to prevent WebKit reload 2026-10-16 11:32:06 +07:00
3dtours b90086c8a8 fix(heal): run neural inpaint via dedicated worker on mobile to eliminate blur fallback and prevent browser reload 2026-10-16 11:05:24 +07:00
3dtours ea7973cb1e fix(heal): continuous anisotropic isophote inpaint on mobile to eliminate reload and blockiness 2026-10-16 10:47:17 +07:00
3dtours ea274de7a9 chore(docker): stamp the image bundle with 8952d1f 2026-10-16 10:37:31 +07:00
3dtours 8952d1f282 fix(heal): run neural inpaint via WebGPU and dedicated worker to prevent crash and blocky patch 2026-10-16 10:37:21 +07:00
3dtours fbff975888 chore(docker): stamp the image bundle with bdea7d3 2026-10-16 09:59:00 +07:00
3dtours bdea7d3395 fix(heal): exemplar-based texture synthesis inpaint on mobile to prevent WebKit crash and grey blob 2026-10-16 09:58:07 +07:00
3dtours e124823e8f chore(docker): stamp the image bundle with ce31e49 2026-10-16 09:40:48 +07:00
3dtours ce31e498d9 fix(heal): enable MI-GAN neural inpaint on mobile with bounded memory to prevent grey blob 2026-10-16 09:39:15 +07:00
3dtours 455800c248 chore(docker): stamp the image bundle with de20fb4 2026-10-16 09:04:44 +07:00
3dtours de20fb4440 fix(heal): handle mobile/iOS with fast local Poisson inpainting to prevent WebKit OOM crash 2026-10-16 09:03:56 +07:00
3dtours 3810bfd2b8 chore(docker): stamp the image bundle with 7a3b81b 2026-10-16 08:57:44 +07:00
3dtours 7a3b81b6ea feat(ui): compact Color Wheels & Match panel with MiniSlider rulers and 4-column color bands 2026-10-16 08:56:49 +07:00
3dtours a841c7f5a0 chore(docker): stamp the image bundle with 2dac868 2026-10-16 08:00:29 +07:00
3dtours 2dac868274 fix(heal): feed crisp photo context to inpaint model and cache patch across zoom levels 2026-10-16 07:58:38 +07:00
3dtours 21659f0d8a chore(docker): stamp the image bundle with 64e6ff2 2026-10-16 07:40:26 +07:00
3dtours 64e6ff2608 fix(heal): restore inpaint-web MI-GAN neural model to resolve grey blob bug 2026-10-16 07:36:25 +07:00
3dtours 4b039ba9c8 chore(docker): stamp the image bundle with f670de1 2026-10-16 06:14:01 +07:00
3dtours f670de1377 revert(heal): cut the mask's own fade again, not a step over the whole brush
This reverts 8809eb8. The step cut takes the whole brush out of the frame, so
the fill below has only the rim left to read from.

The colour leak that cut was for no longer needs it: 49c5c9c handed the mask
the whole spot, and heal-colour-pass.mjs measures the repair 1.08 levels off
the ground with the fade cut — the same 1.08 it measures with the step.
2026-10-16 06:13:35 +07:00
3dtours 1633287a46 chore(docker): stamp the image bundle with f044f77 2026-10-16 06:07:17 +07:00
3dtours f044f7756f fix(heal): take df44200's outer-union band back, and let it rest off the photo
The band is traced as one outer boundary again, as df44200 left it, so a
stroke that crosses itself no longer fills as a figure-eight with its lobes
cut open; and an idle band is opacity 0 again, so no wash of the tool's own
stays on the repair once the pointer is off it.
2026-10-16 06:06:20 +07:00
3dtours 5b7e065b8a chore(docker): stamp the image bundle with 9753c86 2026-10-16 00:49:31 +07:00
3dtours 9753c866ec fix(heal): the fallback reads its grain off the rim, not off a mirror of itself 2026-10-16 00:48:53 +07:00
3dtours 5650fe8888 chore(docker): stamp the image bundle with 49c5c9c 2026-10-15 23:24:41 +07:00
3dtours 49c5c9c5b9 fix(heal): the mask owns the whole spot, and the fill is the diffusion, not the model 2026-10-15 23:24:01 +07:00
3dtours 5830812862 chore(docker): stamp the image bundle with 5ff4611 2026-10-15 22:41:18 +07:00
3dtours 5ff46116ce fix(heal): read the rim for the fill, take the grid from the frame; VIGNETTING both ways; EV in the wide panel
HEAL came back a stain larger and softer than the stain it was asked to
remove, and the model's half of it answered in a colour that appears nowhere
near the repair. Three causes, all inside `engine/inpaint.ts`:

- The fallback's reading ring was the hole dilated by two pixels whatever the
  brush, so the boundary of the fill was the dirt's own penumbra and the fill
  came back as the dirt. The pad is now proportional to the hole's own radius
  (`rimPad`), the fill is an inverse-square average of the clean ring relaxed
  into one field by 36 Jacobi rounds, and the hole's grain is read against a
  2px box of the clean pixels and taken from the pixel it mirrors through its
  own middle. The texture half before this was a no-op — `detail = rgba[p] -
  fR[idx]` with `fR[idx]` the same pixel's own colour, so every donor handed
  over zero and the hole was the smooth field alone (15.6% of the ground's
  detail). Measured on a stain of radius 10 with a halo to 16 under a 12px
  brush (`probe-fallback.mjs`): the repair landed 22.3 levels below the ground
  it sits in, and comes back 0.06 off it.

- The plate the model reads under the mask was the mean of every pixel of the
  crop, so the network met a hard step at the mask's own edge and answered in
  that colour. It is now the rim's own light spread inward (`cropPlate`), and
  the mask is downsampled by "any hole pixel in this cell" rather than by a
  corner sample. Step across the mask's edge: 69.3 levels before, 9.5 after.

- The same repair came back different every time the reader zoomed out of it:
  the crop was sized from the stage's own pixels on the zoom ladder
  (`PREVIEW_QUANTUM`), so each zoom handed the model a different grid, and
  every grid invents its own pixels. The crop's box is now its share of the
  FRAME (`INPAINT_REF_LONG`, `exportEngine` 6e), so one spot is one grid at
  every zoom and at the export's own size.

The patch is deliberately not baked into the photo: a recipe stays reusable on
other frames and the repair stays a pass. ponytail: the pixels handed to the
model are still read from whatever the stage rendered at, so the answer is the
same repair to within the resampling of its input; a patch cache keyed on the
frame's identity and the spots is the rung that makes it identical, and the one
to take if a zoom still shows a repair move.

VIGNETTING now runs both ways (-10..10): down darkens the four corners, up
lifts the lens' own falloff out of them with a white ink — the same curve and
the same weight. EV was defined and live on the phone but absent from the wide
panel's own Light row, which is `DevelopPanels.tsx` and nothing else.

`npx tsc --noEmit`, `vite build`, `heal-check`, `studio-drag` and the other
suites pass. `studio-export-check` fails one assertion here on the code at HEAD
as well — a frame left behind files its waiting look 9s after the pick — so it
is not this change.
2026-10-15 22:37:05 +07:00
3dtours e33bb6a529 chore(docker): stamp the image bundle with 4d9be10 2026-10-15 22:04:41 +07:00
3dtours 4d9be1014c fix(mobile): the phone library wears the app's theme
The phone screen was painted in literals — `#101014`, `#f4f4f5` and forty more
— so it stayed dark whatever theme was set: pick Light, press Mobile Cloud,
and the reader is handed a black page with white ink.

Every surface and ink in `styles/mobile-library.css` is now one alias
(`--ml-bg`, `--ml-text`, …) that reads the theme's own tokens, the faint
linings and fills are a `color-mix` of the ink at the alpha they already
carried, and the amber the file already asked `var(--accent)` for is now what
the star, the pressed pill and the selected frame wear too. Only the four
status inks carry a light-theme override, the tokens having no word for them.

The five inline dark styles left in `MobileLibrary.tsx` read the same aliases.

Measured on the running container at 390×800, Light chosen on the desktop: the
phone was `rgb(16,16,20)` on ink `rgb(244,244,245)` whichever theme was set;
it is now `rgb(255,255,255)` on `rgb(20,24,29)` under Light and `rgb(0,0,0)` on
`rgb(242,243,245)` under Dark, held through the handover and on the way back. A
contrast sweep of the grid and the reader in both themes leaves nothing under
3:1 but the tick glyph, which its own box draws.

`mobile-pick-check` asked for one frozen amber; it asks for the accent the app
is using, which is what that outline has become.
2026-10-15 22:03:50 +07:00
3dtours 3efdfb34f5 chore(docker): stamp the image bundle with 916a9de 2026-10-15 21:38:41 +07:00
3dtours 91c9b1f95b fix(library): a local node's frames stand under the key the column files them by
The column files a local folder as an absolute key — `/2012` — while a frame
carries its own `dir` as a path from the root, `2012`. `shown` set the two
against each other as they stood, so a frame's dir was never the node's dir, and
never under it either: every folder in the disk tree was counted in the row
above it and stood in no pane at all — the wall white, the frames nowhere.

The key sheds the slash it leads with before the two are set against each other,
the way `markOf` already cleans a reading's own path.

Measured on the live account's own catalogue — 112761 frames, 6 under `/2012`:
the node matched 0 frames before, 6 after; the root's own list is untouched
(927).
2026-10-15 21:37:14 +07:00
3dtours d6d554f736 chore(docker): stamp the image bundle with 1b2ddd9 2026-10-15 21:08:45 +07:00
3dtours 18d03637e7 fix(library): the wall reads its own width when it stands, not when the visit opens
The shelf is laid out in the width the wall reports, and that width was read
once, by the effect that watches the wall's own element. The phone's layout
stands where the wall does, so a visit that opens on the phone mounts the wall
with nothing to measure: the effect returned with the width still nought and
never ran again, and every row of the shelf was laid out one pixel wide — a
library of blank boxes until another render made that effect run (SINGLE and
ALL THUMBS are two of them, which is why the toggle brought the pictures back).

It is read after every render now, the way the window of rows the wall draws
already is (see `useWallWindow`), and the effect keeps the resizing itself.

Measured on 8090, phone 390 handed to the desk: the tiles were 1x1 px and are
now 85x113 (and 120x160 at 1440), all loaded, nothing left in `.lib-noimg`.
2026-10-15 21:08:09 +07:00
3dtours 8d275613ef chore(docker): stamp the image bundle with 8b44578 2026-10-15 20:43:37 +07:00
3dtours 694de421cf fix(library): the phone's shelf opens on the frame the studio held
Coming back from the studio on a phone left the grid where it was: the
frame the studio was holding was lit by nothing and looked for by hand.
The shelf already stamps the frame it was left on and the wide screen
already opens on it; the phone's grid never read it.

The stamp travels down as a landing, and the grid lights that frame,
walks the list out to it when it sits past the rows drawn so far, and
scrolls it under the eye. Each landing is spent once, so a list that
changes under the reader is not dragged back to it.

probe-mobile-return.mjs, on a phone: the frame the studio held is the one
lit and the one in view, both when it is at the top of the shelf and when
it is buried past the first window.
2026-10-15 20:41:36 +07:00
3dtours d17ae406e0 fix(studio): the roll stands while the next frame opens
A click on the next frame of the roll emptied the stage before the bytes
were read — `clearPreview` for the picture, and `loadFile` dropping the
catalogue's frame id for the strip — and both the roll and the frame's own
info line lived inside the branch that drew the picture. The whole strip
went with it and was drawn again on the way back: every tile torn down and
read a second time, three loads and three fresh images, for a click that
changed one frame.

The info line and the roll stand outside that branch, so the roll survives
the click, and a caller opening a frame of the catalogue's own roll says so
and keeps the id `loadFile` otherwise clears. The info line now sits above
the roll instead of under it.

probe-strip-click.mjs, desktop and phone: 0 tiles torn down, 0 loads,
the frame clicked is the one lit, the info line above the roll.
2026-10-15 20:41:36 +07:00
3dtours d7a1a0a489 chore(docker): stamp the image bundle with 396b036 2026-10-15 19:12:01 +07:00
3dtours 9607f9594e fix(studio): the zoomed stage keeps the frame's own pixels
The preview copy behind the stage stopped at PREVIEW_HARD_MAX (3200) whatever
the photo was. A 5729px frame opened from Immich, zoomed past 3200 device
pixels, was painted from a 3200px copy stretched over the screen — soft enough
for a reader to call it the thumbnail having opened.

The ceiling is now the photo's own longest edge. `resizedJpeg` never grows a
photo, so at that size it hands the original bytes straight back, and the copy
still only grows when the stage says it needs it: at fitting size the 1600px
one stands and the edit keeps its old cost.

Measured on the operator's own Immich asset (5729x3820, retina 2x, opened on
the stage and zoomed 12 notches in): the stage held 3200x2134 before and
5729x3820 after.
2026-10-15 18:55:41 +07:00
3dtours 50719e19e9 chore(docker): stamp the image bundle with 73db2a5 2026-10-15 18:28:01 +07:00
3dtours 6d16783d17 fix(studio): the strip stands on the frame in hand when the rail changes width
A narrower rail is not the wide one with less of the roll in it: its frames
are a different number of pixels wide, so the scrollLeft the reader was
standing on is a different frame. Opening the studio on a desk and switching
to the phone layout left the strip standing further down the roll than the
frame on the stage — with three hundred frames in the folder, frame 170
where frame 150 was open — and the frames of the open photo were off the
rail. The rail is now put back on the frame in hand whenever its own width
changes, the window is read from where it then stands, and the tiles the
reader is being shown are the ones asked for.
2026-10-15 18:25:18 +07:00
3dtours e7e739ded1 chore(docker): stamp the image bundle with 508edf3 2026-10-15 17:53:21 +07:00
3dtours b2e2ebb266 fix(studio): the strip asks for the frame in view, and lands where it is put
The rail glided to the frame in hand. A glide across a roll of six hundred
fires a scroll event a frame, and every one of them asked the thumbnail
cache for another window: the tiles of the frames being looked at queued
behind two screens of margin, so the strip sat blank exactly where it was
being read while it drew frames nobody had reached. The rail now lands
where it is put, one window is asked for, and the frames in view are the
first asked for a tile.

The strip also keeps its height: `overflow-x: auto` makes the other axis a
scroller too, so a strip squeezed by the column hid its own frames rather
than spilling them. The photo above gives way instead.
2026-10-15 17:52:37 +07:00
3dtours 164055a6c0 chore(docker): stamp the image bundle with 4b4d9b3 2026-10-15 17:18:22 +07:00
3dtours 065c9af1c1 feat(studio): a strip of the folder's own frames under the photo
The studio is where a frame is graded, and the frame next to the one being
graded is the one the reader almost always wants next — a roll is shot in
order and looked through in order. Until now the only way to it was LIBRARY:
the stage had no way back to the folder the frame came from.

The strip stands between the photo and the row that names the file, on the
phone at the same place. It holds the folder the open frame was filed in —
the frames beside it, not every frame the folder was scanned for, which is
what the row's own `dir` is read for — in a file browser's order rather than
the catalogue's newest-shutter-first, because a roll off a camera is the
order it is looked through in. The frame on the stage is the lit one and the
one the rail is scrolled to, wherever the rail had been left; a click is the
same opening the catalogue's wall makes, so the frame arrives with the look
it was last left at.

The tiles are the shelf's own: `makeThumb` off the same store, and the URL
cache the wall keeps is lent to the strip through the hook that fills it.

  docker/frontend/src/ui/Filmstrip.tsx      the strip
  docker/frontend/src/ui/ImageStage.tsx     one optional slot for it
  docker/frontend/src/App.tsx               the folder, read off the catalogue
  docker/frontend/src/Library.tsx           the tile hook, exported
  docker/frontend/src/styles/app.css        the strip and its frames
2026-10-15 17:16:59 +07:00
3dtours b5c8af4982 chore(docker): stamp the image bundle with f96f395 2026-10-15 16:22:18 +07:00
3dtours 1a718a74d9 fix(library): a roll picked at a drive root counts its frames under one spelling
The picker hands a whole drive over named `\`, which `normPath` folds to the
empty root, and a row of that roll was keyed `Roll` where the reading files its
counts, its walked folders and its frames under `/Roll` — one folder under two
names. Every number beside a subfolder of a picked drive read nought, and the
spinner of a reading stood on the roll's own row and nowhere it was reading.

The row key is the roll's own spelling again, and so is the path `relOf` hands
back for a row under it: a reading kept to one folder of a drive root asks for
`/Roll`, and `sub` counted from the old `Roll/` merged nothing. Both are the
spelling the check at the foot of `scripts/tree-scan-check.mjs` asks for, and it
is green again.

A bounded reading also measured itself against `\/Roll` spelled from the raw
name, which no id is filed under: `held` stood at nought and the line read the
catalogue's whole roll as the ground it was climbing from. It is built from
`normPath` of both, the way an id is.
2026-10-15 16:22:15 +07:00