Commit Graph

51 Commits

Author SHA1 Message Date
3dtours 01af863fd8 web: let a photo's repairs read as the marks they are, not as a field of rings
HEAL draws every repair it holds as the circle the shader fills — the brush's
own size at the moment it was laid — so a photo with two repairs has two rings
and a photo with twenty has twenty. Each one is loud enough to be the loudest
thing on the picture, and none of them is the one the user is looking for: the
speck that was mended, and the patch borrowed to mend it, are both inside the
ring that covers them. What the ring is for is the spot that is about to be
taken hold of, and there is only ever one of those.

A repair now wears its circle only while it is the spot in hand — the one the
pointer is over, the one just laid, which is the one the user is watching, or
the one a press chose, which is the one wearing the ×. The moment the pointer
walks elsewhere the ring goes, and what is left is the pair the renderer works
with: the patch it borrowed, still dashed, and a soft print of the place it
mended. The ring comes back under the pointer, which is where the spot is taken
hold of again; the grab was always the geometry — the circle plus a few pixels
of slop — and never depended on the ring being drawn, so an idle spot is as easy
to move as a ringed one (measured: a 60,40px drag on an idle spot moved it
970.2,390.7 -> 1030.3,430.7).

The run a stroke left is the mark of that stroke, so the spots the band stands
for keep their boxes and give up their own edges as before, and no print is laid
under the band: a row of blurred discs under one translucent band is a second,
blurrier band. The one spot of the run that is in hand wears its circle again
over the band, because that is the spot a press would take hold of.

MOSAIC keeps its rings. Its spots are not repairs to be placed and moved — the
circle is the only thing that says where the cover is, and the cover is the
edit.

ponytail: the print is a blurred translucent disc (14% grey, a soft dark
shadow, 1px of blur) rather than a tint read off the pixels under it, so it
reads on a photo of any tone without a second pass over the render — the
upgrade path, if a print that sits on the repaired pixels themselves is wanted,
is the shader the repair already runs through. The ring under the pointer is
reported from the pointer's own move events rather than from a hit test per
frame, so the one case it does not cover is the pointer that has not moved since
the repair landed; that case is covered by the spot just laid being in hand, and
a twitch of the mouse covers it everywhere else. The idle/hover split is HEAL's
only: MOSAIC's spots keep the border they always had, which is the asymmetry
this tool set already had about moving them.

Verified: heal-idle-probe.cjs (new, 23 checks) 23 PASS / 0 FAIL on :5199 and on
:8090 after deploy — a press on the speck lays one repair and that repair is in
hand, so it wears its circle (rgba(255,255,255,0.85)); a repair the pointer has
left is idle with the ring given up (rgba(0,0,0,0)), the print of the place it
mended left (rgba(127,127,127,0.14)) and the patch it borrowed still dashed and
in the same place (640.3,297.9 vs 640.4,297.9); the ring comes back under the
pointer; a press chooses it, puts its × on the photo and the × stays while the
pointer walks off; laying the next repair takes the choice and the × off the
first and gives its ring up; hovering the second leaves the first idle; a drag
still takes hold of an idle spot; a run of 13 spots shows the band, no print
under it and no ring while the pointer is away, and the spot of the run under
the pointer wears its circle again; a MOSAIC spot keeps its ring; 0 page errors.
brush-edit-probe.cjs 33/0 — its "every spot of the run gave up its own edge" now
reads "but the one in hand", which is the rule this commit adds, and its "a spot
with no neighbour keeps its own circle" passes off the repair just laid being in
hand. heal-zoom-drag-probe 28/0 (the wheel, the pan, the × and taking hold of a
repair, at the fit and at x1.52, unchanged), heal-blotch-lab 12, heal-edge-lab 9,
heal-seam-lab 10, heal-skia-lab 28, heal-search-lab 15, heal-probe 49,
heal-zoom-geom 5, heal-zoom-probe 8, mosaic-skia-lab 27, mosaic-probe 51 — all
green on :8090. Regression: landing-test 172/0, pro-gate-test 27/0,
award-column-probe 18/0, otp-code-probe 10/0, tone-curve-probe 42/0, rc=0.
Backend npm test 180 passed, 0 failed. npx tsc --noEmit clean.
2026-09-24 08:10:27 +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 b795517d9f web: read a patch's light before pasting it, and paint with the brush
The brush was not healing: clicking a speck deleted one black spot and made
another, and the borrowed patch landed in a light the spot was not in, so the
repair read as a mark of its own. The circle the brush draws also slid off to
the side of the pointer as soon as the photo was zoomed in, and a drag showed
itself as a row of overlapping circles rather than as a brush being drawn.

The search was comparing the wrong thing. findHealSource scored a candidate
against the spot's own PATCH_TAPS — the centre and a ring at half the radius,
which is INSIDE the brush, where the dust is. The patch that matches a speck
best is then the one carrying a speck of its own, which is exactly how "heal a
spot" became "move it a few pixels": with a neighbour sitting at the 2.6r ring
the search itself prefers, the winner was that neighbour, 26 dark pixels pasted
where the repair was meant to be.

The taps are split now, by what they are for. The light a repair has to sit in
is read off the spot's RING — twelve taps at 1.15r, just outside the dust, the
scale the eye reads a spot's surroundings at — and taken as their MEDIAN,
because the ring can only be a little way out: some of its taps land on the
speck's own softened edge, and a mean drags the whole light down by them (the
eight-tap mean read 84 where the ground was 150, and with the gate below that
refused every candidate on the frame). What a candidate would actually paste is
the mean of its own inside taps, now including the ring at HEAL_FEATHER of the
radius — the circle is copied at full strength out to there, so that is where a
neighbour's dust leaking into the patch shows up and the middle of the patch
would never see it — and its cleanliness is how much those taps spread around
their own mean: dust is an outlier in its own neighbourhood, grain is not.

A candidate from another light is not scored at all. Past LIGHT_GATE (20 levels
of the 0-255 the sampler answers in) the patch IS the mark the user is
complaining about, so the search returns null rather than sending a wrong clone
and the caller leaves the speck alone. Within the gate the score is light * 3 +
cleanliness, so the light decides and cleanliness breaks the ties the eye would
not see. A spot the search refuses is not laid down at all — healUp skips it
instead of recording a self-patch, which was a repair that changed nothing —
and a stroke that is refused end to end reports no spots, which addHealSpots
already treats as nothing to do: no step in the history, no spot on the photo.

The ring had to be a fraction, not an offset. healPos was the pointer's pixels
inside the layer, and the layer carries the stage's transform, so a zoom scaled
that offset a second time: at 1:1 the pointer sat at screen x 846.5 and the
ring was drawn at 1288 — 442px away, the same distance the user sees as "the
circle is in the wrong place when I zoom in". The pointer is stored as a
fraction of the photo now — healPoint already answers one for the spot it lays
— and drawn as a percentage of the layer, so the layer's own transform scales it
once; off the photo there is no ring. The eyedropper's icon had the same shape
of bug (its sample was always right — pickAt reads the photo's own rect) and got
the same fix in the same file, since it was two lines.

The stroke is one mark of the brush. The trail was a circle per point of travel,
laid one HEAL_SPACING (0.6) radii apart, which is what a row of beads looks
like; it is one SVG path with round caps and round joins now, its width the
brush's own diameter and its colour the accent at 45%, so what the pointer draws
reads as the band it is about to lay down. The count of travel is kept on the
element (data-points) so the probe can still hold the run it becomes to the run
it showed.

Verified:
  heal-search-lab.cjs (scratchpad, Node against the bundled heal.ts) — 15 PASS,
    0 FAIL: one speck alone is repaired, from a patch that is clean field, and
    its light is 0.0 levels off the spot's own; a speck with a neighbour exactly
    at the search's first ring borrows from the far side with 0 dark pixels
    pasted; a speck ringed with dust in all eight directions skips past the ring
    (0 pasted); a speck in the corner stays inside the frame; a speck at the lip
    of a shadow, where every reachable patch is 60 against a ground of 150, is
    refused (null); ground with a dark edge through it is not a refusal — the
    repair comes from the light side and its light is 0.0 levels off.
  heal-skia-lab.cjs — 27 PASS, 0 FAIL (the shader and the search unchanged in
    everything the search is not asked here).
  heal-probe.cjs (the rebuilt app at http://localhost:8090) — 49 PASS, 0 FAIL,
    no page errors: the circle rides the pointer at the size the chip reads; one
    click heals a speck to 151 with its four neighbours field; the borrowed
    patch is a real distance away and is clean field; a drag shows ONE mark,
    6.1px wide against a 6.1px brush, standing for 15 points of travel, lays
    exactly 15 spots, clears on release, and UNDO takes the whole stroke back;
    25 spots carried with the first healed speck still first; everything gone
    after a reload; CLEAR brings it all back.
  heal-zoom-geom.cjs — 5 PASS, 0 FAIL: at fit and at 1:1 the ring's screen
    centre is the pointer (846.5,452.5 both times, against 1288 before), the
    ring keeps the brush's size on screen, and a repair made at a zoom lands
    under the pointer.
  heal-zoom-probe.cjs — 8 PASS, 0 FAIL: on a structured 2048px photo at 1:1 the
    speck goes, the donor is at least a ring away, the patched circle is within
    1.07 levels of the ground it landed on, the donor's own circle is drawn on
    the pixels it borrowed; on a navy field with three specks, two repairs land
    0.0 levels from their ground.
  heal-look2.cjs (scratchpad, PNGs in /home/locpham): the pair case used to
    paste its neighbour and read min 3 inside the healed circle — the pasted
    dust — and reads 151 now, the untouched second speck alone in the frame;
    the big-speck case (dust r=9 under a 6px brush) now lays NO spot at all,
    which is the refusal working: the speck is left alone instead of smeared.
  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 refusal leaves the speck on the photo and nothing on the screen —
the user closes the brush in a size that covers it and clicks again — which is
the honest half of the trade the user asked for, but it is silent; a hint would
mean a toast or a shake, and neither is worth a component. The gate is a
flat 20 levels, not a percentage of the local contrast, so a photo with a hard
edge through the brush's own ring reads as one light and can still take a donor
from the other side of it. The search reads the preview JPEG rather than the
original, so a patch near the preview's own edges is chosen from the pixels the
user is looking at, not from the ones the export will print. And the run a
stroke leaves behind is still drawn as its spots, circle by circle, because each
one is a repair with a borrowed patch of its own — drawing the laid run as one
band would need the recipe to remember the gesture (a stroke id on the spots),
which is a recipe change and not what was asked.
2026-09-23 21:36: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 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 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 7dea0f1e7b web: the mixer's card can be dragged off the colour it was read at
The card hangs on the point the eyedropper read, which is exactly where the
user wants to watch the band move — so it covers the patch it is editing. Its
body now takes a drag: the offset is a fraction of the photo, which is the
layer the card lives in, so a zoom keeps it where it was put and a fresh pick
drops it back on its own point. The knobs and the close button keep the pointer
to themselves, and the anchor cannot leave the photo, so the card is always
half in reach of a drag back.
2026-09-22 11:13:21 +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 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 efe578f61c web: the mobile chip strip lies down
Tapping a tab on a phone opened a 148px column with the chips stacked one per
line, so the strip read as a ladder down the side of the photo. Android's own
panel runs its chips as a row — see src/components/AdjustmentPanel.tsx — and
that is what the phone now gets: the columns stack into one vertical scroll
and each chip row runs sideways again, wrapping inside the full width.

Desktop and tablet keep the columns and the stacked chips; the change lives in
the <=860px block.
2026-09-20 07:00:55 +07:00
3dtours 6afb7d9fea web: the mobile rail wears Android's pills
On a phone the studio's tabs are now the row the Android app draws: text
pills in uppercase mono, rounded full, amber and a step larger when open, no
glyph, scrolling sideways when the ten tabs outrun the screen.

Desktop keeps its icon-over-label column — the change lives in the <=860px
block, so nothing above that breakpoint moves.
2026-09-20 06:24:27 +07:00
3dtours d25649a26b web: each PICTURES column is an album shelf, a frame and a strip
Both columns now have the shape the curator asked for: a narrow shelf of
albums down the left — one per account on the uploads side, one per landing
section on the other — the frame that is up in the middle, and the open
album's thumbnails as a strip across the bottom.

The frame keeps its labels, the four section boxes, the look's QR code and
the delete button under the picture, where before they sat beside it. Each
column previews its own frame; both obey the same name, sort and rating
filters.
2026-09-18 20:38:46 +07:00
3dtours f263413313 web: PICTURES back to two album columns, every frame editable in place
The pane is two columns of the same thing: the uploads on the left, one
album per account, and the landing on the right, one album per section —
Film strip, Live preset tester, Custom recipe creator. QR is no longer a
shelf of its own: the code belongs to the frame.

Both columns list their albums the same way and draw the open album's
frames as cards, each with its labels, the four section boxes, its QR
code and the delete button under the picture. Ticking a box files the
frame into that album on the other side straight away.
2026-09-18 20:17:25 +07:00
3dtours 07fbadcdc5 web: star ratings on the reel, and PICTURES becomes an album browser
The landing strip now carries a score: each look and each contributed
frame shows an average, five stars the visitor can press, and how many
votes it has. Votes are keyed photo:<id> or look:<TAG> and one visitor
has one vote per key, so pressing a second star moves a score instead of
stacking one. The API is public and rate-limited; look: scores survive a
cleared pool, photo: scores are pruned with their photo.

PICTURES was four destination rows; it is now an album per uploader with
a search box, a recipe/rating/newest sort, a minimum-star filter, a big
preview and a filmstrip of thumbnails. Deleting and slot picking still
live in the big box.
2026-09-18 19:17:38 +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 1b71c0196f web: the eyedropper reads a colour and the mixer moves that hue band 2026-09-18 17:41:45 +07:00
3dtours 8a889db069 web: the QR card hands out the look that made the photo
A photo's landing section can now be the QR card, and that section is the
only one that hands something out: the server writes the photo's own stored
look back as the app's .recipe file, at
GET /api/photos/:id/preset.recipe, for any row the curator ticked into the
qr slot. Nothing new is stored — the file is built from the recipe the
upload already carried, so it works for a photo uploaded by the phone too.

The admin pane grows a fourth checkbox and a fourth row (QR card); the
row draws the download link as a scannable code, and the box is dead for a
photo with no stored look. The landing's QR card now encodes the curated
photo's own link instead of a mock address. The listing exposes
hasPreset, never the recipe itself.
2026-09-18 16:57:46 +07:00
3dtours 260517c547 web: a photo can sit in every landing section at once
The picker was one dropdown, so a photo lived in exactly one place. The three
destinations are now independent checkboxes on the card, and the column holds
the set as a comma list — the landing page draws a photo in every section it
was ticked into, each still picking one of its own at random per visit.

Ticking nothing is what `off` used to be: the row is kept and the landing page
stops drawing it, which is what the old "not on the landing page" option did.
2026-09-18 16:47:12 +07:00
3dtours ee2c402438 web: pick a photo's landing slot with two columns
The pictures pane now reads as a pool of every upload on the left, three
destination rows on the right. Each photo carries one button per
destination; picking the one it already sits in takes it off the landing
page without deleting the row (slot off), which is what the old select's
"not on the landing page" option did.
2026-09-18 16:28:55 +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 27099c2877 web: overlay a draggable histogram on the photo
The panel in Lightroom is a running read of the render, so this one reads
the preview blob itself: one downscaled 320px canvas pass bins 256
values per channel, and four SVG paths draw them — the grey luma fill
with the three channel curves screened over it, from absolute black to
absolute white. Dragged by its header, clamped inside the photo, parked
top-right on first paint, and dismissed either from its own frame or from
the toolbar button. It steps aside while the crop frame is up.
2026-09-18 14:35:50 +07:00
3dtours 89beb5f160 web: put the traffic screen in the admin frame
installTracking() beacons one view per page load and one click per control that
carries a data-key, so every existing button is already counted. The new STATS
pane reads it back: a 7/30/90-day range, the three totals, an SVG timeline and
eight proportional bar lists (pages, clicked features, country, region, city,
browser, system, device).
2026-09-18 14:23:25 +07:00
3dtours f17cb08a63 web: fold the create form's groups and add the white/black point rows
The CREATE RECIPES form had grown into one long scroll. The six categorical
groups (SIMULATION, DYNAMIC RANGE, GRAIN EFFECT, COLOR CHROME EFFECT, COLOR
CHROME EFFECT BLUE, WHITE BALANCE) are now native <details> folds — the browser
keeps the open flag, so no state and no library.

The two tone-curve ENDS join the numeric grid: EXPOSURE (the matrix gain the IQ
tab already drives), EV (the old EXPOSURE COMP. row, renamed to the phone's
word), WHITE and BLACK. WHITE/BLACK are new ColorAdjustments fields, applied in
TONE_SKSL as cubic end-weights rather than another smoothstep knee — the HL/SH
knees already spend the slope budget, and the cubic keeps the curve monotonic
for every combination (derivative >= 0.46), so a brighter input can still never
come out darker. Both are optional, so stored recipes keep working.
2026-09-18 12:30:36 +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 8259bd468d web: move the section jumps to their own strip and grow the Theme menu
The six section links leave the top bar for a thin shelf under it, each a
bordered pill at the page's normal text size, so the bar itself stays a
row of actions and the jumps still read as buttons.

The Theme menu gains the workspace's colour groups — the landing palette
now takes its hue from the accent tokens — an Auto mode that follows the
OS scheme live, and a dropdown in place of the three font chips.
2026-09-18 11:30:12 +07:00
3dtours 86a93f46be web: bulk-select accounts in the admin table and link the studio from the top bar
The users table grows a checkbox column with a select-all box in its head,
and three controls above it: SELECT ALL, SELECT NONE and DELETE SELECTED
naming the count. An admin account is the API's own privilege source, so it
gets no box and select-all skips it; the picks clear once the deletes land.

The landing top bar gains a RecipesCam web studio button between the account
slot and Download RecipesCam. It hides with the rest of the wide row under
1160px, where the burger sheet already offers the studio.
2026-09-18 11:11:43 +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 40177cb63f web: the studio header carries the mark and the page name too
The landing, /admin and /profile all show the icon + RecipesCam wordmark
with the page name beside it; the studio still showed a bare "RecipesCam
web" text. Same pair now, so the phone header says where the visitor is.
2026-09-18 09:03:35 +07:00
3dtours 6bbf77860b web: account avatars, member /profile, framed admin panel
- an account can carry a picture: POST /api/auth/avatar (raw bytes,
  sniffed, replaces and unlinks the old file) and the public
  GET /api/users/:id/avatar. It rides wherever the account is named —
  the landing chip, the studio TopBar, the profile form.
- new /profile page for members, sharing one Profile form (picture,
  email, password) with the admin drawer.
- /admin is now one bordered frame whose left column is
  Profile / User account / Pictures / Close. Pictures lists every
  photo in the system with the slot that shows it; User account lists
  each account's name, email, picture and contribution count.
- account control opens a menu: Admin page + Log out for an admin,
  Profile + Log out for a member.
2026-09-18 08:19:36 +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 f13fc37b5a feat(panel): cascading columns, WB colour swatches, stronger HDF glow
Layout
- the panel is a cascade of columns: the rail's tabs, the tab's chips, the
  open chip's sub-chips, then the ruler. A child column no longer hides the
  column it came from (TEMP -> COLOR TEMP keeps TEMP visible); chips stack one
  per row instead of wrapping
- FRAME's WATERMARK opens its own column, so the frame chips stay put
- CREATE RECIPES gets the wide column its two-up form needs

WB colour swatches
- the ruler draws a colour box under the slider that follows the value:
  COLOR TEMP is the Kelvin colour (Tanner Helland), TINT runs green -10 ->
  neutral 0 -> magenta +10

HDF EFFECT
- knee 0.55..0.85 -> 0.45..0.75, blur 0.004+0.015n -> 0.006+0.024n of the
  width, screen alpha 0.15+0.35n -> 0.28+0.52n: a wide halo on the highlights
  instead of a hairline glow. Web copy of toneShader only — the phone keeps
  its own tuning.

Tabs
- rail order is PRESETS, FAVORITED, WB, LIGHT, FX, FRAME, CREATE RECIPES
2026-09-18 06:42:38 +07:00
3dtours ffdefd2c9c feat(photos): community film strip uploads + admin moderation
Backend
- photos table + upload storage under DATA_DIR/uploads (magic-byte sniffing,
  no multipart dep, SVG rejected, wx exclusive writes)
- POST/GET /api/photos, GET /api/photos/:id/file with nosniff + sandboxed CSP
- admin routes (ADMIN_EMAILS allowlist): list, delete one, clear all
- identity-keyed rate limits (login 20/15m, signup 5/h, upload 60/h)
- cookie gains Secure when the request is https (via trustProxy)
- /api/auth/me now 200 {user:null} instead of 401 when signed out

Frontend
- landing strip section: signed-in users upload straight from the reel,
  guests get a /app?auth=1 link
- /admin page: grid of uploads with delete + clear all
- nginx: nosniff / X-Frame-Options / Referrer-Policy, forward
  X-Forwarded-Proto so the API can mark cookies Secure behind TLS

Tests: docker/backend test/security.mjs (45 checks)
2026-09-17 22:35:12 +07:00
3dtours 3af5a315e2 Offer a compare-with-original split, and drop the render-time readout 2026-09-17 21:39:07 +07:00
3dtours 014b3e5a4b Zoom and pan the preview with the wheel, and step a parameter from its ruler
Cursor over the image: the wheel zooms about the pointer, and a drag pans,
both clamped to the contain-fit of the loaded bitmap so the photo can never
be lost off-screen. Cursor over an open parameter ruler: the wheel moves that
parameter by one step, clamped to its min/max. While the crop frame is being
dragged the wheel keeps resizing the crop instead, and APPLY puts the view
back on the fit.
2026-09-17 20:57:00 +07:00
3dtours 751beb52c1 Add a draggable crop frame behind APPLY, whole-look undo, and recipe import 2026-09-17 20:14:21 +07:00
3dtours d31d945827 Add the CREATE RECIPES tab to the web app
The phone's RecipeCreateModal becomes a rail tab with the same rows, seeding
from the look on screen and clamping the same way. SAVE RECIPE applies the new
look, lists it under RECIPES and, when signed in, stores it on the account; a
guest's copy stays in memory and goes away with the page. Signed-in users can
also export the recipe as the app's encrypted .recipe file (shared/utils
/recipeShare.ts vendored byte-identical from the RN project).
2026-09-17 18:56:17 +07:00
3dtours 340f0374fc Rebuild the web landing page to the blueprint spec
Ten dark cinematic sections: fixed glass nav with a hamburger sheet, hero
with the badge/dual CTA/stats bar, a pausable 35mm film-strip marquee, the
live preset tester (5 stocks, HUD, spec bars), the three-knob custom recipe
simulator, the 6-card feature grid, the QR sharing showcase, free-vs-Pro
pricing, reviews + FAQ accordion and the final CTA/footer.

The page owns its palette and its .lp-* styles, so it renders the same in
either workspace theme, and it pulls no CDN: Syne is bundled and the six
sample negatives are vendored (see docker/README.md). Store buttons raise a
toast instead of the old alert(). The now-dead landing CSS and the unused
land.* dictionary keys are gone.
2026-09-17 18:26:18 +07:00
3dtours dace19aec4 Match the phone's chip-strip panel on the web
The chips column was a stack of labelled sections; the phone opens one row at
a time. Port that shape: tapping a tool tab shows the tab's chips, tapping a
param chip opens its ruler above them, tapping a group chip opens its options
strip, and either closes the other. RESET leads the row (amber while dirty)
and PHOTO STYLE / RECIPES / WATERMARK are strips instead of inline chips.
2026-09-17 18:11:18 +07:00
3dtours c00c605dd7 Fix header popover hanging off-screen on narrow viewports
The header wraps under 860px, so a menu-wrap can land at the start of a row.
`.popover` is right-anchored to its button, so the theme/language/account menu
rendered at x=-280 of a 390px screen — half of it off the left edge and its
buttons unclickable. Anchor the popover to the full-width header on narrow
screens instead.

Measured at 390x844 before/after: popover x=-280 → x=56, w=326, fully inside.
2026-09-17 17:50:50 +07:00