b824308182576e15868cf1e7865dff5475d24f53
44 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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).
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
e8a0c0d076 |
web: print the grain as clumps, not as static
GRAIN was one value hash per cell, sampled straight off the pixel grid: a square lattice at the picture's own axes, the same field in every session and in every photo, and — measured on a flat gray frame, where every deviation IS grain — a spread that was flat rather than emulsion-like (neighbour correlation rho(1) = 0.183, so half the noise was one pixel wide). The noise is now an emulsion. Three uncorrelated hashes are averaged into a density (a bell, the way an emulsion's density swings, instead of the flat spread of a single hash), that density is read as value noise with a smoothstep cell, and the result is three octaves of it — the cell, then 2x and 4x that cell — over a lattice turned 20 degrees off the picture's axes, so no grid shows through. Only coarser octaves: a finer one (tried 2.043x, 0.72px) goes sub-pixel and rho(1) falls to 0, i.e. back to static. The whole domain is offset by a seed rolled once per page load, so two visitors never print the same clumps while the preview, the compare copy and the file of one session still print the same roll. Same flat 3000x2000 frame, preview render 1600x1067, GRAIN 10: sigma 52.53 -> 50.15 (the 2.95 gain keeps the spread the AMOUNT knob was tuned against, since the repo notes the slider was calibrated on the old field), rho(1) 0.183 -> 0.275, and the three channels still move together — the 6.9 of sigma 50 that is left over is the Overlay blend meeting the frame's own tint, the plane itself is one gray value in all three. Re-rendering the same frame draws the same clumps byte for byte; a reload rolls a new seed and a new field at the same strength. |
||
|
|
3bfa82e7f2 |
web: stamp the photo's own day on the GPS mark
The GPS mark printed Date.now(), so a photo taken in 2019 carried the day it was opened. It now prints the frame's EXIF date — DateTimeOriginal, falling back on CreateDate then ModifyDate — wherever the position came from: - readCapturedAt() reads the date off the file, and readGps() uses it for a position found in the same EXIF. - adoptPhoto holds it in its own state, so a frame with a date but no position still stamps the date when the position is typed in by hand. - The device's own position stamps it too. That path runs inside adoptPhoto, where the render still holds the previous photo's date, so locateMe takes the date as an argument rather than reading state — the panel's own button, which has no such date to hand, passes none and reads the state as before. A file with no date at all still falls back on the visitor's clock: there is nothing else to believe. |
||
|
|
a0965101e1 |
web: hand the upscaler's activations to the chip that is running them
The WebGPU execution provider has no PReLU kernel. The model is 34 convolutions with a PReLU after every one of them, so an export that took the GPU path was split 33 times: each activation came off the chip to be activated on the processor and went straight back, a 64-channel map in both directions, per tile. A machine with a good graphics chip was not exporting any faster for having it. PReLU(x) is exactly Relu(x) - slope * Relu(-x), and Relu, Neg, Mul and Sub the provider does implement, so scripts/realesr-gpu.py writes the 33 activations out as those four and drops the slopes nobody reads any more. The model file is the output of that script, not the file as published. One 256x256 tile through the model before and after, on a WebGPU session: the runtime no longer reports nodes left off the preferred provider (it did, once, before) and the processor path answers bit for bit what it answered before. The warning itself cannot be switched off from here - env.logLevel is read when the runtime module initialises, before any of this runs - so the graph was fixed rather than the lines hidden. |
||
|
|
a2c12a2638 |
web: ask the GPU for the fast one before an export runs
onnxruntime hands a webgpu session whatever adapter the browser picks by default, which on a laptop with both is the one built into the processor: the export then waits on the slow half of the machine for no reason. The runtime reads `env.webgpu.powerPreference` when it builds the webgpu session, so set it to high-performance; a browser with nothing to honour the preference with still falls back to the threaded wasm path exactly as before. |
||
|
|
28a688fd82 |
web: hand the upscaler only the pixels the export is asking for
The model's own factor is 4 and the export's target is some number of pixels, and the two were never reconciled: a 2400x1800 photo exporting at 4K was run through the model at 4x — 9600x7200 of invented detail — and then three quarters of it were thrown away by the draw that lands the file on 3840. The arithmetic was the whole wait. Measured on the wasm path, one export: 153.2s. The photo is now resampled once to `targetLongest / 4` before the model reads it, so the model still answers at its own 4x and the answer is the size the export asked for. Same 2400x1800 to 4K: 42.7s, 80 tiles of model for 20. Half the photo's pixels is the floor — below that the model is no longer enlarging the picture, it is drawing a new one from memory — and the ceiling is the photo's own size, so a gain past 4 behaves exactly as it did. Nothing in the finished file gives the smaller input away: the 6px stripes come back at full contrast (254.9 vs 254.8), the black-to-white step lands on the same pixel (x=625 in both) and rises in 1px instead of 3. The 32MB of runtime and model are also fetched, and one 16x16 tile pushed through the graph, when the export menu opens rather than after a size is picked: the visitor waits for the pixels, not for the download. crop 1:1 2400x1800 to 4K: 155.8s -> 52.6s, crop 3:4: 153.0s -> 54.1s. |
||
|
|
d9bababfd7 |
web: filter the framed print draws so a scaled photo stops staircasing
The polaroid and the wall frame both draw the photo into a window that is not the size of the photo — the instant print shrinks it to 0.898x, the wall frame cover-scales it by max(winW/pw, winH/ph), which is 1.47x up for a 2400x1800 source. A plain drawImageRect is nearest on CanvasKit, so that resize dropped the edge back onto the output pixel grid: a 5 deg straighten inside a frame exported 59.8% of its rows with the crossing pinned to the same pixel as the row above (61.1% in the wall frame), while the same photo without a frame came out at 55.8%. Both draws now go through drawImageRectOptions with FilterMode.Linear, the same call shape the straighten draw already uses. Measured on a 2400x1800 hard-edge fixture at 5 deg, exported at the photo's own size: polaroid 59.8% -> 37.5% flat rows, fracStd 0.336 -> 0.184; wall frame 61.1% -> 25.7%, fracStd 0.467 -> 0.210 — and the residual matches the 0.202 of the unframed export, so the window costs nothing beyond the resample underneath it. A 45 deg edge printed through the instant frame at 0 deg is unchanged at 0.0% flat rows. |
||
|
|
f201deee46 |
web: export a big photo without inventing pixels it already has
The export menu measured the photo off the 1600px preview copy, so a 2400px photo was believed to be 1600px across: the hint named the wrong size, the model was asked to upscale a photo that already had more pixels than the target, and a guest's 2048 ceiling was skipped because 1600 never crossed it. A committed crop made it worse — the crop's longest edge was taken from the wider side of the crop rect rather than the side the frame actually keeps, so a 2400x1800 photo with the default 0.8 frame was called 1280px and ran the model over 80 tiles (158.7s) to reach 2K. The photo's own dimensions are now read off the original bytes, and the crop's long edge is the same axis-aware fraction the stage already uses. The export asks the model only when the photo itself is short of the requested size, or when the crop would have to be stretched past 1.5x to get there; otherwise it resamples — down, or a hair up to make up for the crop — which is what a photo that already holds the pixels deserves. Measured, wasm path, 2400x1800: no crop at 2K went 2.7s/2400px (wrong size) to 3.4s/2048px, the default 0.8 crop went 158.7s/80 tiles to 3.5s/no model, and a 1:1 crop went 2.5s/1800px to 4.0s/2048px. A 1200x900 photo cropped to 1:1 and exported at 2K still runs the model (2048 from a 900px crop, 40.3s), and the superres suite is unchanged: 640x480 to 2K/4K/custom still comes out exact, with the model's 16.6 edge energy against bilinear's 4.8. |
||
|
|
1d4c6b1d66 |
web: upscale on a thread pool instead of one core
The super-resolution export ran single-threaded because the site was not cross-origin isolated and the runtime had no SharedArrayBuffer to spread a tile over. nginx now sends COOP and COEP — on the document, and on the script responses a nested worker fetches, which Chromium checks the same way and blocks as `coep-frame-resource-needs-coep-header` without them — and the loader asks for `min(8, hardwareConcurrency)` threads whenever the page is isolated, falling back to one if the headers ever go missing. A worker script is also why the landing's QR image needed `crossOrigin`: COEP refuses a cross-origin image that did not opt in with CORS. The unpack was the other half. Each tile was clamped a channel at a time and painted whole, padded ring and all; it now writes straight into the Uint8ClampedArray, which clamps and rounds on assignment, and skips the ring rather than drawing it and clipping it away. 640x480 to 4096: 39.0s to 16.2s. 1000x750 to 4096: 89.6s to 30.9s. One 256px tile through the model: 6.8s to 1.8s. Measured on the wasm path — the test browser has no GPU adapter — so a WebGPU export, still per-tile inference, keeps its own times. |
||
|
|
b394bad09e |
web: a wall frame keeps the crop that was applied
CROP + APPLY then a wall frame handed back the whole photo: the crop block was skipped outright for both walls, so the artwork hung the original. The walls' own opening still ignores the aspect chip — that is what the exclusion was for — but the visitor's crop is theirs to keep. Measured with a source banded red on top and blue below, cut away by a 16:9 crop: through WALL FRAME and WALL FRAME LANDSCAPE the bands used to come back (569k and 350k red pixels); both now export clean. |
||
|
|
2f78216b6f |
web: the upscale drops its tile seams
A tile's destination rectangle was placed at x0 * scale, and that scale is rarely whole, so every 256px boundary landed on a fraction of a pixel. The edge was drawn half covered, stayed transparent, and the JPEG export flattened that transparency onto black: a dark line down each seam. Snap both destination edges to whole pixels instead, so neighbouring tiles share the exact same boundary, and make the destination context opaque so no partly covered pixel can survive as transparency again. Measured on a 640px source: the seam at 2K was 46 levels darker than its neighbours (96 at 4K); it is now within one level of them. |
||
|
|
c0c99a9672 |
web: EXPORT offers a size, and a bigger one is upscaled in the browser
The server still never sees a photo, so the model has to run in the page. Real-ESRGAN x4v3 ships as a 4.9MB ONNX in public/models and is loaded lazily on the first export that actually needs it; the wasm runtime is copied next to CanvasKit at build time and stays lazily fetched, cached for 30 days. Vite is told onnxruntime-web is external-wasm so no 28MB asset lands in the bundle. UNCHANGED keeps the old path and the tier cap; 2K/4K/custom upscale only when the request is larger than the photo being edited, otherwise they resize down. Guests keep UNCHANGED and 2K. Tiling is 256px with an 8px overlap, so memory follows the target size rather than four times it. |
||
|
|
f9a40a9e1c |
web: the histogram opens top-left, and CLEAR asks before it forgets
The histogram used to park itself in the top-right corner on the first paint; it now starts at the top-left of the photo and is dragged from there, the way the rest of the overlay is. Nothing else changed in it — same drag, same clamping, same resize. The stage also gains a CLEAR button, sitting before the picker button, which is now OPEN PHOTO. CLEAR takes the photo off the stage, but not before asking: SAVE PHOTO files it first and only then clears, EXPORT IMAGE writes the JPEG and then clears, CLEAR WITHOUT SAVING drops it there and then, and CANCEL leaves everything alone. Saving from that modal resumes the clear once the file has really landed — a guest, a capped account or a cancelled name prompt never loses the frame. Clearing forgets the working photo (source, preview, GPS, ISO, and the IndexedDB copy session.ts now deletes), while the look, the crop and the undo history stay put, so the next photo opens on the same settings the way replacing a photo already did. |
||
|
|
428e7fa682 |
web: a film sim is colour and tone only
The ten PHOTO STYLE sims now carry nothing but their stock's own grade, and each is named for the stock it stands for: PROVIA, VELVIA, CLASSIC CHROME, CLASSIC VIVID (Velvia spliced with Classic Chrome at the blue row), CLASSIC NEGATIVE, ASTIA, ETERNA, ACROS, LC STREETLIFE CLASSIC, LC STREETLIFE VIVID. Grain, clarity, saturation and light moves were dropped from their `adjustments`, so a sim is a clean starting point and the general knobs read their defaults while the look still lands on the pixels. LC STREETLIFE VIVID keeps the one brightness step its stock needs, but as SIM_EXPOSURE_BIAS in colorUtils rather than as an adjustment: it is folded in where the Exposure slider applies, so the picture gets the lift and the parameter stays at 0. Also in this checkpoint: the watermark/GPS boxes and their colour pickers, the WATERMARK chip column, the real admin stats, and the fix that stopped presets from doubling and a frame from refusing to come off when a photo was reopened (/file is the finished render, /base the editable pixels). |
||
|
|
15bacacafa |
web: logging out ends the studio session, not just the cookie
A session followed the browser, not the account: log in, open a frame, log out, come back as a guest — the same photo stood on the stage, because the studio's own store (localStorage knobs + the photo in IndexedDB) outlived the cookie with nothing to clear it. clearSession() now drops both, and the three log-out buttons call it. The studio's own button reloads after the delete has committed — a reload mid- delete aborts the transaction, so the promise resolves on tx.oncomplete, not on the request. The account's frames are untouched: they reopen from MY PHOTOS. |
||
|
|
10466e122a | web: the frame tab straightens the photo by hand | ||
|
|
1b71c0196f | web: the eyedropper reads a colour and the mixer moves that hue band | ||
|
|
b4d5d2926b |
web: give each member a photo folder and burn the strip into the export
Every member gets /photos — their own uploads, counted against a 12-photo cap, each card showing the tagline and the technical line the studio would print. The studio gains SAVE PHOTO n/12 in the top bar: it renders the full resolution look, stores the strip (tag/title/meta) with the upload so the landing reel frames it the same way, and refuses past the cap. EXPORT now burns that strip into the file: the amber #TAG over the photo's top-left plus a dark caption band below carrying the recipe name and the ISO / grain / warmth line. The live preview stays clean, and the saved upload stays clean too — the reel draws its own frame from the stored labels, so a burned band would tag the tag twice. Admins manage any photo through DELETE /api/photos/:id; members only their own. The users table's photo counts stay in step with the folder. |
||
|
|
43d86b4b6f |
web: moderate accounts, accept 12MB uploads, put SAVE under CREATE
- /admin User account rows gain BLOCK/UNBLOCK, REMOVE/RESTORE and DELETE. Blocked = cannot sign in (sessions swept), removed = hidden from the strip and cannot sign in, both reversible; DELETE drops the account with its photos and recipes and unlinks the files. An allowlisted account is never a target, so an admin cannot moderate or delete itself. - Photo uploads move from a 3MB API cap / 4m nginx cap to 12MB / 16m, and the browser shrinks an oversized still before sending it (2048px JPEG, avatars 512px) so the declared type still matches the sniffed bytes. - The studio SAVE leaves the top bar and sits under the CREATE RECIPES tab, labelled SAVE RECIPES. |
||
|
|
8e6c1493e8 |
web: grade the colour matrix before the tone pass
A SkPaint runs its shader BEFORE its colourFilter, so setting the exposure matrix on the same paint as the tone shader landed the gain after the tone pass: HIGHLIGHT -10 rolled a bright pixel back to 0.78, +EXPOSURE then multiplied it by 1.2 and +0.15 and it clamped back to 1.0 — the HIGHLIGHT slider looked dead the moment exposure went up. The matrix now renders into its own image and the tone/cinema chain samples that. Measured on the real engine (HIGHLIGHT -10 first, then EXPOSURE +10): top end stays 0.780 (was 1.000), midtone 0.502 -> 0.722. |
||
|
|
f13fc37b5a |
feat(panel): cascading columns, WB colour swatches, stronger HDF glow
Layout - the panel is a cascade of columns: the rail's tabs, the tab's chips, the open chip's sub-chips, then the ruler. A child column no longer hides the column it came from (TEMP -> COLOR TEMP keeps TEMP visible); chips stack one per row instead of wrapping - FRAME's WATERMARK opens its own column, so the frame chips stay put - CREATE RECIPES gets the wide column its two-up form needs WB colour swatches - the ruler draws a colour box under the slider that follows the value: COLOR TEMP is the Kelvin colour (Tanner Helland), TINT runs green -10 -> neutral 0 -> magenta +10 HDF EFFECT - knee 0.55..0.85 -> 0.45..0.75, blur 0.004+0.015n -> 0.006+0.024n of the width, screen alpha 0.15+0.35n -> 0.28+0.52n: a wide halo on the highlights instead of a hairline glow. Web copy of toneShader only — the phone keeps its own tuning. Tabs - rail order is PRESETS, FAVORITED, WB, LIGHT, FX, FRAME, CREATE RECIPES |
||
|
|
9ba2667c6c | Hang the wall frame landscape too, beside the portrait one | ||
|
|
751beb52c1 | Add a draggable crop frame behind APPLY, whole-look undo, and recipe import | ||
|
|
51e572b9ce |
Persist the studio session across reloads
The working photo and every knob the workspace holds now survive a reload, for guests as much as for signed-in users: - engine/session.ts: the knobs go to localStorage (rc.studio.v1) as small JSON; the photo goes to IndexedDB, because a 12MP JPEG does not fit in localStorage. Both fail soft (private mode, quota) — the studio still works, it just forgets. - App.tsx: state seeds from the stored snapshot synchronously, so the first paint already holds the user's settings; the boot effect pulls the photo back and adopts it with keepGeo, so the restored params are not clobbered by the photo's own EXIF. Recipes a guest creates with SAVE RECIPE stay session-only, as asked — they are still gone on reload (create-test asserts it). |
||
|
|
d31d945827 |
Add the CREATE RECIPES tab to the web app
The phone's RecipeCreateModal becomes a rail tab with the same rows, seeding from the look on screen and clamping the same way. SAVE RECIPE applies the new look, lists it under RECIPES and, when signed in, stores it on the account; a guest's copy stays in memory and goes away with the page. Signed-in users can also export the recipe as the app's encrypted .recipe file (shared/utils /recipeShare.ts vendored byte-identical from the RN project). |
||
|
|
8c6e7930db |
Add self-contained docker/ stack for the web UI
`docker/` now holds the whole web build — frontend (Vite + React + CanvasKit),
backend (Fastify + SQLite) and the compose file — so the folder can be moved to
another machine and run without the React Native project:
cd docker && cp .env.example .env && docker compose up -d --build
Only `${WEB_PORT:-8090}` is published; nginx serves the SPA and proxies /api to
the `api` container over Docker's DNS. Photos never reach the server.
The shared render code is vendored into `docker/frontend/shared/` and aliased to
a CanvasKit shim, so the app's own frameUtils/toneShader/jpegDpi run unchanged.
Fix the all-black render on GPU surfaces: `MakeWebGLCanvasSurface` creates a
separate WebGL context per call, and a texture from one context cannot be
sampled by a surface on another — so any pass that drew a snapshot onto a second
surface (output sharpen, screen sharpen, polaroid/wallframe cards) came out
solid black, while the raster fallback was correct. Use one shared
GrDirectContext + MakeRenderTarget instead.
Verified in headless Chromium against the running stack: 12MP JPEG in, preview
mean=120.5 sd=60.5, export 2048x1536 mean=107.2 sd=62.1, JFIF density 300/300,
EXIF present, no console errors; health/signup/login/me/recipes all 2xx through
the nginx proxy.
|