Commit Graph

63 Commits

Author SHA1 Message Date
3dtours 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).
2026-09-26 08:26:28 +07:00
3dtours 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).
2026-09-25 20:32:52 +07:00
3dtours 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.
2026-09-25 17:44:28 +07:00
3dtours 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.
2026-09-25 08:46:27 +07:00
3dtours 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.
2026-09-24 12:20:33 +07:00
3dtours 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.
2026-09-24 11:18:59 +07:00
3dtours b7ff298789 web: give a mask its knobs as rulers and let the ramp be turned
A mask's knobs were a card of small sliders side by side, three abreast under the
colour they were moving, which is the shape the mixer needs because its whole
point is reading three bands at once. A mask has nothing to read against: its
knobs are a list, and the strip beside the photo has always had the shape for a
list — the ruler, one parameter to a row, with its own name, its own value and
the width to aim with. So the card goes and the rows come: EXPOSURE, CONTRAST,
SATURATION, and FEATHER below them for the shape that fades over one, each the
same ruler the FX panel opens, stacked in the mask's own column next to the chip
that says which shape is chosen.

Only the ramp could be moved and not aimed. An ellipse is stored as an angle, so
turning it is writing a new one; a line is stored as the two points it falls
between, and turning one is moving both of them about their own middle by the
same turn — which is the point of doing it that way: the length survives, the
middle survives, and only the direction the gradient falls in changes. The turn
handle a chosen ramp now wears hangs clear of its middle along the ramp's own
normal, so it never sits on the pin the shape is dragged by, and the angle the
hand asks for is measured in the photo's own pixels, so a quarter turn of the
hand is a quarter turn of the ramp however the photo is shaped or zoomed. Both
handles for both kinds now come out of one list and one map, which is how a ramp
and an ellipse ended up wearing the same class, the round one that says "this
turns me".

ponytail: the ruler row takes no data-key of its own — the parameter's key is
already on the chip that opened it, and the panel's fourth column opens a ruler
under the same name a strip chip answers to, so a second element carrying it
would make an existing selector ambiguous. The mask's rows are found by the label
they print; if a probe ever needs to hook a row directly, the key belongs on the
row and the panel's ruler needs its own name first. Highlights/Shadows and
per-mask invert and range are still out, as before.

Verified: tsc clean; mask-probe 42/0 on the dev server and again on 8090 — the
three rows share an edge and stack, a linear mask carries no feather row, a
radial one carries it fourth, and dragging the ramp's turn handle stands the
gradient up the photo: 61 at the top and 244 at the bottom where it was 61 -> 244
across — brush-edit 33/0, heal-idle 23/0, heal-zoom-drag 28/0, landing/pro-gate/
award-column/otp-code/tone-curve all ALL PASS, backend 180/0.
2026-09-24 10:54:56 +07:00
3dtours b568fa3fdc web: let FX carry Lightroom's two gradient masks, and grade inside them
FX had two tools that change the photo where it is — HEAL repairs a speck,
MOSAIC hides a patch — and every knob that graded the frame graded all of it.
The scratchpad's gradient_mask.md asks for the two local adjustments the phone's
own editor has and Lightroom made familiar: a linear gradient and a radial one.
This is that spec, written for the renderer this app actually has.

A mask is a SHAPE rather than a value, so it is dragged rather than turned: the
LINEAR chip arms a ramp and the next drag on the photo is its two ends — zero at
the press, one at the release, the spec's own convention, which is what makes the
same gesture a wide fade or a hard edge — and RADIAL arms an ellipse whose centre
is the press, whose semi-axes are the drag's own distance and whose axis lies
along the direction the hand went, so the circle a drag describes is the circle
the mask starts life as. Both shapes keep a pin (the whole shape travels by it)
and, while chosen, the handles that move the ends or the axes and the one that
turns the ellipse; what is drawn is the shape the render will read, so the ramp
and the rim are visible before a knob is moved.

Inside the shape, three knobs grade in the spec's own order and its own maths:
exposure as `pow(2.0, e)` in stops (its -5..+5), contrast about the middle,
saturation as a mix away from the pixel's own REC-709 luma — the mixer's
-10..+10 read as the spec's -1..+1 — and a radial mask adds the feather it fades
over, which is the fraction of its own axis the alpha holds full before it dies
at the rim. Several masks run in the order they were drawn, each reading what the
one before it left, which is what a stack of local adjustments is.

The maths is GLSL in the md and the renderer is Skia (canvaskit-wasm, SkSL
runtime effects), so it is ported stage for stage: one pass, after the frame-wide
grade and the vignette and before HEAL, because a local adjustment is part of the
look and not a repair — the pixels a repair borrows are then meant to carry the
mask's light already. Preview and export both come through renderPhoto, so the
file carries the masks the stage is showing by construction, and the shape and
the knobs ride in the recipe's own JSON, which is what makes them survive a save.

The chips sit with HEAL and MOSAIC because all four take the pointer on the
photo, and they are exclusive with every other armed tool, the eyedropper
included — while a mask tool is armed the layer takes the photo, so a drag means
"draw the next shape" and a press on a pin means "take hold of this one", which
is why the shapes already laid are answered through their pin and handles alone.
A knob drag on a mask is one undo step, a shape drag is one more, a press that
only chose a mask records nothing at all, and RESET is the way back with the
whole frame as it was imported.

ponytail: the spec's own "Gợi ý nâng cấp" rung — Highlights and Shadows isolated
with pow(luma, 3) and pow(1-luma, 3) weight masks — is not here, and neither is
Lightroom's per-mask invert and colour/tone range. The three knobs are what
"gradient mask" means until a photo shows a sky that has to be rescued apart from
the grass under it; the md itself calls it an upgrade, not the feature.

Verified: tsc clean; mask-probe 35/0 on the dev server and again on 8090 (the two
chips, both shapes drawn and moved and turned, the ramp read off the pixels —
61 -> 244 at the release and 61 at the press — the feather read off the rings,
DELETE/UNDO/REDO/CLEAR, and one gesture one undo step); brush-edit 33/0,
heal-idle 23/0, heal-zoom-drag 28/0, landing/pro-gate/award-column/otp-code/
tone-curve all ALL PASS, backend 180/0.
2026-09-24 10:42:58 +07:00
3dtours abfc6595e7 web: take hold of a repair, and draw a stroke as the mark it was
A repair laid on the photo was finished the moment it landed. The brush could
only put more spots down, so a repair aimed one brush-width off the speck was
deleted and laid again, and the patch a spot borrowed — the other half of what
the renderer works with — could not be moved at all. And a drag, which is one
mark of the brush and is drawn as one while it is being painted, came back as
the beads it is stored as: a run of circles a fraction of a radius apart, each
showing its own edge, so a long stroke over a scratch read as twenty repairs.

HEAL's spots can now be taken hold of. A press inside a spot's circle moves that
circle — the hole, or the patch it borrowed, one at a time, since the pair is
the user's to arrange — and the repair is re-rendered under the pointer as it
travels, off the same snapshot the preview and the export read from. A press
that does not travel only chooses the spot, and a chosen spot wears a small ×
just off its circle: click it and that one spot goes, the rest keep their
places, and UNDO takes it back. One gesture is still one step — the undo
boundary is the gesture's first actual change, so a drag is a single step
however far it went and a press that only chose a spot records nothing. The
recipe is written exactly as before, one list of fractions and radii, so a moved
or deleted repair survives a reload, rides UNDO and REDO, and reaches the
exported file through the numbers it always did.

The band a stroke leaves is now read back off the recipe's own spots: the ones
that overlap — which is what a drag lays, one spot every 0.6 of a radius — are
joined into one run and drawn as a single path of the brush's own width, and
only a spot with no such neighbour keeps the circle it is. One path per run
rather than one capsule per pair, because the band is translucent and a pair of
capsules would print a darker patch wherever they meet — which is what a row of
overlapping circles looks like in the first place. Two spots whose circles do
not overlap are two marks and stay two: a band drawn through the gap between
them would be paint that is not there. Nothing about a stroke is stored, so the
band is a reading of the geometry the shader works from, and a saved photo opens
onto the same band it was left with.

Three things came out of the probe rather than out of the design, and all three
are in here because the numbers said so:

  - The × first sat on the spot's corner at the brush's own radius. The default
    brush is 6px wide and the badge is 18px across, so the badge covered the
    circle: the next press on the repair — a user putting the spot down again —
    deleted it. Measured: after undo/redo, a press at the spot's centre left one
    spot instead of two. The badge now sits on the top-right diagonal at the
    circle's edge plus a badge's radius, so it can never take a press meant for
    the spot.
  - The hit test first took the hole before the patch. On the default brush the
    patch the search borrows sits about 8px from the hole it fills — inside any
    reach a pointer can use — so dragging the patch's own centre grabbed the hole
    and the patch never moved (measured: dragging the patch from (0.331, 0.300)
    to the neighbouring speck left it at (0.331, 0.300) and painted a stroke
    instead). The nearest circle now wins, and the hole wins a tie with its own
    patch.
  - The reach was first the circle plus 8px, for a brush turned down to a few
    pixels. heal-probe.cjs went to 48 PASS / 1 FAIL: eight clicks on a grid
    12.8px apart were meant to lay eight repairs and four of them landed, because
    four were within 8px of a patch circle and grabbed the spot instead. The
    reach is now the circle plus 4px: the same probe is 49/0 and the patch's
    centre is still 0px from the pointer that grabs it.

MOSAIC's spots are deliberately not held, and that is the one asymmetry here: a
repair is aimed, a mosaic cell is part of a region that gets painted over, and a
grab that could take a cell would also be one the user could not paint through.
Its cells are drawn as one band like HEAL's, since a mosaic stroke is the same
kind of mark.

Verified, on the rebuilt app at http://localhost:8090 (docker compose up -d
--build frontend):

  brush-edit-probe.cjs (new, 33 checks, 0 FAIL): the band is one path of the
    brush's width through all 15 spots of a drag, its length inside 2px of the
    polyline the spots stand for, every joined spot's border transparent and a
    lone spot's not; dragging the hole moves it to (0.550, 0.550) at the size it
    was laid and the speck comes back at (0.300, 0.300), UNDO/REDO move it back
    and forth in one step each; dragging the patch onto the neighbouring speck
    puts the speck back into the repair (level 5 on a field of 151) and UNDO
    returns it; a press chooses a spot and shows the ×, that press records no
    step (the next UNDO still takes the last repair back), the × deletes that
    spot and no other, and UNDO restores it; a mosaic drag's overlapping cells
    are one band and every cell in the run joins it, painting across mosaic
    already laid down paints more cells, and no mosaic spot is ever offered an ×.
  Unchanged and still green: heal-blotch-lab.cjs 12, heal-edge-lab.cjs 9,
    heal-seam-lab.cjs 10, heal-skia-lab.cjs 28, heal-search-lab.cjs 15,
    heal-probe.cjs 49, heal-zoom-geom.cjs 5, heal-zoom-probe.cjs 8,
    mosaic-skia-lab.cjs 27, mosaic-probe.cjs 51 — all 0 FAIL.
  Regressions against the rebuilt app, rc=0, 0 fail: landing-test.cjs 172,
    pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
    tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed; frontend
    tsc --noEmit clean.

ponytail: a stroke is still not stored — the band is derived from the spots that
overlap, so a stroke whose pointer jumped (a coalesced event, a fast flick) lays
spots further apart than the brush is wide and comes back as separate circles,
and a run breaks where the wheel changed the brush size mid-stroke. A stroke id
in the recipe, written once per gesture, is the rung for that, when a photo shows
a run the geometry cannot join. The hit test is the nearest circle within a few
pixels, so a press meant to paint a new repair within that reach of an existing
one moves the existing one instead — a shared modifier to paint regardless is
the rung there. Choosing a spot is an index into HEAL's list, so an UNDO that
changes the list under a chosen spot can leave the × on the spot that took its
place; the × is guarded against an index past the end but not against that. No
keyboard delete: the × is the whole affordance. And the band is drawn only while
the brush is armed — the spots are the recipe's, so nothing outside FX sees
them, which is the same as it was.
2026-09-24 07:21:01 +07:00
3dtours f1385d8a08 web: hide what the brush paints, in cells, and never in a blur
HEAL borrows a patch of the photo and pastes it over what the brush covers. The
other half of the same gesture is the opposite thing — a patch of the photo the
user does not want shown to anyone, a face at a table, a plate, a badge, the
number on a note at the edge of the frame — and hiding it is the second tool on
the same layer: MOSAIC, next to HEAL in the FX row. Everything the two tools
share was already shared by the time this landed: one layer, one circle riding
the pointer, one wheel, one gesture that is one undo step, spots stored as
fractions of the render so the preview and the export draw the same circle. Only
what a spot MEANS split, and it split into two files over the piece of physics
both of them were already carrying: heal.ts and mosaic.ts, and brush.ts under
them for the size and the spacing of the circle they both lay.

What a mosaic spot does is destroy what it covers rather than replace it. The
frame is cut into square cells of MOSAIC_CELL (0.02 of the width — 5.12px on the
probe's 256px photo, 40px on a 2048px one) and every pixel of a cell takes the
colour found at that cell's own middle, read with img.eval so the block is the
snapshot's bilinear tap and not a neighbour's cell. What is under the circle is
still a picture of that place, at a resolution nothing can be read out of. A blur
was never in the running: it leaves the SHAPE of what it hides — a face under a
blur is still a face, a plate still a plate — and the arrangement is exactly what
the user is asking to keep to themselves. Cells coarse enough to lose the
arrangement are what "do not show this to anyone" needs, and the blockiness is
the price of it.

The cells are one grid over the whole frame, not one grid per spot: a pixel's
cell comes from its own position, and every block reads the snapshot rather than
the output, so two overlapping spots never pixelate a pixelation and a run lays
one band with no seam where its circles cross. The rim is hard for the same
reason in reverse — a feather would mix the cells back into the sharp photo along
the edge, which is a half-hidden thing leaking the arrangement it exists to hide.
A mosaic spot borrows nothing, so the layer draws no donor circle beside the
cursor: the second circle appears only when a spot has a source ('sx' in it),
which is the one place the two tools' DOM parts company. Each tool keeps its own
brush size, and each CLEAR chip clears only its own list, because the size a
dust speck is healed at is never the size a face is hidden at.

The recipe carries the list as adjustments.mosaic — x, y, r, the same fractions
HEAL stores, and readMosaic guards them the same way — and the renderer builds
one RuntimeEffect per count exactly as it does for HEAL (mosaicEffectFor), the
pass sitting right after the heal pass so a repair made on the same photo ends up
underneath the cells that hide the rest of it. The backend needed nothing: a
recipe is spread through as it stands, so a saved photo keeps its mosaic and a
shared one opens with it.

Verified:
  mosaic-skia-lab.cjs (scratchpad, CanvasKit against the bundled mosaic.ts) — 27
    passed, 0 failed: the cell rides in the frame block in the render's own
    pixels and is a fraction of the WIDTH, so it is square on any shape; 4912
    cells inside a spot each carry one colour, and 164/164 of them carry the
    colour at their own middle; the 2px white dot on the dark square reads
    250 -> 20; nothing outside the circle changed (0 stray pixels) while the
    cells reach the rim (852 pixels at the edge); a spot wider than the frame
    still runs; overlapping spots share one grid over 6335 pixels with 0
    differing between them (no cascade); readMosaic refuses a zero radius, an
    off-photo spot, junk and a missing list, and keeps a forty-spot list whole.
  mosaic-probe.cjs (the rebuilt app at http://localhost:8090) — 51 PASS, 0 FAIL,
    no page errors: FX offers a MOSAIC chip that arms the same brush layer and
    says which tool it is painting for; the wheel sizes each tool on its own
    (8.0% up, 5.0% back) and the circle follows it; a click lays exactly one spot
    with no borrowed patch beside it; the pixels of the cell are one colour (0
    levels across, cell 5.12px); the dot is unreadable (250 -> 15); nothing
    outside the circle changed (0 pixels, worst 0) and the cells are not the
    photo that was there (221/509 pixels changed); UNDO gives the photo back
    exactly and REDO hides it again; a drag paints ONE band 25.6px wide, as wide
    as the brush, standing for 5 points of travel and laying 5 spots that leave
    0 pixels outside them changed, with the step within a cell 3.43 levels
    against 21.25 between cells (635 + 157 pairs) — the cells are flat and their
    borders jump; one gesture is one undo step; arming HEAL and arming MOSAIC
    hand the pointer over and back with each tool's spots intact; CLEAR hands the
    photo back pixel for pixel and leaves no chip behind.
  The probe's own reading is deliberately a shape, not a colour: the app's
    preview is the engine's render at preview scale with a JPEG on top (and its
    auto dynamic range), so a cell's colour read back from the base would be two
    encodings apart. The exact cell colour is the Skia lab's claim, where no
    encoder sits between the shader and the reading.
  heal-probe.cjs 49 PASS / 0 FAIL against the same build, heal-search-lab.cjs 15,
    heal-skia-lab.cjs 27, heal-zoom-geom.cjs 5, heal-zoom-probe.cjs 8 — the brush
    HEAL paints with is the one MOSAIC now paints with.
  Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
    pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
    tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed; frontend
    tsc --noEmit clean.

ponytail: the cell is a fixed fraction of the width, not a fraction of the brush,
so a brush smaller than one cell paints a single block's colour; tying the cell
to the radius would mean a cell size per spot in the recipe, which is a recipe
change this tool does not need yet. The grid is one grid for the whole frame, so
a run of overlapping spots and one wide spot give the same blocks, and the run's
circles are laid spot by spot — drawing a run as one region wants a stroke id in
the recipe, the same change HEAL's own run is waiting on. A spot is in the
recipe by its fractions alone, so what the export prints is the mosaic the user
saw, and the original pixels under it are gone from the record on purpose.
2026-09-23 22:03:33 +07:00
3dtours 88cff5ca87 web: draw the dust brush into strokes, size it by the wheel, uncap the list
A speck of dust is small and there is never only one, so the brush had three
things wrong with it: the list stopped at sixteen and the seventeenth repair
pushed the first one out of the shader, the size was a choice of three buttons,
and one gesture laid exactly one spot — a scratch across a hundred pixels was a
dozen clicks.

The cap is gone rather than raised. SkSL indexes a uniform array by a constant
only (the trick the tone curve's mixer already uses), so HEAL_SKSL carried
sixteen unrolled blocks and the list was trimmed to fit them. The shader is now
built for the count it is handed — healSkSL(n), with healUniforms returning
(n * 2 + 1) * 4 floats, the same declaration order for any n — and the renderer
caches one compiled effect per count (exportEngine's healEffectFor). readHeal
no longer slices and the app appends whatever a gesture reported. No repair is
dropped to make room for a later one: the speck healed first is the speck that
stays healed.

The wheel is the size now. wheelHealR multiplies the radius by
exp(-deltaY * 0.0015), so a trackpad's small deltas and a mouse's 100px notch
are the same gesture at two speeds, bounded at 0.3% and 25% of the photo's
width — below the first a spot is finer than the pixels it is drawn on, past
the second it would borrow its patch from off the frame. S, M and L are gone,
and because there is nothing left to point at, the HEAL chip's own readout is
the size: the number the brush is set to is the number on the chip.

The pointer paints. Down starts a stroke, move adds a point every HEAL_SPACING
(0.6) radii of travel, and up turns the whole run into spots in one report — so
a stroke is one undo step however long it was, and the trail drawn while the
pointer is down is a preview of that run, in the accent, cleared the moment the
spots land. The part of a stroke that leaves the photo lays nothing down, and
the pointer is captured so a stroke that runs past the edge ends where the
pointer does rather than leaving a spot hanging at the frame.

The wheel had to be stopped, not merely claimed. The heal layer is a child of
the stage, and the stage has its own wheel listener that zooms the photo, so a
wheel over the brush grew the brush AND zoomed the view: the probe caught it as
a cursor circle 15% wider than the readout it was drawing. The layer's listener
(native, because React's own onWheel is passive) now stops propagation — while
the brush is up, the wheel sizes the brush and nothing else.

One number moved that none of the three asks mentioned, and it is what the
probe's remaining failure was about. The feather band was 45% of the radius,
and that band is the only place the pixels being repaired are mixed back into
the patch, so with the default 6px brush it left a ring of the speck's own edge
one pixel inside the circle (115 in a field of 150) — which the preview's own
JPEG then rang around, reading 177 a pixel off the centre of a repair that
should be flat. Narrowing the band to the outer 15% copies the patch over
everything inside 0.85r: sub-pixel at the default brush, still a soft edge at a
big one, and that pixel now reads 151.

Verified:
  heal-skia-lab.cjs (scratchpad, Node + the full CanvasKit build) — 27 PASS,
    0 FAIL: the shader for a count compiles through RuntimeEffect.Make and its
    uniform block is (n * 2 + 1) * 4 floats (n=1 -> 12, n=40 -> 324); a single
    spot copies the donor exactly and leaves the rest of the frame untouched,
    pixel for pixel; forty spots are carried whole with the first and the last
    both drawn; three spots in one run each borrow their own patch; readHeal
    clamps and drops zero-radius spots and no longer trims the list;
    wheelHealR grows, shrinks and clamps at both ends (0.3% and 25%); the
    search finds a patch and still refuses a brush that covers the frame.
  heal-probe.cjs (scratchpad, the rebuilt app at http://localhost:8090) —
    48 PASS, 0 FAIL, no page errors: the circle under the cursor is exactly
    the size the chip reads, before and after a wheel, and the wheel grows,
    shrinks, stops at 25% and at 0.3% and returns to where it started; there
    are no size chips left; one click is one spot, the speck reads 151 at its
    centre and its four neighbours are field too; a drag shows at least three
    trail circles, lays exactly that many spots, clears the trail on release,
    and UNDO takes the whole stroke back at once while leaving the repair made
    before it alone; REDO repaints it; a bigger brush takes a ten-pixel blob;
    twenty-five spots are carried with the first healed speck still first and
    still healed; every speck is gone after a reload; CLEAR brings them all
    back and lays no spot of its own; the chip goes amber only while spots are
    on the photo.
  Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
    pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
    tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed.
  web tsc --noEmit clean.

ponytail: a stroke's repairs land when the pointer comes up, not under it as
they are painted — a live repair would mean recompiling the pass and re-cutting
the preview per point mid-gesture; the trail is what the pointer has drawn, and
it is drawn in the accent so the difference reads. The list is uncapped, so a
runaway stroke pays one shader compile per distinct count it reaches, cached
for the rest of the session: a ceiling would have to come back with the trim.
The search still has no colour-matching term, so the donor is chosen by
resemblance alone, and the spots still live in the rendered photo's
coordinates, so re-cropping or re-rotating after healing slides them.
2026-09-23 21:08:09 +07:00
3dtours 3ee0137d0d web: repair dust with a brush that borrows a patch of the same photo
A sensor speck is not a filter: it is a small lie in one place, and every
slider in the panel is global, so there was no way to say "here, and only
here". The FX row now has a HEAL chip. Arming it turns the pointer into a
circle you can size S, M or L, and every click on a speck covers it with a
patch of skin borrowed from a few radii away — the repaired sites persist in
the recipe like any other edit, and UNDO takes them back one click at a time.

The spot is stored in the rendered photo's fractions, not in the preview's
pixels: x, y and a radius that is a fraction of the photo's WIDTH, so the
circle stays round on a tall or a square frame and the same recipe heals at
preview resolution and at export resolution without a second code path.
`readHeal` is the only door in, and it validates, clamps and drops the spots
with no radius before anything downstream sees them.

The source patch is searched for, not asked for. `findHealSource` walks eight
directions at three distances — 2.6r, 4.2r, 6.5r — and each candidate's mirror
through the spot as well, scores every one with a nine-tap comparison of the
neighbourhood, and hands back the first that actually resembles the ring around
the speck. When nothing fits — a brush wide enough to swallow the whole frame —
it returns null and the click is refused rather than smearing a wrong colour
over it. There is no colour-matching model here and no second draggable source
circle: Lightroom lets you place the donor, this finds one.

The pass runs last on the photo's own pixels. It is inserted after the grade,
the curve and the grain and before the frame, so the patch it pastes is copied
from pixels that have already been graded and grained — it matches by
construction, with no second copy of the pipeline to keep in step — and the
frame, the card and the watermarks are drawn over the result, so healing can
never erase the furniture of the render. The brush is a feathered circle at
0.55r, which is what keeps a repair from reading as a sticker.

SkSL indexes a uniform array by a constant only, so the shader is the block
unrolled HEAL_MAX = 16 times, the same trick the tone curve's mixer already
uses. Sixteen is the ceiling and the oldest spot falls out when the
seventeenth arrives. CLEAR drops the whole field — turning the chip off keeps
the repairs, which is the distinction between disarming the brush and undoing
the work.

Verified:
  heal-skia-lab.cjs (scratchpad, Node + the full CanvasKit build) — 15 PASS,
    0 FAIL: HEAL_SKSL compiles through RuntimeEffect.Make and
    makeShaderWithChildren; the uniform block is 132 floats in declaration
    order (16 spots + 16 sources + size, w/h/feather); a dust speck pinned on
    the canvas comes back as the borrowed patch while the rest of the frame is
    untouched, pixel for pixel; readHeal clamps, drops zero-radius spots and
    caps the list at 16; the search finds a valid donor and returns null for a
    brush that covers everything.
  heal-probe.cjs (scratchpad, the rebuilt app at http://localhost:8090) —
    29 PASS, 0 FAIL, no page errors: the cursor circle is 2 x 0.012 x width and
    centred on the pointer, L is visibly bigger, S and L are exclusive; one
    click is one spot; a speck at 151 reads 154 at its centre after the heal
    and the photo's other specks and empty skin are unchanged; the spot and its
    borrowed source are both drawn; the chip goes amber; CLEAR appears and
    restores everything; UNDO (the TopBar button) brings the dust back and REDO
    heals it again; three specks and one L-sized blob all go; the repairs
    survive a reload.
  Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
    pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
    tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed.
  web tsc --noEmit clean.

ponytail: spots live in the rendered photo's coordinates, so re-cropping or
re-rotating after healing slides them — re-heal or CLEAR when that matters; a
coordinate space pinned to the sensor would need the crop and rotation to carry
the spots through. No live brush-size gesture and no colour-matching term: the
donor is chosen by resemblance alone, add a colour term if skin tones ever
mismatch. The list is capped at 16 with oldest-out rather than refusing the
seventeenth click.
2026-09-23 20:51:41 +07:00
3dtours b24bd78ddd web: draw the picture's own distribution behind the tone curve, and let the card be dragged
Setting a point on the curve was guesswork: the graph showed the mapping but
nothing about the picture it was mapping, so you placed a point where the tones
"probably" were. The graph now draws the picture's own histogram behind the
grid, and the panel can be dragged off the photo it is editing — the two halves
of the same complaint, that the card was describing a picture you could not look
at while you used it.

The histogram is not a second measurement. `ToneCurvePanel` takes the same
`previewUrl` the stage already renders and reads it through `readHistogram`, the
function the HISTOGRAM overlay beside it uses: one 320px sample, luminance bins
on the RGB tab and the channel's own bins on an R, G or B tab, so the shape
follows the tab the way the line does. The bins become one filled path in the
graph's own square, scaled to its own tallest bucket and closed along the floor,
and it is the SVG's first child — grid and curve draw over it, so the graph
reads as curve on distribution rather than two lines crossing. Nothing new is
rendered, sampled or cached: the panel reads the frame that is already there.

It is read from the render, which is post-curve, so the band shifts as the curve
moves. That is Lightroom's behaviour, not an accident, and it is the honest one:
the point of the picture is what you are looking at. A percentile or log scale
would show a shadow-heavy frame better than a linear max does, and the overlay
beside it does not have one either, so the two agree.

The drag is the panel's own head. `pos` is the card's position in the layer's
coordinates (null until first moved), and the first position is materialised
from `offsetLeft/offsetTop`, which is exactly the CSS bottom-left the card sits
at before anyone touches it — so the default layout costs no code and the card
carries no second positioning system. It is bounded by the STAGE, not the photo:
the card may sit off the photo, that is the point of moving it, but never off
the canvas the stage clips at 8px. Window `resize` and a `ResizeObserver` on the
stage re-clamp an existing position, because the stage can shrink under a parked
card and `overflow: hidden` would hide it with no way to reach it.

One real bug, found by the probe rather than by reading: with the head as the
handle, `setPointerCapture` retargets the click that follows, so the close
button in that same head never fired — pressing it started a drag and swallowed
the click. `panStart` now returns early when the pointer went down on a button.
The pre-existing `Histogram` overlay carries the same latent pattern; it has no
interactive children in its chrome, so it was left alone.

Verified:
  tone-curve-probe.cjs (extended, scratchpad) — the rebuilt app at
    http://localhost:8090, 42 PASS, 0 FAIL, no page errors. New checks: the
    graph draws the picture's own distribution and it is the graph's first child
    (`curve-hist`); the drawn band matches a histogram binned independently in
    the page (256 buckets, worst deviation 0.00px); the distribution piles where
    the curve put the tones (peak 128/255 after the black lift, against 9-246
    before it); the card is dragged by its head (729,280 -> 689,190, the exact
    delta); the drag bends no curve and drops no point; the card cannot be
    dragged out of the stage (clamped to stage bounds); it is pulled back in
    when the viewport shrinks to 900x640 (card 636,239 240x291 inside stage
    269,109 615x429); the close button still takes the graph off the photo.
  tone-curve-math.cjs — unchanged, 11/11.
  Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
    pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10;
    backend npm test 180 passed, 0 failed.
  web tsc --noEmit clean.

ponytail: the histogram is read from the render, so it is post-curve and the
band moves with the curve; read it pre-curve by exposing pass 3e's input if the
feedback ever misleads. The card's position is component state, so it resets to
bottom-left when the panel closes — persisting it across a close is a key on the
recipe, add it when someone asks for the card to stay put. Percentile and log
scaling are not implemented: linear max, the same as the overlay beside it.
2026-09-23 20:18:46 +07:00
3dtours 56d4b9df67 web: give LIGHT a tone curve, edited on the graph drawn over the photo
The LIGHT rail was sliders only, so the one control that describes a tone
mapping rather than a scalar had nowhere to live. It now has a TONE CURVE chip;
pressing it puts a curve graph on the photo itself — four channels, RGB plus R,
G and B, exactly the shape Lightroom's point curve has — and dragging a point
bends the picture under it while you drag.

A recipe carries the curve as `adjustments.toneCurve`, an optional map from
channel to point list, `Partial<Record<'rgb'|'r'|'g'|'b', [number, number][]>>`.
The field is optional and the API stores the recipe JSON opaquely, so every
recipe and session written before this commit loads unchanged and simply has no
curve; nothing on the API or in the database moved.

The renderer never sees the points. `shared/utils/toneCurve.ts` turns them into
a 256-entry table per channel and the shader looks the table up in a 256x1
texture: SkSL indexes uniform arrays by constant only, so a per-pixel lookup
has to come from a texture, and a table is the cheaper shape anyway — one
`lut.eval(vec2(v * 255 + 0.5, 0.5))` per channel. The interpolation between
points is a monotone cubic (Fritsch–Carlson) rather than a natural spline,
because a spline overshoots between two close points and that overshoot is the
classic tone-curve tell, a bright halo beside a lifted shadow; a monotone cubic
through the points bends through them and never turns back on itself. The table
is built per channel and then composited through the master, the order the graph
draws it in, so an R point in the shadows survives an RGB contrast S and both
land where the lines say.

Render passes: the curve rides the existing `renderPhoto`, as pass 3e, last —
after the stock, the matrix, the mixer and the seasonal grade, so a point placed
on the graph is the last word on that pixel. Preview and export both call
`renderPhoto`, so the two agree by construction rather than by two matching
implementations. The pass wraps whatever shader the pipeline had built
(`paintShader ?? imageShaderOf()`) as a child of the curve shader, and counts
towards `graded` for the same reason the tone shader does: the curve reads the
matrix's output, so when there is a matrix it has to be in the pixels the curve
samples. Turning the curve on costs one extra render pass and nothing else; off,
`curveIsActive` is false and the pass is not built at all.

That pass is also where this spent its time being invisible. The curve data
reached the recipe and the pixels did not move: `Skia.Image.MakeImage` does not
exist in the shim, so the call threw a TypeError inside the render, the preview
effect's catch swallowed it into `setError('err.generic')`, and the chip, the
graph and the recipe all looked healthy while the canvas kept the old frame. The
fix is in `skiaShim.ts`: CanvasKit keeps that factory top-level (`Skia.MakeImage`)
and only puts the encoded and lazy ones under `Image.`, and its ImageInfo insists
on an explicit `colorSpace` where RN Skia's does not — everything this pipeline
builds is sRGB, so the shim fills it in and the call site keeps RN Skia's shape.
Reproduced in Node first (`curve-skia-lab.cjs`, scratchpad): the shim's call
throws, the translated one returns a 256x1 image.

`ToneCurvePanel.tsx` is the graph: a 224px SVG over the photo's layout box, no
zoom transform, grid plus a dashed diagonal, the composite drawn as a ghost
behind a channel line so a channel edit is still visible against the other
three. Ends are pinned to x 0 and 1, a point cannot be dragged past its
neighbours (2% of the axis is the closest they may sit) and cannot be dragged
out of the square, so the graph can never describe a curve the renderer cannot
apply. One pointerdown grabs the nearest point inside 11px or adds one on the
line under the cursor and keeps dragging, so a click is a point and a drag is a
bend. Deleting a point is the graph's own double-click, not the circle's, and it
has to be: grabbing a point takes pointer capture, so the click that follows is
delivered to the SVG rather than the circle under the cursor.

RESET clears the whole graph, all four channels, and hands back an empty object
that `App.tsx` maps to `undefined` so the recipe drops the field rather than
keeping a `toneCurve: {}` — the field's presence is what "this picture has a
curve" means, and an empty map that means the same as no map is a state two
pieces of code would eventually disagree about. One undo step per visit to the
graph, the rule the ruler and the watermark box already ride: a drag is one
edit, not one per pointer move.

No new i18n keys: the chip and the panel labels are literal uppercase, the same
as EXPOSURE and STRAIGHTEN beside them. Not PRO-gated — the curve is a LIGHT
control like the rest of the tab.

Verified:
  tone-curve-probe.cjs (new, scratchpad) — a 256x256 greyscale ramp uploaded to
    http://localhost:8090, pixels read back off the built app. 33 PASS, 0 FAIL,
    no page errors. The ramp is a ramp before (9..246), a flat curve is two
    points and no pass, the graph is drawn on the photo (graph 729,280 240x291
    against photo 719,325 256x256), every stop of the ramp lands on the drawn
    curve (worst deviation 1), black lifts to 132 while white holds 246 -> 252,
    a point dragged up bends the line itself (M0.00 112.00 L3.50 110.2...), the
    R tab takes the graph over while the composite stays visible behind it and R
    drives red at black to 255 with G and B still on the composite (133,132
    against 132), the recipe carries toneCurve, it survives a reload (254 -> 254,
    chip still amber), a click adds a point and a double-click removes it again,
    RESET returns the ramp to its start (worst 0) and drops the field, and close
    takes the graph off the photo.
  tone-curve-math.cjs (new, scratchpad) — the panel's and the table's own
    arithmetic, 11/11: the ends pin and sort, a dragged point lifts where the
    graph says, a steeper segment never turns back on itself, a channel curve
    runs before the composite, a click lands on the line, two points cannot
    share a spot, an end cannot leave the axis, and the two ends survive a
    delete where a middle point does not.
  Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
    pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10.
  web tsc --noEmit clean.

ponytail: the graph is anchored over the photo, not draggable — it sits at the
photo's own layout box the way the crop frame and the straighten ruler do, and
the one time it would want to move it is when the photo under it is small, at
which point a token drag offset is cheaper than the second positioning system.
Parametric curves (Lightroom's shadows/highlights/darks/lights) are not here:
the point curve is the one the request asked for, and a parametric curve is a
second graph, not a second line on this one — add it as another channel row when
someone asks. The LUT is a texture rather than Skia's table colour filter
because CanvasKit 0.42 has no ColorFilter.MakeTable. The panel's graph size and
hit radius are literals, since exactly one graph exists.
2026-09-23 20:07:51 +07:00
3dtours 0e9f78bd5e web: give the grain a size of its own, and read the count off the print
MONOCHROME GRAIN was one integer knob 0..10 with one meaning, how much. It is now
a strip of three: AMOUNT — the same knob, in half steps — SIZE, a percentage of
the stock's own grain cell (50..200%, so the same number means the same texture
relative to the picture on both platforms), and an inert readout of N/INCH, the
clump count the two knobs and the stock add up to in the print's own terms (300
dpi = 300px of the 1080-wide reference the knob was tuned at).

Emulsion is not one grain size across the frame: the coating settles unevenly.
The field now prints that — the same hash read slowly (ZONE_FREQ = 1/96 cells,
turned off the axes, smoothed so a border between two patches is a slope and not
a seam) swings each patch's own cell by half of ZONE_SWING either way, ±20%.
Nothing in it moves the field's mean: a coarser patch prints bigger clumps, not a
brighter one, which is why the strip can read out one number while the frame
carries a range.

A patch may not swing a cell under the pixel the target can print, or the clumps
are sub-pixel and print as static — aliasing, not a finer emulsion. The shader
takes that floor as a `mincell` uniform beside the cell (u, mincell, seed.xy, in
declaration order): an export passes one output pixel, a preview one device pixel
(1 / PixelRatio), which is the floor the phone's preview already needed.

SIZE is stored as an integer percent so no float noise reaches the recipe JSON,
and it is read by the same two engines that read grain: the web's
grainCell(width, stock, sizePct) and the phone's grainCell(width, minCell,
sizePct). The chip above the strip carries the amount in half steps the way TEMP
carries the kelvin, and the readout moves with SIZE, not with AMOUNT.

Measured:
  grain-controls-test.cjs 20/0 — the strip carries grp-grain, grain:amount,
    grain:size and grain-inch; the AMOUNT ruler is 0..10 step 0.5, and 3 -> 3.5
    moves the frame (sigma 18.53 -> 21.91, new hash) without moving the readout;
    10 -> sigma 60.43, 0 -> sigma 0; SIZE 200% -> 130/INCH (sigma 40.06), 50% ->
    522/INCH (66.56: under the preview's pixel floor what is printed is static,
    not finer grain); the region claim on an 8x8 grid of the flat frame gives
    tile sd 51.0..66.1, max/min 1.295, and a tile mean spread of 1.14 — a coarser
    patch is not a brighter one.
  grain-size-test.cjs 17/0 (was 12/0) — the SIZE rule and the readout on the
    module itself: 200% doubles the cell, 50% halves it, the output pixel still
    floors the smaller one. 4000px file cell 3.704 against 1.083 device px on a 3x
    preview, the old one-dp floor 2.77x coarser, rho1 0.627 against 0.074. The
    harness built its own 3-uniform array; it now passes [u, mincell, seed.xy]
    like every other caller.
  _grain-zone-ck.cjs — the zone's own contribution, at preview scale (cell 1.70,
    1600px, 8x8 tiles of 200px): zone on, tile sd 23.22..24.82 (ratio 1.069);
    zone off, 24.42..24.79 (ratio 1.015). Nothing else differs.
  _grain-ck.cjs — the clump field is otherwise what it was: rho1 0.62
    classic-neg / 0.12 velvia, residual autocorr 0.035 against 0.036 with the
    swing forced to 0, peak/median 32.3 against 27.6.
  _grain-spectrum.cjs (app, 1600px render) — residual autocorr 0.017..0.018,
    spectral peak/median 4.6..6.1: the slow lattice adds no peak of its own.
  grain-stock-test 53/0, sims-test 31/0, fx-mono-test 15/0, wb-preset-test 33/0,
    temp-swatch-test 33/0, wm-font-test 38/0, grain-analog-test 7/0 (its grain
    selectors moved to the strip).
  tsc: web clean; the phone's scoped config reports exactly the pre-change
    baseline (Viewfinder.tsx's own errors, none new).

ponytail: the amount is fractional now, so the two recipe-create forms read grain
through their own half() instead of the int() that would truncate the half the
ruler just spent — every other knob there is still whole. The SIZE knob is one
number for the whole strip: no way to dial a single patch, and no seed control.
The readout is the DESIGN count the field is built on, never a per-patch
measurement.
2026-09-23 15:26:23 +07:00
3dtours c2a4740a3f web: let the white balance carry its tint, and take its ratio in the light
TEMP was a kelvin and nothing else, and the seven presets were seven numbers
the two platforms disagreed about: AUTO 5500, DAYLIGHT 5600, DAYLIGHT -3R
5800, CLOUDY 6500, SHADE 7500 here and 8000 in both recipe-creation forms,
TUNGSTEN 3200, FLUOR 4000. A chip tapped in the recipe panel and the same
chip tapped on the photo printed different frames.

The presets are now the phone's own WB_PAIRS — kelvin AND tint — in all three
places, so every chip lands the same pair on either platform:

  AUTO 5500/0   DAYLIGHT 5500/0   DAYLIGHT -3R 5500/-3   CLOUDY 6500/1
  SHADE 7500/2  TUNGSTEN 3200/0   FLUOR 4000/3

SHADE read 8000K in RecipeCreatePanel and RecipeCreateModal; it is the WB
tab's 7500K/+2 now, so a recipe created in a form prints what the same chip
prints on the photo.

AUTO and DAYLIGHT stand for one pair, so the pair alone cannot say which of
the two is lit. TEMP is therefore keyed by preset and not by value: `wbChoice`
remembers the chip last tapped (the phone's own wbChoice), and `wbValue()`
answers it only while the engine pair still matches, else the first preset
that pair maps to, else the bare kelvin. `wbLabel()` names that pick on the
chip, which now always carries one: TEMP AUTO at the neutral pair, TEMP SHADE
on a preset, TEMP 6300K on the app's own default recipe where a hand-dragged
ruler landed. Measured on the deployed build (`wb-preset-test.cjs`, 33/0):
AUTO and DAYLIGHT print the same frame to the level, DAYLIGHT -3R moves the
green away from DAYLIGHT at the same 5500K, the ruler warms monotonically
across TUNGSTEN 0.525 / FLUOR 0.730 / AUTO 1.000 / CLOUDY 1.141 / SHADE 1.295,
a hand-drag to 10000K names the chip `10000K` and warms the picture R x1.183
B x0.793, SHADE tapped after that drag restores the pair 7500/2 and prints the
same frame the first tap did (R 156.8 vs 156.8), and a temperature drag leaves
a preset's tint standing (FLUOR's +3).

kelvinToRGB itself was the other half. It took the ratio of the sRGB-ENCODED
blackbody colours and square-rooted it, which measured R x1.06 / B x0.89 from
5500K to 10000K — a shift a swatch shows and a sunset does not. White balance
is a gain on LIGHT, so the ratio is taken in linear light now
(`planckianLinear`) and tamed by a new `KELVIN_TAME = 0.5`: the same 10000K
moves the frame R x1.16 / B x0.76, and 2500K its mirror, which is what a
camera does with its WB set 4500K off the scene. One constant scales the whole
ruler and both platforms carry the same one. Measured through the app
(`_temp-probe.cjs`, gains against 5500K): 2500 R x0.569 B x1.640, 4000 R x0.866
B x1.197, 6500 R x1.057 B x0.940, 10000 R x1.140 B x0.759 — the readout is
compressed against the raw gain because the cast lands on encoded, clipping
pixels, which is the reason for the tame in the first place.

ponytail: no scene meter, so AUTO stays the neutral 5500K/0 pair and is a name
for it, not a measurement. Add one when the engine reads the frame.

ponytail: KELVIN_TAME scales the whole ruler both ways. Split it into a warm
and a cool constant only if the two ends are ever asked to move apart.

Verified: `wb-preset-test.cjs` 33/0 and `_temp-probe.cjs`, `sims-test.cjs`
31/0, `fx-mono-test.cjs` 15/0, `wm-font-test.cjs` 38/0 against the deployed
build; web `tsc --noEmit` clean, the phone's scoped check down to its two
pre-existing `skiaImage.ts` nulls.
2026-09-23 13:00:14 +07:00
3dtours 7a8c0aa2b5 web: print what the temperature ruler does, not the colour of the light
COLOR TEMP's swatch was Tanner Helland's blackbody fit, so it painted the
colour of the LIGHT the number names: 2500K came out orange #ff9f46 while the
engine cooled the frame on the same knob. Read off the render of a neutral
128-grey (mean of the painted preview, CCT via McCamy), the two ran opposite
ways at every stop:

  K      old swatch        swatch CCT   picture mean    picture CCT
  2500   #ff9f46           2384K        115,135,204     47691K  (blue)
  3200   #ffb87b           3096K        128,141,171     11246K
  4000   #ffcea6           3931K        135,141,156      8203K
  5500   #ffedde           5414K        144,139,143      6479K  (unity)
  6500   #fffefa           6322K        148,138,139      6029K
  7500   #e6ebff           7730K        149,138,132      5555K
 10000   #cadaff          10024K        153,138,128      5180K  (warm)

So the number is a Kelvin of the scene's light — which is exactly why the
engine warms the picture as K rises — and the swatch was the one thing on the
ruler that disagreed with the ruler. It is now painted from kelvinToRGB
itself, the same gains the render puts on every pixel, on a mid grey, so the
band under the knob can never drift from the frame above it. kelvinToHex goes
away with it: one Kelvin->colour mapping in the app, not two.

Measured after: the swatch's R-B tracks the render's own cast at every stop
(-82 vs -89.8 at 2500K, +23 vs +24.0 at 10000K), 5500K is flat #808080 — the
engine's unity point — and 3200K sits cool / 7500K warm either side of it.

temp-swatch-test.cjs (scratchpad): 33 PASS / 0 FAIL.
2026-09-23 11:17:59 +07:00
3dtours d55b7b49ca web: give each watermark its own collapse, and a face to print in
The panel shared one column between the two marks, so GPS's colour, its two
switches and its hand-typed place stood open beside the custom mark's text,
colour and size whether or not either mark was on. The two are now collapses,
one per mark: the header chip is the section, and that mark's own controls sit
under it. What opens a section is the mark itself — GPS WATERMARK ON opens
GPS's controls, CUSTOM WATERMARK ON opens the custom mark's — so there is no
new state and no way for a panel to disagree with the pixels.

Both marks gain the FONT strip the phone has had (TEXT FONT for the custom
mark, FONT for GPS, whose stamp the phone also lets you set a face on). A
browser has no font service, so the list is exactly what the bundle carries:
the site's two self-hosted families, Inter and Fraunces (SIL OFL), their latin,
latin-ext and vietnamese woff2 subsets decompressed, pinned to weight 400 @
opsz 14 and merged into ONE TTF per family — drawText has no glyph fallback, so
a family mapped to only the latin subset would print a Vietnamese place name as
tofu. DEFAULT stays the bundled Cousine face, which is what every existing
session and every mark without a family prints.

Two engine bugs came out of it. CanvasKit 0.42's Font.getGlyphWidths passes its
output pointer where the wasm export wants the bounds pointer, so every glyph in
a run comes back holding one identical, rounded width — at 64px on the merged
Inter face, 'H' and 'i' both answered 42, while hmtx says 0.743em and 0.242em,
and a box measured off it was 27% too wide ("Hà Nội 09/23" 510px against a true
403px). The shim now rebinds it with the pointers in the order
_getGlyphWidthBounds reads them, and the stage's boxes measure with linear
metrics, which land on hmtx exactly (403.28px against 403.28; hinted is 407).
And CanvasKit's TypefaceFontProvider.matchFamilyStyle answers null for every
style shape this binding accepts, so a name registered with it never resolved —
the shim keeps its own registry keyed by family name instead.

Measured: tsc clean; the engine harness on the merged faces 26/26, including the
registry's advances against hmtx (Inter 6.3013em, Fraunces 6.3475em); the
deployed app under Playwright 38/38 over the two collapses and both FONT strips
— each mark's controls appear only with its own mark on, the DEFAULT/INTER/
FRAUNCES box widths match hmtx, the baked ink fills the box, the top edge
re-hangs off the new ascent (Inter 0.96875em against Cousine's 0.8325em, 3.4px
at this size) with the left edge fixed, and UNDO round-trips. Opening a section
narrows the stage by 168px with no window resize (955px -> 787px), so the stage
now re-measures its drop boxes off a ResizeObserver on the frame and the
picture rather than on the next render.

Not ported: the phone's GPS watermark still prints in the bundled face only
(no emulator here to verify a phone-side font strip), and the FONT options are
not behind the PRO gate the way the phone gates non-default families.
2026-09-23 10:59:49 +07:00
3dtours cf0091b741 web: name a position that lands before the account is known
A photo restored from the session reaches the stage while /me is still in
flight, so the account reading that put it there saw `pro` as false, the place
lookup was skipped, and reloading a photo left the stamp with bare coordinates.

Naming now happens in one effect that watches the position itself: the first
moment it is on the stage unnamed and the account is PRO, it gets a name,
wherever it came from. A position typed in by hand earns its name too, and a
guest's photo is named the moment they sign in.

The two call sites that used to ask for the name — in locateMe and adoptPhoto —
are gone, since the effect covers both.
2026-09-23 09:33:26 +07:00
3dtours 3bfa82e7f2 web: stamp the photo's own day on the GPS mark
The GPS mark printed Date.now(), so a photo taken in 2019 carried the day it
was opened. It now prints the frame's EXIF date — DateTimeOriginal, falling
back on CreateDate then ModifyDate — wherever the position came from:

- readCapturedAt() reads the date off the file, and readGps() uses it for a
  position found in the same EXIF.
- adoptPhoto holds it in its own state, so a frame with a date but no position
  still stamps the date when the position is typed in by hand.
- The device's own position stamps it too. That path runs inside adoptPhoto,
  where the render still holds the previous photo's date, so locateMe takes the
  date as an argument rather than reading state — the panel's own button, which
  has no such date to hand, passes none and reads the state as before.

A file with no date at all still falls back on the visitor's clock: there is
nothing else to believe.
2026-09-23 09:22:19 +07:00
3dtours 28a688fd82 web: hand the upscaler only the pixels the export is asking for
The model's own factor is 4 and the export's target is some number of pixels,
and the two were never reconciled: a 2400x1800 photo exporting at 4K was run
through the model at 4x — 9600x7200 of invented detail — and then three
quarters of it were thrown away by the draw that lands the file on 3840. The
arithmetic was the whole wait. Measured on the wasm path, one export: 153.2s.

The photo is now resampled once to `targetLongest / 4` before the model reads
it, so the model still answers at its own 4x and the answer is the size the
export asked for. Same 2400x1800 to 4K: 42.7s, 80 tiles of model for 20. Half
the photo's pixels is the floor — below that the model is no longer enlarging
the picture, it is drawing a new one from memory — and the ceiling is the
photo's own size, so a gain past 4 behaves exactly as it did.

Nothing in the finished file gives the smaller input away: the 6px stripes come
back at full contrast (254.9 vs 254.8), the black-to-white step lands on the
same pixel (x=625 in both) and rises in 1px instead of 3.

The 32MB of runtime and model are also fetched, and one 16x16 tile pushed
through the graph, when the export menu opens rather than after a size is
picked: the visitor waits for the pixels, not for the download.

crop 1:1 2400x1800 to 4K: 155.8s -> 52.6s, crop 3:4: 153.0s -> 54.1s.
2026-09-23 08:27:00 +07:00
3dtours 52566f6966 web: read the photo's own size off the row under it
The row below the photo carried CLEAR, OPEN and SAVE ORIGINAL but never said how
big the picture was, so the only way to learn the resolution was to open the
export menu and read the hint there. The size now leads that row, before CLEAR:
the file's own pixels turned by the quarter turn and cut by an applied crop, so
it is the number an export at the photo's own size writes. A live crop does not
move it (nothing is cut yet), STRAIGHTEN never does (the rotated rectangle is
fitted back inside the same pixels), and the guest tier's 2048 cap is still only
announced in the export menu where the file itself is capped.

Verified end to end in photo-dims-probe.cjs: 2400x1800 opens as "2400 × 1800",
a 90 deg turn reads 1800 × 2400, an applied 1:1 crop reads 1800 × 1800, and the
export writes exactly that file.
2026-09-23 07:58:56 +07:00
3dtours 500068e63e web: mark SAVE PHOTO PRO and keep MY PHOTOS for the accounts that have one
Saving into the account's own folder has always been the account's act —
the button opened the way in and the API answers an unproven address with
a 403 — but nothing on the button said so, so it read as a button that
quietly did nothing. It now wears the same PRO marker the chips do, and
only while the folder is not the visitor's.

MY PHOTOS is that folder's listing, so the tab is only offered once an
account can hold one. A guest loses the tab entirely rather than opening
it on an empty folder that could never fill; an account that has signed
up but not proven its address keeps the tab, and the tab keeps offering
the way to prove it.
2026-09-22 21:46:05 +07:00
3dtours b9ac7746aa web: gate the newest film sims and the HSL mixer behind PRO
The last three PHOTO STYLE looks (B&W HIGH CONTRAST, LC STREETLIFE
CLASSIC and LC STREETLIFE VIVID) and the whole mixer now belong to the
account, the way PRO frames and the geotag already do: the chip wears
the PRO badge, a guest who picks it is shown the way in, and the look
stays off. The HSL tab keeps its place in the rail but offers the one
PRO chip while locked, so the tab itself is not a dead end; a look that
arrives without the chips — an imported .recipe, or a photo saved
before the gate — is still caught where the gate bites, at export.

STRAIGHTEN's scale turns with the wheel, one degree a notch, because
the ruler is where the angle is being judged and reaching for a slider
elsewhere loses the thread. The listener is native and stops the notch
before the stage sees it, so the photo does not zoom under the pointer.
The scale gives up its opaque card, its blur and its shadow: the frame
it is levelling has to stay readable through it, so legibility comes
from a text shadow on the heading and a drop shadow on the graduations
instead.
2026-09-22 21:33:11 +07:00
3dtours f201deee46 web: export a big photo without inventing pixels it already has
The export menu measured the photo off the 1600px preview copy, so a 2400px
photo was believed to be 1600px across: the hint named the wrong size, the
model was asked to upscale a photo that already had more pixels than the
target, and a guest's 2048 ceiling was skipped because 1600 never crossed it.
A committed crop made it worse — the crop's longest edge was taken from the
wider side of the crop rect rather than the side the frame actually keeps, so
a 2400x1800 photo with the default 0.8 frame was called 1280px and ran the
model over 80 tiles (158.7s) to reach 2K.

The photo's own dimensions are now read off the original bytes, and the crop's
long edge is the same axis-aware fraction the stage already uses. The export
asks the model only when the photo itself is short of the requested size, or
when the crop would have to be stretched past 1.5x to get there; otherwise it
resamples — down, or a hair up to make up for the crop — which is what a photo
that already holds the pixels deserves.

Measured, wasm path, 2400x1800: no crop at 2K went 2.7s/2400px (wrong size) to
3.4s/2048px, the default 0.8 crop went 158.7s/80 tiles to 3.5s/no model, and a
1:1 crop went 2.5s/1800px to 4.0s/2048px. A 1200x900 photo cropped to 1:1 and
exported at 2K still runs the model (2048 from a 900px crop, 40.3s), and the
superres suite is unchanged: 640x480 to 2K/4K/custom still comes out exact,
with the model's 16.6 edge energy against bilinear's 4.8.
2026-09-22 20:43:46 +07:00
3dtours c403dd04c8 web: add the B&W HIGH CONTRAST film sim to the PRESETS rail
The chip lands after ACRIPES and is a mono stock of its own, so it gets its own
baseFilter ('mono-high-contrast') rather than borrowing Acros': the PHOTO STYLE
chips are keyed by baseFilter, and the two greys must sit side by side.

The look is the B&W MIX plus a push at both ends. The mix rides the matrix — a
non-BT.709 row set (0.38/0.56/0.06, identical rows, sum 1.00) so a red roof
reads bright, a blue sky deep, and the separation is contrast before any curve.
The push rides FILM_TONE (shadow -0.32, highlight +0.26) so the ends move
without touching the midtones, and the midtone slope is SIM_CONTRAST_BIAS (4
contrast units) next to the sim's own exposure bias. The knobs stay at neutral:
a sim is colour and tone only.

Both B&W stocks being mono is now asked once, through isMonochromeBase, so the
colour-only stages (saturation, white balance, R/B fine-tune, chrome, hue
mixer) and the MONO strip label can never half-apply to one of them.
2026-09-22 17:56:00 +07:00
3dtours 88afdf6611 web: compare the same frame rendered twice, whatever its geometry
The split used to paint the photo file beside the render, so it only lined up
while nothing had moved: a turn, a straighten, a printed frame and the halves
were two different pictures. The app now renders the same frame twice — once
through the look, once through the neutral stock — and the left of the bar is
that second copy. Rotation, straighten, crop and frame land on both halves by
construction, so the CSS that tried to map the crop onto the file goes away.

The toggle lives in the app now, which is what knows how to ask for the extra
render; it is only asked for while the split is up. The layer waits for that
copy rather than flashing the raw file, whose geometry is already wrong.
2026-09-22 10:59:37 +07:00
3dtours 1d96269139 web: keep compare on offer, and compare at the cropped size
Choosing a crop ratio used to disable COMPARE outright, because the split
painted the whole original into a box that was now the crop's shape. The
original is now looked at through a window of the render's own shape: with a
crop applied the photo is scaled and slid by the crop rect so the same
rectangle lines up, and the split compares like with like.

The window needs the image free to overflow it, so the inline style lifts the
box clamp that .canvas-wrap img puts on every preview.
2026-09-22 10:45:07 +07:00
3dtours c0c99a9672 web: EXPORT offers a size, and a bigger one is upscaled in the browser
The server still never sees a photo, so the model has to run in the page.
Real-ESRGAN x4v3 ships as a 4.9MB ONNX in public/models and is loaded
lazily on the first export that actually needs it; the wasm runtime is
copied next to CanvasKit at build time and stays lazily fetched, cached
for 30 days. Vite is told onnxruntime-web is external-wasm so no 28MB
asset lands in the bundle.

UNCHANGED keeps the old path and the tier cap; 2K/4K/custom upscale only
when the request is larger than the photo being edited, otherwise they
resize down. Guests keep UNCHANGED and 2K. Tiling is 256px with an 8px
overlap, so memory follows the target size rather than four times it.
2026-09-22 09:40:18 +07:00
3dtours f9a40a9e1c web: the histogram opens top-left, and CLEAR asks before it forgets
The histogram used to park itself in the top-right corner on the first
paint; it now starts at the top-left of the photo and is dragged from
there, the way the rest of the overlay is. Nothing else changed in it —
same drag, same clamping, same resize.

The stage also gains a CLEAR button, sitting before the picker button,
which is now OPEN PHOTO. CLEAR takes the photo off the stage, but not
before asking: SAVE PHOTO files it first and only then clears, EXPORT
IMAGE writes the JPEG and then clears, CLEAR WITHOUT SAVING drops it
there and then, and CANCEL leaves everything alone. Saving from that
modal resumes the clear once the file has really landed — a guest, a
capped account or a cancelled name prompt never loses the frame.

Clearing forgets the working photo (source, preview, GPS, ISO, and the
IndexedDB copy session.ts now deletes), while the look, the crop and the
undo history stay put, so the next photo opens on the same settings the
way replacing a photo already did.
2026-09-22 08:56:58 +07:00
3dtours 59d90ae068 web: the sim chips keep their legacy names
The name table is a reference for what each sim has to look like, not a
renaming order: the ten PHOTO STYLE chips go back to PROVIPES, VELVIPES,
CLASSIC CHRIPES, CLASSIC VIVIDIPES, CLASSIC NEGIPES, ASTIPES, ETERNIPES,
ACRIPES, LC STREETLIFE CLASSIC and LC STREETLIFE VIVID. The comment above
FILM_SIMS now says so outright — label on the left, the stock's colour and tone
it must match on the right, and neither side moves the other.

The grading is untouched: a sim is still colour and tone only, its `adjustments`
stay neutral, and LC STREETLIFE VIVID keeps its +2 exposure as SIM_EXPOSURE_BIAS
in colorUtils rather than as a knob.
2026-09-22 08:37:19 +07:00
3dtours 428e7fa682 web: a film sim is colour and tone only
The ten PHOTO STYLE sims now carry nothing but their stock's own grade, and
each is named for the stock it stands for: PROVIA, VELVIA, CLASSIC CHROME,
CLASSIC VIVID (Velvia spliced with Classic Chrome at the blue row), CLASSIC
NEGATIVE, ASTIA, ETERNA, ACROS, LC STREETLIFE CLASSIC, LC STREETLIFE VIVID.
Grain, clarity, saturation and light moves were dropped from their
`adjustments`, so a sim is a clean starting point and the general knobs read
their defaults while the look still lands on the pixels.

LC STREETLIFE VIVID keeps the one brightness step its stock needs, but as
SIM_EXPOSURE_BIAS in colorUtils rather than as an adjustment: it is folded in
where the Exposure slider applies, so the picture gets the lift and the
parameter stays at 0.

Also in this checkpoint: the watermark/GPS boxes and their colour pickers, the
WATERMARK chip column, the real admin stats, and the fix that stopped presets
from doubling and a frame from refusing to come off when a photo was reopened
(/file is the finished render, /base the editable pixels).
2026-09-22 08:32:28 +07:00
3dtours 52b672deec web: PRO needs a proven address — email verification gates the studio
A signed-in account is served exactly like a guest until it opens the
verification link: watermarked 2048px export, no saving, no PRO frames,
GPS stamp or HDF. SMTP is declared in .env; with SMTP_HOST unset the link
goes to the container log. Allowlisted admins count as verified.
2026-09-20 07:39:03 +07:00
3dtours 15bacacafa web: logging out ends the studio session, not just the cookie
A session followed the browser, not the account: log in, open a frame, log
out, come back as a guest — the same photo stood on the stage, because the
studio's own store (localStorage knobs + the photo in IndexedDB) outlived the
cookie with nothing to clear it.

clearSession() now drops both, and the three log-out buttons call it. The
studio's own button reloads after the delete has committed — a reload mid-
delete aborts the transaction, so the promise resolves on tx.oncomplete, not
on the request. The account's frames are untouched: they reopen from MY
PHOTOS.
2026-09-18 21:48:19 +07:00
3dtours 4057566a14 web: the three mixer knobs move the whole image, a frame chip toggles itself, ROTATE lets go when STRAIGHTEN steers
HUE, SAT and LUM leave the colour row: behind an IMAGE divider they are
hslHue/hslSat/hslLum, seeded into the shader's band accumulator at full
weight for every hue, while the eight band chips keep picking which colour
the panel on the photo edits. The image lightness term stays ungated so a
frame drained to grey by -SAT still answers +LUM.

FRAME loses its NO FRAME chip: pressing the frame already on the photo
takes it off.

ROTATE's quarter turns stop lighting the moment the fine angle leaves 0,
so the strip shows which of the two is steering the photo.
2026-09-18 18:45:41 +07:00
3dtours 27035c4acb web: the mixer hangs a panel on the colour it read, and STRAIGHTEN becomes a scale on the photo 2026-09-18 18:27:28 +07:00
3dtours 10466e122a web: the frame tab straightens the photo by hand 2026-09-18 18:07:14 +07:00
3dtours 1b71c0196f web: the eyedropper reads a colour and the mixer moves that hue band 2026-09-18 17:41:45 +07:00
3dtours 4d0d44170f web: an FX chip that flips the photo to monochrome
It is a switch, not a look: it swaps the base filter for the mono stock and
swaps right back to the one the photo was wearing — name and sim included. The
adjustments are never touched, so a knob moved while it is on survives the trip
back, and the swap rides the undo stack like any other edit.
2026-09-18 16:16:28 +07:00
3dtours abaa980f93 web: undo/redo, a clean preview, and a histogram that stays put
Four things the studio owed the visitor:
- UNDO/REDO in the header, so a look can be taken back and put back without
  reloading the photo; a fresh edit clears the redo trail.
- Opening a saved frame, or picking a look out of its history, now drops the
  stale preview buffer instead of leaving the previous render on the stage.
- The picked history look is marked in the accent, so it is plain which look
  the photo is wearing.
- The histogram is re-clamped against the photo box on resize, so opening a
  chip column no longer pushes the overlay past the edge of the canvas.

The 'NEW SAVES: FILM STRIP' chip goes: a save already lands in the strip.
2026-09-18 15:47:08 +07:00
3dtours dcea6b926f web: name a photo on first save, and file looks from a SAVE RECENT tab
Two saves that never had a name of their own now ask for one, through a single
modal (ui/NameModal, shared by both flows).

The first filing of an upload asks what the folder keeps it as, and that name
rides along as the frame's title. A re-save keeps the name it already has, so
it never asks twice.

SAVE RECENT leaves CREATE RECIPES and becomes its own rail tab: it files the
look standing on the stage — sim, WB, light, FX and the frame — as a recipe of
this account's own, refusing a name the account has already spent. The frame
travels in the recipe's JSON, so applying the entry puts the whole look back.
The tab lists those files and is the one place they can be deleted from; the
API already scopes both by user. They also show up in PRESETS/RECIPES, deduped
against anything CREATE filed under the same name in this session.
2026-09-18 14:57:49 +07:00
3dtours 72d5ce1c3e web: re-save over the open frame, keeping its last three looks
Opening one of the folder's own photos and hitting SAVE PHOTO used to
make a second copy of it. Now it replaces that row — same id, same place
— and the look the row carried steps into its history, newest first and
capped at three, because the pixels it described are gone. The frame's
own column in MY PHOTOS lists those looks (click one to put its settings
back on the stage) and carries the landing-page consent as a plain tick,
which answers the click at once. A file from the disk clears the open
id, so a fresh frame still adds one.
2026-09-18 14:47:30 +07:00
3dtours 0627f8dd91 web: keep a saved photo's look in EXIF and its own row
EXPORT no longer burns the caption strip: the pixels stay the photo's own
and the look travels as metadata — ImageDescription (0x010e) for the tag,
UserComment (0x9286, ASCII header) for the recipe JSON.

SAVE PHOTO now stores the look with the frame (photos.recipe) and the
uploader's consent for the community film strip (photos.consent, PATCH
/api/photos/:id for the owner). The landing reel skips non-consented frames,
and a new MY PHOTOS tab lists the account's saves, reopens one with the
settings it was stored with, and carries the two consent switches.
2026-09-18 12:49:05 +07:00
3dtours cf4b01d4b3 web: mark the selected recipe in FAVORITED
The list was all one colour, so an open recipe was invisible in it. The entry
the stage is showing now reads accent (name, border and background, the same
`.chip.on` the rest of the app uses). The match is id AND name: a preset that
was merely filed under a new name shares the id but not the name, and must not
light up.
2026-09-18 12:30:38 +07:00
3dtours 567f650bbb web: accent the brand and add the studio's quick gestures
- the brand "Cam", the avatar and the signed-in name follow the theme accent
- double-click a slider track resets that parameter; double-click the photo
  toggles 1:1 and the whole photo on the stage
- `*`, or a recipe chip dragged onto the star, files the look under FAVORITED
- RESET under CREATE clears the draft form, and the button now sits under a
  rule at the foot of the column
2026-09-18 12:09:06 +07:00
3dtours b4d5d2926b web: give each member a photo folder and burn the strip into the export
Every member gets /photos — their own uploads, counted against a 12-photo
cap, each card showing the tagline and the technical line the studio would
print. The studio gains SAVE PHOTO n/12 in the top bar: it renders the full
resolution look, stores the strip (tag/title/meta) with the upload so the
landing reel frames it the same way, and refuses past the cap.

EXPORT now burns that strip into the file: the amber #TAG over the photo's
top-left plus a dark caption band below carrying the recipe name and the
ISO / grain / warmth line. The live preview stays clean, and the saved
upload stays clean too — the reel draws its own frame from the stored
labels, so a burned band would tag the tag twice.

Admins manage any photo through DELETE /api/photos/:id; members only their
own. The users table's photo counts stay in step with the folder.
2026-09-18 10:56:26 +07:00
3dtours 43d86b4b6f web: moderate accounts, accept 12MB uploads, put SAVE under CREATE
- /admin User account rows gain BLOCK/UNBLOCK, REMOVE/RESTORE and DELETE.
  Blocked = cannot sign in (sessions swept), removed = hidden from the strip
  and cannot sign in, both reversible; DELETE drops the account with its
  photos and recipes and unlinks the files. An allowlisted account is never
  a target, so an admin cannot moderate or delete itself.
- Photo uploads move from a 3MB API cap / 4m nginx cap to 12MB / 16m, and
  the browser shrinks an oversized still before sending it (2048px JPEG,
  avatars 512px) so the declared type still matches the sniffed bytes.
- The studio SAVE leaves the top bar and sits under the CREATE RECIPES tab,
  labelled SAVE RECIPES.
2026-09-18 10:33:35 +07:00
3dtours 2917c034ed Sign in is the default: the landing names the account, and an admin lands on /admin 2026-09-18 08:02:57 +07:00
3dtours 7b79e49c20 Photo slots + admin page: place any upload in the strip or a live slot, sign up in place, brand links home 2026-09-18 07:55:54 +07:00
3dtours bd2f08aaf7 Panel chips: name the picked value on the right edge
Strip chips (D.RANGE, CROP, COLOR CHROME, PHOTO STYLE, ...) now carry their
pick in the chip's .val column instead of only glowing amber, and the two
chips that baked the value into the label (WB TEMP, FRAME ROTATE) use the
same right-edge slot.
2026-09-18 07:24:05 +07:00