3312facd820e2f9c54800aa60749b70ba6ffabd1
298 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3312facd82 |
docs: write down the camera port and the live-view plan
CAMERA_PORT_ANDROID.md lists only what the web build has that Android does not, one row per gap, with the web file to read next to the Android one. The plan alongside it is the sketch for a live camera with a Skia filter on the frame thread. |
||
|
|
8dc409777e |
web: keep the probe that develops a raw in a bare page
A page with no React, no store and no router around it: hand /src/engine/skiaShim.ts a file, develop it, and read the plane back. It is the shortest path to the answer when the stage looks wrong and the question is whether the engine or the app around it is at fault. |
||
|
|
e24ea9e71d |
web: open the stage on the neutral stock, not the CLASSIC NEGIPES preset
The boot recipe was DEFAULT_RECIPES[0], so every file that landed — NEF or JPEG — came in with exposure +2 (+0.5 EV), contrast +1, saturation -1, 6300K / tint +2, highlight -2, clarity +2, grain +4 and the classic-neg filter already on. On APACHAI-20160215-357.NEF that read as pink skin and blown highlights, and it made a correct decode look like a decode bug. Develop itself was fine: 179.0/168.2/171.2 against the embedded preview's 178.2/167.1/170.7. Boot is now BASE_RECIPE — no filter, all knobs default, the same look COMPARE splits against. A session left by the old boot holds that preset verbatim, which was never the visitor's pick, so it is dropped; anything the visitor actually touched moves the stored object off the exact fingerprint and is kept. Render bytes, same 1600x1068 view of the JPEG (R/G/B means): source file 170.7 / 159.1 / 163.1 before 194.7 / 178.0 / 177.2 after 169.5 / 158.5 / 162.0 NEF render stage: 202.4 / 186.3 / 185.4 -> 177.9 / 167.3 / 169.9 Output is byte-identical to the old neutral render (md5 a3932746...), while a clicked chip (classic-neg-default) and a tweaked session both still restore their own bytes. |
||
|
|
d1e9425b9c |
web: read a frame's white off its pile, not off its largest sample
sensorWhite hung its nine counts of window off the plane's largest sample. A hot pixel sits hundreds of counts above the level the sensor stops at, so on a frame that carries one the window held the stray alone, found no pile in it, and handed the develop the factor two instead of the frame's own level. Measured on a Panasonic DMC-LX10 RW2: one sample at 15993 and one at 14665 over a pile of 9,594,544 at 13855. The frame's level is 1.75x the fall-back, so the develop opened 0.81 of a stop bright, clipped the sky the sensor had held to flat, and the fit to the camera's preview could only pull the exposure back after the highlight detail was gone. Against the camera's own JPEG of the shot, mean |dL| 23.48 and dRGB +8.57,+0.90,+4.90 became 19.14 and +5.32,+1.89,+2.04, and the level itself 7908 (gain 8.2872) became 13874 (gain 4.7236) against the 13855 the plane piled at — 0.14%, 0.002 of a stop. The level is now the highest count the plane piled at, off a whole-plane histogram: `floor` counts is a pile, and the same cliff rule as before still has to hold over the count below it, so a smooth bright sky is left alone and a frame that has not clipped still falls back on the factor two. The same stray was in four of the ten bodies to hand. A Nikon _GDN0447.NEF read the fall-back 8190 where its plane piles at 13806 (gain 8.0018 -> 4.6022, 0.79 of a stop), a Fujifilm RAF 29696 where the documented level is 30993, and a Sony ARW 31742 against the 2.002x the body's ratio was measured at. Six were untouched, and a Canon CR2 develops byte-identical through the change — the window moves only on a frame whose largest sample is not its highest pile. scripts/white-level-check.mjs keeps the LX10 shape: a plane piled at 30995 with a stray above it has to answer 30995. |
||
|
|
e6c050581f |
web: make AUTO write the tone and the colour it reads off the frame
The AUTO chip measured the photo and wrote one knob, EV. It now writes the four
the measurement actually names, off the same binned ramp (ui/Histogram.tsx),
which is why it is one chip and not four: the means it needs are all in the
histogram the exposure answer already reads.
EV the mean luma, unchanged
HIGHLIGHT the top 1% (p99 > 0.9 pulls back), to -5 of the ruler at most
SHADOW the bottom 1% (p01 < 0.02 opens up), to +5
TEMPERATURE/TINT the gain that puts the three channel means on each other,
green as the anchor: gray-world on linearised means, then the
closest of 76 temperatures x 21 tints under the renderer's own
kelvinToRGB, so the pair cannot drift from what the ruler applies.
The ends rather than the average is what keeps a small blown window from
dragging the whole frame: a specular in the corner wants HIGHLIGHT, not a
flatter picture everywhere. Both ends stop at half the ruler, so the frame is
corrected and a hand can still finish the move; the knobs then report the
numbers AUTO chose, the way the EV knob does.
Each reading is a pure function of the ramp, so pressing AUTO twice lands on the
same recipe by construction, and a frame with a dead channel leaves the WB ruler
where it is rather than inventing a cast. ponytail: one linear ramp per end and
no scene analysis; add a curve, or weight by how much of the frame is clipped,
when AUTO starts overshooting a scene with a genuine specular in it.
scripts/auto-tone-check.mjs holds the three readings: the percentile walk, the
thresholds that leave a knob alone, and the scan landing back on the gain it was
asked for.
|
||
|
|
224ff0b935 |
web: open a RAW at the resolution of its sensor, not at the quarter of it
LibRaw's half-size demosaic was on. The Ricoh GR's own DNG (D0004128.DNG) developed to 3010x2012 while the JPEG written beside it in the same second is 6000x4000, and the Fuji's RAF to 3008x2007 against its own 6000x4000 -- the quarter was the flag, not the file. With `halfSize: false` the same develop returns 6020x4024 and it is the sensor's frame on every body tried: D0004128.DNG 6020x4024 IMGP6916.DNG 6028x4024 DSCF1701.RAF 6016x4014 _DSC0009.ARW 6024x4024 AFXT2721.RAF 6246x4170 Nikon-D850 NEF 6216x4136 _GDN0447.NEF 4284x2844 P1010607.RW2 3472x3472 5G4A9396.CR2 2880x1920 Nine files, 27s to 155s a develop on one core. Checked through the app itself, not only through LibRaw: photo-dims 6020x4024 on the DNG against 6000x4000 on the JPEG, both err none. The colour it opens with is now fitted per file to the preview the camera wrote into it (previewMatch.ts): a 3x3 over a block grid of the develop against the same grid of that preview, then one cubic a channel for what the 3x3 leaves. The offline per-body table this replaces (cameraMatch.ts) stopped matching the moment the path under it changed -- its rows no longer summed to 1 once the highlight knee landed ahead of it -- and a body with a row opened with a cast one without did not. The file's own preview does not age. The white level the gain carries is the frame's own plateau rather than `maximum` (sensorWhite.ts), a factor of 1.89 to 2.00 out; without it every frame opened a stop bright and a body that sat lower (X-Trans, 1.892) never reached the highlight desaturation at all. The desaturation gate reads the gain-lifted levels as well as the sensor's, which is the whole of the magenta: on a body whose cam_mul lifts red and blue (the GR's [2.64, 1, 1.73]) a blown sky crosses the white level at 0.38 of the raw range in red while green crosses at 1.0, so a gate read on the sensor's levels alone stayed shut across it. Measured in the app against the camera's own JPEG, mean dRGB over a 16x16 block grid: +1.20, -5.95, -6.11 with the sensor's clip alone, +0.21, +0.24, +0.47 with both, mean |dL| 21.5 against 10.3. The same grid on the Fuji comes back balanced (+4.7, +5.0, +3.6) and best aligned at offset 0,0. -HL is recovery and +HL is a lift, so they are different moves now: recovery is the doc's soft knee in linear light over the top half, which is the only term in the tone shader that is not a shift and the only one that can put detail back into a blown sky rather than merely darken it. The four checks pin the develop down where it can only run in a browser: raw-develop-check, preview-match-check, white-level-check, highlight-knee-check. |
||
|
|
b824308182 |
web: hand the RAW develop's plane to Skia as half, so the GPU keeps its shadows
An RGBA_F32 image with an sRGB tag comes back off the GPU backend sampled on a 1/255 grid; the same shader on a raster surface returns the floats untouched. The plane is raw/65535, so the shadows the black level is there to keep sit at 1e-3 and quantise to zero -- a 3010x2012 develop landed 41189 pixels under luma 2 with the dark end speckled blue/yellow, against none on the raster surface. A half is uploaded as float, so the plane stays exact either way. Rejects the earlier guess that the render target's colour space was to blame: gpu+rt-srgb and gpu+img-untagged came back byte-identical to gpu. scripts/half-check.mjs checks the conversion: the named encodings, and no plane value in a 14-bit sensor's range moving more than 4.8e-4 relative. |
||
|
|
610a274103 |
web: give a RAW the colour its own camera would have given it
LibRaw is deliberately kept out of white balance and tone here, so a RAW opened in the studio lands on the neutral demosaic — while the JPEG on the back of the camera carried the body's own rendering. cameraMatch.ts holds that difference as one 3x3 per body, fitted offline against the camera's own preview of the same frame and applied in the develop shader right after the sRGB encode. Measured on held-out blocks, mean CIEDE2000 against the camera preview: GR III 5.99 -> 4.28 X100V 8.19 -> 4.22 GR II 11.68 -> 9.73 X100S 7.41 -> 7.27 X-T3 9.03 -> 6.12 The matrix is fitted luma-preserving and the shader holds that exactly, so the profile moves colour and never exposure: a preview that came out dark stays dark, by design. What is left over is largely high-frequency (sharpening, noise reduction, demosaic) — the error keeps falling as the blocks grow. The fits are weak evidence on their own. Validation on colour charts came out poor: the daylight chart is an Adobe DNG Converter export that aligns to the body's own develop at only 0.785 correlation and gets worse with the profile applied, and the tungsten chart is a different illuminant entirely. The honest claims are the self-fit numbers above and that the X100S — whose cast was small to begin with — barely moves. Verified end to end through the real develop: an unfitted body (Sony ILME-FX30) develops byte-for-byte identically to before, and reading the matrix back out of each profiled develop recovers the fitted one. ponytail: one matrix per body, no tone curve and no 3D LUT (a curve on top measured 2% better and needs a spline plus array uniforms). The match is applied to the 8-bit band the develop already produces — give develop 16-bit output if a profile ever has to grade rather than match. |
||
|
|
432acba9c1 |
web: put the mask's column away with the tool, and keep a blown highlight's hue
The column of mask knobs belongs to an armed LINEAR or RADIAL shape, and it stayed on the stage after the hand had moved on: arm a shape, draw it, click another chip or another tab, and the strip of mask sliders was still there belonging to a tool that was no longer in hand. A capture-phase `pointerdown` on `window`, armed only while `maskTool` is set, now puts the tool down in the same gesture that reaches for something else. A press on the rail (the tabs) or on any chip except the shape's own two dismisses; a press inside the column is left alone, because the column's own chips handle their click themselves, and a press on the photo is left alone, because dragging on the photo is how the shape is drawn. It is the dismissal the STRAIGHTEN tool already had, one effect over, so the tab-switch effect needed no `setMaskTool(null)` of its own. A RAW's blown highlight came out magenta, and was measured before it was touched. `example-sony.ARW` through the lab (`rawblow.html`, the same camera white/black and the same `rgb_cam` the app's develop uses): white 16380, black 512, cam_mul green-normalised to (2.581, 1, 1.553). The pixels the sensor could not hold — 0.7% of the frame, raw max channel at or past 0.99 of the white level — average (0.775, 1.416, 1.108) in raw, and per channel 26.3% / 96.0% / 46.7% are at or over white: green is 1.4x the white level while red is still under it. The develop shader clamped every channel to 1.0 BEFORE the white-balance gains, and that one clamp is the whole cast. Green is the channel the gains are normalised to, so it stopped at 1.0, while red and blue — which need their 2.581 and 1.553 — were already past it and were carried over by the multiply. The blown area therefore left the matrix at (1.0, 0.53, 0.62) instead of at white: measured on the develop output, (254.4, 217.0, 242.9) — red and blue 38 above green, which is magenta. On the stage, pixels with red and blue over 235 and green under 225 in the same framing: 1191 with the clamp, 33 without it. The shader now only floors at zero, keeps the channel ratios through the matrix, and fades whatever ran past white towards white (`mix(rgb / mx, 1, 1 - 1/mx)`, the desaturate-to-white dcraw uses for the same problem). The blown area comes out (254.5, 253.9, 246.6): an overflow that stays bright and stops taking a hue, broken per channel at sd 8.7 / 28.0 / 26.4 today against 5.4 / 3.3 / 17.8 now. Whole-frame averages move by 0.008/0.278/0.140 of a level — the fade only touches pixels that were over white, which are the 0.7%. "cannot be rescued" is the second half of the same fact, and it is now a measurement rather than a hope: LIGHT's HIGHLIGHT row is a curve over what develop emitted, and while red and blue were pinned at 255 the curve had nothing to pull on. The fade leaves a compressed ramp there instead, which is what the row now pulls. LibRaw's own reconstruction modes are not the answer on this file: `-H` 1, 2 and 3 hand back byte-identical develop output to `-H` 0 (`pxAt65535` is 0 — the sensor never reached its 65535, only the camera's white level), so `SETTINGS.highlight` stays 0. What the app cannot do is keep the two stops above white, because the band still leaves develop as 8-bit JPEG; that ceiling is named at the point of the fade, for whoever needs RAW highlights recovered rather than merely correct. `npm run typecheck` and `npm run build` are clean (bundle `index-BJy_3HCz.js`). Probes: verify-mask-column (dev server, real photo, arm a shape and draw it, then reach for another chip and for LIGHT and FX — 8 checks, 8 pass: the column stands while the shape is armed, survives a press on the photo and on the shape's own kind chips, and goes on any other chip or tab), rawblow (the real ARW through the real develop maths in the page, before/after chains side by side, which is where the magenta and the reconstruction modes were measured), probe-raw-highlight (the app itself, ARW uploaded, blown pixels counted and the HIGHLIGHT row driven to both ends). |
||
|
|
9164bf3228 |
web: read DEHAZE off the dark channel, and let it run both ways
DEHAZE read its haze estimate out of the frame's own bilateral reference — the
patch AVERAGE — where the Dark Channel Prior asks for the patch MINIMUM. That
one word is the whole prior: `dark = min(min(r,g,b)/A)` over a neighbourhood
reads 0 for any patch that holds a shadow or a black frame line, so the
transmission stays at 1 and the patch is left alone, while the average of a
patch that holds a dark pixel is still bright, so every patch looked hazy. The
positive end therefore ground the frame down instead of taking haze out of it:
at +9 the mask moved its own middle band -0.2127 and the frame-wide row moved
the whole frame -0.2311, and the local contrast went the WRONG way (dhp -0.0060
on the mask, -0.0056 frame-wide) — a haze remover that lowers contrast is a haze
remover that is lowering everything.
The pass reads the dark channel from the image it is correcting, five by five
taps at DEHAZE_PATCH_STEP (0.625% of the frame's width per tap, a 2.5%-wide
patch — the DCP's own 15 pixels on a 600px frame, and the same fraction of a
4000px export) in DEHAZE_SKSL and in gradientMask's block, so the mask and the
frame-wide row are the same neighbourhood at every render size. Five by five
rather than fifteen by fifteen because 225 child reads per pixel is what
CLARITY_BLUR_SKSL already refused for a reference the prior does not need to be
that wide. The bilateral reference is now only what CLARITY compares against, so
DEHAZE no longer takes a second child at all.
DEHAZE is signed, which it was not: the knob was 0..10 and the export engine
skipped the pass unless the amount was above zero, so a negative value was a
slider the UI would not even offer. It is -10..+10 now, and the transmission
carries the sign — positive pushes t below 1 and `J = (I - A)/t + A` takes the
scattered light out, negative pushes it above 1 and the same expression scatters
light back in. That is the direction a photo shot through mist wants, and it
needs no second formula: one expression, both signs, the ceiling at
1 + DEHAZE_MAX_OMEGA.
CLARITY's negative side was the last place where a knob meant two different
things depending on where it was read: the frame-wide row softened with a mist
blur of its own radius (MakeBlur, sigma |c|/10*4) while a mask mixed toward the
bilateral reference the positive side reads — two neighbourhoods, two strengths,
one name. CLARITY_BLEND_SKSL now carries both directions of the one move (above
zero the doc's unsharp, below it the mix back toward the same reference, gain
1), so the frame-wide row and a mask's CLARITY are the same reference at the
same strength, and the frame-wide mist blur is gone.
Measured in one harness, one photo, one session, knob at +-9, before -> after,
mask phase and frame phase in the same run (the box is the mask's own middle
box for the mask, the stage's own box for the frame-wide row):
- FRAME DEHAZE +9: dmean -0.1680 -> -0.0751, dhp -0.0056 -> +0.0036, white
band -0.2156 -> -0.0522 — it darkens the haze and raises the contrast
instead of lowering both.
- FRAME DEHAZE -9: dmean +0.0469 (was not offered), dhp -0.0010 — the same
knob on the other side, and the frame gets hazier.
- MASK DEHAZE +9: dmean -0.1490 -> -0.0513, dhp -0.0060 -> +0.0039, white band
-0.1234 -> -0.0274, dark band -0.0595 -> -0.0075 — a mask's DEHAZE is now
the frame-wide move on the mask's own pixels (dhp +0.0039 against the
frame's +0.0036).
- MASK DEHAZE -9: dmean +0.0319, dhp -0.0013.
- FRAME CLARITY -9: dhp -0.0200 -> -0.0094, white band -0.1112 -> -0.0203, so
the frame-wide row no longer pays for its soften by flattening every white
in the frame; MASK CLARITY -9 is the same move (dhp -0.0150, white band
-0.0103) and the two now agree in direction, sign and rough magnitude at
-9. CLARITY +9 is untouched on both sides (+0.0335 mask, +0.0307 frame) and
every other knob's numbers are unchanged to within +-0.0005, which is the
run-to-run noise of the same harness.
`step` was the uniform's first name and SkSL refused the shader with it (a
builtin), which is how a whole DEHAZE row came back with all-zero deltas in the
first measurement after the change; `stepPx` is what compiles. `npm run
typecheck` and `npm run build` are clean, and the stage draws with no page error
(the only console error is the dev server's own `/api/events` 404).
Not ported: nothing. The phone's renderer has no gradient mask and no
atmospheric-light estimate to mirror; `shared/utils/toneShader.ts` and
`shared/utils/gradientMask.ts` are the web engine's own files.
Probes: measure-parity (both phases in one run, one photo, before and after —
the same harness the previous commit was scored with), measure-dehaze2 (the same
script with only DEHAZE in both phases, plus a console listener, which is how
the `step` uniform was caught), sim-dehaze-dcp (the offline simulation that
picked the min-patch over the average: clear frame +9, contrast 0.0248 -> 0.0292
against the average's 0.0248 -> 0.0235).
|
||
|
|
5f3257a4d8 |
web: make a mask's knobs the moves the frame-wide row of the same name makes
A gradient mask carried its own copy of the six tonal and spatial formulas, and
four of them had drifted from the columns of the same name. HIGHLIGHT was
inverted: the single shift `0.5 * (shadows*ms - highlights*mh)` put the knob's
`x` on the shadow mask and its `y` on the highlight mask, so turning HIGHLIGHT
up pulled the bright band DOWN and turning it down lifted it — measured at the
mask, +9 moved the top band -0.2295 and -9 moved it +0.0454, and the whole
frame's HIGHLIGHT row reads the other way. WHITE and BLACK were a flat gain on
the end each one owns (`c * (1 + 0.5*k*mh)`), which scales every pixel above the
midtone by the same fraction and so drags the near-whites into the greys rather
than leaving them white: a lowered WHITE took the top band down -0.2117 while
the middle band moved -0.0036, and a raised BLACK pushed the middle band up
+0.0195 for +0.0414 at the bottom — the lift went everywhere except where it was
asked for. CLARITY's negative side was the same unsharp as its positive side
with the sign flipped — `c + k*(c - blur)` with k negative — which is a soften
only in name: it sank the mask's whites (top band -0.0543 at -9) and left the
mask's own contrast where it was (dhp -0.0008), the opposite of what the knob is
named for.
DEHAZE ran after CLARITY, so a mask sharpened its haze and then tried to remove
it; frame-wide the two are the other way round, and for a reason.
The four now mean inside a mask what they mean on the whole frame, because
toneShader.ts is the reference the app's own rows are written from and the same
name on the same knob should not be two different moves. HIGHLIGHT and SHADOW
are TONE_SKSL's additive luma shifts — HIGHLIGHT weighted by the headroom it has
left (1 - t) so it cannot drag a blown white to grey, SHADOW by its own floor —
with the colour difference riding along at TONE_SKSL's damped gain so a lift
cannot collapse a colour. WHITE and BLACK are TONE_SKSL's per-channel point
moves, cubic in each channel's distance from the end it owns, so the toe and the
shoulder move and the midtones stay put. DEHAZE runs before CLARITY, the
frame-wide order. CLARITY's negative side is a real soften, `mix(c, blur, -k)`
toward the same bilateral reference its positive side works against. TONE_SKSL's
two smoothsteps (0.50..1.00 and 0.00..0.55) replace the mask shader's own pair,
and the soft masks are computed in float and narrowed once, the way the
frame-wide shader does it.
Measured in one harness, one photo, one session (the mask's own middle box, knob
at +-9, before -> after on the mask, with the frame-wide knob of the same name
as the reference it is now written from):
- HIGHLIGHT +9: top band -0.2295 -> +0.0298 (frame-wide +0.0547), dark band
0.0000 -> -0.0001 — the lift is a lift, and the inversion is gone.
HIGHLIGHT -9: +0.0454 -> -0.0385 (frame-wide -0.0674).
- WHITE -9: top band -0.2117 -> -0.0646 (frame-wide -0.0860) — a lowered
white stays a white instead of becoming a grey — while the middle band goes
-0.0036 -> -0.0150 (frame-wide -0.0139), which is the move a white ends up
making when it is a point move rather than a gain.
- BLACK +9: bottom band +0.0414 -> +0.0997 (frame-wide +0.1036) and the middle
+0.0195 -> +0.0347 (frame-wide +0.0402) — it goes to the toe it owns.
- CLARITY -9: dhp -0.0008 -> -0.0149 (frame-wide -0.0200) — negative CLARITY
softens now — and the top band -0.0543 -> -0.0113, so it no longer pays for
that soften by sinking the mask's whites. CLARITY +9 was already right
(+0.0333 both sides) and stays.
- DEHAZE, the one knob with nothing on the negative side: unchanged at -9
(-0.1490 both sides, the pass order was the only thing wrong with it), and
the mask and the frame-wide row now agree across the whole range
(1/3/5/7/9 at -0.0124/-0.0397/-0.0711/-0.1074/-0.1490 on the mask against
-0.0133/-0.0423/-0.0759/-0.1151/-0.1619 frame-wide), monotone.
DEHAZE's own numbers are therefore not a mask-only bug: an estimate of the haze
that reads a mask differently from the frame would be inside `atmosphericLight`
and `DEHAZE_MAX_OMEGA`, which both paths share, and changing either moves the
frame-wide DEHAZE column too — left as it is rather than changed under a mask
report.
Everything else about the mask is untouched: `dctrl` is +0.0000 on all six
knobs (a knob still moves the mask's own pixels and nothing outside it), the
shader compiles and the stage draws with no page error.
Not ported: nothing. `shared/utils/gradientMask.ts` is the web engine's own file
and the phone's renderer has no gradient mask to mirror.
Probes: measure-mask-knobs (the six knobs on a selected mask, before and after),
measure-frame-knobs (the same six frame-wide, the reference the mask is now
written from), measure-parity (both phases in one run so the two are the same
photo in the same session), png-parity-report (the before half of that run died
in its frame phase and left no log, so its already-captured mask frames are
re-read off the PNGs with the same box and the same bands), measure-dehaze-curve
(DEHAZE 1..9 on the mask against 1..9 frame-wide, for monotonicity and for the
pass order).
|
||
|
|
97bdf605e2 |
web: import the camera's RAW, and grade it like the phone
The studio took JPEG, PNG and HEIC and nothing else, so a photographer's own negatives never reached it. A RAW now loads the way any other file does — `isRawName` reads the extension off a 24-entry list, the file goes into OPFS under one slot (`current_image.raw`, beside `current_image.name`, so a reload finds it again) and `rawDevelop` runs it through LibRaw-wasm: half size, 16-bit output, camera white balance and the camera's own 3x3 matrix, in bands of 2M pixels so a 30MB file never holds a second copy of itself. `example-sony.ARW` (30.3MB) lands as a 3120x2084 picture, no page error. DEHAZE joins the FX tab, where Lightroom keeps it: a chip off the same PARAM_DEFS entry (`dehaze`, 0..10) so nothing new renders chips, and the pass is the dark channel prior — `atmosphericLight` reads A off a 32x32 draw of the photo, `DEHAZE_SKSL` takes omega up to 0.95 over a floor of 0.1 — measured at 71.8% of the stage's pixels moved between 0 and 10. The gradient mask grows the six knobs the phone's has: HIGHLIGHT, SHADOW, WHITE, BLACK, CLARITY and DEHAZE. The mask's falloff is a smoothstep rather than a line, and CLARITY/DEHAZE inside a mask get a blurred copy of the photo plus the air A as a second child of the mask shader — so a mask's clarity is clarity and not a flat brightness lift. The column shows all nine rulers; CLARITY 9 moves 42.2% of the stage, DEHAZE 9 moves 27.9%. CLARITY stops reading the whole photo per pixel: the single pass that sampled a 15x15 box 225 times is now the three passes the same math wants — 1x15, then 15x1, then a blend, `orig + (orig - B) * 3.2` — about 30 reads. Both signs work (77.4% of the stage moves at +10, 79.6% at -10), and the negative branch keeps its mist as it was. The pointer reviews a look before it is taken: resting on a PHOTO STYLE chip or a recipe chip lays that look on the photo while it stays there and gives it back the moment it leaves — byte-identical, measured on four of them (24.9%, 23.8%, 24.5%, 25.3% of the stage moves on, 0.00% off) — while the recipe, the UNDO stack and the session stay on the look the click left. A hovered look brings its colour alone: the masks, the dust spots and the mosaic of the photo being edited ride along, or a pointer crossing a chip row would rub them off. A PRO sim is left out, since a hover that showed its look would hand over what the click gates. Probes: e2e-raw-verify, e2e-dehaze-mask, e2e-mask-verify, e2e-clarity-verify, e2e-hover-preview2. |
||
|
|
178bc78bbb |
web: an About section on the landing, after Pricing
The nav shelf was a one-pager's anchor list ending at Pricing, and the About copy had nowhere to live. Adds About as the seventh entry — it feeds both the desktop shelf and the mobile sheet off the same array — and the matching `#about` section right behind Pricing, built from the section template the rest of the page uses, so it picks up the page's theme and language switches with no new CSS and no route of its own. The copy is the About RecipesCam brief as bilingual `Txt` pairs, kept beside the markup: vision, the Android app (highlights as steps), the website's three purposes, and the contact address. |
||
|
|
6814b055cb |
web: frame through the recipe, with the viewfinder where the stage was
The live view is the one place where the look is chosen before the picture exists, so the columns that hold it have to stay in reach while the camera is open. The viewfinder was a fixed black sheet over the whole app: the rail and every slider were behind it, and the only way to change the look was to close the camera, grade the file, and open it again. It now covers the stage and nothing else — the stage and the view share a slot (`.stage-slot`, position relative) and the view is absolutely placed inside it, z-index 5, black in either theme. Measured at 1600px: the rail and the columns are on screen, the feed takes 1347px of the 1600 and the stage's own 253px rail the rest. A slider moved with the camera open reaches the very next frame (the render loop reads the recipe per frame, so nothing had to be wired), a monochrome sim takes the live feed from sat 42.1 to 0, and the shutter hands the studio that same frame with the sim already on it. The preview render is skipped while the viewfinder is up (`|| shooting` in the guard, and in the effect's deps): it would run the same recipe through the same renderer as the frames the user is actually watching, and the live one wins. Opening it is PRO, like the HSL chips and the geotag — the frames are the paid ones. The button stays on the page for everyone and carries the badge, and the click runs the app's own `promptPro`: the account dialog for a guest, the verification panel for a signed-in account, `/api/auth/me` being what says which. A verified account gets the camera; a guest gets `.modal-backdrop` and no `camera-view` at all. Probes: cam-pro-gate (guest sees the badge, the click opens the dialog, no viewfinder; verified opens it), cam-live-edit (rail and columns survive the live view, feed covers the stage, the recipe reaches the live frames and the shot), cam-smoke, cam-renegotiate, cam-close-flip. |
||
|
|
cb1839e36b |
web: shoot through the live camera
The one path where the look is chosen before the picture exists: OPEN CAMERA grades the camera's own feed with the recipe in force, many times a second, and the shutter hands the studio the sensor's still under that same recipe. Preview and file differ in resolution only — the still is `takePhoto`'s own frame, not a copy of the small preview video, with `grabFrame` and a 2d copy of the element behind it for the browsers that ship no ImageCapture. The renderer gains two inputs for it: `sourceImage`, a picture the caller already decoded (re-encoding the camera's frame to JPEG only to decode it again would cost more than the whole render), and `drawTo`, which paints the finished picture instead of encoding it. One render is in flight at a time; a frame that arrives during one is dropped, so a slow device shows a lower frame rate rather than a queue of moments that have passed. The view flashed black on a phone. Setting width/height on a canvas resets its bitmap: measured on the preview, a resize leaves mean 0 until the next render lands, which on this box is 0.5s and on a phone more. The buffer was sized from every incoming frame, and a capture that renegotiates its resolution — which Chromium does when the page is too slow to consume its frames, and this pipeline runs ~2 fps at 720p under software GL — strobed black/picture at every switch. The buffer is now sized on the first frame and after that only when the frame's aspect changes: a same-aspect frame is scaled into it. Swapping a 1280x720 stream for a 640x360 one mid-view now leaves the buffer at 1280x720 with no black frame, and 640x360 renders at 6-13 fps instead of 2. The frames are read from a <video>, which is now IN the document (1px, behind the black backdrop) rather than detached: Safari draws blank frames from a detached video, which is the same black-between-pictures. It leaves the document with the view, and the tracks are stopped, so the camera light goes out. Probes: cam-smoke (feed painted, resolution, frame rate, a monochrome sim reaching the live frames, shutter into the studio, close, console clean), cam-renegotiate (no resize, no blank frame, status line on the frames), cam-close-flip (flip returns a picture; video gone on close). |
||
|
|
fd2d9935c1 |
web: filter the exports the model was only smearing
The upscaler ran for every enlargement, including the ones a plain resample wins: measured on a 2048px source cropped and blown back up it loses to lanczos on PSNR and SSIM at 2x and 3x, and its smoothness reads as plastic skin and lost texture next to it. From 4x — the model's own factor — it stops losing, so the threshold moves to 4 and the crop no longer drags a 2x export through it. The resamples it now carries never set imageSmoothingQuality, and the default 'low' point-samples: a 1px stripe comes out at full amplitude instead of the average of what it crossed. Both callers ask for 'high'. Probe on a 2400x1800 source exported at 4K: 299.9s -> 7.8s, correlation with the source's 1px/2px bands 0.89/0.95 -> 0.98/0.98, grain sd 25.4 -> 47.1 (a plain HQ resize of the same source keeps 16.1). |
||
|
|
a9030fc0c0 |
web: give the GPU path a half-precision upscaler
The GPU export now runs the same upscaler in half precision. The chip is handed 2.34MB of weights instead of 4.88MB, and where its shaders can multiply in fp16 it does twice the work per pass. `realesr-fp16.py` is the conversion, run on what `realesr-gpu.py` already wrote (the PReLU-rewritten model), never instead of it. onnxconverter-common's `keep_io_types` needed two of its own mistakes put right: - It rewrites the consumers of the graph input but misses the one that never goes through the network. This model adds a Resize of the ORIGINAL photo to the upsampler's output, that Resize reads the graph input directly, and the runtime refuses a graph whose final Add mixes fp32 and fp16. The consumer is rewired onto the cast that `keep_io_types` should have sent it through. - It also half-precisions Resize's `scales` — ONNX defines that input as float32 whatever the rest of the graph does, and a runtime that opens the file at all rejects the whole graph: "Type 'tensor(float16)' of input parameter (/Constant_output_0) of operator (Resize) is invalid", on the GPU as much as on the processor. The script widens it back and asserts it did. The tensor the app builds stays float32 and the model's two Cast nodes are its own edge, so nothing in superRes.ts or App.tsx has to know which copy it got: 205 nodes, 101 fp16 weights, io still float. `openSession` asks for the model only where the adapter advertises `shader-f16` — a provider without it emulates the type on the same file at the same speed, so the smaller download would be the only thing gained. The order is fp16 on the GPU, fp32 on the GPU, fp32 on the processor, each attempt falling through on its own failure. Measured on the rebuilt container (BASE=http://localhost:8090): - fp16 vs fp32 on a 128x128 tile, same graph: max abs diff 0.0025 (0.65/255), mean 0.00028, psnr 71.0dB. - sr-f16-chooser.cjs 4 PASS / 0 FAIL: on a forged adapter advertising `shader-f16`, the fp16 file is the FIRST model asked for; on one whose device refuses, the fp32 file is fetched for the processor and the 4K export still lands (7,555,377 bytes, 19.6s), no console errors. - superres-test.cjs 32 PASS / 0 FAIL, sr-crop-export.cjs 0 FAIL, web-smoke.cjs 0 FAIL, sr-model-probe.cjs 0 FAIL. - npx tsc --noEmit clean. ponytail: the speed of the fp16 path is NOT measured — this container has no WebGPU adapter (not even lavapipe/swiftshader, headed through xvfb), so every export here runs the wasm fallback. sr-model-probe.cjs on a machine with a GPU is what would show it. Also worth noting for the next person: in a browser with no working adapter, the runtime builds the device BEFORE it fetches the model, so no probe in a GPU-less container can observe which model was chosen — a stub whose device throws leaves the network silent. The chooser probe forges a device good enough to be accepted for exactly that reason. |
||
|
|
12113088c8 |
web: hand the upscaler the photo's own pixels
An export larger than the photo came back flat: the model was never shown the
finest detail the photo held. Before it ran, the source was drawn down to
`scale / 4` of its size — floored at half — and only then handed over, on the
reasoning that a four-for-one model reading `target / 4` invents exactly the
destination and a whole photo would waste three quarters of its output. That
holds for a perfect resampler; it is not what this one is. A 2400px photo going
to 4K was fed at 1200px, and detail finer than the feed's own pixel — 1px
stripes, skin, foliage, fabric — was averaged into flat grey before the model
ever saw it. The draw then had that grey to enlarge, and no model can put back
what it was never given.
The feed is now the bitmap itself, read once at its own size: `drawImage(bitmap,
0, 0)`, no intermediate scale, no floor. The model's four-for-one is spent in
the destination draw instead, which reduces to `scale` and keeps what the photo
actually held. That draw also stops defaulting to `low` — it is usually a
reduction by up to four, and `low` would keep one sample in four of what the
model has just drawn.
Measured against the same running stack, a 2400x1800 source exported at 4K with
bands of 1/2/4/8/16px stripes and a patch of per-pixel grain, each band scored
by how it correlates with the pattern the source held at the source's own pixel
pitch (`r` / on-minus-off swing), plus the grain's high-frequency energy:
p1 p2 grain sd secs
before -0.01 / -0.0 0.94 / 204.0 13.9 51.0
after 0.89 / 179.3 0.95 / 214.7 25.3 188.4
hqresize 0.96 / 83.2 0.99 / 125.9 16.1 —
The 1px band went from uncorrelated and flat to 0.89 — the finest detail the
photo has now reaches the file. Grain lands above the plain-resize reference
rather than below it, which is the model enlarging texture instead of a filter
smearing it.
ponytail: the whole photo per tile means 80 tiles for a 2400px source where 20
were enough, so the wasm path (no WebGPU in the test chromium) grew from 51s to
188s for that export. It is the price of the detail and it is paid once per
export, off the critical path; a device with WebGPU, or a smaller source, does
not pay it this way.
Verified on the rebuilt container (BASE=http://localhost:8090):
- sr-detail-probe.cjs, midtone source so the app's tone pipeline cannot clip
the very detail being measured (an earlier all-contrast version of it reported
"grain 0.00" for the model AND for a plain resize — it was measuring the clip)
- superres-test.cjs 32 PASS / 0 FAIL (export sizes, 4K tile seams clean)
- sr-crop-export.cjs 0 FAIL
- npx tsc --noEmit clean.
|
||
|
|
7e47a153b8 |
web: make EXPOSURE, EV and HIGHLIGHT mean what Lightroom means
A stop is a multiplier on light, so EXPOSURE and EV stop living in the sRGB colour matrix and get a linear-light pass of their own (EXPOSURE_SKSL: linearise, `C * 2^EV`, re-encode). The matrix keeps CONTRAST: a gain on encoded values is what made +1 EV land at x1.5 instead of x2. Measured on the neutral PROVIA sim: EV +1 = x2.011, EV +2 = x3.999, still unclipped at 239. The pass sits between the matrix and the tone shader, and the tone / cinema / curve / glow / halation children all sample through it, so HIGHLIGHT finally sees the value exposure produced instead of the one before it. Recovery keeps `L + strength * mask * (1 - L)` over `smoothstep(0.50,1.00,luma)`, and the colour comes back as `color * (luma_new / luma)`: a blown white stays white (255 -> 255 at -10, 255 at +10), a 0.8 grey loses 33 luma, the midtones beside it do not move. AUTO is the histogram the LIGHT tab already draws: weighted mean luminance (guard 0.001), target 0.48, `log2(0.48 / avg)` clamped to +-2.5 EV, handed to the same knob. A 0.251 grey asks for EV 0.9 and lands at mean 83.0 against the 83.3 predicted, idempotent on a second press. A stock's own bias rides the same pass (`SIM_EXPOSURE_BIAS_EV`, VIVID +0.25 EV) and cancels against the knob, so -1 EXPOSURE on VIVID returns the CLASSIC rendering (measured 0.4149 vs 0.4177). ponytail: the phone app's `src/utils/colorUtils.ts` keeps the old math, so the two copies have to move together; recipes saved before this commit (EXPOSURE 2, HIGHLIGHT +-1) render under the new stop semantics. Verified on the rebuilt container (BASE=http://localhost:8090): - web-exposure-probe.cjs 20 PASS / 0 FAIL (neutral 128 -> 128, EV +1 ratio 2.011, EV +2 ratio 3.999, EXPOSURE +10 ratio 5.62 / -10 ratio 0.172, AUTO EV 0.9, HIGHLIGHT -10 on a 204 grey 204 -> 171, white 255 -> 255, no console errors) - sim-exposure-test.cjs 9 PASS / 0 FAIL (classic 0.4149, vivid 0.4531, knob -1 returning 0.4177, bias 0.0382) - regression suite, 28 probes: mask 53/0, brush-edit 35/0, heal-idle 23/0, heal-zoom-drag 28/0, sims 31/0, sim-vivid 9/0, white-black 4/0, temp-swatch 33/0, tone-curve clean, compare 25/0, create 52/0, wb-preset 33/0, zoom 25/0, save-recent 25/0, web-smoke 9/0 (its export step was stale — EXPORT opens a size picker now). panel-test 4 FAIL, histogram-wb 1 FAIL, studio-save-hl and progate timeouts, landing-test 6 FAIL ($0.99 pricing) are pre-existing. - npx tsc --noEmit clean. |
||
|
|
d2115941c7 |
feat(admin): back the data up, and put it back, from the admin tool
A new BACKUP tab downloads the deployment's whole state — the SQLite file and both media folders, photos included — as one .tar.gz, and takes the same file back. That one artefact therefore does both jobs: the operator's backup and the data package that moves an install onto another box. The database is snapshotted through SQLite's own backup rather than copied, because the file is written to while the archive streams; the media folders are tarred straight off the volume, so no second copy of them is made. A restore replaces the data on disk and then exits — the container's restart policy brings the API back on the restored files, which is the only moment the open handle can be dropped. The state being replaced is tarred aside first, and the archive is checked for `..` entries before anything is unpacked. The API authenticates that route before it reads a byte, and nginx lets that one path past the body cap which holds everywhere else. |
||
|
|
9016ef038d | web: price the plan at $0.99 | ||
|
|
c70edce8c1 |
web: mirror the frame with H-FLIP and V-FLIP, and stamp a typed place
ROTATE gains the two mirrors: H-FLIP and V-FLIP toggle one at a time and stay on through the quarter turns and STRAIGHTEN, which makes them compose with every rotation the strip already offers. ROTATE's own RESET levels the whole frame, mirrors included. The flip itself lands last, in screen space, so a mirrored photo is what the eye sees rather than what the sensor saw; the pixels are copied axis-aligned, so there is nothing to resample. Session state carries the two flags, so a reopened photo comes back mirrored. Also fixes the stamp: a typed PLACE NAME with no GPS fix now prints on its own (latitude/longitude ride in as NaN), instead of the whole stamp and its box being skipped for want of coordinates. |
||
|
|
9d7a5beec2 |
web: say the pricing promise plainly on the landing page
The pricing head asked the reader to start free and go Pro when the grain
matters, which is our joke rather than their question. It now says what
the plans actually are: free to use, forever, and Pro only when they're
ready for more.
The Vietnamese line moves with it — "Miễn phí dùng mãi. Chỉ lên Pro khi
bạn cần nhiều hơn." — so the two languages still promise the same thing.
ponytail: one string on each side of the existing c({ en, vi }) pair, no
new copy key and no layout change.
Verified:
- npx tsc --noEmit clean.
- Against the rebuilt production bundle on :8090, a probe reads #pricing
h2 in both languages: EN "Free to use, forever. Upgrade to Pro only
when you're ready for more.", VI "Miễn phí dùng mãi. Chỉ lên Pro khi
bạn cần nhiều hơn.", VI overflow 0px — 3/3.
- landing-test, lp-probe, subnav-anchor-probe, pro-gate-test all rc=0
fails=0; screenshot of the section in both languages shows the head
wrapping over two lines above the plan cards, nothing clipped.
|
||
|
|
08a4570b2d |
web: put the brushes in a FIX strip and the shapes in a GRADIENT MASK one
The FX row had grown into a flat list where a brush and a filter and a shape sat side by side, and it lied about what the tools are: HEAL and MOSAIC only paint, LINEAR and RADIAL only make a mask. The row now carries FIX and GRADIENT MASK, in that order, before MONOCHROME, and each one opens its own strip holding the tools that belong to it — turning amber when it has a spot or a mask to show for itself, so the state still reads from the row without opening anything. The two brushes moved into the FIX strip with the header FIX above them, the two shapes into the GRADIENT MASK strip under the header GRADIENT MASK, and each CLEAR moved in with the tool it clears instead of sitting at the end of the row. Opening FIX still arms the brush the same way — the strip only changes where the chip lives, not what clicking it does. The mask column (shine, bearing, feather, and the rest) still hangs off the shape you pick, so it now stands right after the options column: the strip that brought it out comes first, then the column it belongs to. Nothing else in the column order moved. ponytail: the two strips ride the existing openGroup and toggleGroup, so a strip key is just a widened GroupKey rather than new state to keep in sync; the option-strip body itself stayed where it was, keeping the diff to the chips that moved. Verified: - npx tsc --noEmit clean. - Frontend probes against the built production bundle on :8090 and the dev server: mask-probe 53/0, brush-edit-probe 35/0, heal-idle-probe 23/0, heal-zoom-drag-probe 28/0, grain-controls 20/0, temp-swatch 33/0, and landing, pro-gate, award-column, otp-code, tone-curve, hsl-panel, chip-edge, chips-desk, slider-reset all PASS rc=0 fails=0. - Backend npm test 180/0. - panel-test keeps exactly its four pre-existing failures (rail labels, WB swatch); they reproduce on the commit before this one. |
||
|
|
cca6fc46f7 |
web: turn a mask by the turn the hand makes
A press anywhere on a mask's outline takes hold of it to turn it, and the turn it asked for was read as an absolute bearing: the direction from the shape's pin to wherever the finger now happened to be. The outline is a grip a hand lands on wherever it likes — a ramp's line runs the whole way across the photo — so the moment a press was set down on it the shape swung round to face that finger. A press on the edge of a ramp lying across the photo stood it upright on a ten-pixel move, and a shape already turned snapped to the angle of the press before it had been dragged at all. The turn is now what the hand turns: the change in bearing about the pin since the press, which on the first move is the press itself. A finger that lands on the outline and stays there leaves the angle exactly as it found it; one that carries the shape round by a quarter turn turns it by a quarter turn, whatever angle it was at to begin with. An ellipse adds that turn to the angle it stores, a ramp spins its two ends about its own middle, and both are measured in the photo's own pixels so the two bearings are the same kind of thing. ponytail: The turn is read against the previous pointer position the drag already keeps, so it costs one subtraction and no state. A pointer that leaves the photo mid-turn keeps the angle it stood at — the rule the brush already follows — and the next move on the picture resumes from the last position that was on it. Verified: tsc clean; mask-probe 52/0 on the dev server and again on 8090 — a press on a ramp's own edge that travels a hundredth of the photo's width leaves the ramp at the angle it was taken hold of at (ends 0.500 and 0.500), and on a shape already turned the same press keeps it upright (0.562,0.260 and 0.558,0.860); a quarter turn of the hand on the edge turns the ramp a quarter of the way round (ends 0.200 and 0.800, one pin, no second shape laid); the pin still takes the shape with the hand to where the hand went (0.560,0.560, no sideways throw); the rim of an ellipse still turns with the hand on it (1 pin); another chip still leaves 0 mask columns and the mask chip brings 1 back; DELETE still takes the chosen shape off the photo with its grade (64 -> 255, then back to 61). Against the code before this change the same probe fails 9, both angle checks among them — the ramp lands at 0.850,0.560 and 0.860,0.562, that is, wherever the finger was. brush-edit 33/0, heal-idle 23/0, heal-zoom-drag 28/0, landing/pro-gate/ award-column/otp-code/tone-curve/hsl-panel/grain-controls/chip-edge/chips-desk/ slider-reset/temp-swatch all ALL PASS, backend 180/0. |
||
|
|
1f6c97be62 |
web: let a mask's own edge turn it, and another chip put it down
A mask could only be taken hold of by its pin or by one of the handles, and the pin of a linear mask sits on the middle of its own line, so the press a hand aims at "that line" was the press that moved the shape. What it moved by was worse: a ramp is stored as the two points it falls between, and 'move' measured the hand against the shape's first point, so the first move of a drag put that end under the pointer and threw the rest of the ramp sideways by half its length — the shape landed somewhere off to the side of the hand instead of under it. The move now takes its delta from the pin the press landed on, which is what the press wanted, and which for an ellipse was already the centre it moved by. The drawn outline is the shape's own handle. A press on the line a ramp falls across — any of the three, anywhere along it — or on an ellipse's rim, turns the shape; the nodes that resize it sit on that same outline and are drawn over it, so a press on one of those is still a resize. That is the Lightroom gesture: the edge is what you drag to aim a gradient, and the ends and the axes are what you drag to lay it out again. Until now a press on the edge was a press on the layer, which read it as the start of the next shape and laid a second mask down while the first was being turned. The band is sixteen screen pixels wide and does not scale with the zoom, so a finger finds it at any size of photo. A mask is chosen by pressing it on the picture, so the keys the hand is on are DELETE, which is what the chip beside the photo already says. And the column the chosen mask puts up — its name, DELETE, and its knobs as rulers — belongs to the mask tool: another chip puts that tool down and takes the column with it, because a photo still armed to draw shapes is not what a hand reaching for a knob is asking for. The shape stays chosen and stays live in the render; the mask chip brings its column straight back. ponytail: Backspace is read as DELETE as well, since that is the key the label sits under on a Mac keyboard, and is the one shortcut this adds. A mask can still only be chosen while a mask tool is armed — the shapes answer the pointer through the layer that only exists then — so the column coming back with the mask chip is also the way back to a shape drawn a moment ago. Verified: tsc clean; mask-probe 50/0 on the dev server and again on 8090 — a ramp lands with its pin on its middle, dragging its own edge turns it (ends at 0.200 and 0.800, one pin, no second shape laid), the same drag on an ellipse's rim turns that (90deg -> -39deg, one pin), the pin takes the shape with the hand to where the hand went (0.560,0.560, no sideways throw), another chip leaves 0 mask columns and the mask chip brings 1 back, and DELETE takes the chosen shape off the photo with its grade (64 -> 255, then back to 61). brush-edit 33/0, heal-idle 23/0, heal-zoom-drag 28/0, landing/pro-gate/award-column/otp-code/tone-curve/ hsl-panel/grain-controls/chip-edge/chips-desk/slider-reset/temp-swatch all ALL PASS, backend 180/0. panel-test (4), histogram-wb (1) and studio-save-hl fail exactly as they do on the build before this one, on their own tabs. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
53554ce42a |
web: let the photo be zoomed and moved under the brush, and keep its × out of the hand
With HEAL armed the wheel was the brush's size and nothing else. A repair is aimed at a detail — a scratch, a speck on a face — and the detail is usually smaller than the photo, so the one gesture the stage uses to bring it closer was the one gesture the brush had taken: a user could size the spot they were about to lay and could not zoom the photo they were laying it on. The same layer took the pointer the stage pans with, so a zoomed-in photo could not be moved out of the way either, and it swallowed the double-click the img answers with. A tool that covers the surface has to hand back the gestures it does not use. The wheel over the brush is the stage's own now, exactly as it is everywhere else in the app: one notch at the pointer, the point under the cursor held still, the same arithmetic the wrap's listener has always used (lifted out of that listener as zoomAt, so the two callers cannot drift). The brush's size — the one knob this tool has — is that same wheel with a modifier held: Alt, or Ctrl/Meta, which is also what a trackpad sends for a pinch, so pinching still sizes the spot without reaching for a key. The photo can be moved out from under the brush as well: the middle button, or the space bar held, drags the photo while a tool is up, at a zoom, which is the only place a pan means anything — the listener is on the layer, so the key is watched on the window and read from a ref. A pan drag paints nothing, and the modifier wheel does not touch the zoom: measured, 24.744px -> 29.6239px of brush across while the photo stayed at 1568px. The × a chosen spot wears is now drawn for the screen rather than the layer: it keeps 18px and a 5px gap at any zoom, scaled back out of the layer's own transform. It was scaling with the photo — 18px of badge is 27.9px at ×1.52 — and at that size its corner sat over the circle it belongs to, so a press meant to take hold of a repair landed on the × and deleted it instead. 5px of daylight at the fit and at ×1.52, measured both. The histogram goes off the photo while a brush is up. It is a panel over the photo's top-left corner with the pointer on it, and the corner is where a user paints first: a drag under it painted nothing, and the wheel over it belonged to the panel rather than to the photo. The crop frame already had it hidden for the same reason. ponytail: the pan is bound to the middle button and the space bar rather than to a second pointer, so a trackpad-only user has the zoom (two fingers) and the brush's own size (pinch) but no one-finger pan while a tool is up; a modifier plus drag, or a small hand tool on the toolbar, is the upgrade path. The brush size now lives behind a modifier that nothing on screen advertises — the chip's percentage is the only hint — so a stepper in the brush chip is the next thing to add if that turns out to be a wall. Double-click to zoom is deliberately not wired up: the layer swallows the press, so the first half of the double-click lays a spot, and a zoom that leaves a stray repair behind is worse than no zoom. The middle-button pan only engages past the fit, where the drag is free rather than a scroll, matching what the stage already did. Verified: heal-zoom-drag-probe.cjs 28 PASS / 0 FAIL on :5199 and on :8090 after deploy — the brush arms as a layer, the histogram is gone from the corner and the circle follows the pointer there, the wheel zooms (1031px -> 1185px) and leaves the brush its size (24.744px -> 24.744px), Alt+wheel sizes the brush (24.744 -> 29.62) without moving the zoom, the modified wheel over the brush sizes it and leaves the window's own frame alone (1440 CSS px, dpr 1, either side of the notch, so the modified notch is not handed on to the browser's page zoom), a plain drag paints 0 -> 7 spots, the middle button moves the photo 215,34 -> 269,66 without painting, space+drag moves it 269,66 -> 179,6 without painting, and taking hold of a repair moves it from the centre, from 0.8 inside its edge and from its other side at both the fit and ×1.52; the × has 5.0px of daylight and is 18px across at both zooms; 0 console errors. brush-edit-probe.cjs 33/0, 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, and heal-probe.cjs and mosaic-probe.cjs had their wheelTo helper moved to Alt+wheel because the plain wheel now zooms the photo, which is the contract they were testing. Regression on :8090: 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. |
||
|
|
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.
|
||
|
|
88d6d0e648 |
web: read the ring's light by direction, and off the dust's soft edge
The last commit pasted the borrowed patch at the light of the place it lands in,
and read that light as one number per spot: the mean of the ring around the dust
minus the mean of the same ring around the patch. That number is a light the
place has when the ring is all one thing. It is not a light the place has when
the ring is not. A twig, a hairline, the edge of a table under the brush, and a
minority of the taps stand on the thing rather than on the ground: the mean then
follows the minority — it is a colour the place never had — and the patch is
pasted in it. The donor was of exactly the right light, the search had gated it
at LIGHT_GATE, and the repair still lands as a dark blotch. A mean is the wrong
estimator for a ring that is not one thing; the search already knew that, and
reads its own ring as a median for the same reason.
So the light is read once PER DIRECTION. Each of the sixteen taps is a pair of
readings — the place's ring and the patch's ring at the same sixteen places —
and a pixel takes the correction of the two readings it lies between,
interpolated by its own angle around the spot, in the same single draw. The rim
then meets the place all the way round instead of on average: a tap that landed
on the twig bends the part of the rim near the twig, and the far side of the
circle is left where it was. Sixteen taps rather than eight because a tap's
influence reaches only as far as the next tap, so the finer the ring, the less
of the rim one hard pixel of the photo can drag with it.
The second half of the change came out of the app, not out of the lab. With the
per-direction reading and no other change, heal-probe.cjs went from 49 PASS to
41 PASS / 8 FAIL: the repair's own centre came out 15-18 levels dark on a flat
field, with the frame around it clean. The reason is the estimator again, from
the other end — one direction is one pair of pixels and carries no averaging, so
whatever the ring reads at that direction, the patch gets in full. And the ring
at RING_R alone is not clear of the dust: a speck spreads about a pixel past
where it is drawn in the pixels the shader samples, so the nearest taps sit
inside the dust's own soft edge and read the dust's light. The mean had been
hiding it: one contaminated tap in eight is a level off; the same tap read whole
is the blotch. The ring is now a pixel further out again (RING_PAD), in the same
pixels the sampler works in — a fraction of the radius would be nothing at all at
the sensor-dust end of the brush, which is where this tool is aimed — and the
probe is back to 49 PASS / 0 FAIL.
Measured on a sweep of the two ways of reading it (heal-ring-sweep.cjs, CanvasKit,
three scenes, the step the eye reads at the rim plus the level of the patch's own
middle against the ground it landed in, levels out of 255):
scene shipped mean 8@1.15 this: 16 taps, per direction
uniform light difference step 0, centre 0 step 0, centre 0
twig across the ring step 53 (mean 16.4), step 56 (mean 2.4),
centre 34 dark centre 0
brush fits the speck centre 5 dark centre 0
The worst step on the twig scene is unchanged — that is the twig's own edge
crossing the rim, which no level can meet, and the floor the copy set at 85. What
moved is the average (16.4 levels to 2.4) and the level of the patch's middle,
which is the blotch: 34 levels of a place that never had them, down to none.
No new dependency. cv.seamlessClone is the same thing this shader already does —
the membrane half of a Poisson edit — and OpenCV.js would be 5-10 MB off a CDN,
solved on the CPU per spot, outside the one draw the preview, the recipe and the
export all read from: the correction is recomputed from the snapshot on every
render, which is why the preview and the exported file agree by construction and
why a saved photo opens onto the same repair. It also cannot run in the worker
the brush paints in or against the fractions the recipe stores.
Verified:
heal-blotch-lab.cjs (scratchpad, CanvasKit, no browser) — new, 12 PASS / 0
FAIL, and 8 PASS / 4 FAIL against the bundle built from
|
||
|
|
57ade27eee |
web: paste the borrowed patch at the light of the place it lands in
HEAL borrows a patch of the photo and copies it over the dust. The copy brings
the patch's texture — which is the point, the repair is the same picture rather
than a blur over the speck — and it also brings the LIGHT the patch was
photographed in, which is not the point at all. The search already refuses a
donor from another light: LIGHT_GATE is 20 levels, and a candidate past that is
not scored. But inside the gate a patch can still be 20 levels off, and 20 levels
is a soft blotch of its own at the rim of the circle — a mark where the dust
used to be, which is what the user is complaining about when they say the repair
is visible. On skin, sky and sand the dust is not the problem the eye finds; the
step the paste puts down is.
So the paste is now the patch's gradients worn at the destination's level: the
shift is the mean of the ring the spot sits in minus the mean of the same ring
around the patch it borrowed, and what lands is the borrowed pixels plus that.
It is the membrane half of a Poisson edit — keep the texture, adopt the level —
and it is eight taps per spot inside the shader that was already running. No
solve, no ping-pong, no extra pass: the correction is recomputed from the
snapshot inside the shader on every render, so the preview and the export agree
by construction and the recipe carries nothing new. The same spot in a saved
photo opens onto the same repair, because nothing about the correction is stored.
Where the ring is measured turned out to be the whole of the change. The first
cut read it at the feather line, 0.85 of the radius, which is where the pasted
patch is still at full strength and therefore looks like the natural place to
compare — but that ring sits just inside the circle, and when the brush fits the
speck snugly, which is exactly how a dust brush is used, it reads the speck: the
light the repair is measured against is then the dust's own, and the patch gets
shifted onto the very dark it exists to erase. It also reversed the smoothstep
edges the moment the ring was pushed outside the brush (a radius past rad, edges
the wrong way round, and the pass quietly drew nothing). The ring now sits just
OUTSIDE the brush, at RING_R of the radius — the same radius the search reads a
spot's light at. Outside, both sides are photographs: the ground the repair has
to sit in, and the ground the patch came from. That the two are the same
measurement is the point: a donor that passed the gate was already within
LIGHT_GATE of this ring, so the shift it now receives is bounded by the gate. The
decision to borrow and the correction to the borrow stopped being two different
opinions about the same pixel.
Eight taps at the same angles on both sides is what makes the difference read as
light rather than as texture: the grain, the detail and the neighbouring specks
that differ between two patches are averaged out by sampling both rings at the
same places, and what is left is the level. The rim, measured as the level inside
the circle against the level of the ground outside it, drops from 20 levels to 0
on a scene built for it, while the borrowed contrast stays at 40 — the level
moved and the gradients did not. That is the line between this and a blur, and it
is the line the lab holds it to.
Verified:
heal-seam-lab.cjs (scratchpad, CanvasKit, no browser) — 10 PASS, 0 FAIL: one
scene, the speck on the grey ground with every reachable patch inside a block
20 levels darker, run twice through the real pipeline — the paste the branch
shipped before this change (the copy, kept inline in the lab as the "before")
against healSkSL from the bundled heal.ts. The copy puts the block's own
level down at the rim: inner 100/110/120 against outer 120/130/140, rim step
20.0 levels, contrast 40. The shift lands the borrowed texture on the
ground's level: inner 120/130/140 against outer 120/130/140, rim step 0.0
levels, contrast 40 — the borrowed feature is still pasted at the strength it
was borrowed at, the hole reads as the ground it sits in, the block the patch
came from is untouched, and the frame away from the repair is the photo.
heal-skia-lab.cjs 28 PASS / 0 FAIL against the bundled module: the pass still
runs, the uniform block is the size its shader declares, and the paste is
still an exact copy of the source pixels — the lab's paste scene now borrows
from the SAME light (a white pixel at the middle of the borrowed patch, so a
copy and a blur of the dust cannot be confused), and its forty-spot and
three-spot runs still draw every spot in order. The scene where the two
lights differ is the seam lab's.
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, against the rebuilt app at http://localhost:8090 (docker compose up -d
--build frontend).
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: the shift is one number per spot, measured over the rim, so a border
the two patches disagree about along its length is only matched on average — a
repair laid across a hard edge keeps a faint step where the edge crosses its rim,
and the other half of the Poisson solve (a correction that bends inside the
circle, a Jacobi solve over the spot's own box, a ping-pong pass per spot) lands
only when a real photo shows that step and the eye can find it. The gate is still
needed and still refuses: a shift corrects a light, it cannot invent a patch
where no patch of that light exists, so a speck surrounded by dust from another
light is left alone rather than covered with a guess. The source is still found
by the ring search — eight directions at three distances, each mirrored — and not
by PatchMatch: the search already refuses dust and wrong light, and PatchMatch
lands when a real photo shows the search picking a bad donor. The run is still
drawn spot by spot, with no stroke id in the recipe, so a long drag is a row of
circles rather than one region.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
e021f7d1ff |
web: prove an address with the six digits the letter carries
Signing up mailed a link and nothing else, so a visitor who signed up on one
device and read the mail on another had to leave the page the studio was open
on, or give up and stay a guest. The letter now carries six digits as well, and
the verify dialog — the face a fresh signup already lands on — takes them.
Backend, one row is both proofs. `createEmailVerification` mints the token as it
did and a `randomInt(0, 1_000_000)` code padded to six, and returns `{ token,
code }`; `sendVerification` passes both to the mailer, which puts the code first
and the link second. The code's clock is `created_at + CODE_TTL_S` (15 minutes)
and the link keeps the row's own 24-hour `expires_at`: two clocks over one row,
so the code needs no expiry column of its own. That row's `code` is NULL for
anything minted before this commit, a value no typed guess can match, so an
in-flight link from the old mail still works and its owner simply has no code to
type. `db.ts` adds both columns with `PRAGMA table_info` + `ALTER TABLE` rather
than a rebuild, and sets `attempts` to 0.
`verifyEmailCode(userId, code)` answers 'ok' | 'bad' | 'stale' | 'locked', and
the shape of the answer is the point. 'stale' is both "no live code" and "too
old", so the caller learns nothing about which; 'locked' is the spent-attempts
state, which only a fresh letter leaves. The attempt is counted BEFORE the
comparison is trusted, so an interrupted request cannot hand back a guess nobody
paid for; five (MAX_CODE_ATTEMPTS) is the cap, which is what keeps a six-digit
secret from being walked through at a hundred requests a second. The comparison
itself is `timingSafeEqual` behind a length check, the same pair the password
path uses. On success the row is deleted and `email_verified` set, so the same
row spends the link with the code — one proof, one use.
The route is `POST /api/auth/verify-code`, a POST and not a GET like the link
because a code in a query string lands in every proxy log on the way. It reads
`auth`, not `requirePro`: the whole point of it is the account that has not
passed the gate yet. Input must be exactly six digits before anything else
happens, so the counter only ever counts real guesses; a wrong or stale code is
400, a locked one 429 with `retry-after: 900`, and an already-verified caller
gets 200 without touching the row. No limiter of its own: the cap lives with the
secret on the row, and a fresh code costs one of the three resends an hour, so
five guesses per code is the budget either way.
On the web side `api.verifyCode` posts the code, and the dialog's verify face
swaps its resend button for a code box plus a smaller resend beside it: the box
is `inputMode="numeric"`, `autoComplete="one-time-code"`, `maxLength 6`, and
strips non-digits as they are typed, so the number pad comes up on a phone and
nothing can paste a password into it. The submit button is disabled until six
digits are there. The two answers a visitor can actually act on get sentences of
their own (`auth.codeBad`, `auth.codeLocked`); everything else is shown as it
comes. The link path is untouched and still works, and the dialog keeps its
"Tôi đã xác thực xong" escape in no place at all — it verified nothing, so
closing the dialog and asking again covers the same ground.
The comment in `docker/.env.example` now says the letter carries both, since a
deployment without a relay writes both to the api log.
Verified:
backend `npm test` — 180 passed, 0 failed. The new section in security.mjs
drives the route end to end against the source: signup leaves a six-digit
code beside the link, a wrong code verifies nothing and leaves the account
unproven, a five-digit body is refused, a signed-out caller cannot type one,
the mailed code verifies, spending it spends the link, five wrong guesses
lock the code out and the right code then does not help, a resent letter
hands out a fresh code that is not locked out by the old guesses, and a code
aged past its quarter hour is refused.
otp-code-probe.cjs (scratchpad) — 10 PASS, 0 FAIL on http://localhost:8090
against the rebuilt app and api, no page errors: a fresh signup lands on the
verify face with the box ready, a wrong code says so and the account stays a
guest, the mailed code unlocks PRO, and a proven address is not asked again
on the next login.
Regressions, 0 fail: landing-test.cjs 172, pro-gate-test.cjs 27,
award-column-probe.cjs 18, tone-curve-probe.cjs 33. web tsc --noEmit clean.
ponytail: the code rides `created_at` rather than an `expires_at` of its own, so
the link's 24 hours and the code's 15 minutes are one column read twice; the day
the two need to drift apart independently, the column is the thing to split. The
route carries no per-IP limiter, only the per-row cap — a stranger can burn one
account's five guesses, which costs that owner a resend, and a limiter keyed on
the address would be the next thing to add if that turns out to be cheap for an
attacker. The code is not usable from another browser: it verifies the session
that asked for it, which is the behaviour the request asked for and not a gap.
|
||
|
|
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.
|
||
|
|
cd9dd28adc |
web: widen the award column and bring it in beside the copy
The award card was a 340px column with a 46px gap to the copy, which left the photograph the whole point of the card small and the two halves of the hero visibly apart rather than one composition. The column is now 440px and the gap 24px, so the card is a third again as wide and sits closer to the headline. Measured on the built app at 1280px: .lp-award 440px wide (was 340), and the photo box inside it 410px (was 310) — 440 less the 1px border and 14px padding on each side. At 4:5 the image goes 387.5px tall to 512.5px. The copy's right edge is now 824 against the card's left edge 848, where before it was 902 against 948: the same 24px of air between them, but the copy's right edge moved 78px left and the card 100px left, so the hero reaches further across and the extra width is photograph rather than margin. The stacking breakpoint below 980px carried the same 340px cap, which would have made the card narrower than the column it replaces; raised to 440px to match. Measured at 900px: the card stacks under the copy at 440px wide. Verified: award-column-probe.cjs (scratchpad) — 18/18 on http://localhost:8090, with the width check now 440 and the geometry line reading copy.r=824 card.l=848. landing-test.cjs clean, lp-arrows-test.cjs 33/33, landing-rating-test.cjs 21/21, landing-photo-guard-test.cjs 21/21 — 0 fail against the rebuilt app. web tsc --noEmit clean. Dark and light themes both eyeballed on the built app (award-hero-dark.png, award-hero-light.png). ponytail: the width is a literal in two places — the grid column and the 980px cap — rather than a custom property, because the two are the same value today and a token would need the media query to read it too; when a third width shows up, lift both to --lp-award-w. The light-theme box-shadow is still the dark card's rgba(0,0,0,0.35); widening the card makes it no more or less wrong, so it is left as it was. |
||
|
|
3f5d2cd017 |
web: give the landing hero a column of the day's best-rated recipes
The hero was a single block of copy with nothing beside it, so a visitor landing
on the page saw no photograph at all until they scrolled. It now splits into
copy + an award card: the highest-rated photos of the current window, one frame
per photo, each labelled for the window it came from.
The frame set is the day's top-rated first, then the week's, both deduped by
photo id, so a photo that is both this day's and this week's best is drawn once
and keeps the day's label — that is the tighter of the two windows and the more
specific claim. Measured on the live data (2026-09-23 UTC, week = ISO Monday
2026-09-21): GET /api/highlights came back with day [] and week a full five
frames (ids 12,13,51,50,3, every one avg 5, n 1). The day window is empty simply
because no rating had landed since 00:00 UTC, and an empty day window must not
empty the column — hence the union rather than a fallback: whatever each window
has, merged, deduped.
Frames change the way the film strip already does, so the card reuses that
machinery rather than inventing a second one: the same .lp-arrow dots and the
same FrameArrows component, which already renders nothing under two frames.
Under two frames the card also keeps still — no arrows and no timer, because a
single frame has nothing to advance to and a timer that swaps a frame for itself
is just a repaint. Five seconds a frame, one second of crossfade: all frames are
stacked in the same box and the active one is the only one at opacity 1, each
transitioning its own opacity over 1s, so the outgoing frame fades out over the
same second the incoming one fades in and the box never flashes empty. Measured
in the built app: mid-step opacities 0.32, 0.68, 0.00, 0.00, 0.00 at the halfway
point of a step, and transitionDuration exactly 1s on every slide. Stepping by
hand restarts that clock instead of letting the old 5s fire on top of the new
frame — an arrow step to 2 then waited 4.2s still sat on that frame, where
without the restart it would have moved on at 5s from the previous frame's
start.
The rating is shown on the frame because it is the whole reason the frame is
there: avg to one decimal, plus the vote count as "1 vote"/"N votes" — one
decent vote and one outstanding vote are not the same window, and the reader
can tell them apart at a glance. Score is mono, bottom-left, over a text
shadow.
Backend side this is one query and one route. topRatedPhotos(since, limit)
joins ratings to photos on CAST(substr(ratings.key, 7) AS INTEGER), since
ratings keys are the strings "photo:<id>"; it filters ratings.key LIKE 'photo:%'
so a look: vote can never award a frame — there is no look to show — and
photos.consent = 1, so a photo pulled from public display is pulled from the
awards with it. Ordering is avg DESC, n DESC, at DESC, id DESC: best average,
then the better-supported average when averages tie, then the freshest, then id
only to make the order total and the frame set stable between requests. avg
comes back rounded to 2dp. GET /api/highlights computes the two windows in UTC
— midnight, and ISO Monday midnight via midnight - ((getUTCDay()+6)%7)*86400000
— and returns { highlights: { day, week } }. HIGHLIGHT_LIMIT is 5.
The column is 340px on the right of the copy, stacking under it below 980px.
Measured on the built app at 1280px: copy ends at 902, card starts at 948, same
hero row, card exactly 340px wide, five slides for five frames, label "Recipe
of the week", score "5.0★1 vote", the two arrows the only .lp-arrow inside
.lp-award, meta #CLASSIC_VIVIDIPES. At 900px the card sits under the copy. With
the window forced to one frame the card draws 0 arrows and 1 slide; with both
windows empty there is no .lp-award and the hero is not split at all, so an
unrated install looks exactly as it did before.
Verified:
award-column-probe.cjs (new, scratchpad) — geometry, arrows, crossfade
opacities, the 5s auto step, the manual step's clock restart, the one-frame
and no-frame windows. 18/18 on http://localhost:8090.
backend npm test — 166 passed, 0 failed, with four new checks in the ratings
section: a vote lands in today's and this week's window, a look: subject is
never an award, and a photo drops off the awards once deleted. The ratings
and photos suites cover the joins the new query leans on.
landing-test.cjs, lp-arrows-test.cjs 33/33, landing-rating-test.cjs 21/21,
landing-photo-guard-test.cjs 21/21 — 0 fail against the built app.
web tsc --noEmit clean. Dark and light themes both eyeballed on the built
app (award-hero-dark.png, award-hero-light.png).
ponytail: the card re-fetches on the page's own reload() rather than polling, so
a rating cast while the tab sits open will not surface until the next reload;
the awards are a landing-page flourish, not a live feed — when they need to be
live, poll the same route on the timer the frames already run. The card's
box-shadow is the dark card's, one rgba(0,0,0,0.35), and reads heavy on the
light theme next to .lp-recipe-card's light-specific shadow; left alone rather
than adding a token for one property.
|
||
|
|
81ed53cf78 |
web: anchor the watermark box to the photo's layout box, not its rect
Two zoom-only defects in the FRAME watermark boxes, both of them units mistakes
between what the stage measures and what the engine draws.
measure() read the box off img.getBoundingClientRect(). Zoom is a TRANSFORM on
the photo, and every layer above it (wm, crop, pick, compare, straighten)
carries that same transform itself, so a rect read off the transformed element
comes back already scaled and is then scaled again by the layer. It only shows
once measure() runs while the stage is zoomed, which is exactly what the zoom
itself causes: onStageZoom re-cuts the preview copy, the new src fires onLoad,
and measure() re-runs on the transformed photo. Measured on the built app (a
2560x1706 upload, a 955px stage, four wheel notches -> 1.749x): the layer came
out at -651,-796 2921x1947 against the photo's own 43,-241 1670x1113 — the zoom
printed twice — with the box at 809,951 while the mark sits at 878,757 and the
baked text at 882,765. The user's report exactly: the textbox not anchored where
the mark was placed, and its frame not drawn where the text is.
The box is now the photo's LAYOUT box (offsetLeft/offsetTop/offsetWidth/
offsetHeight, whose offsetParent is the relative-positioned .canvas-wrap), which
no transform can touch. Same run after: layer 43,-242 1670x1114 against the
photo 43,-241 1670x1113, box x 878 against the mark's own 0.5 x 1670 = 835 + the
handle, and the box at 878,757 against the text at 882,765.
The corner handle mixed the other way round. It read the pointer's travel as
(e.clientX - d.left) / d.width — a SCREEN distance over an UNSCALED width, and
with d.left the mark's own anchor rather than where the handle was grabbed. At
scale 1 the handle sits exactly one box width from that anchor, so f came out 1
and the drag read true; zoomed, the same pull arrives s times larger and the box
it grows was itself s times too wide. Measured: an 80px pull at 1.749x reported
size 1.503 and left the box 163 -> 420px where 163 -> 241px was asked for. The
handlers now take the photo's unscaled width and height plus the scale it is
drawn at (rect.width / offsetWidth) and divide the pointer's screen delta by
both, so a pull reads the same at any zoom, and the face size and the y
compensation it feeds are computed from the photo's own width — that is the width
the engine draws the fraction against. Same pull after: 163 -> 241px, the
top-left corner left where it was.
Verified:
wm-zoom-probe.cjs (new, scratchpad) — 2560x1706 upload, four notches of wheel
held on the handle's own corner: layer vs photo box, box vs the baked white
text, the box's growth against the zoom, then an 80px handle pull. 9/9 on
http://localhost:8090 (built asset index-BOwzIcQD.js). On the previous build
the same probe is 5/9 — the layer, anchoring and size checks above.
wm-test.cjs 23, wm-font-test.cjs 38, wm-font-registry-test.cjs 26,
wm-gps-test.cjs 27, wm-gps-date-test.cjs 18, crop-frame-test.cjs,
compare-test.cjs 25, compare-crop-test.cjs, zoom-test.cjs 25 — 0 fail at
scale 1, where the old arithmetic happened to be right.
web tsc --noEmit clean.
ponytail: offsetWidth/offsetHeight round to whole CSS pixels, so the box can sit
half a pixel off the photo's own box — under a box whose tolerance is the
dashed hairline, not worth un-applying the transform by hand; the day something
reads those pixels, compute the fit instead, as baseRect already does.
|
||
|
|
d37671c359 |
web: print the grain zone by mixing two lattices, not by warping one cell
The coating's patches were drawn by varying the clump CELL with position:
cell = u * (1 + (grainZone(p * ZONE_FREQ) - 0.5) * ZONE_SWING), the same slow
value noise that picks the patch. A lattice whose cell varies with position
smears instead of resizing: its phase accumulates as
d(phase)/ds = 1/cell - s*cell'/cell^2, and c' is read along the radius from the
picture's own origin, so the second term grows with the distance s from it and
the clumps are drawn out wherever the patch's own cell runs. Measured on one
classic-neg paint (1024px, cell 1.09, the app's own Overlay at alpha 0.5, 64
tiles): with the swing on, the tiles' lag-1 correlation of the raw frame spans
-0.065..0.747 — clumps stretched into smooth blotches beside grain. The design's
+-20% swing cannot do that: the SAME field with the swing forced to 0 spans
-0.057..0.045 across its tiles, and the two-lattice field spans -0.055..0.052
(leica: -0.049..0.523 with the swing on, -0.068..0.024 at swing 0).
The zone now MIXES two FIXED lattices, 0.8x and 1.2x the stock's own cell,
weighted by that same patch noise. A fixed lattice's phase is linear in the
picture, so a patch can only choose how much of each is printed, never how
either is shaped — and the two lattices' beat falls at
1/(1/fine - 1/coarse) = 2.4 cells, 2.6px at the 35mm cell: the pixel scale, not
a line the eye reads. The mix is renormalised by sqrt(w^2 + (1-w)^2), the share
of one field's spread a two-field blend carries, so neither the mean nor the
spread follows the patch: the same classic-neg field reads sd 24.85 against
24.91 and leica 23.74 against 23.74, tile by tile.
The zone still reads what it is for. At preview scale (1600px, cell 1.70, 8x8
tiles of 200px) the mixed field's tiles span rho1 0.082..0.265, ratio 3.233,
against 0.169..0.193, ratio 1.141, with the swing forced to 0 — classic-neg;
leica 0.026..0.188, ratio 7.361, against 0.085..0.105, ratio 1.237. A coarser
patch still prints coarser clumps; it just never prints a stretched one.
Both copies carry it: docker/frontend/shared/utils/grainShader.ts and
src/utils/grainShader.ts (the phone's, which the root web harness imports too).
The field is evaluated once per lattice now, so the grain pass costs 1.94x what
one lattice did — the ratio, not the absolute.
Measured:
_grain-zone2.cjs — the 64-tile lag-1 correlation spread above, three modules
on one paint and one seed: swing 0.4 vs swing 0 vs the mix.
_grain-zone-ck.cjs — the zone's own contribution at preview scale, zone on
against the same module with the swing forced to 0, nothing else differing:
classic-neg tile sd 24.21..24.86 (ratio 1.027), hf 0.769..0.937 (1.219),
rho1 0.082..0.265 (3.233) against sd 29.04..32.95 (1.135), hf 0.834..0.858
(1.028), rho1 0.169..0.193 (1.141); leica rho1 0.026..0.188 (7.361) against
0.085..0.105 (1.237). The swing-off row's higher sd is the renormalisation
of a blend with itself (a and b are one field at swing 0), not a contrast
change in the shipped field.
_grain-fft.cjs — same paint, same seeds, three rolls, 1024px: the mix's top
spectral peak sits at 2.6px (classic-neg, cell 1.09) and 2.8..2.9px (leica,
cell 1.00) against the warped field's 3.0/4.3/6.2px and 2.7/3.8/4.5px — both
within a pixel of the clump cell, neither a coarse lattice.
_grain-bench.cjs — 12.68s per 700px field (one lattice) against 24.60s (two),
1.94x on software CanvasKit.
grain-size-test.cjs 17/0 — the SIZE rule and the readout on the module, both
lattices floored at the target's own pixel, file rho1 0.673, preview rho1
0.003.
grain-stock-test.cjs 53/0 on the deployed build — the stock table, the
halation chain and its ordering, no page errors.
grain-controls-test.cjs 20/0 on the deployed build — the patch claim still
holds there: tile sd 56.58..65.48 (mean 61.9, max/min 1.157), tile mean
spread 0.65, so a coarser patch is still not a brighter one.
_grain-spectrum.cjs (app, deployed, 1600px) — residual autocorrelation peak
0.020..0.021, top peaks at 2.0px@20/110 and 2.5px@51.
tsc: web `--noEmit` clean (the docker build runs it); the phone's scoped
config reports its pre-change baseline, nothing in grainShader.ts.
One honest number: the app-level spectral peak/median rises 4.6..6.1 to 10.2
(classic-neg, sim-classic-neg-g6/g10) because two fixed lattices beat where one
warped lattice spread. It is 20x below the value-noise field this work replaced
(29..35, tiling) and 5x below a lattice (50+), and it sits at 2px, the cell
itself.
ponytail: the field is evaluated once per lattice, so the grain pass costs
1.94x. One evaluation cannot hold two cell sizes; revisit only if a preview
budget asks for the pass back. The 0.8/1.2 rungs (ZONE_SWING/2 either side) are
one working set, not a search.
Verified: `grain-stock-test.cjs` 53/0, `grain-controls-test.cjs` 20/0 and
`_grain-spectrum.cjs` against the deployed build at localhost:8090;
`grain-size-test.cjs` 17/0; `_grain-zone2.cjs`, `_grain-zone-ck.cjs`,
`_grain-fft.cjs`, `_grain-bench.cjs` against the module built from HEAD,
`inversesqrt` still the one call the shader needed to renormalise; web
`tsc --noEmit` clean, phone scoped tsc down to its pre-existing errors.
|
||
|
|
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.
|
||
|
|
64f1598076 |
web: print the grain as jittered clumps, not as value noise on a grid
The field was 20-degree-rotated value noise on a square lattice, three dyadic octaves at 1 / 0.5 / 0.25 and a sin hash behind it. Value noise prints the density of the cell's four corners, so every clump sat on a knot of one grid, and the grid's own repeat — 13 cells, 44px at the 35mm cell — is what the eye read as diagonal lines. Measured on the old field through the app (`grain-analog-test.cjs`, `_grain-spectrum.cjs`): off-origin autocorrelation peak 0.32-0.40, spectral peak/median 29-35, top peaks at 5.4-7.8px. The field is jittered clumps now, in both copies (docker/frontend/shared/utils/grainShader.ts and src/utils/grainShader.ts). A clump lands at a random spot inside its cell — Worley F1 over the 3x3 neighbourhood, `grainClump` — so no two clumps share a grid, and what is printed is the distance to the nearest: a smooth mound, not one pixel of static. The hash behind the jitter is sin-free (Hoskins' `p3 = fract(vec3 * 0.1031); p3 += dot(p3, p3.yzx + 33.33)`), because a float sinus whose argument grows with the picture folds back on itself and is a lattice of its own. The three octaves are turned to their own angles — 20, 47, 73 degrees — and sit on 0.53 and 0.29 off the dyadic 1 / 0.5 / 0.25, where a coarse octave's cells land back on the fine one's and stack. Clumps sit higher and tighter than the value noise they replace — mean 0.569 against 0.500, sigma 0.123 against 0.081, measured — so the sum is put back on that mean and spread, `n = (n - 0.5685) * 0.52 + 0.5`, before the AMOUNT knob's own gain. The web keeps its uniforms (cell, seed, per-stock weights, spread); the phone keeps 0.55/0.30/0.15 and 2.95, which are the web's classic-chrome row, so the two print the same texture. Measured on the deployed build: `grain-stock-test.cjs` 53/0 — 35mm still coarser than 120, the halation chain intact per stock, the "halation follows the stock" ordering intact, the field still clumped (r1 0.336-0.506) and still surviving a 2x downscale. `_grain-spectrum.cjs` residual autocorrelation peak 0.02 (was 0.32-0.40), spectral peak/median 7.0-12.7 (was 29-35), top peaks only at 2-3px periods, the cell scale. `_grain-ck.cjs` at the preview cell (U=1.481/1.704): rho1 0.093/0.177 against the old 0.505/0.586, acPeak 0.02/0.028 against 0.38/0.467, peak/med 10.1/11.2 against 76/46.5, top peaks 2.0-2.9px against 5.4-7.8px. `grain-size-test.cjs` 12/0. `grain-analog-test.cjs` 7/0 — with the TEMP pair on AUTO, the field's channel split measures 0 at a cast of 0, so the grain is exactly monochrome and no neighbour lag carries structure (max |rho1..8| 0.118). One honest number: the preview's apparent strength at GRAIN 10 is ~25% higher than the old field's (sigma 61.3 against 47.9 in that harness), and that is the PREVIEW, not the field. Step 9 of src/engine/exportEngine.ts sharpens the preview at alpha 0.5, and the clumps now sit at the cell (~1.7px) instead of on the old ~5px lattice, so that sharpen bites harder. The field's own composite spread is 17% UNDER the old one at the same geometry — lab sdRaw 24.9 against 30.0, and geometry-flat where the old one was ~30 everywhere. The 0.52 normalisation was kept rather than re-tuned upward to the app-visible number: the export path does not carry that sharpen, and a grain tuned to it would print too strong. ponytail: the octave angles and the 0.53/0.29 rungs are one working set, not a search. Re-tune only if a stock's cell is changed again. Verified: 53/0 + 12/0 + 7/0 grain harnesses, `_grain-spectrum.cjs` and `_grain-ck.cjs` A/B against the field built from HEAD, `sims-test.cjs` 31/0, `fx-mono-test.cjs` 15/0, no page errors. |
||
|
|
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. |
||
|
|
4795a2a0ee |
web: give each stock its own grain, and a halo where it belongs
The landing card sells "35mm & 120 Film Grain — authentic grain structures plus halation bloom, tuned per stock rather than one global overlay", and the engine printed one field for everything: a width/1080 cell, one spread, no bleed. `shared/utils/grainShader.ts` (new, the web fork of the phone's src/utils/grainShader.ts) now carries the stock table — format, cell, spread, octave mix, halation, halo radius, halo tint — and `grainStockFor( recipe.baseFilter)` picks the one this recipe prints. FORMAT. 35mm cells are the 1.0 reference the knob was tuned at (classic-negative 1.15, B&W high contrast 1.25); the 120 emulsions sit at 0.55-0.72 and open their base octave (mix 0.55/0.30/0.15 -> 0.62/0.26/0.12), so the same knob prints a finer, smoother texture on the bigger negative. Measured on a flat 128 grey at a 3200px preview (cells 3.41px vs 1.63px), GRAIN 10, luma residual against a 17px box: 35mm CLASSIC NEGIPES r1 0.793 keeps 0.976 35mm CLASSIC CHRIPES r1 0.744 keeps 0.931 35mm B&W HIGH CONTRAST r1 0.812 keeps 1.002 120 PROVIPES r1 0.423 keeps 0.728 120 VELVIPES r1 0.313 keeps 0.672 120 ACRIPES r1 0.543 keeps 0.794 r1 is the lag-1 autocorrelation of the residual — how coarse the clumps are — and "keeps" is the residual sd after a 2x box downscale over the sd before, i.e. how much of its texture a print at half size holds on to. Every 35mm stock beats every 120 stock on both, and VELVIPES (0.55 cell) is finer than PROVIPES (0.62) inside 120, so the format is a look and not a label. Raw sd is NOT the measure: the knob drives one alpha for every stock, so a stock's amount follows its cell and mix rather than the order anyone assumed. HALATION. A new pass 6b thresholds the print (T0 0.62, T1 0.92), tints what is left the stock's halo colour — red, because red is the light the emulsion passes and the backing returns — blurs it at the stock's own radius and screens it back at `halation * grain/10 * 0.6`. Riding the GRAIN knob keeps today's contract: OFF is still a clean frame, the OFF/WEAK/STRONG chips still mean 0/3/6, and a sensor stock carries none at any amount. Measured R-B of the ring around a white block on black, GRAIN 6 minus GRAIN 0 (mean, and the ring's reddest pixel): CLASSIC NEGIPES 7.87 (peak 0 -> 14) VELVIPES 3.71 (0 -> 13) CLASSIC CHRIPES 2.91 (0 -> 10) PROVIPES 2.01 (0 -> 7) B&W HIGH CONTRAST 0.61 (0 -> 5) ACRIPES 0.24 (0 -> 3) LC STREETLIFE CLASSIC 0.09 (0 -> 0) which is the table's own halation column (0.45 > 0.30 > 0.25 > 0.18 > 0.15 > 0.12) in order: the colour negative halates hardest, the B&W emulsions barely, Acros — no colour layer to bleed — least of all, and the sensor not at all. The colour negative's own grade leaves its ring blue at GRAIN 0 (-5.96 there), so the statistic is the change and not the absolute channel; in a crop of the block the bloom itself is unmistakable at GRAIN 6 and 10 and absent at 0. GRAIN_SEED moves here from exportEngine.ts so the roll is still one per page load, and still shared by the preview, the compare copy and the file. Checked: tsc --noEmit clean; grain-stock-test 53 PASS / 0 FAIL; sims-test 31/0, fx-mono-test 15/0, grain-size-test 12/0, grain-analog-test 7/0, wm-font-test green. ponytail: halation rides the GRAIN knob instead of a control of its own, since the card promises no more than "tuned per stock". Add a HALATION chip when the phone grows one. ponytail: `grainCell`'s 1px floor is the aliasing guard, and it also hides the format ratio under a ~1600px preview. Nothing to add: the exported file is always wide enough, and the harness renders at 3200 to see it. |
||
|
|
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. |
||
|
|
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.
|