e43c02c36f69071d4c545ba47e073a768d23a6fb
30 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8d0a46e7f5 |
fix(frame): give OLD FILM PORTRAIT a real portrait output
The sheet was stretched over the visitor's frame, so the portrait variant only turned the paper inside whatever shape the photo had: a landscape photo stayed landscape. The film now has its own opening like the walls do — the frame is scaled up to the photo, the photo is cover-cropped into the whole sheet, and `old-film-portrait` is the PNG turned 90° CW, so the pair is one frame standing and one lying. The torn edge has no straight sides, so the photo runs under all of it instead of being cut against a measured window. |
||
|
|
84b8e64652 | feat(frame): add OLD FILM LANDSCAPE and OLD FILM PORTRAIT from old_film.png | ||
|
|
76d84503c9 |
web: SHARPENING lifts only the edge it was pointed at, so a flat half of the frame keeps the grain it came with
SHARPENING was the doc's §4.2 kernel with the two parts of §4.2 missing from
it. `CLARITY_SKSL` evaluated the 3x3 unsharp mask with Mask = 1 on every pixel
and no coring at all, so a flat sky, a cheek and a noise speckle all took the
gain an eyelash took. That is `thay_doi_thong_so_giong_lightroom.md` §1 written
out as a bug: "khi Sharpen, ảnh nổi đầy sạn hạt cát" — the knob could not raise
the contrast of an edge without raising the noise of everything beside it, and
on a grainy frame the second effect won.
`SHARPEN_SKSL` is the doc's own line, `Image + Amount x HighPass x Mask`, with
the two terms it names:
- EDGE DETECTION: the Sobel magnitude G = sqrt(Gx^2 + Gy^2) on luminance, put
through the doc's soft threshold smoothstep(T, T + 0.1, G). Flat fields
read G = 0 and get Mask = 0 — the pixel is handed back untouched.
- DETAIL (halo coring): a high-pass under SHARPEN_CORE is a speckle, not a
detail, and is suppressed. The coring is soft (a ramp across the threshold,
not a cliff) so a detail sitting on it is not switched on and off from one
pixel to the next.
The HIGH-PASS is the doc's Radius, held at one image pixel — 0.7-0.9px on a
Retina panel — and it is a LUMINANCE high-pass carried by all three channels.
A per-channel kernel sharpens a red edge against a green one and draws a colour
fringe down every contour; the file's own §3 rule is to keep R/L, G/L and B/L
where they were.
`scripts/sharpen-check.mjs` pins the three properties the old kernel could not
have: a flat field and a field of grain come back unchanged, a step below the
threshold comes back unchanged, and a hard step moves apart on both sides while
the flat halves beside it stay put. `CLARITY_SKSL` and `clarityUniforms` are
gone with it, and the header note that said CanvasKit had two convolution steps
to replace now says the one it has.
Checked: node scripts/sharpen-check.mjs; node scripts/denoise-check.mjs; node
scripts/tone-base-check.mjs; node scripts/highlight-knee-check.mjs; node
scripts/auto-tone-check.mjs; node scripts/half-check.mjs; node
scripts/mask-wb-check.mjs; node scripts/preview-match-check.mjs; node
scripts/raw-develop-check.mjs; node scripts/white-level-check.mjs; node
scripts/wb-table-check.mjs; npx tsc --noEmit.
|
||
|
|
90a7ec9e46 |
web: NOISE REDUCTION takes the colour speckle out of the frame and leaves every strand of it where it was
The knob was a `MakeBlur` image filter on the draw of the graded photo — one
sigma over all three channels — so at NOISE REDUCTION 100 a 1024-pixel preview
lost every edge finer than 0.6 of a pixel of its own and nothing brought the
luminance detail back. That is thay_doi_thong_so_giong_lightroom.md §4's own
warning ("Noise Reduction sẽ làm nhòe toàn bộ chi tiết sợi tóc và vân da") written
into the engine, and the filter had a second cost: a paint filter is handed the
shader's INPUT, so the pass could never read the graded pixels it was supposed to
correct.
It is a two-child pass now, NR_SKSL, run after the draw: the frame, and a blurred
copy of it (blurredFrame — the snapshot read back through the same MakeBlur the
ramp's base and the negative sharpening use). The output takes its CHROMA from
the blurred child and its LUMA from the frame, the split the tone ramp's header
already describes (`rgb - luma`), so the output's brightness is the input's at
every amount by construction — the eye is nearly blind to a hue change at that
scale, which is the whole reason the colour half is free. The reference reaches
NR_CHROMA_SPAN = 0.4% of the frame's width, the doc's own 3..5 pixels of a
full-resolution frame, where the knob's blur was 0.6 of a pixel.
The luminance half of §4.1 (its bilateral filter) is deliberately not here: it is
the half that costs detail and no frame has shown grain the colour half left
behind. ponytail: add it as a second child of this same pass when one does.
Checked: `tsc --noEmit` clean; `denoise-check.mjs` (new) compiles NR_SKSL on
CanvasKit, asserts the engine still wires both children and no longer blurs the
draw, and renders the pass at five amounts against a flat pair — amount 0 is the
pixel exactly, amount 1 carries the neighbourhood's colour difference, and the
luma never moves at any of them; `highlight-knee-check.mjs`, `tone-base-check.mjs`
and `mask-wb-check.mjs` still green.
|
||
|
|
bc550569ad |
web: BLACK keeps the picture's texture and not the base it was read off, clarity stops the blur at an edge instead of at a colour, and a backup folder that refuses is handed back to the picker
BLACK at full deflection returned a soft picture, and on a monochrome frame it
returned the blurred base outright. The tone pass reads the ramp at the
neighbourhood's base and then rebuilds the pixel, and it rebuilt it by the RATIO
the base had moved by — `Base' * (Input / Base)`, the other half of
fix_shadow.md's decomposition. A ratio is a gain, and that gain is a function of
the neighbourhood: the knob that takes the base toward zero scales every pixel's
detail by the same coefficient, so the knob that darkens the frame takes its
texture with it. Measured on lightroom_shadow.jpg at 1024px through tone-sim.mjs,
the pass in plain JS with the shader's own constants: at BLACK -100 the finest
gradient came back at 0.75 of the input under the ratio and at 0.98 under the
sum, while the frame darkened the same either way (mean 0.416 to 0.359 both). A
monochrome stock is the whole frame of that error, because all three channels ARE
the pixel's luma there, which is why the picture came back as the base — soft,
and short of every edge it had.
The reconstruction is `Base' + Detail`, ADDED and not scaled, and it costs
nothing where there is no move to make: with every knob on zero the ramp at the
base IS the base, so the difference is exactly zero and the pass is the identity
however coarse the base is. A caller that hands in no neighbourhood at all — a
mask — hands in the pixel's own image as its base and gets the global move back,
which is what the ratio gave it too. `o` is held inside the cube before the
detail is added, so a neighbourhood the ramp has pushed under the floor keeps the
structure around it instead of carrying its pedestal down onto every pixel in the
region. The bright side of the same move is untouched: the lift is still the
neighbourhood's, and at SHADOW +100 the band above the lifted knot still keeps
0.81 of its spread where the global move kept 0.42.
CLARITY drew a light stroke down every contour, and the reason was in what the
blur called a neighbour. The range weight was the colour difference,
`exp(-dot(d, d) * 24)`, which is loose on any coloured edge — two sides of a hair,
a branch or a rail can share a red and differ in green — so the reference reached
across the edge, and the reference is exactly what the blend subtracts. The wider
it reaches, the more a contour reads as detail. It reads LUMINANCE now, one
decision per tap at the doc's own scale (`CLARITY_RANGE_SIGMA` = 0.04), so an edge
of any hue stops the blur dead.
The blend moved the three channels by their own differences, which is what
coloured fringing along every contour was, and the amount it moved them by was
the MASK's `CLARITY_GAIN` borrowed for a different child. It moves LUMINANCE now
— one value carries the whole pixel back with it, so hue is untouchable and skin
does not go sallow at the top of the knob — under the doc's midtone weight
M(L) = 4L(1-L), which deepens the greys a picture is made of and leaves the burnt
ends and the deepest shadows where they are. The positive side's gain lives
inside the shader (4.5, raised from the doc's 1.8 because the range weight above
reaches less far and carries less detail): clarity-halo.mjs, which measures the
pass pushing a pixel outside the range of its own neighbourhood, reads a bright
stroke of 55.3/255 on lightroom_shadow.jpg and 54.3/255 on DSCF1701.JPG at +10,
against the old pass's 145 and 127 at the same knob — and 8.0 already reads 98, so
the knob does not need to go further to keep the flat areas moving.
`exportEngine` passes the knob's own units now, `[clarityKnob / 10]`, since the
gain and the sign are the shader's business and the mask's gain is not the
frame's.
And a backup folder the browser had stopped letting the page write to wrote
nothing and said so, once, with no way back: a permission outlives the tab only
while the tab does, so the row kept reading a permission of a moment ago while
the write went to the disk without one. A refusal that names the permission, or
the folder that is not there, now goes through the picker ONCE — the picker is
the only thing that hands a folder back — and the run is repeated; anything else
is the folder itself saying no, and is not asked twice. The sentence on the
screen is one line over a strip of photographs and cannot carry a reason, so the
reason goes to the console and a word of it into the note (`{why}`, the name the
browser gave the refusal and never a stack), which is the whole of what tells a
folder that was moved from a permission that lapsed. Both dictionaries learn the
word.
Checked: `tsc --noEmit` clean; `tone-base-check.mjs` and `highlight-knee-check.mjs`
updated to the new reconstruction and passing; `tone-sim.mjs` on
lightroom_shadow.jpg at 1024px for the numbers above; `clarity-halo.mjs` for the
stroke.
|
||
|
|
097e383b86 |
light: the tone base is a real blur, not nine point samples of one — the ring aliased the luma and the ramp painted the alias back as mottle
The base layer shipped last commit was nine POINT SAMPLES of the child, one ring out at TONE_BASE_RADIUS. A ring is not an average, and on a frame with texture at the ring's own scale it is worse than one: the nine lumas differ, the sample pattern beats against the texture, and the base field comes out aliased. The gain the pixel then rides, o(base)/base, is a function of the base with a kink at every knot — so each alias of the base becomes an alias of the gain, and the reconstruction paints it straight back over the detail it was supposed to leave standing. Reported on a waterfall: grey patches loose on a mountainside, a smear across the face of the falls, and plateaus in cloud and sky (the pass runs whole-frame, so its base is read in the bright end too). Measured, 1160x774 at SHADOW +100, the high-frequency part of the gain field (9px high-pass, where a real base can hold none): 0.0627 for the ring, 0.0048 for a blur of the same radius. Thirteen times. The fix is not more taps — a 7x7 at a third of the step is still point samples, only smaller — it is to stop sampling: the caller blurs. So the base is now a second CHILD of the tone pass, the frame blurred by Skia's own MakeBlur (blurredBase in exportEngine.ts, the draw drawBlurred already made for the sharpening pass) at TONE_BASE_RADIUS of the frame width and sigma TONE_BASE_SIGMA of that radius — a box's equivalent at the radius, so the neighbourhood is the one the radius always named and the cuts are smooth instead of hard. The shader reads it ONCE per pixel and baseLuma loses its loop and its `bx`. That is also the cheaper pass: nine child evals walked the exposure/matrix chain nine times, one eval does not. A caller with no frame to blur hands in the image it is already shading as the base. The tap then lands exactly on t and the ramp is the global move again — a mask's degenerate bx of zero, spelled as a child that IS the source, which is what gradientMask.ts's own call (base == t) already meant. Checks: tone-base-check.mjs is new — SkSL is only compiled at runtime and nothing here compiled TONE_SKSL whole, so the pass is compiled and rendered for real, with the frame and the base held at two flat values a little apart: the pixel has to land on the value the ramp over THAT base predicts (171 for a 128 pixel over a 76 base), and a base equal to the pixel has to be the identity. highlight-knee-check now pins the one tap, the absence of `bx`, the blur, and the two-child wiring. 9/9 pass, build clean. Skipped: no guided filter proper — the base is a plain blur, so a strong edge is no longer held out of it the way the range weight held it (a dark rock a blur's width from the water reads a lifted base and keeps its own darkness). Add when a frame shows the halo; the doc asks for a plain blur and this is one. |
||
|
|
e61dccc784 |
light: the tone ramp is drawn through a base layer, not the pixel, so a SHADOW lift moves the region and leaves the texture in it standing — the quarter above the knot came back at 0.57x of its own spread, 0.78x now
The four knots were read at the pixel's own luma, which makes the ramp a global curve: every pixel at luma t lands on the same o whatever surrounds it. SHADOW's a1 is the head of the quarter above it, so lifting it squashed that whole quarter to the half slope left over — measured on a real frame, 0.50 of its spread (shadow-band.py), 0.57 on the deployed bundle — the grey sheet the knob was reported for. A curve drawn through the pixel cannot see local contrast; that is what the eye was reading. So the ramp is read at TONE_BASE_RADIUS (2.5% of the frame) of the luma around the pixel — a 3x3 range-weighted blur, the fix_shadow.md Base x Detail split — and the pixel then rides the neighbourhood's gain o/base, keeping its own difference from it. Base moves, detail stays: the same lift on the same pixels, with the texture inside the region left standing. Full deflection keeps 0.78 of the band's spread now. The base is the same maths in every caller: bx = 0 (a mask, which has no neighbourhood) reads the pixel nine times and gets the old global move back, and with every knob on zero the ramp at base IS base, so the ratio is 1 and the pass is the identity however coarse the base is. Skipped: no chroma compensation (Hunt). Measured, the ratio held saturation (0.3184 -> 0.3169), so it is not earned yet. No linear-light ramp either: the multiply is a uniform gain on encoded values, which is the same stop exposureMove already argues for. |
||
|
|
899921ef8a |
A dark detail stops being copied beside itself: DEHAZE reads its prior off a copy of the frame
Raising DEHAZE drew bright copies of every dark detail in the photo, stacked alongside it. The prior was the reason, and the prior was read wrongly. The 5x5 patch of DEHAZE_SKSL was sampled inline, five taps out at 0.625% of the frame's width each — one tap every 6.67 pixels of the 1067-pixel preview, an average spacing over a 26-pixel patch that is meant to be the minimum over it. A detail thinner than that spacing therefore sat between two taps on one row and under a tap on the next, and the transmission swung between "this patch holds a shadow, leave it alone" and "this patch is all haze, divide hard" with a 7-pixel period around every dark thing on the frame. A rising DEHAZE drew that period: `t` is a per-pixel divisor, so the rows of the detail that were left alone stayed put while the rows read as haze came up bright, and the 13.3-pixel column spacing made the next copy and the copy after that. That is what was stacking. Measured on a synthetic frame of sloped haze with four one-pixel dark lines: the old shader departs from the clean correction by +58 to +102 codes (8-bit) at exactly +/-6.67 and +/-13.3 pixels around each line — the two spacings, in both directions, which is the whole signature of the bug. That frame is otherwise flat, so those deviations are the copies. Three things had to be got right, and each is the smallest fix that removes one of them: - The patch is no longer sampled. The caller builds the dark channel as an image — a 64x64 copy of the frame, smallest channel over a cell-wide neighbourhood, then two box passes — and hands it to the pass as its second child, so the shader's own `dark.eval` IS the patch: one cell covers a whole neighbourhood rather than sampling it, and the bilinear upscale interpolates it back up with no period left in it. The box passes are the doc's soft matting in the one form free here — the map is smoothed, not the pixels. The copy is made with drawImageRect, rect to rect: a paint shader drawing a 64x64 rect reads only the source's 4x4 corner, and a one-pixel line in such a copy lands at 166, i.e. pure haze, because the cell covering it is mostly sky. - What travels as that image is the dark channel and not the transmission. t is 1 + 0.95 at the negative end of the knob, more than a channel can carry, so a copy of t would arrive here clipped to 1 and "put the scattered light back" would become a pass that returns its input. The dark channel is 0..1 by construction and the signed amount stays a uniform, where it costs no range — the knob keeps both of its directions. Swept on the real photo, DEHAZE -100 moves 779,237 pixels brighter and 703,114 darker (worst 110 codes) while the same frame at 0 either side of it moves exactly none, and +100 moves the frame the other way at atmosphericLight [0.93155, 0.90980, 0.93084]. - The pass was reading a shader that does not exist yet. `effects()` is what it asks now, not the module variable: nothing above DEHAZE has asked for the effect, so on the first render the variable is still null and the knob stayed dead until some later render happened to fill it in. A map this small only covers the frame if it is told to, and the matrix that does it is the last thing that had to be right: CanvasKit reads a shader's local matrix as the map's own pixels to the frame's, so the 64x64 copy needs frame over map, `[W/64, 0, 0, 0, H/64, 0, 0, 0, 1]`. Without it the pass covers only the top-left 64 pixels and clamps every pixel past them onto the map's last texel — one constant t over the whole photo, a global inversion and not a dehaze. Measured against the ideal ramp, `scaled(n/w)` clamps the same way; the reciprocal lands on it. The knob is left to over-correct at the top of its range, and that is deliberate. A hazy sky still goes white and a saturated colour beside a dark edge still deepens: `(c - a)/t + a` with an airlight near 0.93 and a plain clamp, which is the arithmetic the doc asks for. It is smooth on the frame — 6x zoom panels of the hazy frame at DEHAZE 100 show one wide gradient and no band repeating at any period — so no knee is added to soften a correction that is no longer producing the symptom. If that side ever needs taming, DEHAZE_MAX_OMEGA is the one number. The MASK's DEHAZE still samples the old 5x5 patch inline (gradientMask.ts, unchanged): the frame-wide pass is what the report was about and what is fixed here. Verified: the synthetic harness over four patch configurations puts the new shader at zero deviation from the clean correction at N=64 — the 16.7-pixel cell swallows the test line, which is why the real photo is the judge. The real photo through the running app, with the slider swept 0, 100, -100, 0, is exact at both zero points and moves the frame at both ends, and its zoomed panels at DEHAZE 100 — roof, floor and a wooden rail at 3x and 6x — show the remaining change as one smooth region, the blue of a tarp and the green of a floor stain deepening where the haze was hiding them, with nothing repeated around the dark detail that used to copy itself. npx tsc --noEmit clean, npm run build clean, scripts/mask-wb-check.mjs and scripts/highlight-knee-check.mjs both pass. Co-authored-by: PenguinHarness <noreply@penguin.local> |
||
|
|
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). |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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). |
||
|
|
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. |
||
|
|
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 | ||
|
|
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.
|