158 Commits

Author SHA1 Message Date
3dtours 81637b8502 web: a catalogue of the folders on the visitor's own disk
A RAW studio that cannot see a folder is one photo at a time. /library
now takes a folder through Chromium's directory picker, keeps the handle
in IndexedDB so the folder is there on the next visit, and walks it into
a grid: one thumbnail per frame, the frame's own date, and the recipe it
was last graded with. Nothing is uploaded and nothing is read twice —
the RAW itself is opened only when a tile is clicked, at which point the
studio develops it and the recipe comes back on top. The studio files
every change back against the frame, debounced, so reopening a RAW is
not doing the grade again.

Thumbnails come off LibRaw's unpack_thumb for a RAW, which is a seek and
a copy where a develop is a full decode of every pixel, and off
createImageBitmap for anything else. A RAW with no preview inside it
gets a placeholder tile rather than a minute of decoding per file.

  node scripts/library-check.mjs
  ok  both frames indexed as tiles — 2 tiles from P1010256.JPG + P1010256.RW2
  ok  thumbnail for P1010256.JPG — 40400 bytes, jpeg=true
  ok  thumbnail for P1010256.RW2 — 39895 bytes, jpeg=true      (LibRaw preview)
  ok  studio developed the frame from its handle
  ok  the address was handed back — url=/app (no ?lib= left behind)
  ok  the look was filed back against the frame — baseFilter=none, 19 knobs
  ok  the tile says the frame is edited
2026-09-28 17:46:12 +07:00
3dtours 0f2e109aa2 web: make the studio installable, and give it a shell that opens offline
The three things a browser asks for, without a plugin: a manifest in
public/ (name, /app as the start, three icons cut from the one piece of
art this repo has), a service worker, and the two metas iOS reads
instead of the manifest.

The worker caches the shell — /, /app, /library, all one document under
the SPA fallback — and the hashed assets the build emits. A navigation
is network-first, so a deploy is never pinned behind the cache; a
hashed asset or the wasm is cache-first, because under a given build
those never change. /api and any non-GET go straight out: a worker is a
cache, not a proxy. nginx serves sw.js and manifest.json `no-cache`
(both names outlive their contents) with the isolation headers the
worker script needs under COEP.

The offer is the app's own dialog, not Chromium's mini-infobar: the
event is held, and it is spent either after the visitor has been in the
studio two minutes or the moment an export lands — the point at which
the app has done their work. Safari never fires the event, so it gets
the Share > Add to Home Screen line instead. A refusal is remembered and
never asked again.

  node scripts/make-icons.mjs       192x192 39785B / 512x512 159296B / maskable 512x512 123723B
  node scripts/pwa-check.mjs        manifest 3 icons · worker activated · shell cached
                                    · offline reload of /app paints
  off (https://localhost:8090)      same four, through nginx
2026-09-28 17:46:08 +07:00
3dtours 3312facd82 docs: write down the camera port and the live-view plan
CAMERA_PORT_ANDROID.md lists only what the web build has that Android does
not, one row per gap, with the web file to read next to the Android one.
The plan alongside it is the sketch for a live camera with a Skia filter
on the frame thread.
2026-09-28 17:36:30 +07:00
3dtours 8dc409777e web: keep the probe that develops a raw in a bare page
A page with no React, no store and no router around it: hand
/src/engine/skiaShim.ts a file, develop it, and read the plane back. It is
the shortest path to the answer when the stage looks wrong and the question
is whether the engine or the app around it is at fault.
2026-09-28 17:36:28 +07:00
3dtours e24ea9e71d web: open the stage on the neutral stock, not the CLASSIC NEGIPES preset
The boot recipe was DEFAULT_RECIPES[0], so every file that landed — NEF
or JPEG — came in with exposure +2 (+0.5 EV), contrast +1, saturation -1,
6300K / tint +2, highlight -2, clarity +2, grain +4 and the classic-neg
filter already on. On APACHAI-20160215-357.NEF that read as pink skin and
blown highlights, and it made a correct decode look like a decode bug.
Develop itself was fine: 179.0/168.2/171.2 against the embedded preview's
178.2/167.1/170.7.

Boot is now BASE_RECIPE — no filter, all knobs default, the same look
COMPARE splits against. A session left by the old boot holds that preset
verbatim, which was never the visitor's pick, so it is dropped; anything
the visitor actually touched moves the stored object off the exact
fingerprint and is kept.

Render bytes, same 1600x1068 view of the JPEG (R/G/B means):
  source file          170.7 / 159.1 / 163.1
  before               194.7 / 178.0 / 177.2
  after                169.5 / 158.5 / 162.0
NEF render stage: 202.4 / 186.3 / 185.4 -> 177.9 / 167.3 / 169.9
Output is byte-identical to the old neutral render (md5 a3932746...),
while a clicked chip (classic-neg-default) and a tweaked session both
still restore their own bytes.
2026-09-28 16:29:24 +07:00
3dtours d1e9425b9c web: read a frame's white off its pile, not off its largest sample
sensorWhite hung its nine counts of window off the plane's largest sample. A
hot pixel sits hundreds of counts above the level the sensor stops at, so on a
frame that carries one the window held the stray alone, found no pile in it, and
handed the develop the factor two instead of the frame's own level.

Measured on a Panasonic DMC-LX10 RW2: one sample at 15993 and one at 14665 over
a pile of 9,594,544 at 13855. The frame's level is 1.75x the fall-back, so the
develop opened 0.81 of a stop bright, clipped the sky the sensor had held to
flat, and the fit to the camera's preview could only pull the exposure back
after the highlight detail was gone. Against the camera's own JPEG of the shot,
mean |dL| 23.48 and dRGB +8.57,+0.90,+4.90 became 19.14 and +5.32,+1.89,+2.04,
and the level itself 7908 (gain 8.2872) became 13874 (gain 4.7236) against the
13855 the plane piled at — 0.14%, 0.002 of a stop.

The level is now the highest count the plane piled at, off a whole-plane
histogram: `floor` counts is a pile, and the same cliff rule as before still has
to hold over the count below it, so a smooth bright sky is left alone and a
frame that has not clipped still falls back on the factor two.

The same stray was in four of the ten bodies to hand. A Nikon _GDN0447.NEF read
the fall-back 8190 where its plane piles at 13806 (gain 8.0018 -> 4.6022, 0.79
of a stop), a Fujifilm RAF 29696 where the documented level is 30993, and a Sony
ARW 31742 against the 2.002x the body's ratio was measured at. Six were
untouched, and a Canon CR2 develops byte-identical through the change — the
window moves only on a frame whose largest sample is not its highest pile.

scripts/white-level-check.mjs keeps the LX10 shape: a plane piled at 30995 with
a stray above it has to answer 30995.
2026-09-28 15:55:04 +07:00
3dtours e6c050581f web: make AUTO write the tone and the colour it reads off the frame
The AUTO chip measured the photo and wrote one knob, EV. It now writes the four
the measurement actually names, off the same binned ramp (ui/Histogram.tsx),
which is why it is one chip and not four: the means it needs are all in the
histogram the exposure answer already reads.

  EV          the mean luma, unchanged
  HIGHLIGHT   the top 1% (p99 > 0.9 pulls back), to -5 of the ruler at most
  SHADOW      the bottom 1% (p01 < 0.02 opens up), to +5
  TEMPERATURE/TINT  the gain that puts the three channel means on each other,
              green as the anchor: gray-world on linearised means, then the
              closest of 76 temperatures x 21 tints under the renderer's own
              kelvinToRGB, so the pair cannot drift from what the ruler applies.

The ends rather than the average is what keeps a small blown window from
dragging the whole frame: a specular in the corner wants HIGHLIGHT, not a
flatter picture everywhere. Both ends stop at half the ruler, so the frame is
corrected and a hand can still finish the move; the knobs then report the
numbers AUTO chose, the way the EV knob does.

Each reading is a pure function of the ramp, so pressing AUTO twice lands on the
same recipe by construction, and a frame with a dead channel leaves the WB ruler
where it is rather than inventing a cast. ponytail: one linear ramp per end and
no scene analysis; add a curve, or weight by how much of the frame is clipped,
when AUTO starts overshooting a scene with a genuine specular in it.

scripts/auto-tone-check.mjs holds the three readings: the percentile walk, the
thresholds that leave a knob alone, and the scan landing back on the gain it was
asked for.
2026-09-28 15:24:50 +07:00
3dtours 224ff0b935 web: open a RAW at the resolution of its sensor, not at the quarter of it
LibRaw's half-size demosaic was on. The Ricoh GR's own DNG (D0004128.DNG)
developed to 3010x2012 while the JPEG written beside it in the same second is
6000x4000, and the Fuji's RAF to 3008x2007 against its own 6000x4000 -- the
quarter was the flag, not the file. With `halfSize: false` the same develop
returns 6020x4024 and it is the sensor's frame on every body tried:

  D0004128.DNG  6020x4024   IMGP6916.DNG        6028x4024
  DSCF1701.RAF  6016x4014   _DSC0009.ARW        6024x4024
  AFXT2721.RAF  6246x4170   Nikon-D850 NEF      6216x4136
  _GDN0447.NEF  4284x2844   P1010607.RW2        3472x3472
  5G4A9396.CR2  2880x1920

Nine files, 27s to 155s a develop on one core. Checked through the app
itself, not only through LibRaw: photo-dims 6020x4024 on the DNG against
6000x4000 on the JPEG, both err none.

The colour it opens with is now fitted per file to the preview the camera wrote
into it (previewMatch.ts): a 3x3 over a block grid of the develop against the
same grid of that preview, then one cubic a channel for what the 3x3 leaves.
The offline per-body table this replaces (cameraMatch.ts) stopped matching the
moment the path under it changed -- its rows no longer summed to 1 once the
highlight knee landed ahead of it -- and a body with a row opened with a cast
one without did not. The file's own preview does not age.

The white level the gain carries is the frame's own plateau rather than
`maximum` (sensorWhite.ts), a factor of 1.89 to 2.00 out; without it every
frame opened a stop bright and a body that sat lower (X-Trans, 1.892) never
reached the highlight desaturation at all.

The desaturation gate reads the gain-lifted levels as well as the sensor's,
which is the whole of the magenta: on a body whose cam_mul lifts red and blue
(the GR's [2.64, 1, 1.73]) a blown sky crosses the white level at 0.38 of the
raw range in red while green crosses at 1.0, so a gate read on the sensor's
levels alone stayed shut across it. Measured in the app against the camera's
own JPEG, mean dRGB over a 16x16 block grid: +1.20, -5.95, -6.11 with the
sensor's clip alone, +0.21, +0.24, +0.47 with both, mean |dL| 21.5 against
10.3. The same grid on the Fuji comes back balanced (+4.7, +5.0, +3.6) and best
aligned at offset 0,0.

-HL is recovery and +HL is a lift, so they are different moves now: recovery is
the doc's soft knee in linear light over the top half, which is the only term
in the tone shader that is not a shift and the only one that can put detail
back into a blown sky rather than merely darken it.

The four checks pin the develop down where it can only run in a browser:
raw-develop-check, preview-match-check, white-level-check, highlight-knee-check.
2026-09-28 15:24:37 +07:00
3dtours 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.
2026-09-27 08:56:42 +07:00
3dtours 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.
2026-09-26 22:27:38 +07:00
3dtours 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).
2026-09-26 20:55:05 +07:00
3dtours 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).
2026-09-26 20:16:13 +07:00
3dtours 5f3257a4d8 web: make a mask's knobs the moves the frame-wide row of the same name makes
A gradient mask carried its own copy of the six tonal and spatial formulas, and
four of them had drifted from the columns of the same name. HIGHLIGHT was
inverted: the single shift `0.5 * (shadows*ms - highlights*mh)` put the knob's
`x` on the shadow mask and its `y` on the highlight mask, so turning HIGHLIGHT
up pulled the bright band DOWN and turning it down lifted it — measured at the
mask, +9 moved the top band -0.2295 and -9 moved it +0.0454, and the whole
frame's HIGHLIGHT row reads the other way. WHITE and BLACK were a flat gain on
the end each one owns (`c * (1 + 0.5*k*mh)`), which scales every pixel above the
midtone by the same fraction and so drags the near-whites into the greys rather
than leaving them white: a lowered WHITE took the top band down -0.2117 while
the middle band moved -0.0036, and a raised BLACK pushed the middle band up
+0.0195 for +0.0414 at the bottom — the lift went everywhere except where it was
asked for. CLARITY's negative side was the same unsharp as its positive side
with the sign flipped — `c + k*(c - blur)` with k negative — which is a soften
only in name: it sank the mask's whites (top band -0.0543 at -9) and left the
mask's own contrast where it was (dhp -0.0008), the opposite of what the knob is
named for.
DEHAZE ran after CLARITY, so a mask sharpened its haze and then tried to remove
it; frame-wide the two are the other way round, and for a reason.

The four now mean inside a mask what they mean on the whole frame, because
toneShader.ts is the reference the app's own rows are written from and the same
name on the same knob should not be two different moves. HIGHLIGHT and SHADOW
are TONE_SKSL's additive luma shifts — HIGHLIGHT weighted by the headroom it has
left (1 - t) so it cannot drag a blown white to grey, SHADOW by its own floor —
with the colour difference riding along at TONE_SKSL's damped gain so a lift
cannot collapse a colour. WHITE and BLACK are TONE_SKSL's per-channel point
moves, cubic in each channel's distance from the end it owns, so the toe and the
shoulder move and the midtones stay put. DEHAZE runs before CLARITY, the
frame-wide order. CLARITY's negative side is a real soften, `mix(c, blur, -k)`
toward the same bilateral reference its positive side works against. TONE_SKSL's
two smoothsteps (0.50..1.00 and 0.00..0.55) replace the mask shader's own pair,
and the soft masks are computed in float and narrowed once, the way the
frame-wide shader does it.

Measured in one harness, one photo, one session (the mask's own middle box, knob
at +-9, before -> after on the mask, with the frame-wide knob of the same name
as the reference it is now written from):

  - HIGHLIGHT +9: top band -0.2295 -> +0.0298 (frame-wide +0.0547), dark band
    0.0000 -> -0.0001 — the lift is a lift, and the inversion is gone.
    HIGHLIGHT -9: +0.0454 -> -0.0385 (frame-wide -0.0674).
  - WHITE -9: top band -0.2117 -> -0.0646 (frame-wide -0.0860) — a lowered
    white stays a white instead of becoming a grey — while the middle band goes
    -0.0036 -> -0.0150 (frame-wide -0.0139), which is the move a white ends up
    making when it is a point move rather than a gain.
  - BLACK +9: bottom band +0.0414 -> +0.0997 (frame-wide +0.1036) and the middle
    +0.0195 -> +0.0347 (frame-wide +0.0402) — it goes to the toe it owns.
  - CLARITY -9: dhp -0.0008 -> -0.0149 (frame-wide -0.0200) — negative CLARITY
    softens now — and the top band -0.0543 -> -0.0113, so it no longer pays for
    that soften by sinking the mask's whites. CLARITY +9 was already right
    (+0.0333 both sides) and stays.
  - DEHAZE, the one knob with nothing on the negative side: unchanged at -9
    (-0.1490 both sides, the pass order was the only thing wrong with it), and
    the mask and the frame-wide row now agree across the whole range
    (1/3/5/7/9 at -0.0124/-0.0397/-0.0711/-0.1074/-0.1490 on the mask against
    -0.0133/-0.0423/-0.0759/-0.1151/-0.1619 frame-wide), monotone.

DEHAZE's own numbers are therefore not a mask-only bug: an estimate of the haze
that reads a mask differently from the frame would be inside `atmosphericLight`
and `DEHAZE_MAX_OMEGA`, which both paths share, and changing either moves the
frame-wide DEHAZE column too — left as it is rather than changed under a mask
report.

Everything else about the mask is untouched: `dctrl` is +0.0000 on all six
knobs (a knob still moves the mask's own pixels and nothing outside it), the
shader compiles and the stage draws with no page error.

Not ported: nothing. `shared/utils/gradientMask.ts` is the web engine's own file
and the phone's renderer has no gradient mask to mirror.

Probes: measure-mask-knobs (the six knobs on a selected mask, before and after),
measure-frame-knobs (the same six frame-wide, the reference the mask is now
written from), measure-parity (both phases in one run so the two are the same
photo in the same session), png-parity-report (the before half of that run died
in its frame phase and left no log, so its already-captured mask frames are
re-read off the PNGs with the same box and the same bands), measure-dehaze-curve
(DEHAZE 1..9 on the mask against 1..9 frame-wide, for monotonicity and for the
pass order).
2026-09-26 19:04:32 +07:00
3dtours 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.
2026-09-26 18:18:22 +07:00
3dtours 178bc78bbb web: an About section on the landing, after Pricing
The nav shelf was a one-pager's anchor list ending at Pricing, and the About
copy had nowhere to live. Adds About as the seventh entry — it feeds both the
desktop shelf and the mobile sheet off the same array — and the matching
`#about` section right behind Pricing, built from the section template the rest
of the page uses, so it picks up the page's theme and language switches with no
new CSS and no route of its own.

The copy is the About RecipesCam brief as bilingual `Txt` pairs, kept beside
the markup: vision, the Android app (highlights as steps), the website's three
purposes, and the contact address.
2026-09-26 15:55:16 +07:00
3dtours 6814b055cb web: frame through the recipe, with the viewfinder where the stage was
The live view is the one place where the look is chosen before the picture
exists, so the columns that hold it have to stay in reach while the camera is
open. The viewfinder was a fixed black sheet over the whole app: the rail and
every slider were behind it, and the only way to change the look was to close
the camera, grade the file, and open it again. It now covers the stage and
nothing else — the stage and the view share a slot (`.stage-slot`, position
relative) and the view is absolutely placed inside it, z-index 5, black in
either theme. Measured at 1600px: the rail and the columns are on screen, the
feed takes 1347px of the 1600 and the stage's own 253px rail the rest. A slider
moved with the camera open reaches the very next frame (the render loop reads
the recipe per frame, so nothing had to be wired), a monochrome sim takes the
live feed from sat 42.1 to 0, and the shutter hands the studio that same frame
with the sim already on it.

The preview render is skipped while the viewfinder is up (`|| shooting` in the
guard, and in the effect's deps): it would run the same recipe through the same
renderer as the frames the user is actually watching, and the live one wins.

Opening it is PRO, like the HSL chips and the geotag — the frames are the paid
ones. The button stays on the page for everyone and carries the badge, and the
click runs the app's own `promptPro`: the account dialog for a guest, the
verification panel for a signed-in account, `/api/auth/me` being what says
which. A verified account gets the camera; a guest gets `.modal-backdrop` and no
`camera-view` at all.

Probes: cam-pro-gate (guest sees the badge, the click opens the dialog, no
viewfinder; verified opens it), cam-live-edit (rail and columns survive the
live view, feed covers the stage, the recipe reaches the live frames and the
shot), cam-smoke, cam-renegotiate, cam-close-flip.
2026-09-26 08:39:17 +07:00
3dtours cb1839e36b web: shoot through the live camera
The one path where the look is chosen before the picture exists: OPEN CAMERA
grades the camera's own feed with the recipe in force, many times a second, and
the shutter hands the studio the sensor's still under that same recipe. Preview
and file differ in resolution only — the still is `takePhoto`'s own frame, not a
copy of the small preview video, with `grabFrame` and a 2d copy of the element
behind it for the browsers that ship no ImageCapture.

The renderer gains two inputs for it: `sourceImage`, a picture the caller
already decoded (re-encoding the camera's frame to JPEG only to decode it again
would cost more than the whole render), and `drawTo`, which paints the finished
picture instead of encoding it. One render is in flight at a time; a frame that
arrives during one is dropped, so a slow device shows a lower frame rate rather
than a queue of moments that have passed.

The view flashed black on a phone. Setting width/height on a canvas resets its
bitmap: measured on the preview, a resize leaves mean 0 until the next render
lands, which on this box is 0.5s and on a phone more. The buffer was sized from
every incoming frame, and a capture that renegotiates its resolution — which
Chromium does when the page is too slow to consume its frames, and this pipeline
runs ~2 fps at 720p under software GL — strobed black/picture at every switch.
The buffer is now sized on the first frame and after that only when the frame's
aspect changes: a same-aspect frame is scaled into it. Swapping a 1280x720
stream for a 640x360 one mid-view now leaves the buffer at 1280x720 with no
black frame, and 640x360 renders at 6-13 fps instead of 2.

The frames are read from a <video>, which is now IN the document (1px, behind
the black backdrop) rather than detached: Safari draws blank frames from a
detached video, which is the same black-between-pictures. It leaves the document
with the view, and the tracks are stopped, so the camera light goes out.

Probes: cam-smoke (feed painted, resolution, frame rate, a monochrome sim
reaching the live frames, shutter into the studio, close, console clean),
cam-renegotiate (no resize, no blank frame, status line on the frames),
cam-close-flip (flip returns a picture; video gone on close).
2026-09-26 08:26:28 +07:00
3dtours fd2d9935c1 web: filter the exports the model was only smearing
The upscaler ran for every enlargement, including the ones a plain resample
wins: measured on a 2048px source cropped and blown back up it loses to
lanczos on PSNR and SSIM at 2x and 3x, and its smoothness reads as plastic
skin and lost texture next to it. From 4x — the model's own factor — it
stops losing, so the threshold moves to 4 and the crop no longer drags a
2x export through it.

The resamples it now carries never set imageSmoothingQuality, and the
default 'low' point-samples: a 1px stripe comes out at full amplitude
instead of the average of what it crossed. Both callers ask for 'high'.

Probe on a 2400x1800 source exported at 4K: 299.9s -> 7.8s, correlation
with the source's 1px/2px bands 0.89/0.95 -> 0.98/0.98, grain sd 25.4 ->
47.1 (a plain HQ resize of the same source keeps 16.1).
2026-09-25 20:32:52 +07:00
3dtours 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.
2026-09-25 20:01:51 +07:00
3dtours 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.
2026-09-25 18:48:13 +07:00
3dtours 7e47a153b8 web: make EXPOSURE, EV and HIGHLIGHT mean what Lightroom means
A stop is a multiplier on light, so EXPOSURE and EV stop living in the sRGB
colour matrix and get a linear-light pass of their own (EXPOSURE_SKSL:
linearise, `C * 2^EV`, re-encode). The matrix keeps CONTRAST: a gain on encoded
values is what made +1 EV land at x1.5 instead of x2. Measured on the neutral
PROVIA sim: EV +1 = x2.011, EV +2 = x3.999, still unclipped at 239.

The pass sits between the matrix and the tone shader, and the tone / cinema /
curve / glow / halation children all sample through it, so HIGHLIGHT finally
sees the value exposure produced instead of the one before it. Recovery keeps
`L + strength * mask * (1 - L)` over `smoothstep(0.50,1.00,luma)`, and the
colour comes back as `color * (luma_new / luma)`: a blown white stays white
(255 -> 255 at -10, 255 at +10), a 0.8 grey loses 33 luma, the midtones beside
it do not move.

AUTO is the histogram the LIGHT tab already draws: weighted mean luminance
(guard 0.001), target 0.48, `log2(0.48 / avg)` clamped to +-2.5 EV, handed to
the same knob. A 0.251 grey asks for EV 0.9 and lands at mean 83.0 against the
83.3 predicted, idempotent on a second press. A stock's own bias rides the same
pass (`SIM_EXPOSURE_BIAS_EV`, VIVID +0.25 EV) and cancels against the knob, so
-1 EXPOSURE on VIVID returns the CLASSIC rendering (measured 0.4149 vs 0.4177).

ponytail: the phone app's `src/utils/colorUtils.ts` keeps the old math, so the
two copies have to move together; recipes saved before this commit (EXPOSURE 2,
HIGHLIGHT +-1) render under the new stop semantics.

Verified on the rebuilt container (BASE=http://localhost:8090):
- web-exposure-probe.cjs 20 PASS / 0 FAIL (neutral 128 -> 128, EV +1 ratio
  2.011, EV +2 ratio 3.999, EXPOSURE +10 ratio 5.62 / -10 ratio 0.172, AUTO
  EV 0.9, HIGHLIGHT -10 on a 204 grey 204 -> 171, white 255 -> 255, no console
  errors)
- sim-exposure-test.cjs 9 PASS / 0 FAIL (classic 0.4149, vivid 0.4531, knob -1
  returning 0.4177, bias 0.0382)
- regression suite, 28 probes: mask 53/0, brush-edit 35/0, heal-idle 23/0,
  heal-zoom-drag 28/0, sims 31/0, sim-vivid 9/0, white-black 4/0, temp-swatch
  33/0, tone-curve clean, compare 25/0, create 52/0, wb-preset 33/0, zoom 25/0,
  save-recent 25/0, web-smoke 9/0 (its export step was stale — EXPORT opens a
  size picker now). panel-test 4 FAIL, histogram-wb 1 FAIL, studio-save-hl and
  progate timeouts, landing-test 6 FAIL ($0.99 pricing) are pre-existing.
- npx tsc --noEmit clean.
2026-09-25 17:44:28 +07:00
3dtours d2115941c7 feat(admin): back the data up, and put it back, from the admin tool
A new BACKUP tab downloads the deployment's whole state — the SQLite file
and both media folders, photos included — as one .tar.gz, and takes the same
file back. That one artefact therefore does both jobs: the operator's backup
and the data package that moves an install onto another box.

The database is snapshotted through SQLite's own backup rather than copied,
because the file is written to while the archive streams; the media folders
are tarred straight off the volume, so no second copy of them is made.

A restore replaces the data on disk and then exits — the container's restart
policy brings the API back on the restored files, which is the only moment the
open handle can be dropped. The state being replaced is tarred aside first,
and the archive is checked for `..` entries before anything is unpacked. The
API authenticates that route before it reads a byte, and nginx lets that one
path past the body cap which holds everywhere else.
2026-09-25 08:46:29 +07:00
3dtours 9016ef038d web: price the plan at $0.99 2026-09-25 08:46:29 +07:00
3dtours c70edce8c1 web: mirror the frame with H-FLIP and V-FLIP, and stamp a typed place
ROTATE gains the two mirrors: H-FLIP and V-FLIP toggle one at a time and
stay on through the quarter turns and STRAIGHTEN, which makes them compose
with every rotation the strip already offers. ROTATE's own RESET levels the
whole frame, mirrors included.

The flip itself lands last, in screen space, so a mirrored photo is what the
eye sees rather than what the sensor saw; the pixels are copied axis-aligned,
so there is nothing to resample. Session state carries the two flags, so a
reopened photo comes back mirrored.

Also fixes the stamp: a typed PLACE NAME with no GPS fix now prints on its
own (latitude/longitude ride in as NaN), instead of the whole stamp and its
box being skipped for want of coordinates.
2026-09-25 08:46:27 +07:00
3dtours 9d7a5beec2 web: say the pricing promise plainly on the landing page
The pricing head asked the reader to start free and go Pro when the grain
matters, which is our joke rather than their question. It now says what
the plans actually are: free to use, forever, and Pro only when they're
ready for more.

The Vietnamese line moves with it — "Miễn phí dùng mãi. Chỉ lên Pro khi
bạn cần nhiều hơn." — so the two languages still promise the same thing.

ponytail: one string on each side of the existing c({ en, vi }) pair, no
new copy key and no layout change.

Verified:
- npx tsc --noEmit clean.
- Against the rebuilt production bundle on :8090, a probe reads #pricing
  h2 in both languages: EN "Free to use, forever. Upgrade to Pro only
  when you're ready for more.", VI "Miễn phí dùng mãi. Chỉ lên Pro khi
  bạn cần nhiều hơn.", VI overflow 0px — 3/3.
- landing-test, lp-probe, subnav-anchor-probe, pro-gate-test all rc=0
  fails=0; screenshot of the section in both languages shows the head
  wrapping over two lines above the plan cards, nothing clipped.
2026-09-24 21:54:03 +07:00
3dtours 08a4570b2d web: put the brushes in a FIX strip and the shapes in a GRADIENT MASK one
The FX row had grown into a flat list where a brush and a filter and a
shape sat side by side, and it lied about what the tools are: HEAL and
MOSAIC only paint, LINEAR and RADIAL only make a mask.  The row now
carries FIX and GRADIENT MASK, in that order, before MONOCHROME, and
each one opens its own strip holding the tools that belong to it —
turning amber when it has a spot or a mask to show for itself, so the
state still reads from the row without opening anything.

The two brushes moved into the FIX strip with the header FIX above
them, the two shapes into the GRADIENT MASK strip under the header
GRADIENT MASK, and each CLEAR moved in with the tool it clears instead
of sitting at the end of the row.  Opening FIX still arms the brush the
same way — the strip only changes where the chip lives, not what
clicking it does.

The mask column (shine, bearing, feather, and the rest) still hangs off
the shape you pick, so it now stands right after the options column:
the strip that brought it out comes first, then the column it belongs
to.  Nothing else in the column order moved.

ponytail: the two strips ride the existing openGroup and toggleGroup, so
a strip key is just a widened GroupKey rather than new state to keep in
sync; the option-strip body itself stayed where it was, keeping the
diff to the chips that moved.

Verified:
- npx tsc --noEmit clean.
- Frontend probes against the built production bundle on :8090 and the
  dev server: mask-probe 53/0, brush-edit-probe 35/0, heal-idle-probe
  23/0, heal-zoom-drag-probe 28/0, grain-controls 20/0, temp-swatch
  33/0, and landing, pro-gate, award-column, otp-code, tone-curve,
  hsl-panel, chip-edge, chips-desk, slider-reset all PASS rc=0 fails=0.
- Backend npm test 180/0.
- panel-test keeps exactly its four pre-existing failures (rail labels,
  WB swatch); they reproduce on the commit before this one.
2026-09-24 12:20:33 +07:00
3dtours cca6fc46f7 web: turn a mask by the turn the hand makes
A press anywhere on a mask's outline takes hold of it to turn it, and the turn it
asked for was read as an absolute bearing: the direction from the shape's pin to
wherever the finger now happened to be. The outline is a grip a hand lands on
wherever it likes — a ramp's line runs the whole way across the photo — so the
moment a press was set down on it the shape swung round to face that finger. A
press on the edge of a ramp lying across the photo stood it upright on a ten-pixel
move, and a shape already turned snapped to the angle of the press before it had
been dragged at all.

The turn is now what the hand turns: the change in bearing about the pin since the
press, which on the first move is the press itself. A finger that lands on the
outline and stays there leaves the angle exactly as it found it; one that carries
the shape round by a quarter turn turns it by a quarter turn, whatever angle it
was at to begin with. An ellipse adds that turn to the angle it stores, a ramp
spins its two ends about its own middle, and both are measured in the photo's own
pixels so the two bearings are the same kind of thing.

ponytail: The turn is read against the previous pointer position the drag already
keeps, so it costs one subtraction and no state. A pointer that leaves the photo
mid-turn keeps the angle it stood at — the rule the brush already follows — and
the next move on the picture resumes from the last position that was on it.

Verified: tsc clean; mask-probe 52/0 on the dev server and again on 8090 — a press
on a ramp's own edge that travels a hundredth of the photo's width leaves the ramp
at the angle it was taken hold of at (ends 0.500 and 0.500), and on a shape
already turned the same press keeps it upright (0.562,0.260 and 0.558,0.860); a
quarter turn of the hand on the edge turns the ramp a quarter of the way round
(ends 0.200 and 0.800, one pin, no second shape laid); the pin still takes the
shape with the hand to where the hand went (0.560,0.560, no sideways throw); the
rim of an ellipse still turns with the hand on it (1 pin); another chip still
leaves 0 mask columns and the mask chip brings 1 back; DELETE still takes the
chosen shape off the photo with its grade (64 -> 255, then back to 61). Against
the code before this change the same probe fails 9, both angle checks among them —
the ramp lands at 0.850,0.560 and 0.860,0.562, that is, wherever the finger was.
brush-edit 33/0, heal-idle 23/0, heal-zoom-drag 28/0, landing/pro-gate/
award-column/otp-code/tone-curve/hsl-panel/grain-controls/chip-edge/chips-desk/
slider-reset/temp-swatch all ALL PASS, backend 180/0.
2026-09-24 11:32:58 +07:00
3dtours 1f6c97be62 web: let a mask's own edge turn it, and another chip put it down
A mask could only be taken hold of by its pin or by one of the handles, and the
pin of a linear mask sits on the middle of its own line, so the press a hand aims
at "that line" was the press that moved the shape. What it moved by was worse: a
ramp is stored as the two points it falls between, and 'move' measured the hand
against the shape's first point, so the first move of a drag put that end under
the pointer and threw the rest of the ramp sideways by half its length — the
shape landed somewhere off to the side of the hand instead of under it. The move
now takes its delta from the pin the press landed on, which is what the press
wanted, and which for an ellipse was already the centre it moved by.

The drawn outline is the shape's own handle. A press on the line a ramp falls
across — any of the three, anywhere along it — or on an ellipse's rim, turns the
shape; the nodes that resize it sit on that same outline and are drawn over it,
so a press on one of those is still a resize. That is the Lightroom gesture: the
edge is what you drag to aim a gradient, and the ends and the axes are what you
drag to lay it out again. Until now a press on the edge was a press on the layer,
which read it as the start of the next shape and laid a second mask down while
the first was being turned. The band is sixteen screen pixels wide and does not
scale with the zoom, so a finger finds it at any size of photo.

A mask is chosen by pressing it on the picture, so the keys the hand is on are
DELETE, which is what the chip beside the photo already says. And the column the
chosen mask puts up — its name, DELETE, and its knobs as rulers — belongs to the
mask tool: another chip puts that tool down and takes the column with it, because
a photo still armed to draw shapes is not what a hand reaching for a knob is
asking for. The shape stays chosen and stays live in the render; the mask chip
brings its column straight back.

ponytail: Backspace is read as DELETE as well, since that is the key the label
sits under on a Mac keyboard, and is the one shortcut this adds. A mask can still
only be chosen while a mask tool is armed — the shapes answer the pointer through
the layer that only exists then — so the column coming back with the mask chip is
also the way back to a shape drawn a moment ago.

Verified: tsc clean; mask-probe 50/0 on the dev server and again on 8090 — a ramp
lands with its pin on its middle, dragging its own edge turns it (ends at 0.200
and 0.800, one pin, no second shape laid), the same drag on an ellipse's rim turns
that (90deg -> -39deg, one pin), the pin takes the shape with the hand to where
the hand went (0.560,0.560, no sideways throw), another chip leaves 0 mask
columns and the mask chip brings 1 back, and DELETE takes the chosen shape off the
photo with its grade (64 -> 255, then back to 61). brush-edit 33/0, heal-idle
23/0, heal-zoom-drag 28/0, landing/pro-gate/award-column/otp-code/tone-curve/
hsl-panel/grain-controls/chip-edge/chips-desk/slider-reset/temp-swatch all ALL
PASS, backend 180/0. panel-test (4), histogram-wb (1) and studio-save-hl fail
exactly as they do on the build before this one, on their own tabs.
2026-09-24 11:18:59 +07:00
3dtours b7ff298789 web: give a mask its knobs as rulers and let the ramp be turned
A mask's knobs were a card of small sliders side by side, three abreast under the
colour they were moving, which is the shape the mixer needs because its whole
point is reading three bands at once. A mask has nothing to read against: its
knobs are a list, and the strip beside the photo has always had the shape for a
list — the ruler, one parameter to a row, with its own name, its own value and
the width to aim with. So the card goes and the rows come: EXPOSURE, CONTRAST,
SATURATION, and FEATHER below them for the shape that fades over one, each the
same ruler the FX panel opens, stacked in the mask's own column next to the chip
that says which shape is chosen.

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

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

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

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

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

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

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

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

Verified: tsc clean; mask-probe 35/0 on the dev server and again on 8090 (the two
chips, both shapes drawn and moved and turned, the ramp read off the pixels —
61 -> 244 at the release and 61 at the press — the feather read off the rings,
DELETE/UNDO/REDO/CLEAR, and one gesture one undo step); brush-edit 33/0,
heal-idle 23/0, heal-zoom-drag 28/0, landing/pro-gate/award-column/otp-code/
tone-curve all ALL PASS, backend 180/0.
2026-09-24 10:42:58 +07:00
3dtours 01af863fd8 web: let a photo's repairs read as the marks they are, not as a field of rings
HEAL draws every repair it holds as the circle the shader fills — the brush's
own size at the moment it was laid — so a photo with two repairs has two rings
and a photo with twenty has twenty. Each one is loud enough to be the loudest
thing on the picture, and none of them is the one the user is looking for: the
speck that was mended, and the patch borrowed to mend it, are both inside the
ring that covers them. What the ring is for is the spot that is about to be
taken hold of, and there is only ever one of those.

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

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

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

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

Verified: heal-idle-probe.cjs (new, 23 checks) 23 PASS / 0 FAIL on :5199 and on
:8090 after deploy — a press on the speck lays one repair and that repair is in
hand, so it wears its circle (rgba(255,255,255,0.85)); a repair the pointer has
left is idle with the ring given up (rgba(0,0,0,0)), the print of the place it
mended left (rgba(127,127,127,0.14)) and the patch it borrowed still dashed and
in the same place (640.3,297.9 vs 640.4,297.9); the ring comes back under the
pointer; a press chooses it, puts its × on the photo and the × stays while the
pointer walks off; laying the next repair takes the choice and the × off the
first and gives its ring up; hovering the second leaves the first idle; a drag
still takes hold of an idle spot; a run of 13 spots shows the band, no print
under it and no ring while the pointer is away, and the spot of the run under
the pointer wears its circle again; a MOSAIC spot keeps its ring; 0 page errors.
brush-edit-probe.cjs 33/0 — its "every spot of the run gave up its own edge" now
reads "but the one in hand", which is the rule this commit adds, and its "a spot
with no neighbour keeps its own circle" passes off the repair just laid being in
hand. heal-zoom-drag-probe 28/0 (the wheel, the pan, the × and taking hold of a
repair, at the fit and at x1.52, unchanged), heal-blotch-lab 12, heal-edge-lab 9,
heal-seam-lab 10, heal-skia-lab 28, heal-search-lab 15, heal-probe 49,
heal-zoom-geom 5, heal-zoom-probe 8, mosaic-skia-lab 27, mosaic-probe 51 — all
green on :8090. Regression: landing-test 172/0, pro-gate-test 27/0,
award-column-probe 18/0, otp-code-probe 10/0, tone-curve-probe 42/0, rc=0.
Backend npm test 180 passed, 0 failed. npx tsc --noEmit clean.
2026-09-24 08:10:27 +07:00
3dtours 53554ce42a web: let the photo be zoomed and moved under the brush, and keep its × out of the hand
With HEAL armed the wheel was the brush's size and nothing else. A repair is
aimed at a detail — a scratch, a speck on a face — and the detail is usually
smaller than the photo, so the one gesture the stage uses to bring it closer was
the one gesture the brush had taken: a user could size the spot they were about
to lay and could not zoom the photo they were laying it on. The same layer took
the pointer the stage pans with, so a zoomed-in photo could not be moved out of
the way either, and it swallowed the double-click the img answers with. A tool
that covers the surface has to hand back the gestures it does not use.

The wheel over the brush is the stage's own now, exactly as it is everywhere
else in the app: one notch at the pointer, the point under the cursor held
still, the same arithmetic the wrap's listener has always used (lifted out of
that listener as zoomAt, so the two callers cannot drift). The brush's size —
the one knob this tool has — is that same wheel with a modifier held: Alt, or
Ctrl/Meta, which is also what a trackpad sends for a pinch, so pinching still
sizes the spot without reaching for a key. The photo can be moved out from under
the brush as well: the middle button, or the space bar held, drags the photo
while a tool is up, at a zoom, which is the only place a pan means anything —
the listener is on the layer, so the key is watched on the window and read from
a ref. A pan drag paints nothing, and the modifier wheel does not touch the
zoom: measured, 24.744px -> 29.6239px of brush across while the photo stayed at
1568px.

The × a chosen spot wears is now drawn for the screen rather than the layer: it
keeps 18px and a 5px gap at any zoom, scaled back out of the layer's own
transform. It was scaling with the photo — 18px of badge is 27.9px at ×1.52 —
and at that size its corner sat over the circle it belongs to, so a press meant
to take hold of a repair landed on the × and deleted it instead. 5px of daylight
at the fit and at ×1.52, measured both.

The histogram goes off the photo while a brush is up. It is a panel over the
photo's top-left corner with the pointer on it, and the corner is where a user
paints first: a drag under it painted nothing, and the wheel over it belonged to
the panel rather than to the photo. The crop frame already had it hidden for the
same reason.

ponytail: the pan is bound to the middle button and the space bar rather than to
a second pointer, so a trackpad-only user has the zoom (two fingers) and the
brush's own size (pinch) but no one-finger pan while a tool is up; a modifier
plus drag, or a small hand tool on the toolbar, is the upgrade path. The brush
size now lives behind a modifier that nothing on screen advertises — the chip's
percentage is the only hint — so a stepper in the brush chip is the next thing
to add if that turns out to be a wall. Double-click to zoom is deliberately not
wired up: the layer swallows the press, so the first half of the double-click
lays a spot, and a zoom that leaves a stray repair behind is worse than no
zoom. The middle-button pan only engages past the fit, where the drag is free
rather than a scroll, matching what the stage already did.

Verified: heal-zoom-drag-probe.cjs 28 PASS / 0 FAIL on :5199 and on :8090 after
deploy — the brush arms as a layer, the histogram is gone from the corner and
the circle follows the pointer there, the wheel zooms (1031px -> 1185px) and
leaves the brush its size (24.744px -> 24.744px), Alt+wheel sizes the brush
(24.744 -> 29.62) without moving the zoom, the modified wheel over the brush
sizes it and leaves the window's own frame alone (1440 CSS px, dpr 1, either side of the
notch, so the modified notch is not handed on to the browser's page zoom), a
plain drag paints 0 -> 7 spots, the
middle button moves the photo 215,34 -> 269,66 without painting, space+drag
moves it 269,66 -> 179,6 without painting, and taking hold of a repair moves it
from the centre, from 0.8 inside its edge and from its other side at both the
fit and ×1.52; the × has 5.0px of daylight and is 18px across at both zooms; 0
console errors. brush-edit-probe.cjs 33/0, heal-blotch-lab 12, heal-edge-lab 9,
heal-seam-lab 10, heal-skia-lab 28, heal-search-lab 15, heal-probe 49,
heal-zoom-geom 5, heal-zoom-probe 8, mosaic-skia-lab 27, mosaic-probe 51 — all
green, and heal-probe.cjs and mosaic-probe.cjs had their wheelTo helper moved to
Alt+wheel because the plain wheel now zooms the photo, which is the contract
they were testing. Regression on :8090: landing-test 172/0, pro-gate-test 27/0,
award-column-probe 18/0, otp-code-probe 10/0, tone-curve-probe 42/0, rc=0.
Backend npm test 180 passed, 0 failed. npx tsc --noEmit clean.
2026-09-24 07:47:28 +07:00
3dtours abfc6595e7 web: take hold of a repair, and draw a stroke as the mark it was
A repair laid on the photo was finished the moment it landed. The brush could
only put more spots down, so a repair aimed one brush-width off the speck was
deleted and laid again, and the patch a spot borrowed — the other half of what
the renderer works with — could not be moved at all. And a drag, which is one
mark of the brush and is drawn as one while it is being painted, came back as
the beads it is stored as: a run of circles a fraction of a radius apart, each
showing its own edge, so a long stroke over a scratch read as twenty repairs.

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

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

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

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

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

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

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

ponytail: a stroke is still not stored — the band is derived from the spots that
overlap, so a stroke whose pointer jumped (a coalesced event, a fast flick) lays
spots further apart than the brush is wide and comes back as separate circles,
and a run breaks where the wheel changed the brush size mid-stroke. A stroke id
in the recipe, written once per gesture, is the rung for that, when a photo shows
a run the geometry cannot join. The hit test is the nearest circle within a few
pixels, so a press meant to paint a new repair within that reach of an existing
one moves the existing one instead — a shared modifier to paint regardless is
the rung there. Choosing a spot is an index into HEAL's list, so an UNDO that
changes the list under a chosen spot can leave the × on the spot that took its
place; the × is guarded against an index past the end but not against that. No
keyboard delete: the × is the whole affordance. And the band is drawn only while
the brush is armed — the spots are the recipe's, so nothing outside FX sees
them, which is the same as it was.
2026-09-24 07:21:01 +07:00
3dtours 88d6d0e648 web: read the ring's light by direction, and off the dust's soft edge
The last commit pasted the borrowed patch at the light of the place it lands in,
and read that light as one number per spot: the mean of the ring around the dust
minus the mean of the same ring around the patch. That number is a light the
place has when the ring is all one thing. It is not a light the place has when
the ring is not. A twig, a hairline, the edge of a table under the brush, and a
minority of the taps stand on the thing rather than on the ground: the mean then
follows the minority — it is a colour the place never had — and the patch is
pasted in it. The donor was of exactly the right light, the search had gated it
at LIGHT_GATE, and the repair still lands as a dark blotch. A mean is the wrong
estimator for a ring that is not one thing; the search already knew that, and
reads its own ring as a median for the same reason.

So the light is read once PER DIRECTION. Each of the sixteen taps is a pair of
readings — the place's ring and the patch's ring at the same sixteen places —
and a pixel takes the correction of the two readings it lies between,
interpolated by its own angle around the spot, in the same single draw. The rim
then meets the place all the way round instead of on average: a tap that landed
on the twig bends the part of the rim near the twig, and the far side of the
circle is left where it was. Sixteen taps rather than eight because a tap's
influence reaches only as far as the next tap, so the finer the ring, the less
of the rim one hard pixel of the photo can drag with it.

The second half of the change came out of the app, not out of the lab. With the
per-direction reading and no other change, heal-probe.cjs went from 49 PASS to
41 PASS / 8 FAIL: the repair's own centre came out 15-18 levels dark on a flat
field, with the frame around it clean. The reason is the estimator again, from
the other end — one direction is one pair of pixels and carries no averaging, so
whatever the ring reads at that direction, the patch gets in full. And the ring
at RING_R alone is not clear of the dust: a speck spreads about a pixel past
where it is drawn in the pixels the shader samples, so the nearest taps sit
inside the dust's own soft edge and read the dust's light. The mean had been
hiding it: one contaminated tap in eight is a level off; the same tap read whole
is the blotch. The ring is now a pixel further out again (RING_PAD), in the same
pixels the sampler works in — a fraction of the radius would be nothing at all at
the sensor-dust end of the brush, which is where this tool is aimed — and the
probe is back to 49 PASS / 0 FAIL.

Measured on a sweep of the two ways of reading it (heal-ring-sweep.cjs, CanvasKit,
three scenes, the step the eye reads at the rim plus the level of the patch's own
middle against the ground it landed in, levels out of 255):

  scene                     shipped mean 8@1.15        this: 16 taps, per direction
  uniform light difference  step 0, centre 0           step 0, centre 0
  twig across the ring      step 53 (mean 16.4),       step 56 (mean 2.4),
                            centre 34 dark             centre 0
  brush fits the speck      centre 5 dark              centre 0

The worst step on the twig scene is unchanged — that is the twig's own edge
crossing the rim, which no level can meet, and the floor the copy set at 85. What
moved is the average (16.4 levels to 2.4) and the level of the patch's middle,
which is the blotch: 34 levels of a place that never had them, down to none.

No new dependency. cv.seamlessClone is the same thing this shader already does —
the membrane half of a Poisson edit — and OpenCV.js would be 5-10 MB off a CDN,
solved on the CPU per spot, outside the one draw the preview, the recipe and the
export all read from: the correction is recomputed from the snapshot on every
render, which is why the preview and the exported file agree by construction and
why a saved photo opens onto the same repair. It also cannot run in the worker
the brush paints in or against the fractions the recipe stores.

Verified:
  heal-blotch-lab.cjs (scratchpad, CanvasKit, no browser) — new, 12 PASS / 0
    FAIL, and 8 PASS / 4 FAIL against the bundle built from 57ade27, which is what
    the lab is for. Two scenes, each run twice through the real pipeline: the
    mean (kept inline in the lab as the "before") against healSkSL from the
    bundled heal.ts. Twig across the ring: the mean puts the patch's middle down
    at 116 against a ground of 150 (34 levels dark), the module at 150. Brush
    fitting the speck exactly: 145 against 150, the module 150. In both, the dust
    is gone rather than dimmed, and the frame away from the circle is the photo.
  heal-edge-lab.cjs — 9 PASS / 0 FAIL, its ring metric narrowed to the rim the
    ring has already handed back to the light (one tap spacing either side of the
    band excluded: the rim nearest the band is bent toward the band on purpose,
    and that bend is the fix, not an error). Light side of the rim 0 levels off
    (the one mean level: 32), and the band the ring caught is met at 34 against
    the copy's 90 — under the old ceiling, not over it.
  heal-seam-lab.cjs 10, heal-skia-lab.cjs 28, heal-search-lab.cjs 15,
    heal-probe.cjs 49, heal-zoom-geom.cjs 5, heal-zoom-probe.cjs 8,
    mosaic-skia-lab.cjs 27, mosaic-probe.cjs 51 — all 0 FAIL, against the rebuilt
    app at http://localhost:8090 (docker compose up -d --build frontend).
  Regressions against the rebuilt app, rc=0, 0 fail: landing-test.cjs 172,
    pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
    tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed; frontend
    tsc --noEmit clean.

ponytail: the correction lives on the rim — every pixel takes the two readings it
lies between and interpolates — so it is the boundary of a Poisson edit and not
its interior: a repair laid over something the patch cannot reproduce still
carries the patch's own texture inside, bent to fit the rim, and the other half
of the solve (a correction that relaxes inside the circle, a Jacobi ping-pong per
spot) lands when a real photo shows an interior the eye can find from the rim
alone. A direction's level is one tap pair on each side, so the grain the mean used
to average away now rides the rim as a wedge of a level or two across the circle —
a tangential average of three taps per direction is the rung for that, when a
photo shows the wedge. RING_PAD is one pixel because that is the width of the
dust's own soft edge in the sampler's pixels; a speck whose blur is wider than
that still reaches the ring. The source is still found by the ring search — eight
directions at three distances, each mirrored — and not by PatchMatch, and the
recipe still stores fractions with no correction in it, so nothing about this
change is versioned in a saved photo.
2026-09-24 06:29:47 +07:00
3dtours 57ade27eee web: paste the borrowed patch at the light of the place it lands in
HEAL borrows a patch of the photo and copies it over the dust. The copy brings
the patch's texture — which is the point, the repair is the same picture rather
than a blur over the speck — and it also brings the LIGHT the patch was
photographed in, which is not the point at all. The search already refuses a
donor from another light: LIGHT_GATE is 20 levels, and a candidate past that is
not scored. But inside the gate a patch can still be 20 levels off, and 20 levels
is a soft blotch of its own at the rim of the circle — a mark where the dust
used to be, which is what the user is complaining about when they say the repair
is visible. On skin, sky and sand the dust is not the problem the eye finds; the
step the paste puts down is.

So the paste is now the patch's gradients worn at the destination's level: the
shift is the mean of the ring the spot sits in minus the mean of the same ring
around the patch it borrowed, and what lands is the borrowed pixels plus that.
It is the membrane half of a Poisson edit — keep the texture, adopt the level —
and it is eight taps per spot inside the shader that was already running. No
solve, no ping-pong, no extra pass: the correction is recomputed from the
snapshot inside the shader on every render, so the preview and the export agree
by construction and the recipe carries nothing new. The same spot in a saved
photo opens onto the same repair, because nothing about the correction is stored.

Where the ring is measured turned out to be the whole of the change. The first
cut read it at the feather line, 0.85 of the radius, which is where the pasted
patch is still at full strength and therefore looks like the natural place to
compare — but that ring sits just inside the circle, and when the brush fits the
speck snugly, which is exactly how a dust brush is used, it reads the speck: the
light the repair is measured against is then the dust's own, and the patch gets
shifted onto the very dark it exists to erase. It also reversed the smoothstep
edges the moment the ring was pushed outside the brush (a radius past rad, edges
the wrong way round, and the pass quietly drew nothing). The ring now sits just
OUTSIDE the brush, at RING_R of the radius — the same radius the search reads a
spot's light at. Outside, both sides are photographs: the ground the repair has
to sit in, and the ground the patch came from. That the two are the same
measurement is the point: a donor that passed the gate was already within
LIGHT_GATE of this ring, so the shift it now receives is bounded by the gate. The
decision to borrow and the correction to the borrow stopped being two different
opinions about the same pixel.

Eight taps at the same angles on both sides is what makes the difference read as
light rather than as texture: the grain, the detail and the neighbouring specks
that differ between two patches are averaged out by sampling both rings at the
same places, and what is left is the level. The rim, measured as the level inside
the circle against the level of the ground outside it, drops from 20 levels to 0
on a scene built for it, while the borrowed contrast stays at 40 — the level
moved and the gradients did not. That is the line between this and a blur, and it
is the line the lab holds it to.

Verified:
  heal-seam-lab.cjs (scratchpad, CanvasKit, no browser) — 10 PASS, 0 FAIL: one
    scene, the speck on the grey ground with every reachable patch inside a block
    20 levels darker, run twice through the real pipeline — the paste the branch
    shipped before this change (the copy, kept inline in the lab as the "before")
    against healSkSL from the bundled heal.ts. The copy puts the block's own
    level down at the rim: inner 100/110/120 against outer 120/130/140, rim step
    20.0 levels, contrast 40. The shift lands the borrowed texture on the
    ground's level: inner 120/130/140 against outer 120/130/140, rim step 0.0
    levels, contrast 40 — the borrowed feature is still pasted at the strength it
    was borrowed at, the hole reads as the ground it sits in, the block the patch
    came from is untouched, and the frame away from the repair is the photo.
  heal-skia-lab.cjs 28 PASS / 0 FAIL against the bundled module: the pass still
    runs, the uniform block is the size its shader declares, and the paste is
    still an exact copy of the source pixels — the lab's paste scene now borrows
    from the SAME light (a white pixel at the middle of the borrowed patch, so a
    copy and a blur of the dust cannot be confused), and its forty-spot and
    three-spot runs still draw every spot in order. The scene where the two
    lights differ is the seam lab's.
  heal-search-lab.cjs 15, heal-probe.cjs 49, heal-zoom-geom.cjs 5,
    heal-zoom-probe.cjs 8, mosaic-skia-lab.cjs 27, mosaic-probe.cjs 51 — all 0
    FAIL, against the rebuilt app at http://localhost:8090 (docker compose up -d
    --build frontend).
  Regressions against the rebuilt app, rc=0, 0 fail: landing-test.cjs 172,
    pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
    tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed; frontend
    tsc --noEmit clean.

ponytail: the shift is one number per spot, measured over the rim, so a border
the two patches disagree about along its length is only matched on average — a
repair laid across a hard edge keeps a faint step where the edge crosses its rim,
and the other half of the Poisson solve (a correction that bends inside the
circle, a Jacobi solve over the spot's own box, a ping-pong pass per spot) lands
only when a real photo shows that step and the eye can find it. The gate is still
needed and still refuses: a shift corrects a light, it cannot invent a patch
where no patch of that light exists, so a speck surrounded by dust from another
light is left alone rather than covered with a guess. The source is still found
by the ring search — eight directions at three distances, each mirrored — and not
by PatchMatch: the search already refuses dust and wrong light, and PatchMatch
lands when a real photo shows the search picking a bad donor. The run is still
drawn spot by spot, with no stroke id in the recipe, so a long drag is a row of
circles rather than one region.
2026-09-23 22:10:33 +07:00
3dtours f1385d8a08 web: hide what the brush paints, in cells, and never in a blur
HEAL borrows a patch of the photo and pastes it over what the brush covers. The
other half of the same gesture is the opposite thing — a patch of the photo the
user does not want shown to anyone, a face at a table, a plate, a badge, the
number on a note at the edge of the frame — and hiding it is the second tool on
the same layer: MOSAIC, next to HEAL in the FX row. Everything the two tools
share was already shared by the time this landed: one layer, one circle riding
the pointer, one wheel, one gesture that is one undo step, spots stored as
fractions of the render so the preview and the export draw the same circle. Only
what a spot MEANS split, and it split into two files over the piece of physics
both of them were already carrying: heal.ts and mosaic.ts, and brush.ts under
them for the size and the spacing of the circle they both lay.

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

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

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

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

ponytail: the cell is a fixed fraction of the width, not a fraction of the brush,
so a brush smaller than one cell paints a single block's colour; tying the cell
to the radius would mean a cell size per spot in the recipe, which is a recipe
change this tool does not need yet. The grid is one grid for the whole frame, so
a run of overlapping spots and one wide spot give the same blocks, and the run's
circles are laid spot by spot — drawing a run as one region wants a stroke id in
the recipe, the same change HEAL's own run is waiting on. A spot is in the
recipe by its fractions alone, so what the export prints is the mosaic the user
saw, and the original pixels under it are gone from the record on purpose.
2026-09-23 22:03:33 +07:00
3dtours b795517d9f web: read a patch's light before pasting it, and paint with the brush
The brush was not healing: clicking a speck deleted one black spot and made
another, and the borrowed patch landed in a light the spot was not in, so the
repair read as a mark of its own. The circle the brush draws also slid off to
the side of the pointer as soon as the photo was zoomed in, and a drag showed
itself as a row of overlapping circles rather than as a brush being drawn.

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

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

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

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

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

Verified:
  heal-search-lab.cjs (scratchpad, Node against the bundled heal.ts) — 15 PASS,
    0 FAIL: one speck alone is repaired, from a patch that is clean field, and
    its light is 0.0 levels off the spot's own; a speck with a neighbour exactly
    at the search's first ring borrows from the far side with 0 dark pixels
    pasted; a speck ringed with dust in all eight directions skips past the ring
    (0 pasted); a speck in the corner stays inside the frame; a speck at the lip
    of a shadow, where every reachable patch is 60 against a ground of 150, is
    refused (null); ground with a dark edge through it is not a refusal — the
    repair comes from the light side and its light is 0.0 levels off.
  heal-skia-lab.cjs — 27 PASS, 0 FAIL (the shader and the search unchanged in
    everything the search is not asked here).
  heal-probe.cjs (the rebuilt app at http://localhost:8090) — 49 PASS, 0 FAIL,
    no page errors: the circle rides the pointer at the size the chip reads; one
    click heals a speck to 151 with its four neighbours field; the borrowed
    patch is a real distance away and is clean field; a drag shows ONE mark,
    6.1px wide against a 6.1px brush, standing for 15 points of travel, lays
    exactly 15 spots, clears on release, and UNDO takes the whole stroke back;
    25 spots carried with the first healed speck still first; everything gone
    after a reload; CLEAR brings it all back.
  heal-zoom-geom.cjs — 5 PASS, 0 FAIL: at fit and at 1:1 the ring's screen
    centre is the pointer (846.5,452.5 both times, against 1288 before), the
    ring keeps the brush's size on screen, and a repair made at a zoom lands
    under the pointer.
  heal-zoom-probe.cjs — 8 PASS, 0 FAIL: on a structured 2048px photo at 1:1 the
    speck goes, the donor is at least a ring away, the patched circle is within
    1.07 levels of the ground it landed on, the donor's own circle is drawn on
    the pixels it borrowed; on a navy field with three specks, two repairs land
    0.0 levels from their ground.
  heal-look2.cjs (scratchpad, PNGs in /home/locpham): the pair case used to
    paste its neighbour and read min 3 inside the healed circle — the pasted
    dust — and reads 151 now, the untouched second speck alone in the frame;
    the big-speck case (dust r=9 under a 6px brush) now lays NO spot at all,
    which is the refusal working: the speck is left alone instead of smeared.
  Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
    pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
    tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed.
  web tsc --noEmit clean.

ponytail: a refusal leaves the speck on the photo and nothing on the screen —
the user closes the brush in a size that covers it and clicks again — which is
the honest half of the trade the user asked for, but it is silent; a hint would
mean a toast or a shake, and neither is worth a component. The gate is a
flat 20 levels, not a percentage of the local contrast, so a photo with a hard
edge through the brush's own ring reads as one light and can still take a donor
from the other side of it. The search reads the preview JPEG rather than the
original, so a patch near the preview's own edges is chosen from the pixels the
user is looking at, not from the ones the export will print. And the run a
stroke leaves behind is still drawn as its spots, circle by circle, because each
one is a repair with a borrowed patch of its own — drawing the laid run as one
band would need the recipe to remember the gesture (a stroke id on the spots),
which is a recipe change and not what was asked.
2026-09-23 21:36:33 +07:00
3dtours 88cff5ca87 web: draw the dust brush into strokes, size it by the wheel, uncap the list
A speck of dust is small and there is never only one, so the brush had three
things wrong with it: the list stopped at sixteen and the seventeenth repair
pushed the first one out of the shader, the size was a choice of three buttons,
and one gesture laid exactly one spot — a scratch across a hundred pixels was a
dozen clicks.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

ponytail: the histogram is read from the render, so it is post-curve and the
band moves with the curve; read it pre-curve by exposing pass 3e's input if the
feedback ever misleads. The card's position is component state, so it resets to
bottom-left when the panel closes — persisting it across a close is a key on the
recipe, add it when someone asks for the card to stay put. Percentile and log
scaling are not implemented: linear max, the same as the overlay beside it.
2026-09-23 20:18:46 +07:00
3dtours e021f7d1ff web: prove an address with the six digits the letter carries
Signing up mailed a link and nothing else, so a visitor who signed up on one
device and read the mail on another had to leave the page the studio was open
on, or give up and stay a guest. The letter now carries six digits as well, and
the verify dialog — the face a fresh signup already lands on — takes them.

Backend, one row is both proofs. `createEmailVerification` mints the token as it
did and a `randomInt(0, 1_000_000)` code padded to six, and returns `{ token,
code }`; `sendVerification` passes both to the mailer, which puts the code first
and the link second. The code's clock is `created_at + CODE_TTL_S` (15 minutes)
and the link keeps the row's own 24-hour `expires_at`: two clocks over one row,
so the code needs no expiry column of its own. That row's `code` is NULL for
anything minted before this commit, a value no typed guess can match, so an
in-flight link from the old mail still works and its owner simply has no code to
type. `db.ts` adds both columns with `PRAGMA table_info` + `ALTER TABLE` rather
than a rebuild, and sets `attempts` to 0.

`verifyEmailCode(userId, code)` answers 'ok' | 'bad' | 'stale' | 'locked', and
the shape of the answer is the point. 'stale' is both "no live code" and "too
old", so the caller learns nothing about which; 'locked' is the spent-attempts
state, which only a fresh letter leaves. The attempt is counted BEFORE the
comparison is trusted, so an interrupted request cannot hand back a guess nobody
paid for; five (MAX_CODE_ATTEMPTS) is the cap, which is what keeps a six-digit
secret from being walked through at a hundred requests a second. The comparison
itself is `timingSafeEqual` behind a length check, the same pair the password
path uses. On success the row is deleted and `email_verified` set, so the same
row spends the link with the code — one proof, one use.

The route is `POST /api/auth/verify-code`, a POST and not a GET like the link
because a code in a query string lands in every proxy log on the way. It reads
`auth`, not `requirePro`: the whole point of it is the account that has not
passed the gate yet. Input must be exactly six digits before anything else
happens, so the counter only ever counts real guesses; a wrong or stale code is
400, a locked one 429 with `retry-after: 900`, and an already-verified caller
gets 200 without touching the row. No limiter of its own: the cap lives with the
secret on the row, and a fresh code costs one of the three resends an hour, so
five guesses per code is the budget either way.

On the web side `api.verifyCode` posts the code, and the dialog's verify face
swaps its resend button for a code box plus a smaller resend beside it: the box
is `inputMode="numeric"`, `autoComplete="one-time-code"`, `maxLength 6`, and
strips non-digits as they are typed, so the number pad comes up on a phone and
nothing can paste a password into it. The submit button is disabled until six
digits are there. The two answers a visitor can actually act on get sentences of
their own (`auth.codeBad`, `auth.codeLocked`); everything else is shown as it
comes. The link path is untouched and still works, and the dialog keeps its
"Tôi đã xác thực xong" escape in no place at all — it verified nothing, so
closing the dialog and asking again covers the same ground.

The comment in `docker/.env.example` now says the letter carries both, since a
deployment without a relay writes both to the api log.

Verified:
  backend `npm test` — 180 passed, 0 failed. The new section in security.mjs
    drives the route end to end against the source: signup leaves a six-digit
    code beside the link, a wrong code verifies nothing and leaves the account
    unproven, a five-digit body is refused, a signed-out caller cannot type one,
    the mailed code verifies, spending it spends the link, five wrong guesses
    lock the code out and the right code then does not help, a resent letter
    hands out a fresh code that is not locked out by the old guesses, and a code
    aged past its quarter hour is refused.
  otp-code-probe.cjs (scratchpad) — 10 PASS, 0 FAIL on http://localhost:8090
    against the rebuilt app and api, no page errors: a fresh signup lands on the
    verify face with the box ready, a wrong code says so and the account stays a
    guest, the mailed code unlocks PRO, and a proven address is not asked again
    on the next login.
  Regressions, 0 fail: landing-test.cjs 172, pro-gate-test.cjs 27,
    award-column-probe.cjs 18, tone-curve-probe.cjs 33. web tsc --noEmit clean.

ponytail: the code rides `created_at` rather than an `expires_at` of its own, so
the link's 24 hours and the code's 15 minutes are one column read twice; the day
the two need to drift apart independently, the column is the thing to split. The
route carries no per-IP limiter, only the per-row cap — a stranger can burn one
account's five guesses, which costs that owner a resend, and a limiter keyed on
the address would be the next thing to add if that turns out to be cheap for an
attacker. The code is not usable from another browser: it verifies the session
that asked for it, which is the behaviour the request asked for and not a gap.
2026-09-23 20:08:08 +07:00
3dtours 56d4b9df67 web: give LIGHT a tone curve, edited on the graph drawn over the photo
The LIGHT rail was sliders only, so the one control that describes a tone
mapping rather than a scalar had nowhere to live. It now has a TONE CURVE chip;
pressing it puts a curve graph on the photo itself — four channels, RGB plus R,
G and B, exactly the shape Lightroom's point curve has — and dragging a point
bends the picture under it while you drag.

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

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

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

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

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

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

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

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

ponytail: the graph is anchored over the photo, not draggable — it sits at the
photo's own layout box the way the crop frame and the straighten ruler do, and
the one time it would want to move it is when the photo under it is small, at
which point a token drag offset is cheaper than the second positioning system.
Parametric curves (Lightroom's shadows/highlights/darks/lights) are not here:
the point curve is the one the request asked for, and a parametric curve is a
second graph, not a second line on this one — add it as another channel row when
someone asks. The LUT is a texture rather than Skia's table colour filter
because CanvasKit 0.42 has no ColorFilter.MakeTable. The panel's graph size and
hit radius are literals, since exactly one graph exists.
2026-09-23 20:07:51 +07:00
3dtours cd9dd28adc web: widen the award column and bring it in beside the copy
The award card was a 340px column with a 46px gap to the copy, which left the
photograph the whole point of the card small and the two halves of the hero
visibly apart rather than one composition. The column is now 440px and the gap
24px, so the card is a third again as wide and sits closer to the headline.

Measured on the built app at 1280px: .lp-award 440px wide (was 340), and the
photo box inside it 410px (was 310) — 440 less the 1px border and 14px padding
on each side. At 4:5 the image goes 387.5px tall to 512.5px. The copy's right
edge is now 824 against the card's left edge 848, where before it was 902
against 948: the same 24px of air between them, but the copy's right edge moved
78px left and the card 100px left, so the hero reaches further across and the
extra width is photograph rather than margin.

The stacking breakpoint below 980px carried the same 340px cap, which would
have made the card narrower than the column it replaces; raised to 440px to
match. Measured at 900px: the card stacks under the copy at 440px wide.

Verified:
  award-column-probe.cjs (scratchpad) — 18/18 on http://localhost:8090, with
    the width check now 440 and the geometry line reading copy.r=824 card.l=848.
  landing-test.cjs clean, lp-arrows-test.cjs 33/33, landing-rating-test.cjs
    21/21, landing-photo-guard-test.cjs 21/21 — 0 fail against the rebuilt app.
  web tsc --noEmit clean. Dark and light themes both eyeballed on the built
    app (award-hero-dark.png, award-hero-light.png).

ponytail: the width is a literal in two places — the grid column and the 980px
cap — rather than a custom property, because the two are the same value today
and a token would need the media query to read it too; when a third width shows
up, lift both to --lp-award-w. The light-theme box-shadow is still the dark
card's rgba(0,0,0,0.35); widening the card makes it no more or less wrong, so
it is left as it was.
2026-09-23 18:27:05 +07:00
3dtours 3f5d2cd017 web: give the landing hero a column of the day's best-rated recipes
The hero was a single block of copy with nothing beside it, so a visitor landing
on the page saw no photograph at all until they scrolled. It now splits into
copy + an award card: the highest-rated photos of the current window, one frame
per photo, each labelled for the window it came from.

The frame set is the day's top-rated first, then the week's, both deduped by
photo id, so a photo that is both this day's and this week's best is drawn once
and keeps the day's label — that is the tighter of the two windows and the more
specific claim. Measured on the live data (2026-09-23 UTC, week = ISO Monday
2026-09-21): GET /api/highlights came back with day [] and week a full five
frames (ids 12,13,51,50,3, every one avg 5, n 1). The day window is empty simply
because no rating had landed since 00:00 UTC, and an empty day window must not
empty the column — hence the union rather than a fallback: whatever each window
has, merged, deduped.

Frames change the way the film strip already does, so the card reuses that
machinery rather than inventing a second one: the same .lp-arrow dots and the
same FrameArrows component, which already renders nothing under two frames.
Under two frames the card also keeps still — no arrows and no timer, because a
single frame has nothing to advance to and a timer that swaps a frame for itself
is just a repaint. Five seconds a frame, one second of crossfade: all frames are
stacked in the same box and the active one is the only one at opacity 1, each
transitioning its own opacity over 1s, so the outgoing frame fades out over the
same second the incoming one fades in and the box never flashes empty. Measured
in the built app: mid-step opacities 0.32, 0.68, 0.00, 0.00, 0.00 at the halfway
point of a step, and transitionDuration exactly 1s on every slide. Stepping by
hand restarts that clock instead of letting the old 5s fire on top of the new
frame — an arrow step to 2 then waited 4.2s still sat on that frame, where
without the restart it would have moved on at 5s from the previous frame's
start.

The rating is shown on the frame because it is the whole reason the frame is
there: avg to one decimal, plus the vote count as "1 vote"/"N votes" — one
decent vote and one outstanding vote are not the same window, and the reader
can tell them apart at a glance. Score is mono, bottom-left, over a text
shadow.

Backend side this is one query and one route. topRatedPhotos(since, limit)
joins ratings to photos on CAST(substr(ratings.key, 7) AS INTEGER), since
ratings keys are the strings "photo:<id>"; it filters ratings.key LIKE 'photo:%'
so a look: vote can never award a frame — there is no look to show — and
photos.consent = 1, so a photo pulled from public display is pulled from the
awards with it. Ordering is avg DESC, n DESC, at DESC, id DESC: best average,
then the better-supported average when averages tie, then the freshest, then id
only to make the order total and the frame set stable between requests. avg
comes back rounded to 2dp. GET /api/highlights computes the two windows in UTC
— midnight, and ISO Monday midnight via midnight - ((getUTCDay()+6)%7)*86400000
— and returns { highlights: { day, week } }. HIGHLIGHT_LIMIT is 5.

The column is 340px on the right of the copy, stacking under it below 980px.
Measured on the built app at 1280px: copy ends at 902, card starts at 948, same
hero row, card exactly 340px wide, five slides for five frames, label "Recipe
of the week", score "5.0★1 vote", the two arrows the only .lp-arrow inside
.lp-award, meta #CLASSIC_VIVIDIPES. At 900px the card sits under the copy. With
the window forced to one frame the card draws 0 arrows and 1 slide; with both
windows empty there is no .lp-award and the hero is not split at all, so an
unrated install looks exactly as it did before.

Verified:
  award-column-probe.cjs (new, scratchpad) — geometry, arrows, crossfade
    opacities, the 5s auto step, the manual step's clock restart, the one-frame
    and no-frame windows. 18/18 on http://localhost:8090.
  backend npm test — 166 passed, 0 failed, with four new checks in the ratings
    section: a vote lands in today's and this week's window, a look: subject is
    never an award, and a photo drops off the awards once deleted. The ratings
    and photos suites cover the joins the new query leans on.
  landing-test.cjs, lp-arrows-test.cjs 33/33, landing-rating-test.cjs 21/21,
    landing-photo-guard-test.cjs 21/21 — 0 fail against the built app.
  web tsc --noEmit clean. Dark and light themes both eyeballed on the built
    app (award-hero-dark.png, award-hero-light.png).

ponytail: the card re-fetches on the page's own reload() rather than polling, so
a rating cast while the tab sits open will not surface until the next reload;
the awards are a landing-page flourish, not a live feed — when they need to be
live, poll the same route on the timer the frames already run. The card's
box-shadow is the dark card's, one rgba(0,0,0,0.35), and reads heavy on the
light theme next to .lp-recipe-card's light-specific shadow; left alone rather
than adding a token for one property.
2026-09-23 18:17:50 +07:00
3dtours 81ed53cf78 web: anchor the watermark box to the photo's layout box, not its rect
Two zoom-only defects in the FRAME watermark boxes, both of them units mistakes
between what the stage measures and what the engine draws.

measure() read the box off img.getBoundingClientRect(). Zoom is a TRANSFORM on
the photo, and every layer above it (wm, crop, pick, compare, straighten)
carries that same transform itself, so a rect read off the transformed element
comes back already scaled and is then scaled again by the layer. It only shows
once measure() runs while the stage is zoomed, which is exactly what the zoom
itself causes: onStageZoom re-cuts the preview copy, the new src fires onLoad,
and measure() re-runs on the transformed photo. Measured on the built app (a
2560x1706 upload, a 955px stage, four wheel notches -> 1.749x): the layer came
out at -651,-796 2921x1947 against the photo's own 43,-241 1670x1113 — the zoom
printed twice — with the box at 809,951 while the mark sits at 878,757 and the
baked text at 882,765. The user's report exactly: the textbox not anchored where
the mark was placed, and its frame not drawn where the text is.

The box is now the photo's LAYOUT box (offsetLeft/offsetTop/offsetWidth/
offsetHeight, whose offsetParent is the relative-positioned .canvas-wrap), which
no transform can touch. Same run after: layer 43,-242 1670x1114 against the
photo 43,-241 1670x1113, box x 878 against the mark's own 0.5 x 1670 = 835 + the
handle, and the box at 878,757 against the text at 882,765.

The corner handle mixed the other way round. It read the pointer's travel as
(e.clientX - d.left) / d.width — a SCREEN distance over an UNSCALED width, and
with d.left the mark's own anchor rather than where the handle was grabbed. At
scale 1 the handle sits exactly one box width from that anchor, so f came out 1
and the drag read true; zoomed, the same pull arrives s times larger and the box
it grows was itself s times too wide. Measured: an 80px pull at 1.749x reported
size 1.503 and left the box 163 -> 420px where 163 -> 241px was asked for. The
handlers now take the photo's unscaled width and height plus the scale it is
drawn at (rect.width / offsetWidth) and divide the pointer's screen delta by
both, so a pull reads the same at any zoom, and the face size and the y
compensation it feeds are computed from the photo's own width — that is the width
the engine draws the fraction against. Same pull after: 163 -> 241px, the
top-left corner left where it was.

Verified:
  wm-zoom-probe.cjs (new, scratchpad) — 2560x1706 upload, four notches of wheel
    held on the handle's own corner: layer vs photo box, box vs the baked white
    text, the box's growth against the zoom, then an 80px handle pull. 9/9 on
    http://localhost:8090 (built asset index-BOwzIcQD.js). On the previous build
    the same probe is 5/9 — the layer, anchoring and size checks above.
  wm-test.cjs 23, wm-font-test.cjs 38, wm-font-registry-test.cjs 26,
    wm-gps-test.cjs 27, wm-gps-date-test.cjs 18, crop-frame-test.cjs,
    compare-test.cjs 25, compare-crop-test.cjs, zoom-test.cjs 25 — 0 fail at
    scale 1, where the old arithmetic happened to be right.
  web tsc --noEmit clean.

ponytail: offsetWidth/offsetHeight round to whole CSS pixels, so the box can sit
half a pixel off the photo's own box — under a box whose tolerance is the
dashed hairline, not worth un-applying the transform by hand; the day something
reads those pixels, compute the fit instead, as baseRect already does.
2026-09-23 17:12:31 +07:00
3dtours d37671c359 web: print the grain zone by mixing two lattices, not by warping one cell
The coating's patches were drawn by varying the clump CELL with position:
cell = u * (1 + (grainZone(p * ZONE_FREQ) - 0.5) * ZONE_SWING), the same slow
value noise that picks the patch. A lattice whose cell varies with position
smears instead of resizing: its phase accumulates as
d(phase)/ds = 1/cell - s*cell'/cell^2, and c' is read along the radius from the
picture's own origin, so the second term grows with the distance s from it and
the clumps are drawn out wherever the patch's own cell runs. Measured on one
classic-neg paint (1024px, cell 1.09, the app's own Overlay at alpha 0.5, 64
tiles): with the swing on, the tiles' lag-1 correlation of the raw frame spans
-0.065..0.747 — clumps stretched into smooth blotches beside grain. The design's
+-20% swing cannot do that: the SAME field with the swing forced to 0 spans
-0.057..0.045 across its tiles, and the two-lattice field spans -0.055..0.052
(leica: -0.049..0.523 with the swing on, -0.068..0.024 at swing 0).

The zone now MIXES two FIXED lattices, 0.8x and 1.2x the stock's own cell,
weighted by that same patch noise. A fixed lattice's phase is linear in the
picture, so a patch can only choose how much of each is printed, never how
either is shaped — and the two lattices' beat falls at
1/(1/fine - 1/coarse) = 2.4 cells, 2.6px at the 35mm cell: the pixel scale, not
a line the eye reads. The mix is renormalised by sqrt(w^2 + (1-w)^2), the share
of one field's spread a two-field blend carries, so neither the mean nor the
spread follows the patch: the same classic-neg field reads sd 24.85 against
24.91 and leica 23.74 against 23.74, tile by tile.

The zone still reads what it is for. At preview scale (1600px, cell 1.70, 8x8
tiles of 200px) the mixed field's tiles span rho1 0.082..0.265, ratio 3.233,
against 0.169..0.193, ratio 1.141, with the swing forced to 0 — classic-neg;
leica 0.026..0.188, ratio 7.361, against 0.085..0.105, ratio 1.237. A coarser
patch still prints coarser clumps; it just never prints a stretched one.

Both copies carry it: docker/frontend/shared/utils/grainShader.ts and
src/utils/grainShader.ts (the phone's, which the root web harness imports too).
The field is evaluated once per lattice now, so the grain pass costs 1.94x what
one lattice did — the ratio, not the absolute.

Measured:
  _grain-zone2.cjs — the 64-tile lag-1 correlation spread above, three modules
    on one paint and one seed: swing 0.4 vs swing 0 vs the mix.
  _grain-zone-ck.cjs — the zone's own contribution at preview scale, zone on
    against the same module with the swing forced to 0, nothing else differing:
    classic-neg tile sd 24.21..24.86 (ratio 1.027), hf 0.769..0.937 (1.219),
    rho1 0.082..0.265 (3.233) against sd 29.04..32.95 (1.135), hf 0.834..0.858
    (1.028), rho1 0.169..0.193 (1.141); leica rho1 0.026..0.188 (7.361) against
    0.085..0.105 (1.237). The swing-off row's higher sd is the renormalisation
    of a blend with itself (a and b are one field at swing 0), not a contrast
    change in the shipped field.
  _grain-fft.cjs — same paint, same seeds, three rolls, 1024px: the mix's top
    spectral peak sits at 2.6px (classic-neg, cell 1.09) and 2.8..2.9px (leica,
    cell 1.00) against the warped field's 3.0/4.3/6.2px and 2.7/3.8/4.5px — both
    within a pixel of the clump cell, neither a coarse lattice.
  _grain-bench.cjs — 12.68s per 700px field (one lattice) against 24.60s (two),
    1.94x on software CanvasKit.
  grain-size-test.cjs 17/0 — the SIZE rule and the readout on the module, both
    lattices floored at the target's own pixel, file rho1 0.673, preview rho1
    0.003.
  grain-stock-test.cjs 53/0 on the deployed build — the stock table, the
    halation chain and its ordering, no page errors.
  grain-controls-test.cjs 20/0 on the deployed build — the patch claim still
    holds there: tile sd 56.58..65.48 (mean 61.9, max/min 1.157), tile mean
    spread 0.65, so a coarser patch is still not a brighter one.
  _grain-spectrum.cjs (app, deployed, 1600px) — residual autocorrelation peak
    0.020..0.021, top peaks at 2.0px@20/110 and 2.5px@51.
  tsc: web `--noEmit` clean (the docker build runs it); the phone's scoped
    config reports its pre-change baseline, nothing in grainShader.ts.

One honest number: the app-level spectral peak/median rises 4.6..6.1 to 10.2
(classic-neg, sim-classic-neg-g6/g10) because two fixed lattices beat where one
warped lattice spread. It is 20x below the value-noise field this work replaced
(29..35, tiling) and 5x below a lattice (50+), and it sits at 2px, the cell
itself.

ponytail: the field is evaluated once per lattice, so the grain pass costs
1.94x. One evaluation cannot hold two cell sizes; revisit only if a preview
budget asks for the pass back. The 0.8/1.2 rungs (ZONE_SWING/2 either side) are
one working set, not a search.

Verified: `grain-stock-test.cjs` 53/0, `grain-controls-test.cjs` 20/0 and
`_grain-spectrum.cjs` against the deployed build at localhost:8090;
`grain-size-test.cjs` 17/0; `_grain-zone2.cjs`, `_grain-zone-ck.cjs`,
`_grain-fft.cjs`, `_grain-bench.cjs` against the module built from HEAD,
`inversesqrt` still the one call the shader needed to renormalise; web
`tsc --noEmit` clean, phone scoped tsc down to its pre-existing errors.
2026-09-23 16:47:19 +07:00
3dtours 0e9f78bd5e web: give the grain a size of its own, and read the count off the print
MONOCHROME GRAIN was one integer knob 0..10 with one meaning, how much. It is now
a strip of three: AMOUNT — the same knob, in half steps — SIZE, a percentage of
the stock's own grain cell (50..200%, so the same number means the same texture
relative to the picture on both platforms), and an inert readout of N/INCH, the
clump count the two knobs and the stock add up to in the print's own terms (300
dpi = 300px of the 1080-wide reference the knob was tuned at).

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

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

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

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

ponytail: the amount is fractional now, so the two recipe-create forms read grain
through their own half() instead of the int() that would truncate the half the
ruler just spent — every other knob there is still whole. The SIZE knob is one
number for the whole strip: no way to dial a single patch, and no seed control.
The readout is the DESIGN count the field is built on, never a per-patch
measurement.
2026-09-23 15:26:23 +07:00
3dtours 64f1598076 web: print the grain as jittered clumps, not as value noise on a grid
The field was 20-degree-rotated value noise on a square lattice, three dyadic
octaves at 1 / 0.5 / 0.25 and a sin hash behind it. Value noise prints the
density of the cell's four corners, so every clump sat on a knot of one grid,
and the grid's own repeat — 13 cells, 44px at the 35mm cell — is what the eye
read as diagonal lines. Measured on the old field through the app
(`grain-analog-test.cjs`, `_grain-spectrum.cjs`): off-origin autocorrelation
peak 0.32-0.40, spectral peak/median 29-35, top peaks at 5.4-7.8px.

The field is jittered clumps now, in both copies
(docker/frontend/shared/utils/grainShader.ts and src/utils/grainShader.ts).
A clump lands at a random spot inside its cell — Worley F1 over the 3x3
neighbourhood, `grainClump` — so no two clumps share a grid, and what is
printed is the distance to the nearest: a smooth mound, not one pixel of
static. The hash behind the jitter is sin-free (Hoskins' `p3 = fract(vec3 *
0.1031); p3 += dot(p3, p3.yzx + 33.33)`), because a float sinus whose argument
grows with the picture folds back on itself and is a lattice of its own. The
three octaves are turned to their own angles — 20, 47, 73 degrees — and sit on
0.53 and 0.29 off the dyadic 1 / 0.5 / 0.25, where a coarse octave's cells land
back on the fine one's and stack.

Clumps sit higher and tighter than the value noise they replace — mean 0.569
against 0.500, sigma 0.123 against 0.081, measured — so the sum is put back on
that mean and spread, `n = (n - 0.5685) * 0.52 + 0.5`, before the AMOUNT
knob's own gain. The web keeps its uniforms (cell, seed, per-stock weights,
spread); the phone keeps 0.55/0.30/0.15 and 2.95, which are the web's
classic-chrome row, so the two print the same texture.

Measured on the deployed build: `grain-stock-test.cjs` 53/0 — 35mm still
coarser than 120, the halation chain intact per stock, the "halation follows
the stock" ordering intact, the field still clumped (r1 0.336-0.506) and still
surviving a 2x downscale. `_grain-spectrum.cjs` residual autocorrelation peak
0.02 (was 0.32-0.40), spectral peak/median 7.0-12.7 (was 29-35), top peaks
only at 2-3px periods, the cell scale. `_grain-ck.cjs` at the preview cell
(U=1.481/1.704): rho1 0.093/0.177 against the old 0.505/0.586, acPeak
0.02/0.028 against 0.38/0.467, peak/med 10.1/11.2 against 76/46.5, top peaks
2.0-2.9px against 5.4-7.8px. `grain-size-test.cjs` 12/0.
`grain-analog-test.cjs` 7/0 — with the TEMP pair on AUTO, the field's channel
split measures 0 at a cast of 0, so the grain is exactly monochrome and no
neighbour lag carries structure (max |rho1..8| 0.118).

One honest number: the preview's apparent strength at GRAIN 10 is ~25% higher
than the old field's (sigma 61.3 against 47.9 in that harness), and that is the
PREVIEW, not the field. Step 9 of src/engine/exportEngine.ts sharpens the
preview at alpha 0.5, and the clumps now sit at the cell (~1.7px) instead of on
the old ~5px lattice, so that sharpen bites harder. The field's own composite
spread is 17% UNDER the old one at the same geometry — lab sdRaw 24.9 against
30.0, and geometry-flat where the old one was ~30 everywhere. The 0.52
normalisation was kept rather than re-tuned upward to the app-visible number:
the export path does not carry that sharpen, and a grain tuned to it would
print too strong.

ponytail: the octave angles and the 0.53/0.29 rungs are one working set, not a
search. Re-tune only if a stock's cell is changed again.

Verified: 53/0 + 12/0 + 7/0 grain harnesses, `_grain-spectrum.cjs` and
`_grain-ck.cjs` A/B against the field built from HEAD, `sims-test.cjs` 31/0,
`fx-mono-test.cjs` 15/0, no page errors.
2026-09-23 13:00:14 +07:00
3dtours c2a4740a3f web: let the white balance carry its tint, and take its ratio in the light
TEMP was a kelvin and nothing else, and the seven presets were seven numbers
the two platforms disagreed about: AUTO 5500, DAYLIGHT 5600, DAYLIGHT -3R
5800, CLOUDY 6500, SHADE 7500 here and 8000 in both recipe-creation forms,
TUNGSTEN 3200, FLUOR 4000. A chip tapped in the recipe panel and the same
chip tapped on the photo printed different frames.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Not ported: the phone's GPS watermark still prints in the bundled face only
(no emulator here to verify a phone-side font strip), and the FONT options are
not behind the PRO gate the way the phone gates non-default families.
2026-09-23 10:59:49 +07:00
3dtours 438788e031 web (engine): print the grain from the phone's own module
The web port carried a third copy of the grain shader, so it would have kept
printing the old static, unseeded field after the phone stopped. It now imports
GRAIN_SKSL and grainCell from src/utils/grainShader.ts, exactly like the phone's
export, so there is one shader and one size rule for the whole pipeline. The
floor is one output pixel here, which is the whole story on this path: a
CanvasKit surface is rasterised in the pixels it is given, so no dp-to-device
correction is needed the way the phone preview needs one.

Typechecked only — no harness in the tree runs this file; the deployed app
(docker/frontend) has its own engine and already prints the new grain.
2026-09-23 10:10:53 +07:00
3dtours 3c8a1acc69 Give the grain one roll of film, sized to the picture
The camera preview, the library preview and the exported file each carried
their own copy of the grain shader, and each sized it off the rect it happened
to be drawing on. Two things were wrong with that. The field was a single value
hash, so it looked like digital static rather than film: measured on the noise
plane, neighbour correlation was flat and the lattice sat on the picture's own
axes; and it was the same field in every session, so two photos from two
launches printed identical grain. The size rule was wrong the other way round:
canvas units in Skia are dp, so on a 3x phone the floor of one dp pinned a
390dp-wide preview to a THREE device-pixel cell while the file printed a
1080th of its width — 2.77x coarser than the picture it was previewing, which
is the preview-vs-file mismatch all over again, just the other direction.

Grain now lives in one module, src/utils/grainShader.ts, imported by the
preview (both surfaces), the phone's export and the web engine, so the three
cannot drift. The noise is three octaves of value noise over a density that is
the mean of three hashes — a bell, the way an emulsion's density swings —
turned 20 degrees off the axes, seeded once per app launch, so a session prints
one roll and the next launch prints another. Only coarser octaves: a finer one
lands under the pixel and the clumping is lost outright. The cell is one
1/1080th of the PICTURE's width, floored at one pixel OF THE TARGET — an
export's own output pixel, a preview's device pixel (1 / PixelRatio.get())
instead of a whole dp.

Measured on the plane itself with the real module (grainSize harness): the file
at 4000px and a 390dp camera preview at 3x agree on the same fractional lag,
rho 0.017 against 0.011, while the floor this replaces reads 0.329 — visibly
coarser, as claimed; both keep the spread the AMOUNT knob was tuned on
(sigma 57.8 / 57.9) and still clump (rho(1) 0.91 / 0.39); R, G and B never move
apart, split 0. GRAIN_SKSL compiles on the Skia runtime, and the compile is now
swallowed into null at module scope like the other three effects, so a shader
this app cannot compile costs the grain rather than a launch.

Not ported: the Kotlin grainOverlay probe (per-pixel, path is off by default).
2026-09-23 10:10:49 +07:00
3dtours 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.
2026-09-23 10:01:49 +07:00
3dtours cf0091b741 web: name a position that lands before the account is known
A photo restored from the session reaches the stage while /me is still in
flight, so the account reading that put it there saw `pro` as false, the place
lookup was skipped, and reloading a photo left the stamp with bare coordinates.

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

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

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

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

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

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

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

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

Verified end to end in photo-dims-probe.cjs: 2400x1800 opens as "2400 × 1800",
a 90 deg turn reads 1800 × 2400, an applied 1:1 crop reads 1800 × 1800, and the
export writes exactly that file.
2026-09-23 07:58:56 +07:00
3dtours a5d195c5d2 Describe the app and the two builds it ships from
The repository root had an empty README: anyone landing on it saw the Expo
project and the docker/ web build side by side with nothing saying they are the
same product. This is the English description of RecipesCam — what the recipes
are, the looks and adjustments, the geometry and finishing tools, and the
on-device upscale — plus how the two builds relate and how to run each.
2026-09-23 07:46:20 +07:00
3dtours 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.
2026-09-23 07:46:19 +07:00
3dtours 3a09674831 web: filter the straighten draw so a rotated edge stops staircasing
The FRAME tab's fine rotation drew the photo through canvas.rotate() +
canvas.scale() with a plain drawImage, which CanvasKit samples with
nearest: the edge landed on the same pixel in every row, so a rotated
edge came out as 1px steps every 1/tan(angle) rows. Measured on the 30deg
export of a hard black/white edge: 42.3% of rows repeated the previous
row's edge position, the step across the edge was 252.9 of 255, and there
were no intermediate pixels at all.

Only the *Options/*Cubic call shapes take a sampling option, and
drawImageRectOptions exists in RN Skia too, so the shared renderer can use
it unchanged. The same export now moves the edge in every row (0.2% of
rows repeat, 0.35 intermediate pixels per row) and its edge step drops to
222. Cost: the filtered draw takes 0.35s against 0.24s for the 1600px
preview copy and 2.0s against 1.4s for a 12MP photo, once per render.

Preview and export share the function, so both change together.
2026-09-23 07:33:44 +07:00
3dtours 500068e63e web: mark SAVE PHOTO PRO and keep MY PHOTOS for the accounts that have one
Saving into the account's own folder has always been the account's act —
the button opened the way in and the API answers an unproven address with
a 403 — but nothing on the button said so, so it read as a button that
quietly did nothing. It now wears the same PRO marker the chips do, and
only while the folder is not the visitor's.

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

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

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

Measured, wasm path, 2400x1800: no crop at 2K went 2.7s/2400px (wrong size) to
3.4s/2048px, the default 0.8 crop went 158.7s/80 tiles to 3.5s/no model, and a
1:1 crop went 2.5s/1800px to 4.0s/2048px. A 1200x900 photo cropped to 1:1 and
exported at 2K still runs the model (2048 from a 900px crop, 40.3s), and the
superres suite is unchanged: 640x480 to 2K/4K/custom still comes out exact,
with the model's 16.6 edge energy against bilinear's 4.8.
2026-09-22 20:43:46 +07:00
3dtours 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.
2026-09-22 20:30:02 +07:00
3dtours 3d273a3188 Add the B&W HIGH CONTRAST film sim next to ACRIPES
A second black-and-white stock, so it gets its own baseFilter rather than
borrowing Acros': the PHOTO STYLE chips are keyed by baseFilter and the two
greys have to sit side by side.

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

Both B&W stocks being mono is now asked once, through isMonochromeBase, so the
colour-only stages (saturation, white balance, the R/B fine-tune) and the
chrome/hue-mixer gate can never half-apply to one of them.

Mirrors the web app's rail (c403dd0).
2026-09-22 19:25:54 +07:00
3dtours c403dd04c8 web: add the B&W HIGH CONTRAST film sim to the PRESETS rail
The chip lands after ACRIPES and is a mono stock of its own, so it gets its own
baseFilter ('mono-high-contrast') rather than borrowing Acros': the PHOTO STYLE
chips are keyed by baseFilter, and the two greys must sit side by side.

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

Both B&W stocks being mono is now asked once, through isMonochromeBase, so the
colour-only stages (saturation, white balance, R/B fine-tune, chrome, hue
mixer) and the MONO strip label can never half-apply to one of them.
2026-09-22 17:56:00 +07:00
3dtours 80512d11b5 web: print the shared frame's own labels on the QR recipe card
The card carried studio copy — SUNSET GLOW, a made-up warmth/grain/bloom line
and a creator handle borrowed from a testimonial — no matter which still the
arrows landed on. Stepping the frame changed the picture and the code but left
the text behind, so the card described a photo it was not showing.

The card now reads the frame's own labels: the tagline over the still and the
title and ISO/grain line under it are the ones the uploader saved with the
photo, the same three the film strip prints. The fabricated creator/imports
line goes with them, since nothing behind it was real.
2026-09-22 17:22:54 +07:00
3dtours 2ee49cfd41 web: keep the film strip seamless on a wide window
The marquee loops by translating the track by half its width, so the first half
has to be at least as wide as the window. A short reel — seven stills, about
1780px — covered a 1440px window but not a 1920px or 2560px one: the strip ran
out of frames before the loop restarted, and the band on the right stayed blank
until the next pass drifted in.

The track now measures one frame's pitch and repeats the reel until a half
covers the window, re-measuring on resize. One still in the strip is repeated
enough times to loop cleanly on its own; a reel already wide enough is left at
a single copy, so nothing is duplicated without cause.
2026-09-22 17:11:12 +07:00
3dtours e1f6943f8e web: step the creator and QR previews through their tagged stills
The landing's tester preview has had a ‹ › pair for a while; the custom recipe
creator and the QR recipe card still showed one arbitrary frame from whatever
the curator tagged for them. Both now cycle their own slot the same way, from a
random start so the page does not look identical on every load, wrapping at
both ends.

The QR card's payload is not decoration: it is the link of the frame on screen,
so the code under it is redrawn from the same frame the arrows land on. A slot
holding fewer than two stills gets no arrows, since there is nowhere to step.

The tester's arrows move to the shared FrameArrows component, which is what the
two new pairs use — one implementation, three slots.
2026-09-22 17:03:46 +07:00
3dtours bb51b83399 web: the hero stat labels wear the accent as gradient text
COLOR RECIPES, DOWNLOADS and STORE RATING were flat grey under their numbers.
They now sweep from the theme's own accent to the film red and are cut out of
that gradient, the same treatment the headline above them gets. Because the
sweep starts on var(--accent), the colour group in the Theme menu still
retints it; the light theme starts from the darker ink accent so the labels
keep their contrast on white.
2026-09-22 16:47:35 +07:00
3dtours cfe8512636 web: the landing draws only the stills the studio uploaded
Six bundled sample negatives stood behind eight built-in looks, and they were
the only frames on the page nobody had uploaded. They are gone, along with the
REEL table and the SAMPLE() helper that pointed at them.

The film strip is now exactly the photos the curator put in the strip slot,
repeated once for the marquee loop; a section whose slot holds nothing simply
draws no frame instead of falling back to a stock photo. The reel's rating keys
are all photo:<id> now, and the QR card's filter follows the sunset preset it
claims rather than an index that moved.
2026-09-22 16:35:40 +07:00
3dtours 7f2a5b0b5a web: drop the 300 ppi lines from the landing copy
The claim was printed in four places — the RAW feature card, the quality FAQ,
the custom-recipe readout and the Lite plan list. Each now reads as
print-ready instead, and the readout keeps only RECIPE CUSTOM_01 · EXIF KEPT.
The exporter's own JFIF density is untouched, web-smoke still checks it.
2026-09-22 16:04:51 +07:00
3dtours 339d36eb3e web: the preset tester picks its recipe from a grouped dropdown
The row of pills grew as the library did, so the landing's live tester now
offers the stocks through one native select, grouped into Landscape, Portrait
and Streetlife, five stocks each — fifteen in all. Choosing one still lands on
the preview immediately: the frame, the HUD and the spec card all follow the
selection. Stock names stay proper nouns, the group names are translated with
the rest of the page.
2026-09-22 12:49:18 +07:00
3dtours 7dea0f1e7b web: the mixer's card can be dragged off the colour it was read at
The card hangs on the point the eyedropper read, which is exactly where the
user wants to watch the band move — so it covers the patch it is editing. Its
body now takes a drag: the offset is a fraction of the photo, which is the
layer the card lives in, so a zoom keeps it where it was put and a fresh pick
drops it back on its own point. The knobs and the close button keep the pointer
to themselves, and the anchor cannot leave the photo, so the card is always
half in reach of a drag back.
2026-09-22 11:13:21 +07:00
3dtours 88afdf6611 web: compare the same frame rendered twice, whatever its geometry
The split used to paint the photo file beside the render, so it only lined up
while nothing had moved: a turn, a straighten, a printed frame and the halves
were two different pictures. The app now renders the same frame twice — once
through the look, once through the neutral stock — and the left of the bar is
that second copy. Rotation, straighten, crop and frame land on both halves by
construction, so the CSS that tried to map the crop onto the file goes away.

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

The window needs the image free to overflow it, so the inline style lifts the
box clamp that .canvas-wrap img puts on every preview.
2026-09-22 10:45:07 +07:00
3dtours 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.
2026-09-22 10:31:27 +07:00
3dtours 711e2fc5d6 web: the history arrows sit with the name they undo
Undo and redo were on the far side of the spacer, past RESET, SAVE PHOTO
and EXPORT. They belong next to what they step through: the header now
reads mark, page name, preset name, the two arrows, then the actions.
2026-09-22 10:14:51 +07:00
3dtours 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.
2026-09-22 09:56:12 +07:00
3dtours c0c99a9672 web: EXPORT offers a size, and a bigger one is upscaled in the browser
The server still never sees a photo, so the model has to run in the page.
Real-ESRGAN x4v3 ships as a 4.9MB ONNX in public/models and is loaded
lazily on the first export that actually needs it; the wasm runtime is
copied next to CanvasKit at build time and stays lazily fetched, cached
for 30 days. Vite is told onnxruntime-web is external-wasm so no 28MB
asset lands in the bundle.

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

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

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

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

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

Also in this checkpoint: the watermark/GPS boxes and their colour pickers, the
WATERMARK chip column, the real admin stats, and the fix that stopped presets
from doubling and a frame from refusing to come off when a photo was reopened
(/file is the finished render, /base the editable pixels).
2026-09-22 08:32:28 +07:00
3dtours 52b672deec web: PRO needs a proven address — email verification gates the studio
A signed-in account is served exactly like a guest until it opens the
verification link: watermarked 2048px export, no saving, no PRO frames,
GPS stamp or HDF. SMTP is declared in .env; with SMTP_HOST unset the link
goes to the container log. Allowlisted admins count as verified.
2026-09-20 07:39:03 +07:00
3dtours efe578f61c web: the mobile chip strip lies down
Tapping a tab on a phone opened a 148px column with the chips stacked one per
line, so the strip read as a ladder down the side of the photo. Android's own
panel runs its chips as a row — see src/components/AdjustmentPanel.tsx — and
that is what the phone now gets: the columns stack into one vertical scroll
and each chip row runs sideways again, wrapping inside the full width.

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

Desktop keeps its icon-over-label column — the change lives in the <=860px
block, so nothing above that breakpoint moves.
2026-09-20 06:24:27 +07:00
3dtours 15bacacafa web: logging out ends the studio session, not just the cookie
A session followed the browser, not the account: log in, open a frame, log
out, come back as a guest — the same photo stood on the stage, because the
studio's own store (localStorage knobs + the photo in IndexedDB) outlived the
cookie with nothing to clear it.

clearSession() now drops both, and the three log-out buttons call it. The
studio's own button reloads after the delete has committed — a reload mid-
delete aborts the transaction, so the promise resolves on tx.oncomplete, not
on the request. The account's frames are untouched: they reopen from MY
PHOTOS.
2026-09-18 21:48:19 +07:00
3dtours e57444e88b web: draw each landing photo cut to its own box, not to its own shape 2026-09-18 21:24:02 +07:00
3dtours 2da045f232 web: the preset tester steps through every frame in its slot 2026-09-18 21:02:30 +07:00
3dtours d3c5c47973 web: the reel is curated, not crowd-sourced — no CONTRIBUTE button 2026-09-18 21:02:30 +07:00
3dtours d25649a26b web: each PICTURES column is an album shelf, a frame and a strip
Both columns now have the shape the curator asked for: a narrow shelf of
albums down the left — one per account on the uploads side, one per landing
section on the other — the frame that is up in the middle, and the open
album's thumbnails as a strip across the bottom.

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

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

PICTURES was four destination rows; it is now an album per uploader with
a search box, a recipe/rating/newest sort, a minimum-star filter, a big
preview and a filmstrip of thumbnails. Deleting and slot picking still
live in the big box.
2026-09-18 19:17:38 +07:00
3dtours 4057566a14 web: the three mixer knobs move the whole image, a frame chip toggles itself, ROTATE lets go when STRAIGHTEN steers
HUE, SAT and LUM leave the colour row: behind an IMAGE divider they are
hslHue/hslSat/hslLum, seeded into the shader's band accumulator at full
weight for every hue, while the eight band chips keep picking which colour
the panel on the photo edits. The image lightness term stays ungated so a
frame drained to grey by -SAT still answers +LUM.

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

ROTATE's quarter turns stop lighting the moment the fine angle leaves 0,
so the strip shows which of the two is steering the photo.
2026-09-18 18:45:41 +07:00
3dtours 27035c4acb web: the mixer hangs a panel on the colour it read, and STRAIGHTEN becomes a scale on the photo 2026-09-18 18:27:28 +07:00
3dtours 10466e122a web: the frame tab straightens the photo by hand 2026-09-18 18:07:14 +07:00
3dtours 1b71c0196f web: the eyedropper reads a colour and the mixer moves that hue band 2026-09-18 17:41:45 +07:00
3dtours 8a889db069 web: the QR card hands out the look that made the photo
A photo's landing section can now be the QR card, and that section is the
only one that hands something out: the server writes the photo's own stored
look back as the app's .recipe file, at
GET /api/photos/:id/preset.recipe, for any row the curator ticked into the
qr slot. Nothing new is stored — the file is built from the recipe the
upload already carried, so it works for a photo uploaded by the phone too.

The admin pane grows a fourth checkbox and a fourth row (QR card); the
row draws the download link as a scannable code, and the box is dead for a
photo with no stored look. The landing's QR card now encodes the curated
photo's own link instead of a mock address. The listing exposes
hasPreset, never the recipe itself.
2026-09-18 16:57:46 +07:00
3dtours a35ecf4f1c web: the landing page hands out no more pixels than it draws
A save cannot be beaten — the bytes are already on the machine — so the page
stops handing over the uploader's 4000px original: each photo is decoded,
redrawn at the size of the box it sits in times the screen's pixel ratio (2x at
most) and only that smaller copy reaches the tag. A visitor who saves one gets
a screen-sized file.

Right-click, drag and long-press are turned off on top of it, and the QR code
card — a link image, not a contribution — is left as it was.
2026-09-18 16:47:15 +07:00
3dtours 260517c547 web: a photo can sit in every landing section at once
The picker was one dropdown, so a photo lived in exactly one place. The three
destinations are now independent checkboxes on the card, and the column holds
the set as a comma list — the landing page draws a photo in every section it
was ticked into, each still picking one of its own at random per visit.

Ticking nothing is what `off` used to be: the row is kept and the landing page
stops drawing it, which is what the old "not on the landing page" option did.
2026-09-18 16:47:12 +07:00
3dtours ee2c402438 web: pick a photo's landing slot with two columns
The pictures pane now reads as a pool of every upload on the left, three
destination rows on the right. Each photo carries one button per
destination; picking the one it already sits in takes it off the landing
page without deleting the row (slot off), which is what the old select's
"not on the landing page" option did.
2026-09-18 16:28:55 +07:00
3dtours 4d0d44170f web: an FX chip that flips the photo to monochrome
It is a switch, not a look: it swaps the base filter for the mono stock and
swaps right back to the one the photo was wearing — name and sim included. The
adjustments are never touched, so a knob moved while it is on survives the trip
back, and the swap rides the undo stack like any other edit.
2026-09-18 16:16:28 +07:00
3dtours d7d7be2c6b web: add CLASSIC VIVIDIPES, spliced at the blue row
Classic Chrome's blue row, verbatim, with the red and green rows taken from
VELVIPES: skies keep the muted teal/cyan lean while everything that is not
blue reads loud. The shadow crush rides along from FILM_TONE, since it belongs
to the stock rather than to a row. CLASSIC CHRIPES stays untouched beside it.
2026-09-18 16:09:09 +07:00
3dtours abaa980f93 web: undo/redo, a clean preview, and a histogram that stays put
Four things the studio owed the visitor:
- UNDO/REDO in the header, so a look can be taken back and put back without
  reloading the photo; a fresh edit clears the redo trail.
- Opening a saved frame, or picking a look out of its history, now drops the
  stale preview buffer instead of leaving the previous render on the stage.
- The picked history look is marked in the accent, so it is plain which look
  the photo is wearing.
- The histogram is re-clamped against the photo box on resize, so opening a
  chip column no longer pushes the overlay past the edge of the canvas.

The 'NEW SAVES: FILM STRIP' chip goes: a save already lands in the strip.
2026-09-18 15:47:08 +07:00
3dtours 78fe2b097b web: ask before a saved photo goes
Removing a saved frame is not like removing a note: its history goes with it,
and nothing on screen said so. A shared modal now names the photo, warns how
many looks ride along, offers the stored file as a download first, and only
then deletes — from MY PHOTOS in the studio and from the folder page alike.
2026-09-18 15:47:06 +07:00
3dtours 97836fde8a api+admin: let the curator take a photo off the landing, not just move it
Uploads still land in the community strip with consent on, by the uploader's own
tick — that default stays. What was missing is the curator's removal: the slot
select only offered the other three live placements, so "off the strip" meant
publishing the photo somewhere else or deleting the uploader's row.

`off` is a fifth slot value. The reel draws slot === 'strip' and each live slot
draws its own, so an `off` photo renders nowhere on the landing, while its owner
still has it in MY PHOTOS.
2026-09-18 15:07:04 +07:00
3dtours dcea6b926f web: name a photo on first save, and file looks from a SAVE RECENT tab
Two saves that never had a name of their own now ask for one, through a single
modal (ui/NameModal, shared by both flows).

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

SAVE RECENT leaves CREATE RECIPES and becomes its own rail tab: it files the
look standing on the stage — sim, WB, light, FX and the frame — as a recipe of
this account's own, refusing a name the account has already spent. The frame
travels in the recipe's JSON, so applying the entry puts the whole look back.
The tab lists those files and is the one place they can be deleted from; the
API already scopes both by user. They also show up in PRESETS/RECIPES, deduped
against anything CREATE filed under the same name in this session.
2026-09-18 14:57:49 +07:00
3dtours 72d5ce1c3e web: re-save over the open frame, keeping its last three looks
Opening one of the folder's own photos and hitting SAVE PHOTO used to
make a second copy of it. Now it replaces that row — same id, same place
— and the look the row carried steps into its history, newest first and
capped at three, because the pixels it described are gone. The frame's
own column in MY PHOTOS lists those looks (click one to put its settings
back on the stage) and carries the landing-page consent as a plain tick,
which answers the click at once. A file from the disk clears the open
id, so a fresh frame still adds one.
2026-09-18 14:47:30 +07:00
3dtours 27099c2877 web: overlay a draggable histogram on the photo
The panel in Lightroom is a running read of the render, so this one reads
the preview blob itself: one downscaled 320px canvas pass bins 256
values per channel, and four SVG paths draw them — the grey luma fill
with the three channel curves screened over it, from absolute black to
absolute white. Dragged by its header, clamped inside the photo, parked
top-right on first paint, and dismissed either from its own frame or from
the toolbar button. It steps aside while the crop frame is up.
2026-09-18 14:35:50 +07:00
3dtours 8308b3e65f web: move WHITE/BLACK onto the WB tab as per-channel points
LIGHT already spends its slope budget on the tone knees, so the two end
points were doing nothing a tone knob could not. On WB they act on each
channel's own distance from the end: the darker channel of a shadow and
the brighter channel of a highlight move most, which neutralises a cast
at the toe and the shoulder. Both shuffles stay cubic in the channel
value, so every channel's curve is still monotonic (>= 0.46).
2026-09-18 14:35:48 +07:00
3dtours 89beb5f160 web: put the traffic screen in the admin frame
installTracking() beacons one view per page load and one click per control that
carries a data-key, so every existing button is already counted. The new STATS
pane reads it back: a 7/30/90-day range, the three totals, an SVG timeline and
eight proportional bar lists (pages, clicked features, country, region, city,
browser, system, device).
2026-09-18 14:23:25 +07:00
3dtours 8a7192eae8 api: count page views and feature clicks
One events row per beacon: the kind, the page, the clicked control, a salted
hash of the address (never the address), its coarse place, and the browser,
system and device read off the UA. A new public POST /api/events writes it and
always answers 204; GET /api/admin/stats reads it back as totals, a day series
and one grouped breakdown per dimension, behind the admin gate.
2026-09-18 14:23:23 +07:00
3dtours 1c4cdf8af1 web: put the white and black points on the LIGHT tab
WHITE (whites) and BLACK (blacks) get the same slider rows as HIGHLIGHT
and SHADOW, so the tone curve's two ends are editable on the stage, not
only typed into the CREATE form.
2026-09-18 12:49:07 +07:00
3dtours 0627f8dd91 web: keep a saved photo's look in EXIF and its own row
EXPORT no longer burns the caption strip: the pixels stay the photo's own
and the look travels as metadata — ImageDescription (0x010e) for the tag,
UserComment (0x9286, ASCII header) for the recipe JSON.

SAVE PHOTO now stores the look with the frame (photos.recipe) and the
uploader's consent for the community film strip (photos.consent, PATCH
/api/photos/:id for the owner). The landing reel skips non-consented frames,
and a new MY PHOTOS tab lists the account's saves, reopens one with the
settings it was stored with, and carries the two consent switches.
2026-09-18 12:49:05 +07:00
3dtours cf4b01d4b3 web: mark the selected recipe in FAVORITED
The list was all one colour, so an open recipe was invisible in it. The entry
the stage is showing now reads accent (name, border and background, the same
`.chip.on` the rest of the app uses). The match is id AND name: a preset that
was merely filed under a new name shares the id but not the name, and must not
light up.
2026-09-18 12:30:38 +07:00
3dtours f17cb08a63 web: fold the create form's groups and add the white/black point rows
The CREATE RECIPES form had grown into one long scroll. The six categorical
groups (SIMULATION, DYNAMIC RANGE, GRAIN EFFECT, COLOR CHROME EFFECT, COLOR
CHROME EFFECT BLUE, WHITE BALANCE) are now native <details> folds — the browser
keeps the open flag, so no state and no library.

The two tone-curve ENDS join the numeric grid: EXPOSURE (the matrix gain the IQ
tab already drives), EV (the old EXPOSURE COMP. row, renamed to the phone's
word), WHITE and BLACK. WHITE/BLACK are new ColorAdjustments fields, applied in
TONE_SKSL as cubic end-weights rather than another smoothstep knee — the HL/SH
knees already spend the slope budget, and the cubic keeps the curve monotonic
for every combination (derivative >= 0.46), so a brighter input can still never
come out darker. Both are optional, so stored recipes keep working.
2026-09-18 12:30:36 +07:00
3dtours 567f650bbb web: accent the brand and add the studio's quick gestures
- the brand "Cam", the avatar and the signed-in name follow the theme accent
- double-click a slider track resets that parameter; double-click the photo
  toggles 1:1 and the whole photo on the stage
- `*`, or a recipe chip dragged onto the star, files the look under FAVORITED
- RESET under CREATE clears the draft form, and the button now sits under a
  rule at the foot of the column
2026-09-18 12:09:06 +07:00
3dtours 0f52572f20 web: restate the pricing plans as Lite and Pro with store buttons 2026-09-18 11:37:25 +07:00
3dtours 8259bd468d web: move the section jumps to their own strip and grow the Theme menu
The six section links leave the top bar for a thin shelf under it, each a
bordered pill at the page's normal text size, so the bar itself stays a
row of actions and the jumps still read as buttons.

The Theme menu gains the workspace's colour groups — the landing palette
now takes its hue from the accent tokens — an Auto mode that follows the
OS scheme live, and a dropdown in place of the three font chips.
2026-09-18 11:30:12 +07:00
3dtours e437946ee0 web: share the landing page to social networks with a real preview card
Open Graph and Twitter Card meta point at a 1200x630 cover shot of the
hero, and the final CTA grows a share row: Facebook, X, LinkedIn and
Telegram each open with this origin filled in, plus a native share sheet
that falls back to copying the link. The top bar now leads with the web
studio pill next to the store CTA, with the theme, language and account
controls trailing them.
2026-09-18 11:17:45 +07:00
3dtours 86a93f46be web: bulk-select accounts in the admin table and link the studio from the top bar
The users table grows a checkbox column with a select-all box in its head,
and three controls above it: SELECT ALL, SELECT NONE and DELETE SELECTED
naming the count. An admin account is the API's own privilege source, so it
gets no box and select-all skips it; the picks clear once the deletes land.

The landing top bar gains a RecipesCam web studio button between the account
slot and Download RecipesCam. It hides with the rest of the wide row under
1160px, where the burger sheet already offers the studio.
2026-09-18 11:11:43 +07:00
3dtours b4d5d2926b web: give each member a photo folder and burn the strip into the export
Every member gets /photos — their own uploads, counted against a 12-photo
cap, each card showing the tagline and the technical line the studio would
print. The studio gains SAVE PHOTO n/12 in the top bar: it renders the full
resolution look, stores the strip (tag/title/meta) with the upload so the
landing reel frames it the same way, and refuses past the cap.

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

Admins manage any photo through DELETE /api/photos/:id; members only their
own. The users table's photo counts stay in step with the folder.
2026-09-18 10:56:26 +07:00
3dtours 43d86b4b6f web: moderate accounts, accept 12MB uploads, put SAVE under CREATE
- /admin User account rows gain BLOCK/UNBLOCK, REMOVE/RESTORE and DELETE.
  Blocked = cannot sign in (sessions swept), removed = hidden from the strip
  and cannot sign in, both reversible; DELETE drops the account with its
  photos and recipes and unlinks the files. An allowlisted account is never
  a target, so an admin cannot moderate or delete itself.
- Photo uploads move from a 3MB API cap / 4m nginx cap to 12MB / 16m, and
  the browser shrinks an oversized still before sending it (2048px JPEG,
  avatars 512px) so the declared type still matches the sniffed bytes.
- The studio SAVE leaves the top bar and sits under the CREATE RECIPES tab,
  labelled SAVE RECIPES.
2026-09-18 10:33:35 +07:00
3dtours 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.
2026-09-18 10:33:32 +07:00
3dtours 40177cb63f web: the studio header carries the mark and the page name too
The landing, /admin and /profile all show the icon + RecipesCam wordmark
with the page name beside it; the studio still showed a bare "RecipesCam
web" text. Same pair now, so the phone header says where the visitor is.
2026-09-18 09:03:35 +07:00
3dtours 6bbf77860b web: account avatars, member /profile, framed admin panel
- an account can carry a picture: POST /api/auth/avatar (raw bytes,
  sniffed, replaces and unlinks the old file) and the public
  GET /api/users/:id/avatar. It rides wherever the account is named —
  the landing chip, the studio TopBar, the profile form.
- new /profile page for members, sharing one Profile form (picture,
  email, password) with the admin drawer.
- /admin is now one bordered frame whose left column is
  Profile / User account / Pictures / Close. Pictures lists every
  photo in the system with the slot that shows it; User account lists
  each account's name, email, picture and contribution count.
- account control opens a menu: Admin page + Log out for an admin,
  Profile + Log out for a member.
2026-09-18 08:19:36 +07:00
3dtours 2917c034ed Sign in is the default: the landing names the account, and an admin lands on /admin 2026-09-18 08:02:57 +07:00
3dtours 7b79e49c20 Photo slots + admin page: place any upload in the strip or a live slot, sign up in place, brand links home 2026-09-18 07:55:54 +07:00
3dtours bd2f08aaf7 Panel chips: name the picked value on the right edge
Strip chips (D.RANGE, CROP, COLOR CHROME, PHOTO STYLE, ...) now carry their
pick in the chip's .val column instead of only glowing amber, and the two
chips that baked the value into the label (WB TEMP, FRAME ROTATE) use the
same right-edge slot.
2026-09-18 07:24:05 +07:00
3dtours f13fc37b5a feat(panel): cascading columns, WB colour swatches, stronger HDF glow
Layout
- the panel is a cascade of columns: the rail's tabs, the tab's chips, the
  open chip's sub-chips, then the ruler. A child column no longer hides the
  column it came from (TEMP -> COLOR TEMP keeps TEMP visible); chips stack one
  per row instead of wrapping
- FRAME's WATERMARK opens its own column, so the frame chips stay put
- CREATE RECIPES gets the wide column its two-up form needs

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

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

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

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

Tests: docker/backend test/security.mjs (45 checks)
2026-09-17 22:35:12 +07:00
3dtours 9ba2667c6c Hang the wall frame landscape too, beside the portrait one 2026-09-17 21:58:18 +07:00
3dtours f1174b8c57 Let the web demo use HDF EFFECT instead of greying it out 2026-09-17 21:49:22 +07:00
3dtours 3af5a315e2 Offer a compare-with-original split, and drop the render-time readout 2026-09-17 21:39:07 +07:00
3dtours 99be7e96bd Size the preview copy to what the stage actually paints
The copy was fixed at 1600px, so a hidpi screen was already stretching it at
1:1 and every wheel notch made it worse. The stage now reports the size it is
painting at — the contain-fit times the pixel ratio times the zoom — and the
app re-cuts the source copy to match, quantised and capped at 3200px, which is
the largest copy the grade can still afford. The wheel keeps its instant
transform; the sharper copy lands once the gesture stops.
2026-09-17 21:17:54 +07:00
3dtours 8747b1686b Tint the landing nav from the theme, not from a dark constant
The fixed bar sat on a hardcoded rgba(9, 10, 15, 0.72), so switching the
page to light left it dark under dark text. --lp-nav now carries the tint in
both palettes.
2026-09-17 20:57:02 +07:00
3dtours 014b3e5a4b Zoom and pan the preview with the wheel, and step a parameter from its ruler
Cursor over the image: the wheel zooms about the pointer, and a drag pans,
both clamped to the contain-fit of the loaded bitmap so the photo can never
be lost off-screen. Cursor over an open parameter ruler: the wheel moves that
parameter by one step, clamped to its min/max. While the crop frame is being
dragged the wheel keeps resizing the crop instead, and APPLY puts the view
back on the fit.
2026-09-17 20:57:00 +07:00
3dtours 6e8a600a82 Re-derive the preview source at the crop's own resolution
A committed crop kept only its share of the 1600px whole-photo copy, so the
stage then showed a 2x upscale and the photo read as broken. While a crop is
live the source is remade 1/f larger (f = the crop's longest side as a
fraction of the photo's), capped so the decode stays bounded; UNDO/CANCEL
and a new photo put the 1600px copy back.
2026-09-17 20:34:08 +07:00
3dtours 751beb52c1 Add a draggable crop frame behind APPLY, whole-look undo, and recipe import 2026-09-17 20:14:21 +07:00
3dtours cc313f2ea9 Enlarge the landing logo and put RecipesCam back on one word
.lp-logo is a flex row, so the bare text node "Recipes" and the <em>Cam</em>
were two flex items and the 6px gap landed between them: the wordmark read
"Recipes Cam". They now live in one span, so the gap only separates the icon
from the name.

Along with it: mark 22px -> 30px (24px under 680px), wordmark 18px -> 24px
(19px on phones), gap 6px -> 8px. Colours untouched — Cam is still the amber
accent, and the mark keeps its rounded corners.
2026-09-17 20:05:33 +07:00
3dtours cc88471256 Offer the three proposed font groups in Themes
The theme menu now carries a fourth choice beside light/dark and the accent:
the font pairing. All three pairings are free for commercial use (SIL OFL),
have a Vietnamese subset, and are self-hosted — the landing still makes no
CDN request.

- styles/fonts.css: 22 @font-face blocks for Plus Jakarta Sans, Inter,
  JetBrains Mono, Fraunces, Be Vietnam Pro, Courier Prime and Space Mono,
  vietnamese/latin-ext/latin subsets only, under public/assets/fonts.
- styles/tokens.css: [data-fonts="studio|editorial|native"] sets --font,
  --font-heading and --mono. Studio (Plus Jakarta Sans + Inter + JetBrains
  Mono) is the default.
- theme/: FontSetId + FONT_SETS, persisted as rc.fonts, applied as
  <html data-fonts> next to data-theme and data-accent.
- TopBar and the landing nav both get the picker; on the landing the theme
  tool now opens a small popover (light/dark + font group) instead of
  toggling on click.
- landing.css: --lp-display/--lp-mono now resolve to the chosen group, so the
  picker retypes the whole page. Syne.woff2 goes with its @font-face.
2026-09-17 19:20:23 +07:00
3dtours 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).
2026-09-17 19:12:29 +07:00
3dtours 1446e852b1 Apply the CREATE form's knobs to the photo in realtime
Every row now feeds the open render as it is typed, so a recipe's effect is
visible before it is saved. The draft is not an undo step: SAVE/EXPORT stays the
only thing that commits it, and merely opening the tab changes nothing.
2026-09-17 19:05:59 +07:00
3dtours a684b178b6 Use the app icon as the web favicon and landing logo
RecipesCamIcon.png (scaled to 256px) becomes favicon + apple-touch-icon and
replaces the drawn camera svg in front of the landing wordmark, which now sits
a touch closer to it.
2026-09-17 18:59:41 +07:00
3dtours d31d945827 Add the CREATE RECIPES tab to the web app
The phone's RecipeCreateModal becomes a rail tab with the same rows, seeding
from the look on screen and clamping the same way. SAVE RECIPE applies the new
look, lists it under RECIPES and, when signed in, stores it on the account; a
guest's copy stays in memory and goes away with the page. Signed-in users can
also export the recipe as the app's encrypted .recipe file (shared/utils
/recipeShare.ts vendored byte-identical from the RN project).
2026-09-17 18:56:17 +07:00
3dtours f2e5e9abc9 Give the landing the app's theme and language switches
The blueprint page was dark-only and English-only. It now carries the same
two controls the workspace TopBar has, in the nav's top-right cluster: ◐ flips
light/dark (a paper palette for the same funnel — surfaces and ink flip, the
amber/red accents stay) and VI/EN flips the language, with the whole page of
copy, the FAQ, the pricing tables and the VIP badge all following it. Amber
text switches to a darker #a16207 on the light theme so it keeps ~4.9:1 on
white.

The register account is back as the old landing had it: a "ĐĂNG KÝ" button
pointing at /app?auth=1, and that URL now actually opens the auth dialog on
its sign-up tab (AuthModal takes an initialMode). The nav also collapses to
the hamburger below 1160px now, and the logo/tools shrink below 680px, so the
row still fits a 360px phone.
2026-09-17 18:40:05 +07:00
3dtours 340f0374fc Rebuild the web landing page to the blueprint spec
Ten dark cinematic sections: fixed glass nav with a hamburger sheet, hero
with the badge/dual CTA/stats bar, a pausable 35mm film-strip marquee, the
live preset tester (5 stocks, HUD, spec bars), the three-knob custom recipe
simulator, the 6-card feature grid, the QR sharing showcase, free-vs-Pro
pricing, reviews + FAQ accordion and the final CTA/footer.

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

Measured at 390x844 before/after: popover x=-280 → x=56, w=326, fully inside.
2026-09-17 17:50:50 +07:00
3dtours 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.
2026-09-17 17:43:03 +07:00
3dtours 608901bd0f Require 300 ppi + EXIF parity on web export 2026-09-17 17:21:51 +07:00
3dtours 73c063a6ac Record verified W0 export output (300 DPI JPEG) 2026-09-17 16:58:48 +07:00
3dtours 8b4c4aa591 Add CanvasKit web engine spike + W0 findings (recipes-web) 2026-09-17 16:58:29 +07:00
3dtours cef3ddb307 Add Docker-hosted web UI plan (recipes-web) 2026-09-17 16:22:13 +07:00
205 changed files with 52686 additions and 4127 deletions
@@ -0,0 +1,106 @@
# Kế hoạch: Real-time Skia filter trên camera live view dùng vision-camera v4
## Vấn đề
`expo-camera` render camera preview lên native surface (PreviewView/SurfaceView) không thể intercept bằng Skia. Periodic capture bị từ chối vì mất live view.
## Giải pháp
Dùng `react-native-vision-camera@4.7.3` với `useSkiaFrameProcessor` — frame processor chạy native thread, mỗi frame camera được vẽ qua Skia với ColorMatrix trước khi hiển thị.
## Các bước thực hiện
### Bước 1: Cài react-native-vision-camera@4.7.3
```bash
npm install react-native-vision-camera@4.7.3
```
- v4.7.3 là latest v4, có export `useSkiaFrameProcessor` và `frameProcessor` prop trên `Camera`
- v5+ đã xoá Skia integration, không dùng được
### Bước 2: Thêm Expo Config Plugin
Sửa `app.json`:
- Thay `expo-camera` plugin bằng `react-native-vision-camera` plugin (v4 có plugin riêng)
- Thêm `withEnableFrameProcessorsAndroid` để bật frame processor trên Android
```json
"plugins": [
"react-native-vision-camera",
[
"expo-camera",
{
"cameraPermission": "..."
}
]
]
```
Cần kiểm tra: v4 có expo plugin riêng (`withVisionCamera`) hay dùng thẳng tên package.
### Bước 3: Cập nhật App.tsx
- Import `useCameraPermission` từ `react-native-vision-camera` thay vì `useCameraPermissions` từ `expo-camera`
- Props: `hasPermission`, `requestPermission`
### Bước 4: Viết lại Viewfinder.tsx
Component mới dùng:
- `Camera` từ vision-camera với prop `frameProcessor={frameProcessor}`
- `useSkiaFrameProcessor` hook — worklet chạy native, mỗi frame:
1. Tạo `Skia.Paint()` với `Skia.ColorFilter.MakeMatrix(colorMatrix)`
2. Gọi `frame.render(paint)` — vẽ frame qua color filter
- `colorMatrix` là `SharedValue` từ react-native-reanimated (để worklet đọc được)
- Grain noise, frame border, GPS watermark: giữ nguyên overlay React Native / Skia Canvas trên top
```tsx
const App = () => {
const frameProcessor = useSkiaFrameProcessor((frame) => {
'worklet';
const paint = Skia.Paint();
paint.setColorFilter(Skia.ColorFilter.MakeMatrix(colorMatrixSV.value));
frame.render(paint);
}, [colorMatrixSV]);
return (
<Camera
style={StyleSheet.absoluteFill}
device={device}
isActive={true}
frameProcessor={frameProcessor}
/>
);
};
```
### Bước 5: Loại bỏ expo-camera khỏi code
- `CameraView` → `Camera` (vision-camera)
- Xoá imports không dùng
- Giữ `expo-camera` trong package.json (không gỡ, tránh lỗi autolinking)
### Bước 6: Re-prebuild & build APK
```bash
rm -rf android
npx expo prebuild --platform android
echo "sdk.dir=/home/locpham/android-sdk" > android/local.properties
cd android && ./gradlew assembleDebug
```
(Không chạy `clean` — tránh lỗi CMake codegen paths)
## API đã xác nhận (v4.7.3)
| Export | Path |
|---|---|
| `Camera` | `react-native-vision-camera` |
| `useCameraDevice` | `react-native-vision-camera` |
| `useCameraPermission` | `react-native-vision-camera` |
| `useSkiaFrameProcessor` | `react-native-vision-camera` |
| `SkiaCameraCanvas` | `react-native-vision-camera` |
| Expo plugin (`withVisionCamera`) | `lib/module/expo-plugin/withVisionCamera` |
## Rủi ro & Xử lý
| Rủi ro | Xử lý |
|---|---|
| `react-native-worklets-core` conflict | Đã có sẵn qua `react-native-reanimated` dependency |
| CameraX dependency (`NoClassDefFoundError`) | v4 dùng `CameraX` không phải Nitro — build an toàn hơn v5 |
| CMake codegen paths lỗi sau `clean` | Không dùng `./gradlew clean`, dùng `--rerun-tasks` nếu cần |
| Frame processor không chạy trên Android | Bật flag qua Expo plugin: `withEnableFrameProcessorsAndroid` |
## Kiểm thử
1. Build APK thành công
2. Camera live view hiển thị với filter:
- B&W/monochrome → true grayscale
- Saturation, temp, tint, exposure thay đổi real-time
3. Grain noise, frame border, GPS watermark vẫn hoạt động
4. Library mode không bị ảnh hưởng
-234
View File
@@ -1,234 +0,0 @@
# 4 — FIX: HIGHLIGHT bị EXPOSURE kéo ngược (thứ tự pass màu sai)
Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06.
Nguồn đối chiếu: nhánh `recipes-web`, commit **`8e6c149`** — bản web đã sửa và đo được.
---
## 1. Hiện tượng
Kéo **HIGHLIGHT −10** rồi **EXPOSURE +10**: vùng sáng đã được ghì xuống lại bị đẩy
trở lại mức cháy (1.0). Nhìn như thanh HIGHLIGHT "chết" — càng tăng EXPOSURE càng
mất khả năng giữ highlight.
---
## 2. Nguyên nhân gốc
Skia áp **shader TRƯỚC colorFilter** trong cùng một `SkPaint`. Ma trận màu
(chứa `adjustments.exposure` + gain EV) và tone shader đang nằm trên **cùng một
paint**, nên gain exposure chạy **sau** pass tone:
1. Pass tone: pixel sáng 1.0 → HIGHLIGHT −10 ghì về **0.78**
2. Ma trận exposure: **×1.2 + 0.15** → 1.086 → **kẹp về 1.0**
Thanh HIGHLIGHT vẫn chạy đúng — nó chỉ bị pass sau xoá sạch kết quả.
### 2.1 `src/utils/exportEngine.ts` (engine JS/Skia — đường export)
```ts
// dòng 328
const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix));
paint.setColorFilter(colorFilter);
// dòng 330 — comment mô tả SAI thứ tự thực tế:
// "3b. Tone shader — DR / Highlight / Shadow curve on top of the matrix."
// dòng 354
paint.setShader(toneShader);
```
Comment nói tone nằm "on top of the matrix", nhưng Skia làm ngược lại.
### 2.2 `src/components/Viewfinder.tsx` (preview camera trực tiếp)
Cùng lỗi, cùng một paint:
```ts
// dòng 1207
paint.setColorFilter(baseColorFilter);
// dòng 1303
paint.setShader(fullShader);
```
Khối HDF (dòng ~1339) còn **cố tình nhân ma trận màu lần thứ hai**:
```ts
Skia.ImageFilter.MakeCompose(
glowBlur,
keep(Skia.ImageFilter.MakeColorFilter(baseColorFilter, null)) // "bloom lands on GRADED pixels"
)
```
→ vệt bloom cũng bị đẩy sáng sai. Sau khi sửa phải bỏ phần nhân lại này.
### 2.3 `modules/recipescam-export/.../RecipescamExportModule.kt` (module native Kotlin)
Thứ tự pass hiện tại là **tone → cinema → matrix**, tức matrix chạy **cuối** — đúng
cùng một lỗi:
| Dòng | Pass |
|---|---|
| 423–429 | `// 2. Tone pass` → `applyTone(bmp, dr, hl, sh)` |
| 434–438 | `// 3. Cinema seasonal grade` → `applyCinema(bmp, cinemaFlat)` |
| 443–452 | `// 4. Color matrix via paint pass` → `ColorMatrixColorFilter` |
| 423–426 | comment: "Engine order: the tone shader samples the base image, cinema samples the tone output, **then the color matrix filter applies on top**" |
---
## 3. Bằng chứng đo được (bản web, trước và sau khi sửa)
Nguồn: ảnh gradient dọc 0.98 → 0.30 (320×240 JPEG), đo p95 (vùng sáng) và trung vị
(midtone) của ảnh render. Engine thật, không phải mô phỏng.
| Trường hợp | top (p95) | mid |
|---|---|---|
| nguồn | 1.000 | 0.502 |
| HIGHLIGHT −10 | 0.780 | 0.518 |
| EXPOSURE +10 | 1.000 | 0.769 |
| HIGHLIGHT −10 + EXPOSURE +10 — **một paint (lỗi)** | **1.000** | 0.722 |
| HIGHLIGHT −10 + EXPOSURE +10 — **hai pass (đã sửa)** | **0.780** | 0.722 |
Sau khi sửa: midtone vẫn sáng lên (0.502 → 0.722) nhưng vùng sáng giữ nguyên 0.780.
---
## 4. Cách sửa
**Nguyên tắc:** ma trận màu phải tạo ra **ảnh đã grade** trước, rồi tone/cinema shader
mới lấy ảnh đó làm con. Đúng một câu: *matrix → tone → cinema*.
### 4.1 `src/utils/exportEngine.ts`
Thêm helper (đặt cạnh `screenSharpenImage`), rồi đổi bước 3:
```ts
// Ma trận màu, áp riêng vào một ảnh mới. SkPaint chạy shader TRƯỚC colorFilter,
// nên pixel đã grade phải tồn tại như một ảnh trước khi tone/cinema đọc nó.
function gradeThrough(image: any, w: number, h: number, colorFilter: any): any {
const surf = createSurface(w, h);
if (!surf) return null;
try {
const p = Skia.Paint();
p.setColorFilter(colorFilter);
surf.getCanvas().drawImage(image, 0, 0, p);
p.dispose();
return surf.makeImageSnapshot() ?? null;
} finally {
surf.dispose();
}
}
```
Trong `renderPhoto`:
```ts
const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix));
const matrixIsIdentity = evMatrix.every((v, i) => v === (i % 6 === 0 ? 1 : 0));
const tone = getToneUniforms(adjustments, recipe.baseFilter);
const cinemaOn = /* cinema && cinemaIsActive(cinema.flat) */;
let toneShader: any = null;
let paintShader: any = null;
// Ma trận phải thành ảnh riêng khi có pass phía sau đọc lại pixel.
let gradeImage = skImage;
const graded = (toneIsActive(tone) || cinemaOn) && !matrixIsIdentity;
if (graded) {
const snapshot = gradeThrough(skImage, width, height, colorFilter);
if (snapshot) { own(snapshot); gradeImage = snapshot; }
}
const imageShaderOf = () =>
own(gradeImage.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None));
if (toneIsActive(tone)) {
toneShader = own(toneEffect.makeShaderWithChildren(toneUniformArray(tone), [imageShaderOf()]));
}
if (cinemaOn) {
const cs = own(cinemaEffect.makeShaderWithChildren(cinema.flat, [toneShader ?? imageShaderOf()]));
if (cs) paintShader = cs;
}
if (!paintShader && toneShader) paintShader = toneShader;
// Chỉ MỘT trong hai được tô màu pixel: ảnh đã grade đã mang ma trận rồi,
// chỉ nhánh chưa grade mới cần colorFilter.
if (paintShader) paint.setShader(paintShader);
else if (!graded) paint.setColorFilter(colorFilter);
```
Và trong bước HDF (5b): bloom đọc pixel đã grade, nên **bỏ lần áp ma trận thứ hai**:
```ts
const srcShader = paintShader ?? imageShaderOf();
const bloomGrade = (inner?: any) =>
graded ? undefined : own(Skia.ImageFilter.MakeColorFilter(inner ?? colorFilter, null));
// ...
glowPaint.setImageFilter(grade ? own(Skia.ImageFilter.MakeCompose(blur, grade)) : blur);
```
Hai điều kiện đệm: khi **không có tone/cinema** hoặc **ma trận là identity** thì
`graded === false` → không tạo surface thừa, giữ nguyên đường vẽ cũ.
### 4.2 `src/components/Viewfinder.tsx` (preview mỗi frame)
Cùng nguyên tắc, nhưng đây là đường chạy **mỗi frame** nên phải cân nhắc chi phí:
ma trận phải được vẽ vào một surface phụ (hoặc texture trung gian) **trước**, rồi
tone/cinema shader lấy ảnh đó làm `imageShader` con.
- Đổi `paint.setColorFilter(baseColorFilter)` + `paint.setShader(fullShader)` trên
cùng một paint thành: grade trước vào texture `gradedTex`, `fullShader` dựng từ
`gradedTex.makeShaderOptions(...)`.
- `hasTone === false && hasCinema === false` (hoặc ma trận identity): giữ đường vẽ
một-pass như hiện tại, đừng thêm surface.
- Bỏ `MakeColorFilter(baseColorFilter, null)` khỏi chuỗi imageFilter của glow —
bloom giờ đọc thẳng pixel đã grade.
- **Đo lại fps sau khi sửa**: thêm 1 pass fullscreen/frame. Nếu tụt quá ngưỡng
chấp nhận, phương án rẻ hơn là nhân hệ số gain exposure **bên trong SKSL của tone
shader** (trước khi áp curve) — nhưng chỉ khi vẫn giữ được thứ tự *film matrix →
curve*; đừng quay lại nhét exposure vào sau curve.
### 4.3 `RecipescamExportModule.kt` (native)
Đổi thứ tự các bước cho khớp: **matrix → tone → cinema**. Matrix (bước "4." hiện
tại) phải chạy trước bước tone "2.":
```kotlin
// 2. Color matrix (grade phim + exposure) — PHẢI chạy trước tone.
val androidMatrix = FloatArray(20) { i -> if (i % 5 == 4) matrix[i] * 255f else matrix[i] }
val out = Bitmap.createBitmap(bmp.width, bmp.height, Bitmap.Config.ARGB_8888)
Canvas(out).drawBitmap(bmp, 0f, 0f, Paint().apply { colorFilter = ColorMatrixColorFilter(androidMatrix) })
bmp.recycle()
bmp = out
// 3. Tone (DR / highlight / shadow) trên pixel đã grade.
if (toneActive) applyTone(bmp, toneDr.toFloat(), toneHl.toFloat(), toneSh.toFloat())
// 4. Cinema seasonal grade.
if (cinemaActive) applyCinema(bmp, cinemaFlat)
```
`ensureMutable(bmp)` giữ nguyên vị trí (trước tone), và sửa lại comment dòng 423–426
— bỏ câu "then the color matrix filter applies on top".
---
## 5. Kiểm thử (bắt buộc trước khi coi là xong)
1. Nguồn: gradient dọc 0.98 → 0.30 (320×240 JPEG) — có headroom để thấy highlight.
2. Export với `highlight = -10`, `exposure = +10`.
3. Kỳ vọng: **p95 ≈ 0.78** (KHÔNG được 1.00), midtone tăng rõ so với ảnh gốc.
4. Chạy đủ **cả ba đường** (export engine JS, preview camera, module native Kotlin)
và so ảnh với nhau — parity gate của `PLAN.md`.
5. Đối chiếu với bản web: `recipes-web` @ `8e6c149`, file
`docker/frontend/src/engine/exportEngine.ts` (helper `gradeThrough`).
---
## 6. Ghi chú phụ
- Lỗi này **không** nằm ở công thức tone (`toneShader.ts`) hay ma trận màu
(`colorUtils.ts`) — cả hai đều đúng. Chỉ sai **thứ tự thực thi**.
- `HIGHLIGHT +10` không lộ lỗi vì kết quả dù sao cũng kẹp về 1.0 — chỉ nhánh âm mới
nhìn thấy. Đừng "kiểm thử" bằng HIGHLIGHT dương.
- Bất kỳ pass nào khác đọc lại pixel sau tone/cinema (bloom HDF, grain, vignette)
đều phải đọc **ảnh đã grade**, và ma trận không được áp lần hai.
-264
View File
@@ -1,264 +0,0 @@
# 5 — Đốt dải thông tin ("strip") vào ảnh xuất
Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06.
Nguồn đối chiếu: nhánh `recipes-web`, commit **`b4d5d29`** — bản web đã làm và đo được.
---
## 1. Mục tiêu
Ảnh **xuất ra** phải mang đúng dải thông tin mà app đang hiển thị trên thẻ recipe:
một **tagline amber** `#TAG` nằm góc trên-trái **trên chính bức ảnh**, và một **dải
caption nền tối** treo **bên dưới** ảnh, gồm **tên recipe** (đậm, sáng) và **dòng
kỹ thuật** `ISO · GRAIN · WARMTH/MONO` (mono, xám).
Đây là bản sao của khối `.lp-shot` + `.lp-frame-meta` trên landing:
```html
<div class="lp-shot">
<img … />
<span class="lp-tagline">#ACROS_100</span> <!-- amber, góc trên-trái -->
</div>
<div class="lp-frame-meta">
<b>ACROS 100</b> <!-- tên recipe -->
<span>ISO 400 · GRAIN 4 · WARMTH +2</span> <!-- dòng kỹ thuật, mono -->
</div>
```
Thuộc tính web (để đối chiếu màu/kích thước): `docker/frontend/src/styles/landing.css`
dòng 288–291 — tagline `color: var(--lp-amber)`, meta `font-family: var(--lp-mono)`.
**Ranh giới quan trọng:** dải này CHỈ có trong **file xuất**. Preview / viewfinder /
màn hình chỉnh sửa **không** được vẽ nó — nếu không, người dùng thấy hai lần.
---
## 2. Giá trị lấy từ đâu
Bản web tính trong một `useMemo` duy nhất (`recipes-web` @ `b4d5d29`,
`docker/frontend/src/App.tsx`, dòng ~658–671):
```ts
const name = (recipe.name || 'RECIPE').trim();
const slug = name.replace(/[^\p{L}\p{N}]+/gu, '_').replace(/^_+|_+$/g, '').toUpperCase();
const grain = Math.max(0, Math.round(recipe.adjustments.grain ?? 0));
const warmth = Math.round((recipe.adjustments.temperature - 5500) / 250);
const tail = recipe.baseFilter === 'monochrome'
? 'MONO'
: `WARMTH ${warmth >= 0 ? '+' : ''}${warmth}`;
const strip = {
tag: `#${slug || 'RECIPE'}`, // UPPERCASE, khoảng trắng -> '_'
title: name, // giữ nguyên hoa/thường
meta: `ISO ${iso ?? 'AUTO'} · GRAIN ${grain} · ${tail}`,
};
```
- `·` là **U+00B7 MIDDLE DOT**, có dấu cách hai bên.
- `iso` là ISO đọc từ **EXIF của ảnh nguồn**; không có thì in `AUTO` (xem §5).
- `grain` lấy thẳng từ knob `adjustments.grain` (0..10), **không** cộng phần re-grain
của NOISE REDUCTION âm — dòng kỹ thuật mô tả *công thức*, không mô tả pass xuất.
- `warmth` là bước nhảy 250K quanh mốc 5500K, luôn kèm dấu (`+2` / `-3`).
- `baseFilter === 'monochrome'` thì thay hẳn `WARMTH …` bằng `MONO`.
---
## 3. Hình học & màu (chính xác từng pixel)
Đặt `W`, `H` = chiều rộng/cao của ảnh **sau khi đã ghép frame** (ảnh export cuối cùng,
trước encode). Mọi kích thước suy từ `W`:
| Đại lượng | Công thức |
|---|---|
| `band` | `round(W * 0.155)` |
| `pad` | `round(W * 0.03)` |
| `tagSize` | `round(W * 0.026)` |
| `titleSize` | `round(W * 0.042)` |
| `metaSize` | `round(W * 0.026)` |
Các bước vẽ, **theo đúng thứ tự**:
1. Tạo surface mới `(W, H + band)` — **ảnh cao thêm đúng `band`**. Vẽ ảnh cũ vào `(0, 0)`.
2. **Tagline** trên ảnh, góc trên-trái, font mono:
- bản `#000000` tại `(pad + 1, pad + tagSize + 1)` (drop shadow, để tag đọc được
trên trời sáng),
- bản `#f59e0b` (amber) tại `(pad, pad + tagSize)`.
3. **Dải nền** dưới ảnh: hình chữ nhật `(0, H, W, band)` tô `#0b0b0b`.
4. **Tên recipe** `#f2f2f2`, baseline `(pad, H + pad + titleSize)`.
5. **Dòng kỹ thuật** `#9a9a9a`, baseline
`(pad, H + pad + titleSize + round(metaSize * 1.7))` — tức cách baseline tên
`round(metaSize * 1.7)`.
Font: **Cousine Regular** — cùng typeface app đã bundle
(`assets/Cousine-Regular.ttf`; engine đã nạp sẵn trong `watermarkFonts.typeface`).
Ví dụ đo được (web): nguồn 1200×900 → file xuất **1200×1086** (`band = 186`).
---
## 4. Ba đường phải sửa (parity gate)
Bản web chỉ có một engine; bản app có **ba** đường vẽ, cả ba phải ra **cùng một file**:
### 4.1 `src/utils/exportEngine.ts` — engine Skia (bước 9b)
Chèn ngay **trước** dòng encode (`let bytes = resultImage.encodeToBytes(...)`, ~dòng 917),
sau bước snapshot + sharpen (bước 9 hiện tại, ~dòng 896):
```ts
const caption = options?.caption ?? null; // thêm vào ExportOptions
if (caption) {
const W = resultImage.width();
const band = Math.round(W * 0.155);
const capSurface = createSurface(W, resultImage.height() + band);
const loaded = watermarkFonts; // cùng typeface Cousine đã nạp
if (capSurface && loaded) {
own(capSurface);
const cc = capSurface.getCanvas();
cc.drawImage(resultImage, 0, 0, own(Skia.Paint()));
const pad = Math.round(W * 0.03);
const tagSize = Math.round(W * 0.026);
const tagFont = own(Skia.Font(loaded.typeface, tagSize));
const shadow = own(Skia.Paint()); shadow.setColor(Skia.Color('#000000'));
const amber = own(Skia.Paint()); amber.setColor(Skia.Color('#f59e0b'));
cc.drawText(caption.tag, pad + 1, pad + tagSize + 1, shadow, tagFont);
cc.drawText(caption.tag, pad, pad + tagSize, amber, tagFont);
const bandTop = resultImage.height();
const bandPaint = own(Skia.Paint()); bandPaint.setColor(Skia.Color('#0b0b0b'));
cc.drawRect(Skia.XYWHRect(0, bandTop, W, band), bandPaint);
const titleSize = Math.round(W * 0.042);
const metaSize = Math.round(W * 0.026);
const titleFont = own(Skia.Font(loaded.typeface, titleSize));
const metaFont = own(Skia.Font(loaded.typeface, metaSize));
const titlePaint = own(Skia.Paint()); titlePaint.setColor(Skia.Color('#f2f2f2'));
const metaPaint = own(Skia.Paint()); metaPaint.setColor(Skia.Color('#9a9a9a'));
cc.drawText(caption.title, pad, bandTop + pad + titleSize, titlePaint, titleFont);
cc.drawText(caption.meta, pad, bandTop + pad + titleSize + Math.round(metaSize * 1.7),
metaPaint, metaFont);
const composed = own(capSurface.makeImageSnapshot());
release(owned, resultImage); // đăng ký `own`/`release` như mọi buffer khác
resultImage = composed;
} else if (capSurface) {
capSurface.dispose();
}
}
```
`ExportOptions` thêm:
```ts
// Dải file mang theo: tagline amber trên ảnh + caption band bên dưới.
// Để trống cho preview/thumbnail — chỉ đường xuất truyền vào.
caption?: { tag: string; title: string; meta: string } | null;
```
Và tại call-site xuất (App.tsx) truyền `caption: strip` (đúng object §2) — **không**
truyền vào đường preview.
### 4.2 `src/utils/nativeExport.ts` + `RecipescamExportModule.kt` — đường native
Đường native không đi qua Skia, nên phải **vẽ band trong Kotlin**. Thêm `caption`
vào `NativeExportOptions` và đẩy xuống map `adjust` (cùng chỗ với `geo`, `frameId`):
```ts
caption: options?.caption ?? null, // tag/title/meta — null khi không xuất file
```
Trong `RecipescamExportModule.kt`, chèn **giữa bước 7 (sharpen) và bước 8 (encode)**
(~dòng 568, sau khối `if (sharpen > 0) { … }`), vẽ lên chính `render`:
```kotlin
// 7b. Strip (parity với exportEngine #9b): band mới cao hơn ảnh.
val cap = adjust["caption"] as? Map<*, *>
if (cap != null) {
val tag = cap["tag"] as? String
val title = cap["title"] as? String
val meta = cap["meta"] as? String
if (tag != null && title != null && meta != null) {
val W = render.width; val H = render.height
val band = Math.round(W * 0.155f)
val taller = Bitmap.createBitmap(W, H + band, Bitmap.Config.ARGB_8888)
val cc = Canvas(taller)
cc.drawBitmap(render, 0f, 0f, null)
val pad = Math.round(W * 0.03f)
val tagSize = Math.round(W * 0.026f)
val titleSize = Math.round(W * 0.042f)
val metaSize = Math.round(W * 0.026f)
// Cùng typeface Cousine mà stampGeo đang dùng (raw assets_cousineregular).
val face = geoTypefaceRes(ctx, "assets_cousineregular")
// tagline: shadow đen lệch 1px rồi amber
val shadow = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.BLACK; typeface = face; textSize = tagSize.toFloat() }
val amber = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#f59e0b"); typeface = face; textSize = tagSize.toFloat() }
cc.drawText(tag, (pad + 1).toFloat(), (pad + tagSize + 1).toFloat(), shadow)
cc.drawText(tag, pad.toFloat(), (pad + tagSize).toFloat(), amber)
// dải nền tối
cc.drawRect(0f, H.toFloat(), W.toFloat(), (H + band).toFloat(),
Paint().apply { color = Color.parseColor("#0b0b0b") })
val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#f2f2f2"); typeface = face; textSize = titleSize.toFloat() }
val metaPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#9a9a9a"); typeface = face; textSize = metaSize.toFloat() }
cc.drawText(title, pad.toFloat(), (H + pad + titleSize).toFloat(), titlePaint)
cc.drawText(meta, pad.toFloat(), (H + pad + titleSize + Math.round(metaSize * 1.7)).toFloat(), metaPaint)
render.recycle()
render = taller
}
}
```
Lưu ý: band nằm **dưới frame** — RETRO POLAROID / WALL FRAME đã ghép xong trước đó
(bước 6b), nên `H` lúc này là chiều cao tấm card/khung, không phải ảnh gốc. Web làm
y hệt: bước 9b chạy sau khi frame đã ghép.
### 4.3 `src/components/Viewfinder.tsx` — preview
**Không** vẽ gì. Đây là điểm dễ sai nhất: dải thuộc về *file*, không thuộc về màn hình.
---
## 5. ISO từ EXIF
Bản web: `exifr.parse(bytes, { pick: ['ISO', 'ISOSpeedRatings'] })` →
`number | null`; `null` in `AUTO` (`recipes-web` @ `b4d5d29`,
`docker/frontend/src/engine/imageOps.ts`).
App hiện **chưa đọc tag ISO** (chỉ có GPS trong `src/utils/exifGps.ts` và ghi EXIF
trong `exifWrite.ts`). Thêm một helper nhỏ cạnh `exifGps.ts` đọc APP1/TIFF tag
`0x8827` (ISOSpeedRatings) từ bytes ảnh nguồn, hoặc lấy từ EXIF mà asset của ảnh đã
nạp sẵn có. Chưa có thì trả `null` → dòng kỹ thuật in `ISO AUTO`.
Đừng bịa ISO từ knob: build này `ISO_SUPPORTED = false`
(`src/components/RecipeCreateModal.tsx` dòng 74) — ISO trong dòng kỹ thuật là ISO
**của máy ảnh lúc chụp**, không phải một lựa chọn trong app.
---
## 6. Kiểm thử (bắt buộc trước khi coi là xong)
1. Xuất ảnh có recipe đặt tên, grain > 0, temperature ≠ 5500.
2. Kỳ vọng hình học: file cao hơn ảnh gốc đúng `round(W * 0.155)`; ví dụ nguồn
1200×900 → **1200×1086**.
3. Đo pixel: hàng trong dải nền tối (độ sáng < 0.2); **tagline amber** có pixel
`#f59e0b` (đếm > 20 pixel) ở góc trên-trái *trên ảnh*; chữ `#f2f2f2` /
`#9a9a9a` nằm trong dải dưới.
4. Chạy **cả ba đường** — engine Skia, native Kotlin, và (đối chiếu) web @ `b4d5d29`.
Band phải trùng khít giữa ba đường: cùng chiều cao, cùng màu, cùng baseline.
5. Preview / viewfinder: khẳng định **không** xuất hiện dải (không có pixel amber
`#f59e0b` ngoài watermark vốn có).
6. Đối chiếu web: `docker/frontend/src/engine/exportEngine.ts` bước **9b**
(`recipes-web` @ `b4d5d29`).
---
## 7. Ghi chú phụ
- Band đặt **sau** frame và **trước** encode — nếu vẽ trước frame, khung sẽ đè mất
chữ; nếu vẽ sau encode, file không còn pixel để vẽ.
- `band` tính từ **chiều rộng ảnh cuối**, không phải chiều rộng màn hình: ảnh 12MP
và ảnh 1200px ra tỉ lệ chữ khác nhau, đúng như mong đợi.
- Khi ảnh đã có watermark người dùng / GPS stamp, dải vẫn treo ngoài ảnh — không
chồng lên các lớp đó.
- Nếu sau này app có "lưu vào bộ sưu tập" tách khỏi "tải xuống": bản web cố ý lưu
bản **sạch** (không đốt dải) cho lưới cộng đồng và chỉ đốt dải vào file tải xuống,
để khung của lưới tự vẽ tagline từ nhãn đã lưu — tránh tag hai lần. Nếu app không
có lưới cộng đồng thì cứ đốt thẳng vào file xuất.
-191
View File
@@ -1,191 +0,0 @@
# 6 — CREATE RECIPES: gấp nhóm + thêm EXPOSURE / EV / WHITE / BLACK
Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06.
Nguồn đối chiếu: nhánh `recipes-web`, commit **`f17cb08`** ("web: fold the create
form's groups and add the white/black point rows") — bản web đã làm, đã đo và
đang chạy ở `http://localhost:8090/app`. Commit `cf4b01d` là việc đổi màu tên
recipe đang chọn trong FAVORITED; bản app **đã có sẵn** (`AdjustmentPanel.tsx`,
tab `favorites`: `active: r.id === currentRecipeId`) nên không cần làm lại.
Đây là ghi chú yêu cầu, **chưa implement trên app**. Web đã xong và là bản mẫu
để đối chiếu từng dòng.
---
## 1. Mục tiêu
Form CREATE RECIPES đã dài thành một cuộn dọc. Hai việc:
1. **Gấp được** sáu nhóm phân loại: SIMULATION, DYNAMIC RANGE, GRAIN EFFECT,
COLOR CHROME EFFECT, COLOR CHROME EFFECT BLUE, WHITE BALANCE.
2. **Thêm bốn ô số**: EXPOSURE, EV, WHITE, BLACK.
Thứ tự hàng giữ nguyên như điện thoại đang có, chỉ chèn thêm — trừ
`EXPOSURE COMP.` đổi tên thành `EV` (cùng một tham số `exposureCompensation`,
chỉ khác nhãn; web đã đổi để hai chữ EXPOSURE/EV không lẫn nhau).
---
## 2. Việc 1 — gấp nhóm (thuần UI, chỉ `RecipeCreateModal.tsx`)
Web dùng `<details>`/`<summary>` gốc của trình duyệt: không state, không
thư viện, trình duyệt tự giữ cờ mở/đóng. App dùng React Native nên phải tự làm:
một `useState<Record<string, boolean>>` cho sáu nhóm, nhãn nhóm bấm được, mũi
tên xoay 90° khi mở, và phần thân chỉ render khi nhóm đang mở.
Ranh giới từng nhóm trong `RecipeCreateModal.tsx` (số dòng theo bản hiện tại):
| Nhóm | Hàng nằm trong |
| --- | --- |
| SIMULATION | dải chip `FILM_SIMS` |
| DYNAMIC RANGE | dải chip `DR_OPTS` |
| GRAIN EFFECT | dải chip `GRAIN_OPTS` |
| COLOR CHROME EFFECT | dải chip `CC_OPTS` |
| COLOR CHROME EFFECT BLUE | dải chip `CC_OPTS` |
| WHITE BALANCE | dải chip `WB_PRESETS` **+ hai ô WB SHIFT RED/BLUE** |
Ngoài nhóm (luôn hiện): NAME, lưới số (`fieldSets`), hàng ISO, SAVE/EXPORT.
Khối WB SHIFT đi cùng WHITE BALANCE — về mặt nhiếp ảnh nó là một phần của WB.
Mặc định **mở cả sáu** để form không đổi hành vi cho tới khi người dùng tự gấp.
CSS web đã dùng (đối chiếu nếu muốn giống hệt):
```css
.create-sec { display: flex; flex-direction: column; gap: 6px; }
.create-sec > summary { display: flex; align-items: center; gap: 6px; cursor: pointer; list-style: none; user-select: none; }
.create-sec > summary::-webkit-details-marker { display: none; }
.create-sec > summary::before { content: '▸'; font-size: 9px; transition: transform 0.15s ease; }
.create-sec[open] > summary::before { transform: rotate(90deg); }
```
---
## 3. Việc 2 — bốn ô số
`fieldSets` hiện tại:
```ts
{ label: 'HIGHLIGHT', value: fHigh, set: setFHigh },
{ label: 'SHADOW', value: fShadow, set: setFShadow },
{ label: 'COLOR', value: fColor, set: setFColor },
{ label: 'NOISE RED.', value: fNr, set: setFNr, ph: '-10..10' },
{ label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '-10..10' },
{ label: 'CLARITY', value: fClarity, set: setFClarity },
{ label: 'EXPOSURE COMP.', value: fEv, set: setFEv, ph: 'EV' },
```
Web sau khi sửa (đúng thứ tự này):
```ts
{ label: 'HIGHLIGHT', value: fHigh, set: setFHigh },
{ label: 'SHADOW', value: fShadow, set: setFShadow },
{ label: 'WHITE', value: fWhites, set: setFWhites },
{ label: 'BLACK', value: fBlacks, set: setFBlacks },
{ label: 'COLOR', value: fColor, set: setFColor },
{ label: 'NOISE RED.', value: fNr, set: setFNr, ph: '-10..10' },
{ label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '-10..10' },
{ label: 'CLARITY', value: fClarity, set: setFClarity },
{ label: 'EXPOSURE', value: fExposure, set: setFExposure },
{ label: 'EV', value: fEv, set: setFEv, ph: '±3 EV' },
```
Thêm ba state (`fWhites`, `fBlacks`, `fExposure`) song song với các `f*` đang
có, seed từ `seedAdj`/`valuesOf`, và **bắt buộc** thêm vào `handleSave` — thiếu
một dòng là ô vừa nhập bị `...seedAdj` ghi đè và recipe lưu ra khác màn hình:
```ts
whites: int(fWhites, simAdj.whites ?? 0, -10, 10),
blacks: int(fBlacks, simAdj.blacks ?? 0, -10, 10),
exposure: int(fExposure, simAdj.exposure, -10, 10),
```
`EXPOSURE` là `adjustments.exposure` (-10..10, độ lợi ma trận) — đã có sẵn trong
model và trong tab LIGHT, chỉ thiếu hàng trong CREATE. `EV` là
`exposureCompensation` (-3..+3 EV) như hàng `EXPOSURE COMP.` cũ.
---
## 4. `WHITE` / `BLACK` là tham số MỚI — phải thêm cả model lẫn shader
Không có `whites`/`blacks` ở đâu trong app (đã grep cả hai nhánh). Nếu chỉ vẽ ô
nhập mà không đụng engine thì đó là **ô chết**: nhập xong không ảnh nào đổi.
### 4.1 `src/types/index.ts` — `ColorAdjustments`
```ts
highlight: number; // -10 to +10 (- pulls highlights down / roll, + brightens)
shadow: number; // -10 to +10 (+ lifts shadows, - deepens)
whites?: number; // -10 to +10 (white point: the very top of the tone curve)
blacks?: number; // -10 to +10 (black point: the very toe of the tone curve)
```
Để **optional** (`?`) — recipe cũ trong máy không có hai khóa này vẫn đọc được.
### 4.2 `src/utils/toneShader.ts` — `TONE_SKSL`
Khai báo uniform ngay sau `sh` (thứ tự này phải khớp `toneUniformArray`):
```glsl
uniform float wh;
uniform float bl;
```
Trong `main`, chèn **sau** hai dòng Dynamic Range và **trước** `o = clamp(o, …)`:
```glsl
// White/black point. The HL/SH knees above already spend the slope budget
// (each just under 1.0), so these two use cubic end-weights instead of
// another smoothstep: only the last/first stretch of the ramp moves, and the
// derivative stays >= 1 - 3*0.18 = 0.46 for every combination, so the curve
// can never invert. + lifts that end (faded blacks / brighter whites),
// - digs it in (true black / rolled white).
o += bl * 0.18 * (1.0 - t) * (1.0 - t) * (1.0 - t);
o += wh * 0.18 * t * t * t;
```
Vì sao **không** dùng thêm một cặp `smoothstep` như HL/SH: hai knee 0.65..1.00
và 0.00..0.55 đã tiêu gần hết "ngân sách độ dốc" (mỗi cái ~0.94/1.0); thêm một
knee nữa là đẩy tổng độ dốc qua 1.0 và đường cong **đảo** (điểm sáng hơn ra tối
hơn). Cubic end-weight chỉ chạm ~đoạn cuối của dải, đạo hàm luôn ≥ 0.46.
### 4.3 Bốn chỗ còn lại trong cùng file
```ts
export interface ToneUniforms {
dr: number; hl: number; sh: number;
wh: number; // -1..1 (adjustments.whites / 10 — white point)
bl: number; // -1..1 (adjustments.blacks / 10 — black point)
vib: number; …
}
```
```ts
const wh = Math.max(-1, Math.min(1, (adj.whites ?? 0) / 10));
const bl = Math.max(-1, Math.min(1, (adj.blacks ?? 0) / 10));
```
`getToneUniforms` trả thêm `wh, bl`; `toneIsActive` thêm `u.wh !== 0 || u.bl !== 0`
(không thêm thì pass tone bị bỏ qua và hai ô không có tác dụng); và
`toneUniformArray` — **thứ tự phần tử phải trùng khai báo GLSL**:
```ts
return [u.dr, u.hl, u.sh, u.wh, u.bl, u.vib,
u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb];
```
`dr`/`hl`/`sh` hai nhánh giống nhau; chỉ 4 khối ở mục 4.2–4.3 là mới.
---
## 5. Kiểm thử (đo được, không đoán)
Web đã đo bằng ảnh thật (`/tmp/w0/p2.jpg`): `WHITE +10` nâng độ chói decile trên
cùng, `BLACK +10` nâng decile dưới cùng và **không** đụng đỉnh, `BLACK -10` hạ
decile dưới. Vòng round-trip `.recipe` (XML xor16-v1) giữ nguyên
`whites = 4`, `blacks = -3`, `exposureCompensation = 2` qua chính importer của
điện thoại.
Bản app cần đo lại trên **cả ba đường vẽ** (engine Skia xuất file, native export,
preview viewfinder) như ghi chú 5 đã nêu: cùng một cặp WHITE/BLACK phải ra cùng
một ảnh.
-158
View File
@@ -1,158 +0,0 @@
# 7 — SCAN QR: quét mã trên trang web để import recipe vào app
Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06.
Nguồn đối chiếu: nhánh `recipes-web`, commit **`8a889db`** ("web: the QR card
hands out the look that made the photo") — bản web đã làm, đã đo, đang chạy ở
`http://localhost:8090`.
Đây là ghi chú cho phần mềm Android **và đã implement trên nhánh này**. Khác
các ghi chú 1–6 (chỉ nêu yêu cầu), ghi chú này ghi lại cả hai nửa: web đã mở ra
cái gì, và app đọc cái đó bằng cách nào.
---
## 1. Web đã mở ra cái gì (phía `recipes-web`)
Trang admin có thêm **checkbox thứ tư `qr`** cạnh `strip / tester / creator`.
Tick `qr` cho một ảnh nghĩa là: ảnh đó xuất hiện ở thẻ QR trên landing page.
Ảnh được tick `qr` phải có **preset** (`photos.recipe`, cột JSON). Khi có,
thẻ QR trên landing trỏ tới một route công khai mới của backend:
```
GET /api/photos/:id/preset.recipe
```
| Trường hợp | Trả về |
| --- | --- |
| `id` không phải số, không có hàng, `recipe IS NULL`, hoặc ảnh không tick `qr` | `404` |
| Hợp lệ | `200`, `content-type: application/xml; charset=utf-8`, `x-content-type-options: nosniff`, `content-disposition: attachment; filename="recipescam-<id>.recipe"` |
Nội dung file là **đúng định dạng `.recipe` mà app đã xuất/nhập**: XML bọc
payload xor16-v1, sinh tại `docker/backend/src/recipeFile.ts` (bản sao của
`shared/utils/recipeShare.ts`). Salt mới mỗi lần tải — hai lần quét cùng một mã
ra hai file khác byte nhưng cùng recipe. `test/security.mjs` khoá hình dạng
envelope này (+12 check, backend 134/134).
Vì vậy **mã QR chỉ chứa URL**, không chứa recipe. Vài chục byte, không phải
vài KB base64 — camera chỉ cần bắt được một liên kết ngắn.
Nhãn i18n liên quan (đã có trên web): `adm.pickQr` = `QR card` / `Thẻ QR`,
`adm.noPreset` = `no look to hand out` / `không có look để chia sẻ`.
---
## 2. Vì sao KHÔNG dùng vision-camera để quét
Đường thẳng nhất là quét QR bằng chính session camera của viewfinder. Không
được: **vision-camera v5 trên Android không có object output.**
```text
modules/.../HybridCameraFactory.kt:112
createObjectOutput → throw "CameraObjectOutput is not available on Android!"
```
`useObjectOutput` là API iOS-only. Barcode/QR trên Android phải đi đường khác,
nên app dùng **`expo-camera`** — nó bọc chính barcode scanner của Android.
```bash
npx expo install expo-camera # ~57.0.5
```
`app.json` thêm plugin trước `expo-sharing`:
```json
["expo-camera", { "cameraPermission": "Allow RecipesCam to scan a recipe QR code." }]
```
**Hệ quả bắt buộc: phải build lại dev client / APK.** `expo-camera` là native
module mới; bundle JS cũ không có nó.
App giờ có **hai thư viện camera**. CameraX không cho hai client cùng giữ một
ống kính, nên khi sheet quét mở thì viewfinder phải nhả camera ra — xem mục 4.
### 2.1 HTTP thuần (cleartext) — chỉ chạy ở debug
Route preset hiện phục vụ qua **HTTP thuần** (`http://<host>:8090/...`). Android
9+ chặn cleartext mặc định, nhưng manifest debug đã bật sẵn:
```xml
<!-- android/app/src/debug/AndroidManifest.xml -->
<application android:usesCleartextTraffic="true" ... />
```
`android/app/src/main/AndroidManifest.xml` **không** có dòng đó. Nghĩa là:
| Bản build | Quét thẻ QR |
| --- | --- |
| dev client / APK debug | chạy — cleartext được bật |
| APK release | `fetch` bị chặn, `Import failed` |
Nếu bản release cũng phải quét được thì **một trong hai**: phục vụ landing qua
HTTPS (tốt hơn — khi đó không phải đụng gì), hoặc bật cleartext cho release bằng
`expo-build-properties` (`android.usesCleartextTraffic: true`) — plugin này chưa
cài, nên chỉ thêm khi thật sự cần.
---
## 3. `src/components/QrScanModal.tsx` (mới)
Sheet quét: `Modal` + `CameraView` (`facing="back"`, `barcodeScannerSettings` chỉ
`['qr']`), xin quyền bằng `useCameraPermissions()` khi mở, và một **chốt
`takenRef`** — scanner báo mã ở mọi frame còn giữ mã, chốt chặn lần đầu để
không import mười lần. Mở lại sheet thì chốt reset.
Props: `{ visible, onCancel, onScanned }`. Khung ngắm màu hổ phách + dòng
`POINT AT A RECIPE QR` + nút X đóng. Khung ngắm `pointerEvents="none"` để cú
chạm rơi xuống camera bên dưới.
---
## 4. Nối vào App
| File | Thay đổi |
| --- | --- |
| `App.tsx` | state `scanVisible`; `storeImportedRecipe(xml)` tách ra dùng chung với IMPORT; `handleScanRecipe(value)`; truyền `onScanRecipe` xuống panel; render `<QrScanModal>`; truyền `paused={scanVisible}` xuống viewfinder |
| `src/components/AdjustmentPanel.tsx` | prop `onScanRecipe: () => void`; chip `SCAN QR` sau chip `IMPORT` trong tab `recipes` |
| `src/components/Viewfinder.tsx` | prop `paused?: boolean` (mặc định `false`); `isActive={appActive && !ultraWideOn && !paused}`, `active={appActive && ultraWideOn && !paused}` |
| `app.json` | plugin `expo-camera` + câu xin quyền |
| `package.json` | `expo-camera ~57.0.5` |
`handleScanRecipe` **đóng sheet trước**, rồi mới xử lý — thất bại là một hộp
thoại, không phải một scanner còn chạy. Cổng `liteSaveBlocked` /
`promptRecipeLimit()` đứng trước, giống đường IMPORT.
Liên kết phải đúng route preset, không thì từ chối kèm lý do:
```ts
const link = /^https?:\/\/\S+\/api\/photos\/\d+\/preset\.recipe$/.exec(value.trim());
if (!link) throw new Error('That code is not a RecipesCam recipe link.');
const res = await fetch(link[0]);
if (!res.ok) throw new Error(`The recipe link answered ${res.status}.`);
await storeImportedRecipe(await res.text());
```
Nhờ vậy mã QR của wifi, link shop, link app store… đều bị chặn bằng câu
`That code is not a RecipesCam recipe link.` chứ không im lặng nuốt.
`storeImportedRecipe` chính là thân cũ của `handleImportRecipe`: dynamic-import
`importRecipeXml` → `saveCustomRecipe` → `getAllRecipes` → `applyRecipe` →
Haptics success → `Alert.alert('Recipe Imported', …)`. Hai đường (chọn file và
quét mã) giờ dùng chung một chỗ lưu, nên recipe quét về nằm cùng danh sách
custom như recipe import bằng file.
---
## 5. Kiểm thử
- `node --stack-size=12000 node_modules/typescript/bin/tsc --noEmit` → đúng 15
lỗi có sẵn (`Viewfinder.tsx`, `skiaImage.ts`), **không lỗi nào ở
`App.tsx` / `QrScanModal.tsx` / `AdjustmentPanel.tsx`.
(`npx tsc --noEmit` trên cây Android crash `RangeError: Maximum call stack size exceeded` — lỗi có sẵn, phải truyền `--stack-size`.)
- Phía web: `preset-roundtrip.cjs` 11/0 — file backend sinh ra import được bằng
chính parser của app (tiếng Việt có dấu, `classic-vivid`, adjustments,
`frameId`, `useGeotag`), và envelope lạ bị từ chối.
- **Còn phải đo trên máy thật** sau khi build lại: quét thẻ QR trên landing →
recipe xuất hiện trong PRESETS và look đổi; quét một mã không phải link
RecipesCam → hộp thoại từ chối; mở sheet trong khi viewfinder đang chạy →
không có lỗi CameraX "camera already in use".
-715
View File
@@ -1,715 +0,0 @@
# 8 — PORT BỘ CÔNG CỤ CHỈNH ẢNH TỪ WEB SANG APP ANDROID
Nhánh đích: `feat/vision-camera-v5` (base khi viết: **`d5e5da4`**). Ngày: 2026-09-26.
Nguồn đối chiếu: nhánh **`recipes-web`**, commit **`6814b05`** (bản web đã chạy và đo được).
Tài liệu này là **spec để code**, không phải báo cáo. Mỗi mục có: nguồn web (file/hàm),
hiện trạng Android, việc phải làm, mã mẫu, và **tiêu chí nghiệm thu**. Toàn bộ số dòng
đã đối chiếu trực tiếp trên hai nhánh tại thời điểm viết.
> **Cách lấy nguồn tham chiếu:** nhánh `feat/vision-camera-v5` **không chứa** thư mục
> `docker/frontend`. Đọc bản web bằng:
>
> ```sh
> git show origin/recipes-web:docker/frontend/shared/utils/toneShader.ts
> git show origin/recipes-web:docker/frontend/src/engine/exportEngine.ts
> ```
>
> Đường dẫn "web" trong tài liệu này luôn tính từ `docker/frontend/`.
---
## 0. Phạm vi và bảng khoảng trống
Bảy hạng mục người dùng yêu cầu, theo đúng thứ tự sẽ làm:
| # | Hạng mục | Web có (nguồn) | Android hiện có | Việc phải làm |
|---|----------|----------------|-----------------|----------------|
| 1 | EXPOSURE / EV / HIGHLIGHT | `shared/utils/toneShader.ts` `EXPOSURE_SKSL` + `TONE_SKSL`; `shared/utils/colorUtils.ts` `EV_PER_UNIT`/`SIM_EXPOSURE_BIAS_EV`/`exposureStops` (`171-193`) | EV nhét trong ma trận màu (`src/utils/colorUtils.ts:425-426`, `applyExposureGain:444-455`); highlight knee `smoothstep(0.65,1.0)` gain `0.22` không có `(1-t)` (`src/utils/toneShader.ts:131,134`) | Tách EV sang pass linear; port đúng knee/`(1-t)` của web; **áp luôn fix thứ tự pass của `4_FIX_HIGHLIGHT_ORDER.md`** |
| 2 | AUTO (auto setting) | `src/ui/Histogram.tsx` `readHistogram` + `autoExposureStops`; `src/App.tsx:2497-2504` | Không có (ROTATE có AUTO riêng cho straighten — khác việc) | Histogram 256 bin từ pixel ảnh gốc + `autoExposureStops`, ghi vào `exposureCompensation` |
| 3 | WHITE / BLACK trong WB | `TONE_SKSL` web dòng `168-169`; uniform `wh`/`bl` (`78-79`, `338-339`, `390-391`, `433-434`, `454`, `466-467`); `paramDefs.ts:137,148` | Không có `whites`/`blacks`. `paramDefs.wb` chỉ có `temperature`,`tint` (`src/utils/paramDefs.ts:109-132`) | Thêm 2 uniform + 2 entry `paramDefs.wb` |
| 4 | TONE CURVE | `shared/utils/toneCurve.ts` (203 dòng) + `src/ui/ToneCurvePanel.tsx` | Không có | Port toán + LUT + panel graph |
| 5 | GRADIENT MASK | `shared/utils/gradientMask.ts` (169 dòng) + `App.tsx` `gradientChips:2750`/`maskRulers:2355` | Không có | Port shader + geometry + UI chip/strip/cột chỉnh |
| 6 | FIX (HEAL, MOSAIC) | `shared/utils/heal.ts` (324), `mosaic.ts` (104), `brush.ts` (26) | Không có | Port shader + pixel sampler + UI |
| 7 | H-FLIP / V-FLIP trong ROTATE | `shared/utils/skiaImage.ts` `flipSkImage:84`, `applyPhotoRotation:114` | `applyPhotoRotation(image, quarter, straighten)` — **không có flip** (`src/utils/skiaImage.ts:69`) | Thêm flip + state + UI strip |
### 0.1 Đã có sẵn trên nhánh này — KHÔNG làm lại
Các commit `c8e2b6e`…`d5e5da4` đã port xong và **không** nằm trong phạm vi tài liệu này:
- **HSL selective colour mixer** (`cbd0178`): đã có `hslBands`/`hslHue`/`hslSat`/`hslLum`
trong `ColorAdjustments` (`src/types/index.ts:77-104`), `HSL_BANDS`/`hslBandGaps`/
`sanitizeHslBands` trong `colorUtils.ts:11-115`, band block trong `TONE_SKSL`
(`toneShader.ts:51-58,76-82,194`) và tab `HSL` trong `ToolRail.tsx:18`.
⇒ **Bỏ** mọi ghi chú cũ kiểu "HSL web-only".
- **Stock `classic-vivid` và `mono-high-contrast`** (`c8e2b6e`, `85b55dc`): đã có trong
`BaseFilter` (`src/types/index.ts:69`) **và** đã có entry `FILM_TONE`
(`src/utils/toneShader.ts:299-322`, gồm `'mono-high-contrast': { sh: -0.32, hl: 0.26 }`).
- **`STOCK_BIAS`** (`colorUtils.ts:161-168`) đã thay vai trò của `SIM_EXPOSURE_BIAS_EV` +
`SIM_CONTRAST_BIAS` bên web, nhưng còn ở **đơn vị cũ** — xem mục 3.3.
Ghi chú phạm vi khác: ba nhánh light/chroma, split-tone, Color Chrome, vibrance trong
`TONE_SKSL` Android **đã khớp** web — không đụng.
---
## 1. Hiện trạng Android
### 1.1 Ba đường render — mọi pass mới phải khớp cả ba
| Đường | File | Đặc điểm |
|-------|------|----------|
| Export JS/Skia (imperative) | `src/utils/exportEngine.ts` → `processAndExportPhoto` | Toàn quyền: surface phụ, snapshot, LUT, cache effect theo count. Đây là đường "chuẩn" để so. |
| Preview library (declarative) | `src/components/Viewfinder.tsx`, `libPhoto()` (`3127-3200`) | Chỉ lồng được `<Shader source={effect}>` quanh một chuỗi shader của ảnh. **Không** chèn được pass giữa tuỳ ý. |
| Camera preview (worklet) | `Viewfinder.tsx` `<SkiaCamera onFrame={handleFrame}>`, `drawFullFrame` | Mỗi frame; tránh thêm pass nặng. |
| Export native Kotlin | `src/utils/nativeExport.ts:47` + `modules/recipescam-export/android/.../RecipescamExportModule.kt` | Bật bằng `EXPO_PUBLIC_NATIVE_EXPORT=1` (`App.tsx:61`). Chỉ dùng khi `pro` (`App.tsx:1376`). |
`Viewfinder.tsx:461` dựng ảnh tĩnh đã quay cho chế độ library bằng memo
`applyPhotoRotation(loadedImage, photoRotation, photoStraighten)`.
### 1.2 Hạ tầng đã có
- `Skia.RuntimeEffect.Make` + `makeShaderWithChildren` + `<Shader source>` — đã dùng
(`exportEngine.ts:371-380`, `Viewfinder.tsx:3141-3146`).
- Mẫu đọc pixel: `src/utils/horizon.ts:18-56` — `Skia.Surface.Make(w,h)` (`32`) →
`drawImageRectOptions` downscale → `makeImageSnapshot()` → `readPixels()` (`50`)
(Uint8Array RGBA hoặc Float32Array). Dùng lại cho histogram/AUTO và `findHealSource`.
- Undo: `App.tsx` `commit(coalesce, mutate)` (`757-775`, `PEEK_GAP_MS = 700` tại `751`),
`applyLook` (`804`), `handleUndo` (`820`), `Look` (`719-720`) — thêm field vào `Look`
là tự động sống qua undo/redo/session (`lookNow` tại `728`).
- Chip UI: `AdjustmentPanel.tsx` `chipRow` (`867`), `paramChips` (`475`),
`groupDefs` (`652`), `groupDefs.rotate` (`713-733`), `rotateChip()` (`791-803`).
- `RecipeCreateModal.handleSave` spread `...seedAdj`, nên knob **không có row** trong form
vẫn được lưu (không bị zero).
### 1.3 Điều KHÔNG có (phải dựng mới)
Không có: brush, heal, mosaic, mask vẽ tay, curve editor, flip. Không SVG, không
`react-native-gesture-handler` — mọi touch đi qua RN responder. Không i18n (chuỗi
hardcode tiếng Anh, giữ nguyên).
`exportEngine.ts` **không** cache `RuntimeEffect` theo count: chỉ có `watermarkFonts`
(`184`) ở module scope, mọi effect khác dựng lại trong hàm. Heal/mosaic/mask bắt buộc
phải cache — xem mục 2.4.
### 1.4 Thứ tự pass hiện tại của `exportEngine.ts`
```
218 1 nạp ảnh → 241 1 rotate → 281 1b crop → 250 1c deviceFloor → 337 2 surface
350 3 color matrix (+ STOCK_BIAS, +2^EV gain) ← CÙNG paint với 3b/3c: SAI (xem doc 4)
359 3b tone shader → 392 3c cinema
414 4 denoise/clarity/soften → 477 5 draw → 489 5b HDF
572 6 grain → 619 6b vignette → 648 7 frame
832 8 watermark → 924 9 snapshot/JPEG/EXIF → 976 10 file → 988 11 gallery
```
`4_FIX_HIGHLIGHT_ORDER.md` đã chỉ ra `SkPaint` chạy **shader TRƯỚC colorFilter**, nên
ma trận (mang exposure) hiện đang áp **sau** tone → HIGHLIGHT bị xoá sạch. Kiểm tra
`grep -rn gradeThrough src/` → **rỗng**, fix này **chưa được áp**. Mục 3 dưới đây áp nó
cùng lúc với việc tách EV.
---
## 2. Quy tắc chung cho mọi pass mới
### 2.1 Thứ tự pass đích
```
rotate(+flip) → crop → matrix → [EXPOSURE linear] → tone(hl/sh/wh/bl) → cinema
→ curve → denoise/clarity/soften → draw → HDF
→ masks → heal → mosaic → grain → vignette → frame → watermark
```
### 2.2 Sai lệch **có chủ ý** so với web
Web đặt `masks → heal → mosaic` **sau** grain và vignette
(`exportEngine.ts:708-800`). Android đặt chúng **trước** grain/vignette. Lý do:
1. Preview library chỉ lồng được shader trong chuỗi shader của ảnh — mask/heal/mosaic
phải nằm trong chuỗi đó, tức trước lớp grain/vignette vốn là node riêng
(`Viewfinder.tsx:3230-3233`/`3288-3291` grain, `renderVignette:2809,3237,3295,3348`).
2. Grain/vignette chạy sau sẽ **phủ đều** lên vết sửa, nên vết heal/mosaic trông tự
nhiên hơn (nhiễu hạt chồng lên đúng như mọi vùng khác).
Điều phải giữ bằng mọi giá: `masks` chạy **trước** `heal` (web có comment giải thích ở
`gradientMask.ts:149-155` và `exportEngine.ts:708-737`) — vết heal phải mượn pixel **đã
mang ánh sáng của mask**.
### 2.3 Điểm chèn
| Đường | Vị trí chèn |
|-------|-------------|
| `exportEngine.ts` | Ngay sau khối vignette, trước `// 7. Frame.` (`648`) |
| `Viewfinder.tsx` | Trong `libPhoto()` (`3127`), sau `<Blur>`/clarity và trước HDF glow |
| `nativeExport.ts` / Kotlin | Theo `4_FIX_HIGHLIGHT_ORDER.md` §4.3 rồi nối tiếp |
### 2.4 Quy ước code
- Shader dựng theo **số lượng** phần tử (`healSkSL(n)`, `mosaicSkSL(n)`,
`gradientMaskSkSL(n)`) và **cache** effect theo count — copy mẫu web
`exportEngine.ts:154-210` (`healEffectFor`/`mosaicEffectFor`/`maskEffectFor`).
- Mọi pass mới phải có nhánh "tắt": uniform toàn 0 / danh sách rỗng ⇒ **không** tạo
surface, **không** đổi đường vẽ cũ. Đây là điều kiện để không regress ảnh hiện tại.
- Không đổi ngữ nghĩa `evFromCamera` (`exportEngine.ts:35,353`): ảnh chụp từ camera đã
nhận AE bias phần cứng, **không** cộng thêm EV của `exposureCompensation`.
---
## 3. EXPOSURE / EV / HIGHLIGHT
### 3.1 Nguồn web
- `shared/utils/toneShader.ts` — `EXPOSURE_SKSL`: linear hoá sRGB (`0.04045/12.92` +
`2.4`), nhân `exp2(ev)`, encode lại. Chạy **giữa** ảnh đã grade và tone shader.
- `shared/utils/colorUtils.ts:171-193`:
- `SIM_EXPOSURE_BIAS_EV = { 'leica-vivid': 0.25 }`, `SIM_CONTRAST_BIAS = { 'mono-high-contrast': 4 }`
- `EV_PER_UNIT = 0.25` → núm EXPOSURE ±10 = ±2.5 EV
- `exposureStops(adj, baseFilter) = (adj.exposure ?? 0) * EV_PER_UNIT + bias`
- Comment khẳng định: EV/biến thiên sim **không** nằm trong ma trận màu.
- `exportEngine.ts:458-476` — bước 3: ma trận riêng một ảnh, rồi `evStops = exposureStops() + userEv`.
### 3.2 Hiện trạng Android (sai)
`src/utils/colorUtils.ts`:
```
186 const exposure = adj.exposure + (STOCK_BIAS[baseFilter]?.exposure ?? 0);
425 const expScale = 1 + (exposure / 10) * 0.2; // -10 → 0.8x, +10 → 1.2x
426 const expOffset = (exposure / 10) * 0.15;
444 export function applyExposureGain(matrix, evStops) // gain 2^ev trong gamma-space
```
`exposureCompensation` ±3 EV (`src/types/index.ts:104`), áp qua `applyExposureGain` tại
`exportEngine.ts:353-357` và `nativeExport.ts:61`. Kết quả: +1 EV đẩy mid-grey 0.5 →
**1.0** (cháy), đúng như web đã sửa.
`STOCK_BIAS['leica-vivid'] = { exposure: 2 }` (`colorUtils.ts:163`) là **đơn vị cũ**: web
đo ra ≈ **0.25 EV** và đã chuyển sang thang stops.
### 3.3 Việc phải làm
**Bước A — tách EV khỏi ma trận.**
1. `colorUtils.ts:186` — bỏ `+ (STOCK_BIAS[baseFilter]?.exposure ?? 0)`; xoá khối
`expScale`/`expOffset` (`425-438`) khỏi `getSkiaColorMatrix`; xoá
`applyExposureGain` (`444-455`) khỏi mọi caller. Giữ `STOCK_BIAS.contrast`
(`mono-high-contrast: 4`) — web cũng giữ `SIM_CONTRAST_BIAS` trong ma trận.
2. Đổi `STOCK_BIAS['leica-vivid']` từ `{ exposure: 2 }` → **bỏ khỏi `STOCK_BIAS`**, và
thêm đúng bản web:
```ts
// src/utils/colorUtils.ts
export const EV_PER_UNIT = 0.25; // núm EXPOSURE ±10 = ±2.5 EV (web: giống hệt)
const SIM_EXPOSURE_BIAS_EV: Partial<Record<BaseFilter, number>> = { 'leica-vivid': 0.25 };
/** EV người dùng (núm), biến thiên sim, và EV thô — cộng ở thang stops. */
export function exposureStops(
adj: ColorAdjustments, baseFilter: BaseFilter | undefined, userEv: number
): number {
return (adj.exposure ?? 0) * EV_PER_UNIT
+ (SIM_EXPOSURE_BIAS_EV[baseFilter ?? 'none'] ?? 0)
+ userEv;
}
```
**Bước B — thêm `EXPOSURE_SKSL`** vào `src/utils/toneShader.ts` (copy nguyên văn web,
giữ comment gốc):
```ts
export const EXPOSURE_SKSL = `
uniform shader src;
uniform float ev;
vec3 toLinear(vec3 c) {
return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c));
}
vec3 toEncoded(vec3 c) {
return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c));
}
vec4 main(vec2 xy) {
vec4 c = src.eval(xy);
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
return vec4(clamp(toEncoded(toLinear(rgb) * exp2(ev)), 0.0, 1.0), c.a);
}
`;
```
**Bước C — áp `4_FIX_HIGHLIGHT_ORDER.md` §4.1/§4.2/§4.3 nguyên trạng.** Ma trận phải
thành ảnh riêng (`gradeThrough`) trước khi tone/cinema đọc. Đồng thời **bỏ lần áp ma
trận thứ hai** trong khối HDF (`exportEngine.ts:529,549`, `Viewfinder.tsx:1369,1387`).
Thứ tự sau khi sửa: `matrix → exposure(linear) → tone → cinema`. Exposure chèn cùng chỗ
với ma trận: `gradeImage` (kết quả `gradeThrough`) → shader exposure → tone shader con.
`ev = exposureStops(adjustments, recipe.baseFilter, evFromCamera ? 0 : exposureCompensation)`.
Với `evFromCamera` thì **vẫn** giữ phần núm EXPOSURE + biến thiên sim (chúng chưa từng
đi qua phần cứng), chỉ bỏ `exposureCompensation`.
### 3.4 Nghiệm thu
1. Parity với web trên **cùng ảnh + cùng recipe** (so pixel, không so cảm giác).
2. Mid-grey +1 EV cho ra **≈ 0.69** encoded, **không** cháy 1.00.
3. Bảng p95 của `4_FIX_HIGHLIGHT_ORDER.md` §5: nguồn gradient dọc 0.98→0.30,
`HIGHLIGHT −10` → p95 **0.780**, `HIGHLIGHT −10 + EXPOSURE +10` → p95 **vẫn 0.780**,
midtone 0.502 → 0.722.
4. `leica-vivid` sau khi sửa sáng thêm **≈ 0.25 EV** so với trước (đo trên cùng ảnh), và
**giống** bản web.
5. Chạy đủ ba đường (export JS, preview library, Kotlin) và so ảnh với nhau.
> **Thay đổi hành vi phải báo trước:** núm EXPOSURE từ "±10 đơn vị ≈ ×0.8…×1.2" thành
> "±2.5 EV". Recipe cũ đặt EXPOSURE ≠ 0 sẽ sáng/tối hơn rõ rệt. Web đã chấp nhận đánh
> đổi này (`colorUtils.ts` comment "every recipe already saved with a non-zero EXPOSURE
> gets brighter with it").
---
## 4. HIGHLIGHT
### 4.1 Nguồn web (`TONE_SKSL`)
```
57 float hlMask = smoothstep(0.50, 1.00, t);
60 float o = t + hl * hlMask * (1.0 - t) + sh * 0.34 * shMask;
63 o -= dr * 0.18 * hlMask * t;
```
Knee **0.50**, biên độ **1.0** (không scale), dạng **`(1-t)`** (càng gần trắng càng ít
đẩy) ⇒ monotonic, giữ được vùng sáng không cháy. `shMask = 1 - smoothstep(0, 0.55, t)`.
### 4.2 Hiện trạng Android (`src/utils/toneShader.ts`)
```
131 float hlMask = smoothstep(0.65, 1.00, t);
134 float o = t + hl * 0.22 * hlMask + sh * 0.34 * shMask;
```
Knee 0.65, gain 0.22, cộng thẳng không có `(1-t)` ⇒ vùng sáng đã cháy vẫn bị đẩy lên và
thanh "chết" đúng như `4_FIX_HIGHLIGHT_ORDER.md` §1 mô tả.
### 4.3 Việc phải làm
Thay hai dòng `131` và `134` bằng bản web. Giữ `sh * 0.34` (hai bên giống nhau). Không
đổi `dr` (dòng `137`, đã khớp). Cập nhật comment đầu file (mô tả knee 0.65) cho khớp.
`FILM_TONE` (`toneShader.ts:299-322`) đã có đủ sáu stock web hỗ trợ, gồm
`classic-vivid` và `mono-high-contrast` — **không đụng**.
### 4.4 Nghiệm thu
- `HIGHLIGHT −10` trên ảnh có vùng trắng đẩy p95 xuống ~0.78 và **không** đổi midtone
(so với ảnh gốc, mid lệch < 0.01).
- `HIGHLIGHT +10` rồi `−10` quay về đúng ảnh gốc (đối xứng, sai số ≤ 1/255).
- `mono-high-contrast` (dùng `hl: 0.26`) giữ nguyên diện mạo sau khi đổi knee — kiểm bằng
ảnh so trước/sau.
---
## 5. WHITE / BLACK trong WB
### 5.1 Nguồn web
`TONE_SKSL` — chèn **sau** nhánh lightness/chroma, trước split-tone:
```
168 vec3 dk = 1.0 - rgb;
169 rgb = clamp(rgb + bl * 0.18 * dk * dk * dk + wh * 0.18 * rgb * rgb * rgb, 0.0, 1.0);
```
Trọng số **cubic** theo khoảng cách của kênh tới đầu mút: trong tối kênh tối nhất dịch
nhiều nhất, trong sáng kênh sáng nhất dịch nhiều nhất ⇒ kéo R/G/B về chung một toe và
shoulder — đây là **động tác white balance**, không phải thêm một thanh tone. Monotonic:
đạo hàm `1 − 3×0.18 = 0.46` tại đầu mút, không thể đảo chiều.
Uniform web: `uniform float wh; uniform float bl;` (`toneShader.ts:78-79`), nhận
`Math.max(-1, Math.min(1, (adj.whites ?? 0) / 10))` (`390-391`), đưa vào mảng đúng thứ tự
khai báo sau `dr, hl, sh` (`454`), và `toneIsActive` thêm `u.wh !== 0 || u.bl !== 0`
(`466-467`).
`shared/utils/paramDefs.ts:137,148` — thứ tự WB: `temperature, tint, whites` (label
`WHITE`), `blacks` (`BLACK`), min/max ±10, step 1, `display: sign`.
### 5.2 Việc phải làm
1. `src/types/index.ts` — thêm `whites?: number; // -10..+10` và `blacks?: number` vào
`ColorAdjustments` (interface ở dòng `77`), đặt ngay sau `tint`/`wbBlue` cho khớp web.
2. `src/utils/toneShader.ts`:
- thêm `uniform float wh; uniform float bl;` **ngay sau** `uniform float sh;` (dòng `66`);
- thêm khối `dk` ở đúng vị trí web (sau nhánh lightness/chroma, trước split-tone ở `147`);
- thêm `wh`/`bl` vào `ToneUniforms` (`275`), `getToneUniforms` (`325`, chia 10 và kẹp ±1),
`toneUniformArray` (`388`, đúng thứ tự khai báo), `toneIsActive` (`396`).
3. `src/utils/paramDefs.ts:109-132` — thêm 2 entry vào `wb` sau `tint`, label
`WHITE`/`BLACK`, ±10 step 1, `display: sign`, `get: (a) => a.whites ?? 0`,
`set: (v) => ({ whites: v })` (tương tự `blacks`). `chipRow` của tab WB
(`AdjustmentPanel.tsx:957`) tự hiện chip — không phải sửa UI.
4. `RecipeCreateModal` — **không cần** thêm row: `handleSave` đã spread `seedAdj`.
### 5.3 Nghiệm thu
- `WHITE +10` chỉ nâng vùng sáng, vùng tối và mid lệch < 1/255.
- `BLACK +10` chỉ nâng vùng tối (đen thành xám nhẹ), vùng sáng lệch < 1/255.
- Trên ảnh xám trung tính, `WHITE −10` **không** sinh cast màu (ba kênh dịch như nhau).
- Recipe cũ (không có `whites`/`blacks`) render **y hệt** trước khi sửa.
---
## 6. AUTO (auto setting)
### 6.1 Nguồn web
`src/ui/Histogram.tsx`:
```ts
const BINS = 256; // 0..255
const SAMPLE = 320; // downscale cạnh dài trước khi bin
export const AUTO_EV_MAX = 2.5;
export function autoExposureStops(lum: number[]): number {
let sum = 0, weighted = 0;
for (let i = 0; i < lum.length; i++) { sum += lum[i]; weighted += (i / (lum.length - 1)) * lum[i]; }
const avg = Math.max(0.001, sum > 0 ? weighted / sum : 0);
const stops = Math.log2(0.48 / avg);
return Math.max(-AUTO_EV_MAX, Math.min(AUTO_EV_MAX, stops));
}
```
Luma weights `0.2126/0.7152/0.0722` (đúng trọng số shader dùng). Target **0.48**, guard
`≥ 0.001`, kẹp ±2.5 EV.
`src/App.tsx:2497-2504` `autoExposure()`: đọc histogram của **ảnh gốc đã load**, rồi
`setAdjustment({ exposureCompensation: Math.round(stops * 10) / 10 })`. Chip `AUTO` đứng
**đầu** row tab LIGHT (`App.tsx:2586-2587`).
### 6.2 Việc phải làm (Android)
1. Thêm `src/utils/histogram.ts`:
```ts
export const AUTO_EV_MAX = 2.5;
// Pixel đã downscale (cạnh dài 320) từ readPixels — cùng mẫu horizon.ts:32-56.
export function lumaBins(rgba: Uint8Array | Float32Array, isFloat: boolean): number[] { /* 256 bin */ }
export function autoExposureStops(lum: number[]): number { /* y như web */ }
```
Nguồn pixel: `Skia.Surface.Make` + `drawImageRectOptions` downscale về cạnh dài **320**
trên **ảnh gốc đã load** (rotation không đổi histogram, không cần `applyPhotoRotation`),
theo đúng mẫu `src/utils/horizon.ts:32-56`. Nhớ `dispose()` surface/snapshot — Hermes
không tự thu hồi buffer native (`exportEngine.ts:113-121`).
2. Chip `AUTO` **đứng đầu** row LIGHT: `AdjustmentPanel.tsx:951` hiện là
`chipRow([...paramChips(paramDefs.iq), groupChip('dr')])` → đổi thành
`chipRow([autoChip(), ...paramChips(paramDefs.iq), groupChip('dr')])`, nhãn `AUTO`.
3. `onPress`: chạy `autoExposureStops` → `handleUpdateAdjustments({ exposureCompensation: ev })`
(`App.tsx:869`). Đi qua `handleUpdateAdjustments` để có `commit(true, …)` ⇒ **một bước undo**.
### 6.3 Nghiệm thu
- **Idempotent:** bấm AUTO hai lần liên tiếp ra cùng một giá trị EV (vì đọc ảnh gốc,
không đọc ảnh đã áp EV).
- Ảnh phơi sáng đúng (avg ≈ 0.48) ⇒ EV ≈ 0 (|EV| ≤ 0.1).
- Ảnh tối đen ⇒ EV = +2.5 (không `Infinity`, không `NaN`).
- Ảnh cháy trắng ⇒ EV = −2.5.
- Undo một lần quay về EV trước khi bấm.
Ngoài phạm vi: web **chỉ** auto EV. Không auto contrast/white balance/tone.
---
## 7. TONE CURVE
### 7.1 Nguồn web — `shared/utils/toneCurve.ts` (203 dòng)
| Hàm/hằng | Vai trò |
|----------|---------|
| `CURVE_CHANNELS = ['rgb','r','g','b']` | master + 3 kênh |
| `CURVE_LUT_SIZE = 256`, `IDENTITY_CURVE = [[0,0],[1,1]]` | LUT 256 mức |
| `CURVE_MIN_GAP = 0.02`, `FLAT = 0.002` | khoảng cách điểm tối thiểu; ngưỡng "phẳng" |
| `curvePoints(curve, ch)` | clamp01 + sort + ghim hai đầu về (0,0) và (1,1) |
| `curveIsActive(curve)` | có kênh nào lệch `FLAT` khỏi đường chéo |
| `tangents(pts)` | tiếp tuyến **Fritsch–Carlson** (monotone cubic) |
| `sampleCurve(pts, x)`, `curveLut(curve)` | master ∘ kênh → `Uint8Array` RGBA, A=255 |
| `CURVE_SKSL` | 2 child (`src`, `lut`), sample `v * 255 + 0.5` |
| `addCurvePoint` | thêm điểm **trên** đường (click không nhảy giá trị) |
| `moveCurvePoint` | hai đầu bị ghim theo trục x |
| `removeCurvePoint`, `isFlatCurve` | xoá điểm, kiểm tra phẳng |
`curveLut` áp theo thứ tự **master ∘ channel**.
### 7.2 UI web — `src/ui/ToneCurvePanel.tsx` (357), `src/App.tsx`
- Graph `SIZE = 224`, `STEPS = 64` vẽ đường, `HIT = 11px` bán kính bắt điểm.
- Tabs `RGB / R / G / B` + `RESET` (`{ }` = xoá toàn bộ curve).
- Histogram vẽ **sau** lưu đồ (nền).
- Kéo card bằng head; pointer capture khi kéo điểm.
- `put()` xoá channel khi phẳng (không lưu curve rỗng).
- Chip `TONE CURVE` nằm sau group D.RANGE trong row LIGHT; amber khi `curveIsActive`.
### 7.3 Việc phải làm (Android)
1. `src/utils/toneCurve.ts` — port nguyên toán + hằng số + `CURVE_SKSL`. `curveLut` trả
`Uint8Array(256 * 4)`.
2. Dựng LUT thành ảnh Skia cho shader: `Skia.Image.MakeImage({ width: 256, height: 1,
colorType: RGBA_8888, alphaType: Premul }, data, 256 * 4)`; shader con thứ hai là
`lutImage.makeShaderOptions(TileMode.Clamp, TileMode.Clamp, FilterMode.Linear,
MipmapMode.None)` — như web (`exportEngine.ts:533-560`, khối 3e).
3. `ColorAdjustments.toneCurve?: ToneCurve`.
4. Chip `TONE CURVE` trong row LIGHT **sau** `groupChip('dr')` (`AdjustmentPanel.tsx:951`);
mở panel graph khi `openParam === 'curve'`; amber khi `curveIsActive`.
5. Panel: graph 224px dựng bằng Skia (`<Canvas>` + `<Path>` + `<Circle>`), tabs
RGB/R/G/B, RESET, histogram nền (dùng lại hàm của mục 6), kéo điểm bằng RN responder
trên `<View>` phủ (không có pointer capture sẵn — dùng `PanResponder`), tap-trên-đường
để thêm điểm, double-tap để xoá (`CURVE_MIN_GAP 0.02`, HIT 11px).
6. Một cử chỉ kéo = **một** bước undo: copy cách `handleUpdateAdjustments` gộp
(`App.tsx:869`, `PEEK_GAP_MS = 700` tại `751`).
### 7.4 Nghiệm thu
- Curve identity ⇒ ảnh **không đổi** (diff = 0) và pass bị bỏ (không tạo LUT/surface).
- Master kéo 1/4 → 0 làm nửa dưới tối đúng theo đường monotone; `R` kéo xuống ⇒ ảnh ngả
cyan, và **chỉ** kênh đỏ đổi.
- `Fritsch–Carlson` không overshoot: LUT monotone khi các điểm vào monotone.
- Export == preview (cùng LUT).
- **Rủi ro cao cần kiểm trên device:** nhánh declarative `<Shader source={curveEffect}>
{graded}</Shader>` cần **2 child shader** (chuỗi đã grade + `ImageShader` của LUT với
`fit="none"` và `rect={rect(0, 0, 256, 1)}`). Nếu `@shopify/react-native-skia` không
nhận `rect` như mong đợi, fallback: render curve trong đường **imperative** cho preview
tĩnh (dùng đúng `gradeThrough` của mục 3), và giữ declarative chỉ khi tone/cinema.
Đường export imperative thì không có rủi ro này.
---
## 8. GRADIENT MASK
### 8.1 Nguồn web — `shared/utils/gradientMask.ts` (169 dòng)
Hằng số: `MASK_KIND = { linear: 0, radial: 1 }`, `MASK_EXPOSURE_MAX = 5`,
`MASK_DEFAULT_FEATHER = 0.5`, `MASK_MIN = 0.01`.
`maskUniforms` — layout `(3n + 1) × vec4`:
| offset | nội dung |
|--------|----------|
| `i * 4` | `[x, y, ex, ey]` — gốc (pin) và đầu kéo (linear) |
| `(n + i) * 4` | `[rx, ry, angle, feather]` — ellipse (radial) |
| `(2n + i) * 4` | `[exposure, contrast / 10, saturation / 10, kind]` |
| `3n * 4` | `[width, height, 0, 0]` — khung mà các phân số là của nó |
Alpha (web, `maskBlock`): linear `a = smoothstep(0, 1, clamp(dot(pos − pin·size, d) / |d|²))`;
radial `st = (pos − center·size) / size.x`, xoay `−angle`, `d = length(r / max(rx, ry, 1e-5))`,
`a = 1 − smoothstep(max(0, 1 − feather), 1, d)`. `maskAdjust` = exposure (luỹ thừa 2)
→ contrast quanh 0.5 → saturation như mix khỏi luma REC-709, rồi clamp.
Chạy **trước** HEAL (`gradientMask.ts:149-155`, `exportEngine.ts:708-737`).
`readMasks` lọc: kind hợp lệ, kẹp trong khung, và loại mask suy biến — linear có
`hypot(ex−x, ey−y) > MASK_MIN`, radial có `rx > 0 && ry > 0`.
### 8.2 UI web (`App.tsx`)
- Chip `GRADIENT MASK` trong row FX, amber khi `masks.length > 0` (`2636`).
- `gradientChips` (`2750-2765`): `LINEAR` / `RADIAL` / `CLEAR`.
- Cột chỉnh mask (`3136-3155`) + `maskRulers` (`2355-2406`): EXPOSURE ±5 EV step 0.1,
CONTRAST ±10, SATURATION ±10, FEATHER 0-100% (chỉ radial), DELETE.
- Geometry kéo: `maskPin` / `maskFromDrag` / `maskDragged` (`121-230`).
- `setMaskKnob` (`1123-1145`).
### 8.3 Việc phải làm (Android)
1. `src/utils/gradientMask.ts` — port hằng số + `readMasks` + `maskUniforms` +
`maskAdjust` + `gradientMaskSkSL(count)`.
2. **Thêm uniform `origin`.** Web vẽ lên canvas chính là ảnh, nên `pos` đã là toạ độ ảnh.
Preview Android có `dispRect` (`Viewfinder.tsx:1705,3331`) nên shader phải dùng
`pos − origin` trước khi chia `size`. Gói `origin` vào `vec4 size` đang có
(`[width, height, originX, originY]`) để **không** đổi layout 3n+1.
3. `GradientMask` interface (kind, x, y, ex, ey, rx, ry, angle, feather, exposure,
contrast, saturation) + `ColorAdjustments.masks?: GradientMask[]`.
4. Pass: cache `maskEffectFor(n)` (mẫu web `exportEngine.ts:204-210`), snapshot → clear →
vẽ lại như web (`exportEngine.ts:708-737`).
5. UI: chip `GRADIENT MASK` trong row FX + strip `LINEAR`/`RADIAL`/`CLEAR`
+ cột chỉnh (EXPOSURE ±5 EV 0.1 / CONTRAST ±10 / SATURATION ±10 / FEATHER 0-100%
chỉ radial / DELETE). Touch: RN responder trên `<View>` phủ — kéo pin/kéo đầu/đổi
bán kính ellipse; vẽ outline + handle + đường chéo bằng Skia.
6. Drag < `MASK_MIN` khi thả ⇒ huỷ, không tạo mask (khớp `readMasks`).
### 8.4 Nghiệm thu
- `+2 EV` trong mask làm vùng trong mask sáng **≈ 2 stop**, vùng ngoài lệch < 1/255.
- Hai mask chồng nhau: mask thứ hai đọc kết quả mask thứ nhất (đúng "stack").
- Kéo thả < 0.01 không sinh mask rác trong state.
- MASK chạy trước HEAL: heal đặt trong mask mượn pixel đã mang ánh sáng mask (ảnh kiểm:
vết heal trên vùng mask tối không còn sáng hơn nền).
- FEATHER = 0 (radial) ⇒ rìa cứng, `a` nhảy 1 → 0 tại `d = 1`.
- Toạ độ đúng khi zoom/pan: mask dán vào **ảnh**, không dán vào khung nhìn
(kiểm bằng cách zoom rồi so vị trí outline với pixel).
---
## 9. FIX — HEAL và MOSAIC
### 9.1 Nguồn web
`shared/utils/heal.ts` (324 dòng) — mọi hằng số phải giữ nguyên:
```
HEAL_FEATHER 0.85 HEAL_DEFAULT_R 0.012
SEARCH_DISTANCES [2.6, 4.2, 6.5] SEARCH_DIRS 8 RING_R 1.15 RING_PAD 1
RING_TAPS: 12 điểm vành, lấy MEDIAN INSIDE_TAPS: 16
LIGHT_GATE 20 LIGHT_WEIGHT 3 BLEND_TAPS 16
```
- `healUniforms` — `(2n + 1)` vec4 (mỗi spot 2 vec4: vị trí/bán kính + nguồn `sx,sy`).
- `healSkSL(count)` — `correction` theo góc, sample `pos + ps − pd`.
- `findHealSource(sample, x, y, r)` — 8 hướng × 3 khoảng × bản mirror, tính điểm lệch
sáng có trọng số (`LIGHT_WEIGHT 3`, `LIGHT_GATE 20`), từ chối (`null`) nếu không có
nguồn hợp lệ.
`shared/utils/mosaic.ts` (104) — `MOSAIC_CELL 0.02`, `MOSAIC_DEFAULT_R 0.05`;
`mosaicUniforms` `(n + 1)` vec4, vec4 cuối `[width, height, cell, 0]`,
`cell = max(1, MOSAIC_CELL * width)`; `mosaicSkSL(count)` lấy ô
`img.eval((floor(pos / cell) + 0.5) * cell)`, **rim cứng**.
`shared/utils/brush.ts` (26) — `BRUSH_MIN_R 0.003`, `BRUSH_MAX_R 0.25`,
`BRUSH_SPACING 0.6`, `wheelBrushR(r, deltaY)`.
Types web: `HealSpot { x, y, r, sx, sy }`, `MosaicSpot { x, y, r }`.
### 9.2 UI web (`App.tsx`)
- Chip `FIX` amber khi heal hoặc mosaic có spot; `fixChips` (`2715-2745`): `HEAL` /
`MOSAIC` kèm readout `%`, `CLEAR`, `brushTool`.
- Chọn spot HEAL để kéo và có nút `×` xoá; MOSAIC **không** kéo được.
- `spotUnder` bắt theo bán kính `max(4, r * box.width)`.
- Một cử chỉ vẽ = một bước undo.
### 9.3 Việc phải làm (Android)
1. `src/utils/brush.ts`, `heal.ts`, `mosaic.ts` — port **nguyên hằng số và công thức**,
gồm `findHealSource`.
2. Pixel sampler cho `findHealSource`: dùng đúng mẫu `horizon.ts:32-56` — render ảnh
(đã grade, **chưa** mask/heal) xuống surface cạnh dài ~512, `readPixels()` →
`Uint8Array` RGBA. Đây là "sample" đầu vào. **Không** dùng ảnh preview đã áp mask.
3. UI: chip `FIX` trong row FX + strip `HEAL` / `CLEAR` / `MOSAIC` / `CLEAR`.
Android **không có con lăn** ⇒ thêm một row slider `SIZE` (thay `wheelBrushR`), map
tuyến tính vào `BRUSH_MIN_R..BRUSH_MAX_R`.
4. HEAL: chạm/kéo tạo spot, chạm vào spot để chọn, kéo để dời, nút `×` để xoá.
MOSAIC: chạm/kéo tạo spot, không dời. Một cử chỉ = `commit(true, …)` một bước undo.
5. Shader theo count + cache (`healEffectFor(n)`, `mosaicEffectFor(n)`), snapshot → clear
→ vẽ lại. Thứ tự: **masks → heal → mosaic** (web `exportEngine.ts:708-737` (masks), `740-775` (heal), `776-800` (mosaic)).
6. Nếu `findHealSource` trả `null`: **giữ nguyên** pixel gốc (không bịa nguồn).
### 9.4 Nghiệm thu
- Xoá hạt bụi trên nền phẳng (trời, tường): vết sửa không lộ, seam viền ≤ ~2/255 so với
nền (đo dọc vành spot).
- Không có nguồn hợp lệ (hạt nằm trên biên tương phản cao) ⇒ ảnh **không đổi**.
- Mosaic rim **cứng** — kiểm pixel tại rìa: nhảy bậc, không gradient.
- 30 spot vẫn render (đo thời gian; cache effect hoạt động, không dựng lại shader mỗi frame).
- Export == preview.
---
## 10. H-FLIP / V-FLIP trong ROTATE
### 10.1 Nguồn web — `shared/utils/skiaImage.ts`
```
84 export function flipSkImage(image, horizontal, vertical)
canvas.scale(h ? -1 : 1, v ? -1 : 1);
canvas.translate(h ? -w : 0, v ? -h : 0);
canvas.drawImage(image, 0, 0);
114 export function applyPhotoRotation(image, quarter = 0, straighten = 0, flipH = false, flipV = false)
turn (rotateSkImage90 × turns) → straighten (rotateSkImageBy) → flip
```
Comment web: *"a flip is what the user sees, so it mirrors the photo as it stands,
whatever turn and angle are already on it"* ⇒ flip áp **sau** turn + straighten.
`rotateSkImage90` đổi chiều rộng/cao; flip **không** đổi dims.
### 10.2 Hiện trạng Android
`src/utils/skiaImage.ts:69-73` — `applyPhotoRotation(image, quarter = 0, straighten = 0)`,
không flip. Callers phải sửa:
- `src/utils/exportEngine.ts:241`
- `src/components/Viewfinder.tsx:461` (memo) và `:803` (`detectTilt(applyPhotoRotation(src.image, src.quarter, 0))`)
### 10.3 Việc phải làm
1. `src/utils/skiaImage.ts` — thêm `flipSkImage` + hai tham số `flipH`/`flipV` cho
`applyPhotoRotation`, copy nguyên web (gồm `dispose()` các bản trung gian).
2. State: `photoFlipH`/`photoFlipV` (boolean) cạnh `photoRotation`/`photoStraighten`
(`App.tsx:104-105`); thêm vào `Look` (`719-720`), `lookNow()` (`728`), `applyLook`
(`804-812`), "look đã đổi" (`699-700`), và `ExportOptions` (`exportEngine.ts:91-92`).
3. Truyền xuống: options export (`App.tsx:1753-1754`), `Viewfinder` (`App.tsx:1983-1984`
và `2100-2101`), `nativeExport`.
4. RESET của strip ROTATE phải reset **cả hai** flip (như đang reset turn + angle —
`AdjustmentPanel.tsx:727-729`).
5. UI: `groupDefs.rotate.options` (`AdjustmentPanel.tsx:718-726`) thêm
`{ v: 'fliph', d: 'H-FLIP' }` và `{ v: 'flipv', d: 'V-FLIP' }`; `onPick`
(`730-745`) toggle cờ. `rotateChip()` (`791-803`) thêm phần `H-FLIP`/`V-FLIP` vào
`parts` và vào `amberValue`.
**Lưu ý:** strip ROTATE hiện là radio (một `value`), `value` đang là
`openParam === 'straighten' ? 'straighten' : String(photoRotation)` (`716`). Thêm hai
toggle thì `value` phải ưu tiên `fliph`/`flipv` khi cờ bật — cùng cách `straighten`
đang chiếm `value`.
### 10.4 Nghiệm thu
- `H-FLIP` ↔ `V-FLIP`: mirror đúng trục (kiểm 4 pixel góc đối xứng).
- `H-FLIP + V-FLIP` **pixel-identical** với `180°` (diff = 0 trên ảnh không đối xứng).
- Dims **không** đổi sau flip (kể cả khi đang có turn 90 và straighten ≠ 0).
- Crop vẫn hợp lệ: `cropRect` áp sau rotate+flip, không tràn khung.
- Export == preview.
- Sống qua reload/session và qua undo (một lần bấm = một bước undo).
---
## 11. Kiểm thử chung và parity gate
1. **Parity gate ba đường** (theo `PLAN.md`): cùng ảnh + cùng recipe → export JS,
preview library (ảnh tĩnh), và Kotlin (nếu `EXPO_PUBLIC_NATIVE_EXPORT=1`) phải khớp
(đo diff pixel, ngưỡng đã dùng trước đây trong repo).
2. **Không regress:** một recipe cũ (chỉ matrix + tone + grain, không mask/heal/curve/flip)
phải render **y hệt** trước và sau khi port. Đây là test hồi quy bắt buộc chạy đầu tiên.
3. **Bảng số của doc 4** phải tái lập được (mục 3.4).
4. **Idempotence:** AUTO bấm hai lần ra cùng EV; ROTATE `AUTO` (straighten) bấm hai lần
ra cùng góc (đã có).
5. **Undo:** mỗi knob mới (whites/blacks/curve/mask/heal/mosaic/flip) đều phải undo được
một bước và redo khớp.
6. **Session:** mọi field mới sống qua reload (đi qua `Look`/session serialise).
7. **Perf:** đo fps preview library sau khi thêm pass; mask + heal + mosaic + curve cùng
lúc là trường hợp nặng nhất. Nếu tụt quá ngưỡng, dùng phương án rẻ của doc 4 §4.2
(nhân gain bên trong SKSL) — nhưng **không** phá thứ tự `matrix → exposure → tone`.
---
## 12. Checklist thứ tự làm và rủi ro
Thứ tự cố định — mỗi bước phải xanh test trước khi sang bước sau:
| Bước | Việc | Phụ thuộc | Rủi ro |
|------|------|-----------|--------|
| A | §3 tách EV + `EXPOSURE_SKSL` + **áp doc 4** (`gradeThrough`, bỏ ma trận lần hai ở HDF) | — | Vuốt: đây là thay đổi chạm mọi ảnh. Làm riêng, có test hồi quy (11.2). |
| B | §4 knee HIGHLIGHT (2 dòng) | A | Thấp; nhưng phải làm sau A mới thấy đúng. |
| C | §5 WHITE/BLACK (uniform + paramDefs) | B | Thấp. Chỉ thêm uniform, không đổi layout cũ. |
| D | §10 H-FLIP/V-FLIP | — | Thấp; chạm state/session nên phải kiểm reload + undo. |
| E | §6 AUTO | A | Trung bình; `readPixels` tốn — đo thời gian, `dispose()` đủ. |
| F | §7 TONE CURVE | A | Cao ở nhánh declarative (2 child shader). Fallback imperative. |
| G | §8 GRADIENT MASK | A | Trung bình; uniform `origin` là điểm dễ sai toạ độ. |
| H | §9 FIX (HEAL/MOSAIC) | G | Cao nhất: pixel sampler + `findHealSource` + brush UI mới. |
Rủi ro tổng:
- **Hermes không thu hồi buffer native** — mọi surface/snapshot/image mới đều phải
`dispose()` tường minh (xem comment đầu `exportEngine.ts:113-121`).
- **Declarative preview không chèn pass tuỳ ý** — mask/heal/mosaic/curve đều phải nằm
trong chuỗi shader của ảnh; nếu một pass không lồng được, chuyển nhánh preview tĩnh
sang imperative.
- **`RuntimeEffect` dựng lại mỗi frame** là bug hiệu năng kinh điển — cache theo count.
---
## 13. Những gì KHÔNG port
- **Per-mask invert / highlight-shadow weighting** — web hiện không có; không thêm.
- **`grainSize`** — web-only ở thời điểm này.
- **Chuột lăn đổi cỡ brush** (`wheelBrushR`) — Android dùng row slider SIZE.
- **Halation** — không có trong phạm vi 7 hạng mục.
- **`hslOn`/band HSL, `classic-vivid`, `mono-high-contrast`, `STOCK_BIAS.contrast`** —
**đã** port xong trên nhánh này (mục 0.1). Không làm lại, chỉ điều chỉnh
`STOCK_BIAS.exposure` của `leica-vivid` theo mục 3.3.
-264
View File
@@ -1,264 +0,0 @@
# 9 — RECIPE / QR: NHỮNG GÌ WEB VỪA SỬA VÀ APP ANDROID PHẢI KHỚP
Nhánh đích: `feat/vision-camera-v5` (base khi viết: **`f3d26e3`**). Ngày: 2026-09-29.
Nguồn đối chiếu: nhánh **`recipes-web`**, commit **`3b92e4e`** — bản web đã chạy ở
`http://127.0.0.1:8090`, bundle live `index-J8ISFv_z.js`.
Bốn commit web trong đợt này, tất cả ngày 2026-09-29:
```
34f8601 studio: the develop column becomes five panels, and the four tone knobs move knots instead of channels
c0aaa67 Studio: fold WB and FX into LIGHT, and put every develop slider on -100..+100
bd57dd7 EXPOSURE reads in stops: -5..+5, two decimals, on an unchanged store
3b92e4e A gradient mask can carry the LIGHT column's white balance now: COLOR TEMP and TINT
```
Tài liệu 8 (`8_EDITOR_TOOLS_PORT.md`) đối chiếu web ở `6814b05`. Tài liệu này ghi **phần
lệch phát sinh sau đó** và, quan trọng hơn cho đội Android, **cái gì trong file `.recipe`
(QR) đã đổi / chưa đổi**. Lệnh đọc nguồn web vẫn như doc 8:
```sh
git show origin/recipes-web:docker/frontend/shared/utils/gradientMask.ts
```
---
## 0. Bảng khoảng trống
| # | Web vừa đổi gì | Nguồn web | Ảnh hưởng `.recipe` | Android phải làm |
|---|----------------|-----------|---------------------|------------------|
| 1 | EXPOSURE đọc theo **stop** (−5..+5, 2 số lẻ) | `shared/utils/paramDefs.ts:53-66`, `shared/utils/colorUtils.ts:201` | **Store không đổi** (±10 unit, 1 unit = 0.25 EV) | Chỉ UI/đơn vị hiển thị. Recipe cũ đọc y nguyên |
| 2 | Mọi slider "develop" thành **−100..+100** | `shared/utils/paramDefs.ts:40-43,160,184` | **Store không đổi** (±10) — ×10 là của slider | Không bắt buộc. Không port thì recipe vẫn khớp |
| 3 | Rail **10 tab → 8 tab** (WB, FX gộp vào LIGHT) | `src/ui/ToolRail.tsx:15-27` | Không | Không (layout, không vào recipe) |
| 4 | Bốn knob tone thành **knot của một ramp** + knee giữ hue | `shared/utils/toneShader.ts:109,196-221,242-247` | Không có field mới, nhưng **look đổi** | Port theo doc 8 §4/§5 (xem §3.3) |
| 5 | Mask gradient mang **COLOR TEMP + TINT** | `shared/utils/gradientMask.ts:63-65,116-117,166-167,201,320`, `shared/types/index.ts:139-175,228` | **Có 2 field mới trong `adjustments.masks[]`** | Port mask (doc 8 §8) + 2 field + 1 array uniform `wb` |
Điểm đáng chú ý cho đội Android: **bốn trong năm thay đổi không đụng tới payload**. Chỉ mục 5
thêm field. Nhưng mục 1 + 4 làm **cùng một recipe cho ra look khác** giữa hai bên — xem §3.3.
---
## 1. Đơn vị: slider web vs store (và vs Android)
Web tách hẳn **đơn vị slider** khỏi **đơn vị store**: `paramDefs.ts` có `get`/`set` dịch hai
chiều, nên một recipe viết trước đợt này vẫn đọc ra đúng look.
```ts
// shared/utils/paramDefs.ts:40-43
const HUNDRED = new Set(['contrast', 'color', 'vibrance', 'highlight', 'shadow', 'whites',
'blacks', 'tint', 'denoise', 'clarity', 'dehaze', 'sharpening']);
const deepen = (def) => HUNDRED.has(def.key)
? { ...def, min: -100, max: 100, get: (a) => def.get(a) * 10, set: (v) => def.set(v / 10) }
: def;
```
```ts
// shared/utils/paramDefs.ts:53-66
{ key: 'exposure', min: -5, max: 5, step: 0.01, display: twoStops, // '+0.50' / '-0.25'
get: (a) => (a.exposure ?? 0) * EV_PER_UNIT,
set: (v) => ({ exposure: Math.round((v / EV_PER_UNIT) * 1e4) / 1e4 }) }
```
| Trường | Đơn vị store (web **và** Android) | Slider web (`3b92e4e`) | Slider Android (`src/utils/paramDefs.ts`) |
|--------|-----------------------------------|------------------------|-------------------------------------------|
| `adjustments.exposure` | ±10 unit, 1 unit = **0.25 EV** (`EV_PER_UNIT`, `colorUtils.ts:201`) | −5..+5 **stop**, 2 số lẻ | −10..+10, step 1, `display: sign` (`paramDefs.ts:32-41`) |
| `contrast` `highlight` `shadow` `whites` `blacks` `tint` `denoise` `clarity` `dehaze` `sharpening` `vibrance` `color` | ±10 | −100..+100 (`HUNDRED` + `deepen`) | ±10, step 1 |
| `temperature` | 2500..10000 K | 2500..10000, step 100, `'${v}K'` (`paramDefs.ts:163-172`) | 2500..10000 (đã khớp) |
| `masks[].exposure` | **chính là EV**, ±5 (`MASK_EXPOSURE_MAX`, `gradientMask.ts:45`) | −5..+5, step 0.1 | — (chưa có mask) |
| `masks[].temperature` | 2500..10000 K, **default 5500** (`NEUTRAL_K`, `gradientMask.ts:63`) | 2500..10000, step 100 | — |
| `masks[].tint` | ±10 (default 0) | −100..+100 (maskKnobRow ×10, `App.tsx:2474-2492`) | — |
**Hệ quả cho Android:** ba dòng đầu **không cần làm gì để recipe khớp** — store giữ nguyên
thang cũ. Chỉ nên đổi UI nếu muốn hai bên trông giống nhau (Android `EXPOSURE` ±10 unit =
±2.5 EV, web đọc thành −5..+5 stop; cùng một con số lưu, khác nhãn). Chú ý `EV_PER_UNIT`
**đã export** ở cả hai phía — dùng nó, đừng viết lại `0.25`.
Chú ý riêng: `masks[].exposure` **không** theo thang ±10 của `adjustments.exposure`. Nó là
EV trực tiếp vì shader đọc `pow(2.0, a.x)`. Đây là chỗ dễ sai nhất khi port.
---
## 2. Cái gì thực sự đi qua file `.recipe` / QR
Không đổi gì so với `7_SCAN_QR.md`: **QR chỉ chứa URL** `GET /api/photos/:id/preset.recipe`,
file trả về là XML bọc payload xor16-v1 (`docker/backend/src/recipeFile.ts`, bản sao của
`shared/utils/recipeShare.ts`; nguồn sự thật là bản web).
Hình dạng shareable — **web và Android giống hệt nhau, từng chữ**:
```ts
// recipeShare.ts:59-77 (web) == src/utils/recipeShare.ts:59-77 (Android)
const shareable = { name, baseFilter, adjustments, frameId, useGeotag };
```
`adjustments` được gửi **nguyên khối**. Nghĩa là:
1. **Field mới tự chảy qua.** Web ghi `adjustments.masks[].temperature/tint`; không cần bump
`version="1"`, không cần đổi `ALGORITHM`, không cần salt mới.
2. **Android hiện giữ được field lạ.** `importRecipeXml` làm
`{ ...DEFAULT_ADJUSTMENTS, ...raw.adjustments }` rồi **chỉ** sanitize `hslBands`
(`recipeShare.ts:97-102`). `masks` vào bộ nhớ, sống qua `Look`/session, và **xuất lại
nguyên vẹn** vì `exportRecipeXml` gửi lại chính `recipe.adjustments`.
⇒ Web → Android → Web **không mất mask**. Đây là hành vi phải **khoá bằng test**, không
phải tính năng tình cờ (xem §4.2).
3. **Nhưng Android không render mask** (`grep -n masks src/types/index.ts` → rỗng;
`ColorAdjustments` `src/types/index.ts:81-105` không có `masks`). Ảnh xuất từ Android vì
thế **thiếu toàn bộ mask** dù recipe có. Đó là việc của doc 8 §8, không phải lỗi format.
4. `RecipeCreateModal.handleSave` spread `...seedAdj`, nên mở một recipe import được rồi bấm
LƯU **không** làm rơi `masks`. Phải giữ đúng như vậy sau khi thêm field (đừng liệt kê tay
từng knob).
5. Web clamp khi **đọc** (`readMasks`, `gradientMask.ts:87-121`), không clamp khi ghi. Android
cũng nên clamp ở `readMasks` tương đương, không nhét clamp vào `importRecipeXml`.
---
## 3. Việc Android phải làm
### 3.1 Tách `whiteBalanceGain` (thuần refactor, kết quả không đổi)
Android **đã có** công thức WB frame-wide, viết inline trong `getSkiaColorMatrix`:
```
src/utils/colorUtils.ts:384-394
const rgbTemp = kelvinToRGB(temperature);
const tintMagenta = (tint / 10) * 0.08;
const wbGain = normalizeGainLuma([
rgbTemp.r * (1 + tintMagenta),
rgbTemp.g * (1 - tintMagenta),
rgbTemp.b * (1 + tintMagenta),
]);
```
Web đã rút đúng khối này ra thành một hàm export, **vì nó được hỏi hai lần** (frame-wide và
từng mask). Việc cần làm: chép y nguyên chữ ký web, cho `getSkiaColorMatrix` gọi lại nó.
```ts
// shared/utils/colorUtils.ts:130-137 (web) — chép sang src/utils/colorUtils.ts
export function whiteBalanceGain(temperature: number, tint: number): { r: number; g: number; b: number } {
const rgbTemp = kelvinToRGB(temperature); // kelvinToRGB đã có ở Android:117
const tintMagenta = (tint / 10) * 0.08;
return normalizeGainLuma([ // đã có ở Android:147
rgbTemp.r * (1 + tintMagenta),
rgbTemp.g * (1 - tintMagenta),
rgbTemp.b * (1 + tintMagenta),
]);
}
```
Gain tại 5500 K / tint 0 = `(1, 1, 1)` chính xác (`kelvinToRGB(5500) === kelvinToRGB(5500)`,
rồi `normalizeGainLuma`). `KELVIN_TAME = 0.5` nằm trong `kelvinToRGB`, không đụng.
**Nghiệm thu bắt buộc:** `getSkiaColorMatrix` trước và sau refactor phải ra **cùng mảng 20
số** với mọi cặp `(baseFilter, temperature, tint)` trong lưới thử (kể cả `wbRed/wbBlue`, kể
cả stock mono — nhánh `isMonochromeBase` phải bỏ qua WB **trước khi** gọi hàm).
### 3.2 Mask gradient (doc 8 §8) + hai field mới
Port theo doc 8 §8, thêm đúng ba điểm so với spec cũ:
1. `GradientMask` thêm `temperature?: number` (2500..10000) và `tint?: number` (±10) —
giống `shared/types/index.ts:174-175` (comment web ở `:170-173` giải thích: cùng đơn vị,
cùng gain, chỉ khác vùng đọc).
2. `readMasks` resolve default: `temperature = clampK(num(m.temperature, 5500))`,
`tint = clampA(num(m.tint, 0))` (`gradientMask.ts:116-117`) — mask lưu trước đó ⇒ gain 1.
3. Uniform: thêm **một mảng `float4` nữa**, `wb[count]`, **sau** `fx[count]`; buffer thành
`6*n + 1 (+1 nếu air)` float4, mảng `wb` ở index `5*n + i`, `size` (width/height) ở `6*n`,
`air` ở `6*n+1` (`gradientMask.ts:132-171, 314-322`).
Shader — một dòng, ngay sau EXPOSURE, trước CONTRAST:
```sksl
half3 maskAdjust(half3 c, half3 wb, float4 a, float4 tone, float4 fx, half dark /*, half3 blur, float3 air*/) {
c = c * half(pow(2.0, a.x)); // EXPOSURE, EV
c = clamp(c * wb, half3(0.0), half3(1.0)); // ← MỚI: WB của mask
c = (c - half(0.5)) * half(1.0 + a.y) + half(0.5);
...
```
`wb` là gain **hoisted lên JS**, không tính trong SkSL: fit Kelvin là một đường cong, viết lại
trong shader là bản sao thứ hai của nó. Call site: `maskAdjust(c.rgb, wb[i].rgb, adj[i], tone[i], fx[i], dark)`.
**Chiều đã đo trên web** (đừng đảo): 2500 K = **lạnh**, 10000 K = **ấm**. Trong mask, đặt
10000 K đo được R/B `1.250 → 1.724`; pixel ngoài mask `0.994 → 0.994` (không đổi). Đó là
bằng chứng mask-ảnh-hưởng-cục-bộ phải tái lập được.
### 3.3 Cảnh báo lệch: tone **trong mask** chưa bằng tone frame-wide
Đây là điểm dễ hiểu sai nhất khi đọc song song doc 8 và bản web mới nhất.
- Frame-wide (`shared/utils/toneShader.ts:242-247`, từ `c0aaa67`): knee giữ hue, không còn
`cg = clamp(o / max(t, 0.0004), 0.55, 1.35)`:
```sksl
float k = 1.0;
float hiC = max(max(rgb.r, rgb.g), rgb.b);
float loC = min(min(rgb.r, rgb.g), rgb.b);
if (hiC > t) k = min(k, (1.0 - o) / (hiC - t));
if (loC < t) k = min(k, o / (t - loC));
rgb = clamp(vec3(o) + (rgb - vec3(t)) * k, 0.0, 1.0);
```
và bốn knob tone là **knot của một ramp** (`TONE_ANCHOR = 0.25`; `blMask/shMask/whMask/hlMask`
tại `:196-199`; `a0..a4` tại `:214-221`).
- Trong mask (`shared/utils/gradientMask.ts:218-220`): **vẫn công thức cũ**
`half cg = clamp(lifted / max(l, 0.0004), 0.55, 1.35); c = clamp(lifted + (c - l) * cg, …)`,
và mask **không** có knot ramp. Comment ngay trên đó (`:207-213`) vẫn nói "TONE_SKSL's own
cg" — **comment đã cũ** sau `c0aaa67`.
⇒ Trên web hiện tại, HIGHLIGHT của một mask **không** phải cùng một phép move như HIGHLIGHT
toàn khung, đúng cái điều comment hứa. Đã hỏi chủ dự án có align không — chưa có quyết định.
Cho tới lúc đó:
- **Android port mask theo bản web đang chạy** (công thức `cg` cũ trong mask), nếu mục tiêu là
parity với web **hôm nay**.
- Nếu chủ dự án chốt align, việc cần làm là thay khối `:218-220` bằng công thức `k` giữ hue —
và khi đó **mask phải chạy trước** phép move tone nào khác của khung (`masks` đứng **sau**
tone frame-wide trong chuỗi pass, xem doc 8 §2.1). Ghi lại ở đây để cả hai bên cùng đổi một
lần, tránh web sửa trước rồi Android port theo bản cũ.
### 3.4 Port tối thiểu nếu chỉ muốn "giữ field, chưa render"
Nếu ưu tiên là **không mất dữ liệu khi chia sẻ QR** trước khi kịp port shader mask, chỉ cần:
```ts
// src/types/index.ts — đủ để TS không nuốt field khi ai đó truy cập
masks?: GradientMask[];
```
Ở runtime Android **đã** giữ field rồi (§2.2), nên thay đổi này là để code đọc/ghi tường minh,
không phải để cứu dữ liệu. **Không** thêm sanitize cho `masks` ở giai đoạn này — sanitize nửa
vời sẽ âm thầm cắt dữ liệu mà web vẫn gửi (ví dụ bỏ mask chưa có `temperature`).
---
## 4. Tiêu chí nghiệm thu
1. **Round-trip QR, không mất field.** Recipe web có mask + WB (ví dụ 8000 K / tint −3):
web xuất `.recipe` → `importRecipeXml` Android → `exportRecipeXml` Android → import lại
web. So `adjustments.masks` sau khi bỏ salt: phải **bằng byte** bản gốc, gồm cả
`temperature`, `tint`, `kind`, `angle`, `feather`, `exposure` (EV), và thứ tự các mask.
2. **Không có test nào phụ thuộc vào việc "field lạ bị bỏ".** Thêm một test khoá hành vi §2.2
(một field lạ bất kỳ trong `adjustments` phải sống qua import → export).
3. **Clamp đúng biên.** Mask gửi `temperature: 99999` ⇒ đọc ra 10000; `2000` ⇒ 2500; `tint: 40`
⇒ 10. Mask **không** có hai field ⇒ gain `(1,1,1)` và ảnh **không đổi một pixel** so với
trước khi port.
4. **Refactor WB trung tính.** `getSkiaColorMatrix` ra cùng 20 số trước/sau §3.1, trên lưới
`(baseFilter × temperature × tint)` gồm cả stock mono.
5. **WB của mask là cục bộ.** Đặt 10000 K: pixel **trong** mask đổi tỉ lệ R/B (ấm lên), pixel
**ngoài** mask **không đổi** (web đo `1.250 → 1.724` trong mask, `0.994 → 0.994` ngoài mask).
6. **Đơn vị EV của mask.** `masks[].exposure = +1` phải sáng đúng **1 stop** (không phải 1/4
stop như nếu lỡ dùng thang ±10 của `adjustments.exposure`).
7. **Parity ba đường** như doc 8 §11.1, và **look cũ không regress** (recipe chỉ có matrix +
tone + grain phải render y hệt trước/sau).
8. **Tone:** nếu chủ dự án chốt align tone trong mask (§3.3), phải có số đo hue trước/sau
tương đương bảng trong commit `c0aaa67` — HIGHLIGHT −100 Δhue `0.00°` trên mọi patch.
---
## 5. Những gì KHÔNG làm
- **Không đổi `version` / `ALGORITHM` / salt** của envelope `.recipe`. `test/security.mjs`
(backend 134/134) khoá hình dạng này.
- **Không nhét mask vào QR**: mã QR vẫn chỉ là URL (§2). Recipe nằm ở nội dung file tải về.
- **Không commit đi theo đợt này:** `preview`/`mask` UI của web chỉ đổi layout, không có gì
để port sang app.
- **Không port thang −100..+100** sang Android chỉ để "giống web" — đó là lựa chọn UI, store
hai bên đã khớp; đổi thì phải đổi cả `paramDefs`, cả chip readout, cả test ảnh.
+26 -227
View File
@@ -10,29 +10,23 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import TopBar from './src/components/TopBar';
import Viewfinder, { type ViewfinderHandle } from './src/components/Viewfinder';
import AdjustmentPanel, { HslPickPanel } from './src/components/AdjustmentPanel';
import AdjustmentPanel from './src/components/AdjustmentPanel';
import CameraControls from './src/components/CameraControls';
import ToolRail, { TabId } from './src/components/ToolRail';
import SettingsModal from './src/components/SettingsModal';
import PhotoViewerModal from './src/components/PhotoViewerModal';
import LibraryPickerModal from './src/components/LibraryPickerModal';
import QrScanModal from './src/components/QrScanModal';
import ProModal from './src/components/ProModal';
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, FlashMode, StartupMode, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT, HslBand, HslBandId } from './src/types';
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, FlashMode, StartupMode, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT } from './src/types';
import { getAllRecipes, saveCustomRecipe, updateCustomRecipe, deleteCustomRecipe, getFavoriteIds, setFavoriteId } from './src/utils/storageUtils';
import { DEFAULT_ADJUSTMENTS, FILM_SIMS, filmSimToRecipe } from './src/utils/defaultRecipes';
import { HSL_BANDS, nearestHslBand, rgbToHsl } from './src/utils/colorUtils';
import { getCurrentGPS, requestLocationPermissions, reverseGeocode, withDeadline } from './src/utils/locationUtils';
// Types are erased at build time, so this one is free.
import type { PickedPhotoAsset } from './src/utils/photoGps';
import { PARAM_DEFS } from './src/utils/paramDefs';
import { LITE_FAVORITE_LIMIT, LITE_MARK, LITE_RECIPE_LIMIT, proLookInUse } from './src/utils/entitlement';
import { initProUnlock, useProUnlock } from './src/utils/proUnlock';
import { readJpegIso } from './src/utils/exifWrite';
import { readPhotoBytes, devicePerf } from './src/utils/photoMeta';
import { BURST_FRAMES, BURST_MIN_ISO, mergeBurst } from './src/utils/burstMerge';
import { canBurst } from './src/utils/deviceFloor';
import UltraWide from './modules/recipescam-ultrawide';
import RecipescamExport from './modules/recipescam-export';
import VolumeShutter from './modules/recipescam-volume';
@@ -60,6 +54,13 @@ const CAMERA_SETTINGS_KEY = 'camera_settings';
// decides which engine ships.
const NATIVE_EXPORT = process.env.EXPO_PUBLIC_NATIVE_EXPORT === '1';
// B1: the shutter's CameraX capture mode. 'balanced' ships as the default —
// measured on the 12S Ultra at the same 3000x4000, a frame takes ~340 ms
// against ~540 ms for 'quality', with no resolution loss, because this device
// has ZSL (supportsSpeedQualityPrioritization) and 'balanced'/speed ride it.
// EXPO_PUBLIC_CAPTURE_MODE still overrides, but ONLY in dev bundles: Metro
// inlines EXPO_PUBLIC_*, the release HBC build does not (B0 finding) — never
// gate shipped behaviour on it.
// How long a shutter may wait for a GPS fix before exporting without it. The
// photo is never held hostage by the receiver: indoors a Balanced fix takes
// 20s+ (measured 25s on the V2430), which is the whole reason a capture used to
@@ -75,6 +76,10 @@ const loadRenderer = (native: boolean) =>
native
? import('./src/utils/nativeExport').then((m) => m.processAndExportPhotoNative)
: import('./src/utils/exportEngine').then((m) => m.processAndExportPhoto);
const CAPTURE_MODE =
process.env.EXPO_PUBLIC_CAPTURE_MODE === 'speed' || process.env.EXPO_PUBLIC_CAPTURE_MODE === 'quality'
? process.env.EXPO_PUBLIC_CAPTURE_MODE
: 'balanced';
export default function App() {
const [mode, setMode] = useState<'camera' | 'library'>('camera');
@@ -138,16 +143,6 @@ export default function App() {
// Custom temporary overrides for the sliders
const [adjustments, setAdjustments] = useState<ColorAdjustments>({ ...DEFAULT_ADJUSTMENTS });
// HSL mixer: the band ruler and the eyedropper are shared by the chip row
// (AdjustmentPanel) and by the pick layer over the picture (Viewfinder), so
// they live here.
const [hslBand, setHslBand] = useState<HslBandId>('red');
const [picking, setPicking] = useState(false);
const [hslSample, setHslSample] = useState<{ r: number; g: number; b: number } | null>(null);
// Where on the picture the sample was read: fractions of the image, the
// floating panel's anchor.
const [hslPickedAt, setHslPickedAt] = useState<{ fx: number; fy: number } | null>(null);
const [selectedFrame, setSelectedFrame] = useState<FrameId>('none');
// PRO only: the GPS stamp is a paid look, so a LITE session starts without
// it and `handleToggleGeotag` refuses to turn it on.
@@ -282,9 +277,6 @@ export default function App() {
const [viewerIndex, setViewerIndex] = useState<number | null>(null);
// In-app library picker (grid -> full-screen preview -> USE/CANCEL).
const [libraryPickerVisible, setLibraryPickerVisible] = useState(false);
// QR sheet: reads a recipe link off a screen, so the viewfinder camera behind
// it is paused while it is up (one lens, two camera libraries).
const [scanVisible, setScanVisible] = useState(false);
// Gear sheet (sound / ratio / metering mode). Camera captures never auto-open
// the viewer anymore — the thumbnail button does.
const [settingsVisible, setSettingsVisible] = useState(false);
@@ -335,17 +327,7 @@ export default function App() {
// Full-resolution photo output — attached to the live <Camera> in Viewfinder.
// Photo capture is resolution-independent from the Skia preview output.
const photoOutput = usePhotoOutput({
// LATENCY, measured: 'balanced' is CAPTURE_MODE_MINIMIZE_LATENCY (one
// frame, no CameraX still-image post-processing); 'quality' is
// CAPTURE_MODE_MAXIMIZE_QUALITY, which adds in-camera multi-frame noise
// reduction plus reprocessing. Same scene at ISO ~410 on the 12S Ultra,
// 3 shots per arm: in-camera START->onImageCaptured 291 ms vs 499 ms,
// tap->file on disk 797 ms vs 1025 ms (-22%), and the two JPEG sets were
// indistinguishable (flat-area sigma 1.08 vs 1.09, edge energy -3%).
// Dark scenes are handled by this app's own burst + merge below (ISO >=
// 800), so CameraX MFNR would only ever be a second, redundant pass.
// Revert to 'quality' if a high-ISO A/B ever shows merge losing to MFNR.
qualityPrioritization: 'balanced',
qualityPrioritization: CAPTURE_MODE,
});
// Second photo output for RAW DNG sidecars (RATIO panel → RAW on). Kept as
// a live output only while rawEnabled — CameraX cannot bind two ImageCapture
@@ -778,29 +760,6 @@ export default function App() {
lastStepRef.current = 0;
setUndoDepth(0);
};
// FX's MONOCHROME switch: not a look of its own — it swaps the base filter for
// the mono stock and swaps right back. Adjustments are never touched, so a knob
// turned while it is on survives the round trip.
const monoOn = selectedRecipe?.baseFilter === 'monochrome';
const monoPrevRef = useRef<Recipe | null>(null);
const handleToggleMono = () => {
commit(true, () => {
if (selectedRecipe?.baseFilter === 'monochrome') {
const back = monoPrevRef.current ?? filmSimToRecipe(FILM_SIMS.find((f) => f.id === 'sim-provia')!);
monoPrevRef.current = null;
setSelectedRecipe(back);
} else {
monoPrevRef.current = selectedRecipe;
setSelectedRecipe({
...(selectedRecipe ?? filmSimToRecipe(FILM_SIMS[0])),
id: 'mono',
name: 'MONOCHROME',
baseFilter: 'monochrome',
});
}
});
};
const applyLook = (l: Look) => {
setSelectedRecipe(l.recipe);
setAdjustments(l.adjustments);
@@ -890,40 +849,6 @@ export default function App() {
});
};
// One band triple, as stored. A band back at [0,0,0] is DELETED, and the
// field goes back to `undefined` (not {}) when the last one leaves: the
// shallow differs() behind resetDirty then reads a clean mixer as clean.
const setBandKnob = (which: 0 | 1 | 2, v: number) => {
const band = adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
const next: HslBand = [band[0], band[1], band[2]];
next[which] = Math.round(v);
const bands = { ...adjustments.hslBands };
if (next[0] === 0 && next[1] === 0 && next[2] === 0) delete bands[hslBand];
else bands[hslBand] = next;
handleUpdateAdjustments({ hslBands: Object.keys(bands).length ? bands : undefined });
};
// The mixer's three whole-image knobs: one door for all three, so the ruler
// and the field it writes are the same value wherever they were opened.
const setHslGlobal = (which: 0 | 1 | 2, v: number) => {
const key = which === 0 ? 'hslHue' : which === 1 ? 'hslSat' : 'hslLum';
const clamped = Math.max(-10, Math.min(10, Math.round(v)));
handleUpdateAdjustments({ [key]: clamped } as Partial<ColorAdjustments>);
};
// The eyedropper's report: print the colour it read, point the ruler at that
// colour's band, hang the panel on the spot it came from, and put the tool
// away — one pick, one colour.
const pickColor = (
rgb: { r: number; g: number; b: number },
at: { fx: number; fy: number }
) => {
setHslSample(rgb);
setHslBand(nearestHslBand(rgbToHsl(rgb.r, rgb.g, rgb.b).h));
setHslPickedAt(at);
setPicking(false);
};
const handleUpdateFrame = (frameId: FrameId) => commit(false, () => applyFrame(frameId));
const applyFrame = (frameId: FrameId) => {
setSelectedFrame(frameId);
@@ -1103,18 +1028,6 @@ export default function App() {
}
};
// PRESETS -> IMPORT / SCAN QR: a `.recipe` file that arrived from elsewhere —
// picked off storage or fetched from a scanned link — becomes a normal custom
// recipe, and the look on screen switches to it.
const storeImportedRecipe = async (xml: string) => {
const { importRecipeXml } = await import('./src/utils/recipeShare');
const created = await saveCustomRecipe(importRecipeXml(xml));
setRecipes(await getAllRecipes());
applyRecipe(created);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Alert.alert('Recipe Imported', `"${created.name}" has been added.`);
};
// PRESETS -> IMPORT: a recipe file shared from another phone, picked from
// storage, decoded and stored as a normal custom recipe.
const handleImportRecipe = async () => {
@@ -1125,30 +1038,13 @@ export default function App() {
try {
const picked = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true });
if (picked.canceled) return;
await storeImportedRecipe(await FileSystem.readAsStringAsync(picked.assets[0].uri));
} catch (e) {
Alert.alert('Import failed', e instanceof Error ? e.message : String(e));
}
};
// PRESETS -> SCAN QR: the landing page's recipe card carries a link to a
// photo's own `.recipe` file, so a scan is the same import with the file
// fetched instead of picked. The code holds a URL, never the recipe, so
// anything that is not the preset route — a shop link, a wifi code — is
// refused with the reason. The sheet closes either way; a failure is a
// dialog, not a scanner left running.
const handleScanRecipe = async (value: string) => {
setScanVisible(false);
if (liteSaveBlocked) {
promptRecipeLimit();
return;
}
try {
const link = /^https?:\/\/\S+\/api\/photos\/\d+\/preset\.recipe$/.exec(value.trim());
if (!link) throw new Error('That code is not a RecipesCam recipe link.');
const res = await fetch(link[0]);
if (!res.ok) throw new Error(`The recipe link answered ${res.status}.`);
await storeImportedRecipe(await res.text());
const xml = await FileSystem.readAsStringAsync(picked.assets[0].uri);
const { importRecipeXml } = await import('./src/utils/recipeShare');
const created = await saveCustomRecipe(importRecipeXml(xml));
setRecipes(await getAllRecipes());
applyRecipe(created);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Alert.alert('Recipe Imported', `"${created.name}" has been added.`);
} catch (e) {
Alert.alert('Import failed', e instanceof Error ? e.message : String(e));
}
@@ -1498,56 +1394,6 @@ export default function App() {
)
).filePath;
}
// Low light, hand-held: one frame is mostly sensor noise, and no OEM stack is
// reachable from here (the extension probe found the vendor pipelines closed to a
// third-party client). So take the frames and average them ourselves. Four ways
// out, checked before an extra exposure is spent: the LED is the light source
// (a burst would ride it while it recharges), the ultra-wide still came from the
// native Camera2 session, a held burst is the user asking for ONE frame, and RAW
// needs the second ImageCapture output CameraX will not bind alongside this one.
// canBurst: on a 1 GB floor device (deviceFloor.ts) three raws plus the
// alignment rasters are seconds of shutter lag, so the plain frame stands.
// Set when the low-light burst below starts its merge. The merge is not
// awaited on the shutter path: the export task awaits it instead, so the
// next shot is accepted while the frames are still being averaged.
let burstMerge: Promise<string | null> | null = null;
if (!ultraWideShot && !opts?.burst && !rawEnabled && !(flashAvailable && flashMode !== 'off') && canBurst(devicePerf().ramMb)) {
try {
// ISO is the only signal this app can read for ambient light: the controller
// reports 0.0 for every still, so it comes off the file the shutter just
// wrote. No tag, no burst.
const jpeg = await readPhotoBytes(toUri(filePath));
const iso = jpeg ? readJpegIso(jpeg) : null;
if (iso !== null && iso >= BURST_MIN_ISO) {
const frames = [filePath];
for (let i = 1; i < BURST_FRAMES; i++) {
const extra = await photoOutput.capturePhotoToFile(
// No second shutter sound and no flash: three clicks for one photo
// reads as a malfunction.
{ enableShutterSound: false, flashMode: 'off' },
{},
);
frames.push(extra.filePath);
}
// Averaging three 12 MP frames costs ~1.4 s measured on the 12S Ultra
// (three decodes, two alignment stages, one draw pass, one JPEG
// encode) and all three frames are already in hand, so the merge
// rides the FIFO export queue below instead of the shutter path:
// the next shot is accepted as soon as the exposures land, and only
// the export of this photo waits on the average. A refused merge
// (null: drift too large, a frame unreadable) keeps the shutter frame.
// ponytail: a merge in the queue and a following capture can each
// hold a 12 MP frame (~48 MB); the FIFO serializes the merge against
// the render, not against the camera. Re-serialize if a low-RAM body
// starts swapping.
burstMerge = mergeBurst(frames);
}
} catch (burstError) {
// A failed burst costs two wasted exposures, never the photo.
console.warn('Low-light burst skipped:', burstError);
}
}
const sourceUri = toUri(filePath);
// RAW DNG sidecar: fire the second exposure immediately after the JPEG
@@ -1608,14 +1454,12 @@ export default function App() {
// shutter instant as the EXIF capture date.
exported: false,
capturedAt,
// 300dpi header. No evFromCamera: the camera AE bias is never written,
// so the export applies the same software EV gain the preview shows.
// No forced output sharpen either: the 0.5 unsharp mask multiplied
// sensor noise ~3.2x (5x at Nyquist), so dim/high-ISO captures came
// back far noisier than the preview. The SHARPENING knob still
// sharpens when the user dials it above 0.
// 300dpi header + full-res screen sharpen. No evFromCamera: the camera
// AE bias is never written, so the export applies the same software EV
// gain the preview shows.
// aspect: crop to the on-screen composition ratio (full = keep 4:3 native).
dpi: 300,
sharpen: true,
aspect: aspectRatio === 'full' ? undefined : aspectRatio,
watermark: wmForExport(),
liteMark,
@@ -1627,10 +1471,7 @@ export default function App() {
frameWindowZoom: frameZoom,
};
const paint = await loadRenderer(nativeExport);
// The burst average lands here, in the queue: the export waits for it,
// the shutter did not. sourceUri is the frame the shutter already wrote.
const source = burstMerge ? toUri((await burstMerge) ?? filePath) : sourceUri;
return paint(source, recipeArgs, selectedFrame, useGeotag, captureGps, options);
return paint(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options);
};
enqueueExport(async () => {
try {
@@ -1938,10 +1779,6 @@ export default function App() {
const handleToggleTab = (tab: TabId) => {
setOpenTab((prev) => (prev === tab ? null : tab));
setOpenParamKey(null);
// The eyedropper belongs to the HSL row: leave the tab (or close the panel)
// and the layer goes with it, so an invisible pick layer cannot sit over
// the picture swallowing pan and zoom.
setPicking(false);
};
// While a panel parameter row is open, a vertical drag anywhere on the live
@@ -1984,9 +1821,6 @@ export default function App() {
photoStraighten={shown.photoStraighten}
cameraPermissionGranted={cameraPermissionGranted}
onRequestCameraPermission={requestCameraPermission}
// One lens, two camera libraries: while the QR sheet is up its own
// scanner owns the camera and this session has to let go.
paused={scanVisible}
photoOutput={photoOutput}
rawOutput={rawPhotoOutput}
rawEnabled={rawEnabled}
@@ -2021,22 +1855,6 @@ export default function App() {
setOpenParamKey(null);
setWmEditNonce((n) => n + 1);
}}
// HSL eyedropper: the viewer owns the pick layer and hangs the panel
// it is handed; the state behind both is the HSL row's, above.
picking={picking}
hslPanelAt={hslPickedAt}
onPickColor={pickColor}
hslPanel={
openTab === 'hsl' && hslSample && hslPickedAt ? (
<HslPickPanel
sample={hslSample}
bandLabel={HSL_BANDS.find((b) => b.id === hslBand)?.label ?? ''}
band={shownAdjustments.hslBands?.[hslBand] ?? [0, 0, 0]}
onKnob={setBandKnob}
onClose={() => setHslPickedAt(null)}
/>
) : undefined
}
/>
</View>
@@ -2109,8 +1927,6 @@ export default function App() {
openParam={openParamKey}
onOpenParamChange={setOpenParamKey}
currentBaseFilter={selectedRecipe?.baseFilter}
monoOn={monoOn}
onToggleMono={handleToggleMono}
onCreateRecipe={handleCreateRecipe}
editingRecipe={editingRecipe}
onCloseEdit={() => setEditingRecipe(null)}
@@ -2123,7 +1939,6 @@ export default function App() {
onToggleGeotag={handleToggleGeotag}
onSaveRecipe={handleSaveRecipe}
onImportRecipe={handleImportRecipe}
onScanRecipe={() => setScanVisible(true)}
recipeLimitReached={liteSaveBlocked}
onRecipeLimit={promptRecipeLimit}
onDeleteRecipe={handleDeleteRecipe}
@@ -2137,14 +1952,6 @@ export default function App() {
wmRotation={shown.wmRotation}
onRotateWm={() => commit(false, () => setWmRotation((r) => (r + 90) % 360))}
onWmPanelChange={setWmPanelOpen}
hslBand={hslBand}
onHslBand={setHslBand}
picking={picking}
onPicking={setPicking}
hslSample={hslSample}
onSetHslGlobal={setHslGlobal}
// A live preview has no pixel to read, so PICK is library-only.
canPick={mode === 'library'}
/>
</View>
)}
@@ -2158,14 +1965,6 @@ export default function App() {
onSelect={handleLibraryPicked}
/>
{/* QR sheet: reads a recipe link off another screen (the landing page's
card) and imports the file it points at. */}
<QrScanModal
visible={scanVisible}
onCancel={() => setScanVisible(false)}
onScanned={handleScanRecipe}
/>
<CameraControls
lastPhotoUri={lastPhotoUri}
onCapture={handleCapture}
+258
View File
@@ -0,0 +1,258 @@
# Camera trực tiếp: chỉ port những gì web có mà Android chưa có
Phạm vi tài liệu: **chỉ liệt kê delta web-only**. Mọi thứ Android đã có thì ghi nhận một dòng rồi bỏ qua,
không mô tả cách làm lại.
- Web (tham chiếu): `docker/frontend/` — Vite/React/TS, CanvasKit WASM. Camera thêm ở `cb1839e` +
`6814b05`.
- Android (đích): `App.tsx` + `src/` ở gốc repo — Expo/RN, `react-native-vision-camera-skia` (worklet +
GPU texture).
Quy ước trích dẫn: cột **Web** trỏ vào file trong `docker/frontend/`; cột **Android** trỏ vào `App.tsx` /
`src/` ở gốc repo.
**Tóm tắt:** web-only đáng port chỉ có **3** việc — P1 (quan trắc FPS/độ phân giải), P2 (gate PRO ở lối
vào camera, *cần quyết định*), P3 (luồng "chụp rồi chỉnh"). Web-only còn lại hoặc không port được, hoặc
Android đã có bản tương đương. §6 ghi thêm phương án thay thế: đóng gói chính web studio thành app mobile.
---
## 1. Đối chiếu tính năng — cái nào thực sự web-only
| | **Web** | **Android** | Delta |
|---|---|---|---|
| Lối vào | Nút `OPEN CAMERA` + badge `PRO` (`ui/ImageStage.tsx:1443-1444`, `:2002-2006`) | Chế độ `mode === 'camera'` (`App.tsx:1675`, `:1978`) | khác chỗ đặt, không phải thiếu |
| Gate | **Khoá cả camera** sau PRO: `cameraSupported() ? () => (pro ? setShooting(true) : promptPro()) : undefined` (`App.tsx:3237-3241`), `pro = !!user?.verified` (`:522`) | Gate ở **lúc render look**: `proLookInUse` (`App.tsx:1269`), `promptPro` (`:1275-1285`). `CameraControls.tsx` không có `pro` nào | **WEB-ONLY → P2** |
| Số đo trên viewfinder | `{w}×{h} · {fps} fps` (`ui/CameraView.tsx:214-218`, `i18n/en.ts:40`) | Không hiển thị gì (nhịp tim `frameBeatSync` chỉ phục vụ watchdog) | **WEB-ONLY → P1** |
| Ảnh chụp đi đâu | Still **full cảm biến → studio**; look do studio áp, file ghi sau bằng export chung (`docker/frontend/src/App.tsx:902-918`) | Render look **ngay** → file + gallery + `pushPhoto`; **không mở editor** (`App.tsx:1476-1489`) | **WEB-ONLY → P3** |
| Xem trước khi chỉnh | Cùng `renderPhoto`, khác đích ra (`drawTo` so với `encodeToBytes`) | Cùng SkSL, chạy trong worklet | tương đương |
| Lens | Chỉ `facingMode user/environment` (`engine/camera.ts:12-14`) | Flip, zoom 1–10x, ultra-wide 0.5x qua module Camera2 ẩn (`Viewfinder.tsx:599-713`), RAW DNG (`App.tsx:337-340`) | Android hơn |
| Panel khi đang ngắm | Rail + cột PRESETS vẫn cạnh viewfinder (`.stage-slot`, `styles/app.css:723-750`) | Overlay dp + panel ngay dưới khung (`Viewfinder.tsx:2774-2819`) | tương đương |
| Chuỗi UI | i18n EN/VI, 9 khoá `cam.*` (`i18n/en.ts:36-44`, `i18n/vi.ts:45-53`) | Hardcode tiếng Anh (app không có hạ tầng i18n) | khác phạm vi, xem P6 |
| Chống treo phiên | không cần (rAF tự dừng/tiếp) | Watchdog remount sau ~6 s không có khung (`Viewfinder.tsx:1846-1900`) | Android hơn |
---
## 2. Delta kỹ thuật theo lớp
| Lớp | Web | Android | Kết luận |
|---|---|---|---|
| Nhận biết khả năng | `navigator.mediaDevices?.getUserMedia` + yêu cầu **secure context** (`engine/camera.ts:27-32`) | `useCameraPermission()` (`App.tsx:322`), manifest `CAMERA` | web-only, **không port** (không có khái niệm tương ứng) |
| Phân loại lỗi | `err.name === 'NotAllowedError'` → `cam.denied`, còn lại `cam.fail` (`CameraView.tsx:176-181`) | 3 alert riêng: `Permission Needed` / `Camera Not Ready` / `Capture Failed` (`App.tsx:1338`, `:1348`, `:1499`) | ngữ nghĩa đã tương đương |
| Mở phiên | `getUserMedia` → `<video>` **phải nằm trong DOM** (Safari vẽ trắng nếu tách) → `.camera-source-host` 1×1 px (`CameraView.tsx:137-141`, `app.css:764-777`) | `isActive={appActive && !ultraWideOn}` (`Viewfinder.tsx:2876`), `onStarted/onStopped` (`:544-554`) | web-only, **không port** |
| Đóng phiên | Stop mọi track + `srcObject=null` + `remove()` (`engine/camera.ts:62-67`) | Unmount (đổi `mode`/`key`) + `frame.dispose()` (`Viewfinder.tsx:1489`) | tương đương |
| Độ phân giải preview | `ideal 1280×720`, **không bao giờ `exact`**; lý do **nhiệt** (`engine/camera.ts:3-7`, `:18-20`) | Chọn 4:3 gần 1280×960 từ `getSupportedResolutions('stream')`; lý do **hình học** (`Viewfinder.tsx:400-420`) | Android pin tỉ lệ tốt hơn; phần *lý do nhiệt* là web-only → **P5 (hoãn)** |
| Đổi giữa phiên | Buffer chỉ resize khi **aspect** đổi > 0.01; cùng aspect thì scale qua scratch canvas (`CameraView.tsx:67-117`) | Chỉ có bản hẹp cho handoff 0.5x (`uwCover`+`holdCover`, `:628-713`); remount thì màn trống tới khung đầu | **web-only, một phần → P4** |
| Render preview | vòng `requestAnimationFrame` → `MakeImageFromCanvasImageSource(video)` → `renderPhoto({sourceImage, drawTo})` → `readPixels` → `putImageData` (`CameraView.tsx:143-173`, `:76-118`) | worklet đồng bộ vẽ thẳng lên GPU texture: color matrix + CLARITY + `toneEffect`→`cinemaEffect`→`glowEffect` qua `canvas.drawRect` (`Viewfinder.tsx:1177-1371`) | khác cơ chế vì khác nền tảng; **không port** |
| Slider → khung kế | `liveRef` gán lại mỗi render, vòng lặp đọc lại mỗi khung (`CameraView.tsx:60-62`, `:158`) | `createSynchronizable` + `getDirty()` trong worklet (`Viewfinder.tsx:479-528`, `:1149-1170`) | tương đương |
| Nhịp khung | `busyRef`: một render tại một thời điểm, khung tới khi bận thì **bỏ, không xếp hàng** (`CameraView.tsx:12-15`, `:152-172`) | Worklet đồng bộ, không có hàng đợi để phình | **không port** |
| Quan trắc nhịp | Đếm khung trong cửa sổ 500 ms rồi in (`FPS_WINDOW_MS`, `CameraView.tsx:19`, `:143-151`) | Không có số nào | **WEB-ONLY → P1** |
| Màn trập | 3 mức: `takePhoto` → `grabFrame` → copy 2D (`engine/camera.ts:69-96`) | `photoOutput.capturePhotoToFile` (+ `UltraWide.captureAsync`, RAW, burst) (`App.tsx:1381-1410`) | Android hơn; **không port** |
| Đường ảnh ra | Still → editor, file ghi lúc export (`docker/frontend/src/App.tsx:902-918`) | Render ngay → file + gallery + lịch sử (`App.tsx:1476-1489`) | **WEB-ONLY → P3** |
| Parity look | Cùng `renderPhoto`; chỉ khác đích ra (`exportEngine.ts:1068-1073` vs `:1075`) | Cùng `colorUtils` + SkSL (`Viewfinder.tsx:34-36,471-474` ↔ `exportEngine.ts:7-10,327-329`) | đã parity |
| Gate PRO | Ở lối vào, badge trên nút (`App.tsx:3237-3241`, `ImageStage.tsx:1443-1444`) | Ở lúc export theo look + `LITE_MARK` (`App.tsx:1269-1295`, `:1321-1322`) | **WEB-ONLY → P2** |
| Lỗi biên | `cam.unsupported` / `cam.denied` / `cam.fail` / `cam.shotFail` (`CameraView.tsx:121-124,176-181,199-202`) | Gate quyền + không device + lỗi phiên + lỗi chụp, mỗi thứ một alert riêng (`Viewfinder.tsx:2838-2863`, `App.tsx:950-961`, `:1499`) | tương đương |
---
## 3. Web-only, ĐÁNG PORT
### P1 — Dòng số `W×H · N fps` trên viewfinder *(rẻ, không đổi hành vi)*
**Mục tiêu.** Web in độ phân giải khung đang chạy và nhịp thật của pipeline (`CameraView.tsx:214-218`).
Android có đủ dữ liệu nhưng vứt đi: `frameBeatSync` được worklet tăng mỗi khung, JS đọc bằng `getDirty()`,
chỉ dùng cho watchdog (`Viewfinder.tsx:1180`, `:1866-1877`).
**Kỹ thuật.** Đừng đo trên JS thread (khung do worklet nhận — đếm ở JS là vô nghĩa). Tái dùng đúng nhịp
tim; độ phân giải lấy `frameTarget` (`:400-420`).
```tsx
// Số của thiết bị, không phải control — chỉ cần đủ mới để đọc (như FPS_WINDOW_MS của web).
const FPS_WINDOW_MS = 500;
const [fps, setFps] = useState(0);
useEffect(() => {
if (mode !== 'camera') return;
let last = frameBeatSync.getDirty();
const id = setInterval(() => {
const beat = frameBeatSync.getDirty();
setFps(Math.round(((beat - last) * 1000) / FPS_WINDOW_MS));
last = beat;
}, FPS_WINDOW_MS);
return () => clearInterval(id);
}, [mode, frameBeatSync]);
```
**Cạm bẫy.** `frameBeatSync` đếm khung **đã giao cho worklet**, không phải FPS cảm biến — đúng thứ cần
đo, nhưng đừng ghi nhãn "FPS camera".
**Nghiệm thu.** Mở camera: số khớp `frameTarget` (mặc định 4:3 ≈ 1280×960); che tối để máy tự hạ nhịp —
số phải tụt; chụp/burst/0.5x không đổi.
---
### P2 — Badge/gate PRO ở lối vào camera *(cần quyết định sản phẩm — mặc định KHÔNG làm)*
**Sự thật.** Web khoá **cả camera** sau PRO (`App.tsx:3237-3241`). Android để camera mở cho mọi người,
chỉ chặn ở lúc render look (`App.tsx:1269`) và đóng dấu `RECIPESCAM` lên bản LITE.
**Khuyến nghị.** **Không port gate cứng**: khoá camera là biến app thành app không chụp được. Web làm
được vì web là bề mặt dẫn khách sang tài khoản; Android là sản phẩm chính.
Nếu vẫn muốn, đây là wiring đúng chỗ — dùng lại `promptProFeature` sẵn có, không thêm cơ chế:
```tsx
// App.tsx, cạnh handleSetMode (1675) — chặn ở lối vào, không chặn trong viewfinder.
const openCameraMode = () => {
if (!pro) { promptProFeature('The live camera'); return; } // :1288-1295
handleSetMode('camera');
};
// …và truyền openCameraMode vào chỗ đang gọi handleSetMode('camera') (App.tsx:1978).
```
Badge: `CameraControls.tsx` chưa import entitlement; thêm tag góc theo đúng `renderChip`
(`AdjustmentPanel.tsx:348-371`, `proTag = c.pro && !pro`).
**Nghiệm thu.** LITE (`src/provariant.ts:3`, `node tools/set-variant.mjs`) → bấm CAMERA → `PRO FEATURE` +
`ProModal`, `mode` không đổi. PRO → mở như cũ.
---
### P3 — "Chụp rồi chỉnh": đưa ảnh chụp vào editor với look đang áp *(đáng làm nếu muốn luồng web)*
**Mục tiêu.** Web: màn trập → still full cảm biến vào studio, look do studio áp, file ghi sau
(`docker/frontend/src/App.tsx:902-918`, `CameraView.tsx:37-39`). Android: render ngay, không mở editor
(`App.tsx:1476-1489`).
**Kỹ thuật (quan trọng).** Đừng đưa ảnh **đã render** vào editor — look bị áp hai lần (lúc chụp và lúc
export) và `LITE_MARK` in hai lần. Đường đúng: chụp lấy **file thô** từ `photoOutput.capturePhotoToFile`
(`App.tsx:1384-1394`), rồi vào editor bằng chính hàm mà ảnh thư viện đi (`handleLibraryPicked`,
`App.tsx:1186`) — **bỏ `resetEditsToRecipe()`** (`:1198`) để `adjustments`/`selectedFrame` giữ nguyên look:
```tsx
// trong handleCapture, nhánh mode === 'camera', thay cho enqueueExport(...)
const shot = await photoOutput.capturePhotoToFile({ /* như :1384-1394 */ }, {});
if (shotToEditor) { // cờ mới
await handleLibraryPicked({ uri: toUri(shot.filePath), assetId: null });
return; // KHÔNG render, KHÔNG ghi gallery ở đây
}
enqueueExport(...); // đường cũ giữ nguyên
```
**Hệ quả phải chấp nhận.** Một ảnh chụp đi **một** trong hai đường: (a) render ngay → file + gallery +
lịch sử (hiện tại), hoặc (b) vào editor, file chỉ ra đời ở export. Không thể vừa render ngay vừa edit mà
không tốn gấp đôi công render. `pushPhoto` cũng sẽ không có entry ngay sau khi chụp.
**Nghiệm thu.** Chụp → sang `mode === 'library'` với ảnh **thô**; slider/HDF/frame giữ nguyên giá trị
trước khi chụp; gallery **không** có `RC_*.jpg` mới; export ra đúng **một** `LITE_MARK`.
---
### P4 — Che khung khi phiên khởi động lại *(web có, Android chỉ có một phần)*
Web giữ khung cuối khi độ phân giải đổi giữa phiên — "mờ chứ không đen" (`CameraView.tsx:67-117`).
Android đã làm điều này **cho handoff 0.5x** (`uwCover` + `holdCover` + đợi 3 nhịp khung,
`Viewfinder.tsx:628-713`) nhưng không che khi `isActive` bật lại hay watchdog remount (`:1856-1875`) —
màn trống tới khung đầu. Cách làm: tổng quát hoá `holdCover` thành "che tới khi nhịp tim tiến", gọi trong
`remountCameraSession` và ở nhánh `setAppActive(true)`, trần 1500 ms.
### P5 — Chọn độ phân giải preview theo nhiệt *(web-only, HOÃN)*
Web có lý do nhiệt rõ cho 720p (`engine/camera.ts:3-7`); Android không có xử lý nhiệt nào. Muốn làm đúng
phải thêm module Kotlin đọc `PowerManager.getCurrentThermalStatus()` rồi hạ `frameTarget` một bậc — tức
module native mới + đường tính lại + test máy nóng thật. YAGNI: chỉ làm khi có phản hồi thật về máy nóng.
---
## 4. Web-only nhưng KHÔNG port
| Thứ web-only | Vì sao bỏ |
|---|---|
| `cameraSupported()` + yêu cầu secure context (`engine/camera.ts:27-32`) | Trình duyệt mới có khái niệm origin an toàn |
| `<video>` phải nằm trong DOM + `.camera-source-host` (`CameraView.tsx:137-141`) | Mẹo riêng của Safari |
| Chuỗi dự phòng `ImageCapture` → `grabFrame` → copy 2D (`engine/camera.ts:69-96`) | Android luôn có `photoOutput` full-res |
| Cổng `busyRef` "bỏ chứ không xếp hàng" (`CameraView.tsx:12-15`) | Worklet chạy đồng bộ, không có hàng đợi để phình |
| Scratch canvas để scale khung lệch cỡ (`CameraView.tsx:109-117`) | Chỉ có nghĩa khi phải giữ buffer cố định; ở đây vẽ thẳng lên texture |
| Đặt tên `camera-<timestamp>.jpg` (`engine/camera.ts:114-120`) | Ảnh web không EXIF/tên gốc; Android đã có `RC_<yyyymmdd_hhmmss>.jpg` (`nativeExport.ts:113`) |
| i18n `cam.*` (9 khoá, `i18n/en.ts:36-44`) | App Android chưa có hạ tầng i18n (0 tham chiếu) — việc của cả app, không phải camera |
| `savePhoto` để ảnh chụp sống qua reload (`docker/frontend/src/App.tsx:910-911`) | Android có gallery + `pushPhoto` là bền hơn |
---
## 5. Android đã có — không port lại
| Lớp | Bằng chứng |
|---|---|
| Phiên + quyền + gate thiếu quyền | `useCameraPermission` (`App.tsx:322`), `Viewfinder.tsx:2838-2863` |
| Chọn lens, flip, zoom 1–10x, ultra-wide 0.5x | `Viewfinder.tsx:599-713`, `:735-741` |
| Pin tỉ lệ khung 4:3 theo cảm biến | `Viewfinder.tsx:400-420` |
| Màn trập full-res + flash + RAW DNG + burst | `App.tsx:1381-1420`, `:1652-1672` |
| Render look trong worklet, parity export | `Viewfinder.tsx:1177-1371` ↔ `exportEngine.ts` |
| Overlay dp (grain, vignette, frame, watermark) | `Viewfinder.tsx:2774-2819` |
| Watchdog + keep-awake + AppState | `Viewfinder.tsx:571-585`, `:1846-1900` |
| Lịch sử + gallery + EXIF/DPI | `App.tsx:1482-1489`, `exportEngine.ts:923-935` |
| Gate PRO theo look + `LITE_MARK` + badge chip | `App.tsx:1269-1295`, `entitlement.ts:52-60`, `AdjustmentPanel.tsx:348-371` |
---
## 6. Phương án B — đóng gói chính web studio thành app mobile
Đúng: build web là **SPA tĩnh**, không router, nên bọc được. Nhưng có 4 chỗ chắc chắn phải xử lý, và
chúng quyết định chọn cách bọc nào.
**Hiện trạng build:** `dist/` = 45 MB (`public/wasm` 35 MB gồm canvaskit + onnxruntime, `public/models`
7.1 MB), 5 trang định tuyến bằng `window.location.pathname`, SPA fallback do nginx lo — **không có**
`manifest.webmanifest`, **không** service worker (`docker/frontend/index.html`), nên hiện **chưa phải
PWA**.
| Vấn đề | Chi tiết trong code | Cách xử lý |
|---|---|---|
| API là đường dẫn tương đối | `fetch('/api' + path)` (`src/api.ts:152`, `:205`, `:320-325`); `src/track.ts:6`; nginx proxy `/api` → container `api` | App bọc phải có origin phục vụ `/api`: dùng **domain https thật** (CORS + cookie) hoặc thêm base URL cấu hình được trong `api.ts` |
| Camera cần **secure context** | `engine/camera.ts:27-29` — `http://192.168.x.x:8090` không có `mediaDevices` | WebView phải là `https://` (Capacitor cấu hình được scheme qua `androidScheme` — phải để `https`), **và** tầng native phải cấp quyền: `WebChromeClient.onPermissionRequest` → `PermissionRequest.grant`. TWA giải quyết sẵn vì chạy bằng Chrome |
| Xuất ảnh | `navigator.share({files})` (`docker/frontend/src/App.tsx:1775`), fallback `download()` (blob + `<a download>`) | Android WebView **không** tải blob bằng `<a download>` nếu thiếu `DownloadListener` → cần plugin Filesystem/Share; muốn ảnh vào thư viện ảnh máy thì phải qua MediaStore (web không có khái niệm này) |
| WASM nặng + ONNX upscale | `canvaskit.wasm`, `public/wasm/ort`, `public/models/realesr-*.onnx`; `vite.config.ts` nạp wasm lúc chạy | WebView có WASM; WebGPU có thể không có ⇒ rơi về WASM; WASM threads cần `SharedArrayBuffer` ⇒ cần COOP/COEP |
**Ba cách bọc, chọn theo cái phải bỏ ra:**
1. **TWA (Trusted Web Activity)** — rẻ nhất về code: không bundle gì, Chrome lo camera/download/share/CSS
hiện đại, cảm giác như app thật. Đổi lại: **bắt buộc có domain https** + `.well-known/assetlinks.json`
+ đăng ký store. Web giữ nguyên một bản.
2. **Capacitor** — bundle `dist/` để mở offline. Đổi lại phải tự làm: quyền camera trong WebView, plugin
file/share, base URL cho `/api`, và vẫn cần backend https (hoặc API remote).
3. **PWA** — thêm `manifest.webmanifest` + service worker rồi cài từ Chrome. Rẻ nhất, không store, nhưng
vẫn cần https và người dùng phải tự "Thêm vào màn hình chính".
**Cái bọc không cho được** (nên cân nhắc trước khi chọn B thay vì port P1–P3): ultra-wide 0.5x, zoom
quang, RAW DNG, burst, ghi trực tiếp vào thư viện ảnh, IAP của Google Play (web gắn PRO với **tài khoản
`verified`**, `App.tsx:522` — không phải hoá đơn store), và độ mượt/nhiệt của pipeline native.
**Khuyến nghị.** Nếu mục tiêu là "có app Android nhanh từ web studio" → **TWA**, vì nó không đụng vào code
web và giữ nguyên camera/download/share. Nếu mục tiêu là app chụp ảnh hằng ngày → port P1 + P3 vào app
Android hiện có (vài chục dòng mỗi việc), không bọc.
---
## 7. Nghiệm thu
**Web (đã có):** Playwright với `APP_URL=http://localhost:8090/app` (localhost = secure context), camera
giả `--use-fake-device-for-media-stream`. Tên probe theo commit: `cam-pro-gate` (6/6), `cam-live-edit`,
`cam-smoke`, `cam-renegotiate`, `cam-close-flip`.
**Android (phải máy thật):** hai biến thể bằng `node tools/set-variant.mjs` (`src/provariant.ts:3`):
P1 đọc được dòng số và thấy nó tụt khi che tối; P2 LITE bấm CAMERA ra `PRO FEATURE`; P3 chụp → editor với
ảnh thô + đúng một `LITE_MARK` lúc export; P4 bật/tắt app liên tục không thấy màn đen. Hồi quy: burst 30,
handoff 0.5x, RAW, flip, `pushPhoto`.
**Nếu chọn bọc (§6):** kiểm tra theo thứ tự — `window.isSecureContext` trong WebView, `mediaDevices` có
tồn tại không, quyền camera của app, `/api` trả 200, và xuất một ảnh ra được thư viện ảnh máy.
## 8. Bản đồ file
| Việc | Web | Android |
|---|---|---|
| Truy cập thiết bị, phiên, màn trập | `docker/frontend/src/engine/camera.ts` | `App.tsx:320-343`, `:1327-1502` |
| Viewfinder + vòng render | `docker/frontend/src/ui/CameraView.tsx` | `src/components/Viewfinder.tsx` |
| Nút chụp / flip | `CameraView.tsx:225-247` | `src/components/CameraControls.tsx` |
| Pipeline look | `docker/frontend/src/engine/exportEngine.ts` (`renderPhoto`, `sourceImage`, `drawTo`) | `src/utils/exportEngine.ts`, `toneShader.ts`, `frameUtils.ts` |
| Tài nguyên GPU | `docker/frontend/src/engine/skiaShim.ts` | `react-native-vision-camera-skia` + `disposeTrash` |
| Gate PRO | `App.tsx:522,585-587,3237-3241` | `App.tsx:1269-1295`, `src/utils/entitlement.ts`, `ProModal.tsx` |
| Đóng gói mobile (§6) | `docker/frontend/index.html`, `vite.config.ts`, `nginx.conf`, `src/api.ts` | — |
+865
View File
@@ -0,0 +1,865 @@
# Port HSL (selective colour mixer) — `docker/frontend` → Android
Tài liệu này mô tả **chính xác** cách đưa tính năng HSL của bản web (`docker/frontend/`) sang app Android (Expo + Skia, source ở repo root).
Web là **bản tham chiếu**: `docker/frontend/shared/` là bản copy của `src/` bên Android nhưng đã đi trước. Hướng port là:
```
docker/frontend/shared/** → src/**
docker/frontend/src/** → src/components/**, App.tsx
```
---
## 0. Phạm vi — chỉ HSL
Diff giữa `src/` và `docker/frontend/shared/` **không chỉ có HSL**. Các delta sau nằm cùng file nhưng **KHÔNG thuộc tài liệu này**, tuyệt đối không port kèm:
| Delta web-only | Nơi xuất hiện | Kết luận |
|---|---|---|
| WB white/black point (`whites`/`blacks`) | `types`, `paramDefs`, `toneShader` (`wh`/`bl`), `defaultRecipes` | **BỎ** |
| Base filter `classic-vivid` (`sim-classic-vivid`) | `types`, `toneShader` `FILM_TONE`, `defaultRecipes` | **BỎ** |
| Frame `wallframe-landscape` | `types` `FrameId`, `recipeShare` `FRAMES`, chips ROTATE/wall | **BỎ** |
| `GLOW_T0/GLOW_T1` 0.55/0.85 → 0.45/0.75 | `toneShader` | **BỎ** — đây là chỉnh demo của web; Android giữ `0.55/0.85` |
| `exifWrite.ts` diff (22 dòng) | — | không liên quan HSL |
Chỉ port: **types HSL**, **`colorUtils` HSL**, **`toneShader` mixer**, **`recipeShare` sanitize**, **`defaultRecipes` 3 field toàn ảnh**, **UI tab HSL**.
---
## 1. Ngữ nghĩa (đọc trước khi code)
Mixer chọn màu theo **8 dải hue**. Mỗi dải có 3 knob `[hue, sat, lum]`, mỗi knob `-10..+10`:
- **hue** — xoay hue, ±30° khi full (`acc.x * 30/360`).
- **sat** — scale saturation, `-10` = xám (`s * (1 + acc.y)`, `acc.y = -1`).
- **lum** — cộng lightness, ±0.25 khi full (`acc.z * 0.25`), additive nên không đảo ramp.
Ba knob **toàn ảnh** (`hslHue`, `hslSat`, `hslLum`) là "seed" của accumulator: mọi hue nhận full weight.
Quy tắc bất biến — port phải giữ đúng:
1. **Partition of unity.** Weight mỗi dải là tent tuyến tính: full tại anchor của nó, về 0 tại anchor hai dải kề. Hai tent kề cắt nhau đúng tại 0.5 ở midpoint ⇒ 8 weight **cộng lại bằng 1 tại mọi hue**. Không pixel nào bị tính hai lần, không hue nào rơi vào khe.
2. **Anchor cố định 1 nguồn.** `HSL_BANDS` (cho chip) và `BAND_BLOCK` (cho shader) sinh từ cùng bảng qua `hslBandGaps()` ⇒ anchor không thể lệch.
3. **Gate xám.** `gate = smoothstep(0.0, 0.08, hsl.y)` — pixel có saturation < 8% bị loại khỏi weight, vì `rgb2hsl` trả hue 0 cho xám và nếu không gate thì **mọi pixel trung tính sẽ chạy theo dải RED**.
4. **`gl` KHÔNG gate.** Lightness toàn ảnh (`gl`) được cộng **sau** gate, nên ảnh đã bị `-SAT` rút về xám vẫn còn `+LUM`.
5. **Monochrome tắt mixer.** `if (!colour) hslOn = 0;` — stock đen trắng không có hue để chọn.
6. **Dải toàn 0 bị xoá.** Band `[0,0,0]` không bao giờ vào recipe; kéo knob về 0 thì dải biến mất không để lại dấu.
7. **Mixer chạy CUỐI**, sau split-tone/kv và sau vibrance — để band edit được đánh giá trên màu người dùng thực sự sample.
---
## 2. `src/types/index.ts`
Thêm 2 type (đặt cạnh `BaseFilter`/`ColorAdjustments`):
```ts
export type HslBandId = 'red' | 'orange' | 'yellow' | 'green' | 'aqua' | 'blue' | 'purple' | 'magenta';
// [hue, sat, lum], mỗi số -10..+10; dải toàn 0 bị xoá khỏi recipe
export type HslBand = [number, number, number];
```
Trong `interface ColorAdjustments` (sau `vibrance?`, trước `exposureCompensation`):
```ts
hslBands?: Partial<Record<HslBandId, HslBand>>; // selective colour: mỗi dải [hue, sat, lum], -10..+10
hslHue?: number; // -10..+10 xoay hue toàn ảnh
hslSat?: number; // -10..+10 scale saturation toàn ảnh
hslLum?: number; // -10..+10 cộng lightness toàn ảnh
```
> `hslBands` là **một field lồng**, không phải 24 field phẳng — đây là lý do nó cần gate riêng khi import (mục 5).
---
## 3. `src/utils/colorUtils.ts`
File Android hiện chỉ có `kelvinToRGB`, `getSkiaColorMatrix`, `applyExposureGain`. Thêm nguyên khối sau **lên đầu file** (sau import):
```ts
import { ColorAdjustments, BaseFilter, HslBand, HslBandId } from '../types';
// 8 dải của mixer chọn màu, và anchor hue mỗi dải đứng (độ trên vòng HSL).
// Anchor cố tình không đều: red/orange/yellow cách nhau 30° vì mắt phân biệt
// kỹ vùng này, còn phía blue là một khúc 60°. TONE_SKSL dựng đúng 8 weight này
// từ bảng dưới, nên anchor chip sửa và anchor shader mask không thể lệch.
//
// `label` là chữ chip in ra — chuỗi của app, KHÔNG nằm trong i18n.
export const HSL_BANDS: { id: HslBandId; hue: number; label: string }[] = [
{ id: 'red', hue: 0, label: 'RED' },
{ id: 'orange', hue: 30, label: 'ORANGE' },
{ id: 'yellow', hue: 60, label: 'YELLOW' },
{ id: 'green', hue: 120, label: 'GREEN' },
{ id: 'aqua', hue: 180, label: 'AQUA' },
{ id: 'blue', hue: 240, label: 'BLUE' },
{ id: 'purple', hue: 280, label: 'PURPLE' },
{ id: 'magenta', hue: 320, label: 'MAGENTA' },
];
// Hue (độ) của hai dải kề mỗi dải, đúng như TONE_SKSL cần. Suy ra từ HSL_BANDS
// để hai bảng không thể cãi nhau: tent weight của một dải về 0 tại anchor của
// hai dải kề, khiến 8 weight là partition of unity trên vòng hue (cộng = 1 tại
// mọi hue).
export function hslBandGaps(): { id: HslBandId; hue: number; left: number; right: number }[] {
const n = HSL_BANDS.length;
return HSL_BANDS.map((b, i) => {
const prev = HSL_BANDS[(i - 1 + n) % n].hue;
const next = HSL_BANDS[(i + 1) % n].hue;
// Red (0°) wrap: dải kề trái là magenta 320°, tức 40° về sau.
return { id: b.id, hue: b.hue, left: (b.hue - prev + 360) % 360, right: (next - b.hue + 360) % 360 };
});
}
// 0..255 sRGB → HSL. Hue theo độ, saturation/lightness 0..1 — chiều ngược của
// hsl2rgb trong shader, và là thứ readout của eyedropper in ra.
export function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
const R = r / 255;
const G = g / 255;
const B = b / 255;
const mx = Math.max(R, G, B);
const mn = Math.min(R, G, B);
const l = (mx + mn) / 2;
const d = mx - mn;
if (d < 1e-6) return { h: 0, s: 0, l };
const s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
let h: number;
if (mx === R) h = ((G - B) / d + (G < B ? 6 : 0)) * 60;
else if (mx === G) h = ((B - R) / d + 2) * 60;
else h = ((R - G) / d + 4) * 60;
return { h, s, l };
}
// Dải mà một hue sample thuộc về: anchor gần nhất. Các dải chồng nhau trong
// shader, nên hàm này chỉ quyết định ruler đang trỏ vào dải nào.
export function nearestHslBand(hue: number): HslBandId {
const wrapped = ((hue % 360) + 360) % 360;
let best = HSL_BANDS[0];
let bestD = 361;
for (const band of HSL_BANDS) {
const d = Math.abs(((wrapped - band.hue + 540) % 360) - 180);
if (d < bestD) {
bestD = d;
best = band;
}
}
return best.id;
}
// Một band triple như khi lưu: số nguyên -10..10, dải toàn 0 bị xoá để recipe
// chỉ mang theo thứ người dùng thực sự đổi.
const clamp10 = (v: unknown): number =>
typeof v === 'number' && Number.isFinite(v) ? Math.max(-10, Math.min(10, Math.round(v))) : 0;
export function sanitizeHslBands(raw: unknown): Partial<Record<HslBandId, HslBand>> | undefined {
if (!raw || typeof raw !== 'object') return undefined;
const out: Partial<Record<HslBandId, HslBand>> = {};
for (const band of HSL_BANDS) {
const v = (raw as Record<string, unknown>)[band.id];
if (!Array.isArray(v) || v.length !== 3) continue;
const triple: HslBand = [clamp10(v[0]), clamp10(v[1]), clamp10(v[2])];
if (triple[0] || triple[1] || triple[2]) out[band.id] = triple;
}
return Object.keys(out).length ? out : undefined;
}
```
Không sửa `getSkiaColorMatrix`: mixer nằm ở tone shader, matrix 4×5 không làm được per-pixel hue.
---
## 4. `src/utils/toneShader.ts` — shader mixer
Đây là phần lõi. Sáu sửa đổi, theo thứ tự trong file.
### 4.1 Import + `BAND_BLOCK`
```ts
import { BaseFilter, ColorAdjustments } from '../types';
import { HSL_BANDS, hslBandGaps } from './colorUtils';
```
Thêm **trước** `export const TONE_SKSL`:
```ts
// Tám dòng band của mixer trong TONE_SKSL, sinh từ HSL_BANDS để anchor và gap
// trong shader chính là số mà chip dựng ra. Mỗi dòng đọc 3 giá trị của dải nó
// với index HẰNG — SkSL chỉ index uniform array bằng hằng, nên block này phải
// unroll chứ không loop được.
const BAND_BLOCK = hslBandGaps()
.map(
(b, i) => ` float w${i} = bandW(hd, ${b.hue.toFixed(1)}, ${b.left.toFixed(1)}, ${b.right.toFixed(1)}) * gate;
acc += vec3(w${i} * hslH[${i}], w${i} * hslS[${i}], w${i} * hslL[${i}]);\n`
)
.join('');
```
### 4.2 Comment header
Thêm vào khối comment đầu file (cạnh `vib`):
```
// hslOn/hslH/hslS/hslL - mixer chọn màu: 8 dải hue, mỗi dải một hue shift, một
// saturation scale và một lightness offset (-1..1, từ knob -10..10). Dải
// nào sở hữu pixel nào được quyết định Ở ĐÂY, per-pixel theo hue — nên
// khác mọi thứ phía trên, 8 dải không phải một phép move toàn cục và
// không thể nằm trong colour matrix. Xem band block ở cuối TONE_SKSL.
// gh/gs/gl - move toàn ảnh của mixer: đúng ba đại lượng đó cho TOÀN ảnh, nên
// chúng chỉ là giá trị khởi tạo của accumulator và mọi hue nhận full
// weight. Lightness KHÔNG bị gate theo saturation (khác band), nên ảnh bị
// -SAT rút về xám vẫn trả lời +LUM.
```
### 4.3 Uniform khai báo trong `TONE_SKSL`
Thêm **sau `uniform float ccb;`** (Android không có `wh`/`bl`, nên HSL nối thẳng sau `ccb`):
```glsl
uniform float hslOn;
uniform float hslH[8];
uniform float hslS[8];
uniform float hslL[8];
uniform float gh;
uniform float gs;
uniform float gl;
```
Rồi **ngay trước `vec4 main(vec2 xy)`**, thêm 4 helper + mixer vào chuỗi SkSL:
```glsl
// sRGB <-> HSL. Mixer làm việc trong HSL vì đó là không gian knob được đặt tên
// theo: một hue shift không được đổi độ sáng của màu, và một lightness move
// không được đổi hue — đúng thứ mà scale RGB làm sai.
vec3 rgb2hsl(vec3 c) {
float mx = max(max(c.r, c.g), c.b);
float mn = min(min(c.r, c.g), c.b);
float l = (mx + mn) * 0.5;
float d = mx - mn;
if (d < 0.00001) return vec3(0.0, 0.0, l);
float s = l > 0.5 ? d / max(0.00001, 2.0 - mx - mn) : d / max(0.00001, mx + mn);
float h;
if (mx == c.r) h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
else if (mx == c.g) h = (c.b - c.r) / d + 2.0;
else h = (c.r - c.g) / d + 4.0;
return vec3(h / 6.0, s, l);
}
float hueChannel(float p, float q, float t) {
t = fract(t);
if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t;
if (t < 0.5) return q;
if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
return p;
}
vec3 hsl2rgb(vec3 hsl) {
if (hsl.y < 0.00001) return vec3(hsl.z);
float q = hsl.z < 0.5 ? hsl.z * (1.0 + hsl.y) : hsl.z + hsl.y - hsl.z * hsl.y;
float p = 2.0 * hsl.z - q;
return vec3(
hueChannel(p, q, hsl.x + 1.0 / 3.0),
hueChannel(p, q, hsl.x),
hueChannel(p, q, hsl.x - 1.0 / 3.0)
);
}
// Mức sở hữu một hue của một dải: full tại anchor của dải, giảm tuyến tính về 0
// tại anchor hai dải kề (gap không đều — red cách orange 30° và cách magenta
// 40°). Tính tuyến tính là mấu chốt: hai tent kề cắt nhau đúng 0.5 tại midpoint,
// nên 8 weight cộng lại bằng 1 tại mọi hue. Không pixel nào bị tính hai lần,
// không pixel nào rơi vào khe, và hue nằm đúng anchor nhận full giá trị của dải
// đó thay vì một phần.
float bandW(float hue, float anchor, float gapL, float gapR) {
float d = mod(hue - anchor + 180.0, 360.0) - 180.0;
return d <= 0.0 ? max(0.0, 1.0 + d / gapL) : max(0.0, 1.0 - d / gapR);
}
```
### 4.4 Mixer trong `main()` — thay đoạn kết
Trong `main`, đoạn cuối Android hiện là:
```glsl
float kv = 1.0 + vib * 0.75 * (1.0 - chroma);
return vec4(clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0), c.a);
}
```
Đổi thành:
```glsl
float kv = 1.0 + vib * 0.75 * (1.0 - chroma);
rgb = clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0);
// Selective colour theo dải hue — move CUỐI, để một band edit được đánh giá
// trên đúng màu người dùng sample từ render.
//
// Pixel xám bị loại trước khi đọc weight: rgb2hsl trả hue 0 cho nó, nên nếu
// không gate thì MỌI pixel trung tính trong khung sẽ bị coi là đỏ nguyên chất
// và trôi theo dải red. Dưới 8% saturation thì cũng không có hue để move.
//
// Ba accumulator là giá trị band nhân mức sở hữu, nên một hue nằm giữa hai
// anchor nhận hỗn hợp tỉ lệ của hai edit — đúng blend mà weight đã cộng ra.
// Hue là phép xoay (±30° khi full), saturation là scale (0 = xám tại -10),
// lightness là additive (±0.25 khi full) nên không thể đảo ramp.
if (hslOn > 0.5) {
vec3 hsl = rgb2hsl(rgb);
float gate = smoothstep(0.0, 0.08, hsl.y);
float hd = hsl.x * 360.0;
// Move toàn ảnh là seed: mọi hue nhận hue turn và saturation scale ở full
// weight, các dải cộng thêm phần của mình lên trên. Lightness được cộng
// bên dưới KHÔNG gate, nên nó vẫn nâng một màu đã bị -SAT rút về xám.
vec3 acc = vec3(gh, gs, 0.0) * gate;
${BAND_BLOCK} hsl.x = fract(hsl.x + acc.x * (30.0 / 360.0));
hsl.y = clamp(hsl.y * (1.0 + acc.y), 0.0, 1.0);
hsl.z = clamp(hsl.z + (acc.z + gl) * 0.25, 0.0, 1.0);
rgb = hsl2rgb(hsl);
}
return vec4(clamp(rgb, 0.0, 1.0), c.a);
}
```
> `${BAND_BLOCK}` phải nằm trong template literal — 8 dòng sinh ra nối liền ngay trước `hsl.x =`. Giữ đúng dạng đó, đừng thụt lề lại: SkSL không quan tâm, nhưng giữ khớp bản web để diff sau này đọc được.
### 4.5 `ToneUniforms`
Thêm vào interface (sau `ccb`):
```ts
hslOn: number; // 1 khi có band hoặc move toàn ảnh được set (0 = skip mixer)
hslH: number[]; // 8 × -1..1 theo dải, thứ tự HSL_BANDS (±30° hue khi full)
hslS: number[]; // 8 × -1..1 (saturation scale, -1 = xám)
hslL: number[]; // 8 × -1..1 (lightness additive, ±0.25 khi full)
gh: number; // -1..1 xoay hue toàn ảnh (±30° khi full)
gs: number; // -1..1 scale saturation toàn ảnh
gl: number; // -1..1 cộng lightness toàn ảnh (±0.25 khi full, ungated)
```
### 4.6 `getToneUniforms`
Trong thân hàm, **trước `return {`**:
```ts
// Selective colour: một slot mỗi dải, theo thứ tự HSL_BANDS, để flat buffer
// khớp với array của shader. Dải người dùng chưa move giữ ba số 0 và chỉ tốn
// slot của nó.
const bands = adj.hslBands ?? {};
const tenth = (v: unknown) =>
typeof v === 'number' && Number.isFinite(v) ? Math.max(-1, Math.min(1, v / 10)) : 0;
const hslH: number[] = [];
const hslS: number[] = [];
const hslL: number[] = [];
let hslOn = 0;
for (const band of HSL_BANDS) {
const v = bands[band.id];
const [h, s, l] = v ? [tenth(v[0]), tenth(v[1]), tenth(v[2])] : [0, 0, 0];
hslH.push(h);
hslS.push(s);
hslL.push(l);
if (h || s || l) hslOn = 1;
}
// Stock monochrome không có hue để chọn theo.
if (!colour) hslOn = 0;
// Move toàn ảnh của mixer, mọi hue nhận full weight.
const gh = tenth(adj.hslHue);
const gs = tenth(adj.hslSat);
const gl = tenth(adj.hslLum);
if (colour && (gh || gs || gl)) hslOn = 1;
```
Và thêm vào object return (cuối, sau `ccb`):
```ts
hslOn,
hslH,
hslS,
hslL,
gh,
gs,
gl,
```
> `colour` đã có sẵn trong hàm (`const colour = baseFilter !== 'monochrome'`). Đặt khối HSL **sau** dòng đó.
### 4.7 `toneUniformArray`
```ts
export function toneUniformArray(u: ToneUniforms): number[] {
return [
u.dr, u.hl, u.sh, u.vib,
u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb,
u.hslOn, ...u.hslH, ...u.hslS, ...u.hslL, u.gh, u.gs, u.gl,
];
}
```
Tổng độ dài: `12 + 1 + 8 + 8 + 8 + 3 = 40`.
### 4.8 `toneIsActive`
`u.hslOn !== 0 ||` là **clause đầu tiên**:
```ts
export function toneIsActive(u: ToneUniforms): boolean {
return (
u.hslOn !== 0 ||
u.dr !== 0 ||
... // phần còn lại giữ nguyên
);
}
```
---
## 5. `src/utils/recipeShare.ts`
Import:
```ts
import { sanitizeHslBands } from './colorUtils';
```
Trong `importRecipeXml`, **trước** dòng `const frameId: FrameId = ...`:
```ts
// Mixer là adjustment duy nhất đến dưới dạng object lồng, nên là cái duy nhất
// cần gate riêng: band id lạ bị bỏ, mọi giá trị bị kẹp về range knob, và một
// dải để nguyên 0 bị xoá thay vì mang theo vô ích.
adjustments.hslBands = sanitizeHslBands(raw.adjustments.hslBands);
```
`exportRecipeXml` **không cần sửa**: `adjustments` được nhân bản nguyên khối vào payload JSON, `hslBands` tự đi theo. Format `.recipe` (XML bọc hex xorshift) giữ nguyên `version="1"`.
> `storageUtils.getAllRecipes` merge `{ ...DEFAULT_ADJUSTMENTS, ...r.adjustments }` — đủ cho 3 field toàn ảnh. `hslBands` từ storage là do chính app ghi nên không cần sanitize; nếu muốn chắc, gọi thêm `sanitizeHslBands` trong `fill`.
---
## 6. `src/utils/defaultRecipes.ts`
Thêm 3 field vào `DEFAULT_ADJUSTMENTS` (sau `vibrance` nếu có, trước `exposureCompensation`):
```ts
hslHue: 0,
hslSat: 0,
hslLum: 0,
```
Không thêm `hslBands` vào đây — nó optional và `undefined` mới là "không có dải nào"; thêm `hslBands: {}` sẽ làm `differs()` trong `App.tsx` báo dirty sai.
---
## 7. Uniform buffer — các điểm Android phải sửa thêm
`exportEngine.ts` **không cần sửa**: nó gọi `toneUniformArray(tone)` + `toneIsActive(tone)`, cả hai đã tự mang HSL.
`nativeExport.ts` (dev probe, `EXPO_PUBLIC_NATIVE_EXPORT`) **không mang HSL** — giống `colorChrome`, nó chỉ truyền `toneDr/toneHl/toneSh` vào `PhotoAdjust`. Ghi rõ trong comment cạnh comment "port them into applyTone()" hiện có; không cần làm gì thêm cho tới khi native path thành đường chính.
### 7.1 `src/components/Viewfinder.tsx` — BẮT BUỘC, dễ sót
Camera worklet đọc flat buffer qua `toneSync`, có hai chỗ hardcode theo độ dài cũ:
```tsx
// dòng ~499
const toneSync = useMemo(
() => createSynchronizable<number[]>(new Array(10).fill(0)),
[],
);
```
```tsx
// dòng ~1270
const tone = toneSync.getDirty();
const hasTone =
toneEffect != null &&
(tone[0] !== 0 || tone[1] !== 0 || ... || tone[11] !== 0);
```
Sửa:
1. `new Array(40).fill(0)` — đúng bằng độ dài `toneUniformArray`.
2. `hasTone` thêm `tone[12] !== 0` (đây là `hslOn`).
Nếu bỏ sót, worklet đọc `undefined` ở các slot mới ⇒ uniform `NaN` ⇒ **cả tone pass hỏng trên preview camera**, không chỉ HSL.
Library (declarative `<Shader>`) — thêm key vào object `toneUniforms` (dòng ~2999):
```tsx
const toneUniforms = {
dr: toneParams[0],
hl: toneParams[1],
sh: toneParams[2],
vib: toneParams[3],
shTr: toneParams[4],
shTg: toneParams[5],
shTb: toneParams[6],
hlTr: toneParams[7],
hlTg: toneParams[8],
hlTb: toneParams[9],
cc: toneParams[10],
ccb: toneParams[11],
hslOn: toneParams[12],
hslH: toneParams.slice(13, 21),
hslS: toneParams.slice(21, 29),
hslL: toneParams.slice(29, 37),
gh: toneParams[37],
gs: toneParams[38],
gl: toneParams[39],
};
```
`toneOn = toneParams.some((v) => v !== 0)` giữ nguyên — buffer phẳng nên vẫn đúng.
> **Cần xác nhận trên máy:** nhánh declarative `<Shader uniforms>` của RN Skia phải nhận **array** uniform (`float hslH[8]`). Nếu nó flatten/không nhận, chuyển nhánh library preview sang `makeShaderWithChildren(toneUniformArray(tone), [imageShader])` như camera/export đã làm — cùng một nguồn số, bỏ hẳn object named-uniform.
---
## 8. UI Android
### 8.1 `src/components/ToolRail.tsx`
```ts
export type TabId = 'recipes' | 'favorites' | 'iq' | 'wb' | 'filters' | 'hsl' | 'frame';
```
Thêm vào `TOOLS`, **giữa `filters` và `frame`** (web đặt HSL cùng nhóm màu):
```ts
{ id: 'filters', label: 'FX' },
{ id: 'hsl', label: 'HSL' },
{ id: 'frame', label: 'FRAME' },
```
### 8.2 State — nâng lên `App.tsx`
Web để state HSL trong `App`. Android cũng phải vậy, vì **cả `AdjustmentPanel` (chip) lẫn `Viewfinder` (eyedropper + panel nổi) đều cần**:
```tsx
const [hslBand, setHslBand] = useState<HslBandId>('red'); // dải ruler đang sửa
const [picking, setPicking] = useState(false); // eyedropper đã arm chưa
const [hslSample, setHslSample] = useState<{ r: number; g: number; b: number } | null>(null);
const [hslPickedAt, setHslPickedAt] = useState<{ fx: number; fy: number } | null>(null); // vị trí trên ảnh
```
Truyền xuống `Viewfinder`: `picking`, `hslPickedAt`, `onPickColor`, và một node `hslPanel` (JSX panel, xem 8.5). Truyền xuống `AdjustmentPanel`: `hslBand`, `onHslBand`, `picking`, `onPicking`, `hslSample`, `adjustments`, `onUpdateAdjustments` (đã có).
Hai setter (đặt cạnh nhau, cả hai gọi `onUpdateAdjustments`):
```tsx
// Một band triple như khi lưu. Dải về [0,0,0] bị XOÁ, và khi không còn dải nào
// thì field trả về undefined (không phải {}) để `differs(adjustments,
// DEFAULT_ADJUSTMENTS)` trong resetDirty không báo dirty sai.
const setBandKnob = (which: 0 | 1 | 2, v: number) => {
const band = adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
const next: HslBand = [band[0], band[1], band[2]];
next[which] = Math.round(v);
const bands = { ...adjustments.hslBands };
if (next[0] === 0 && next[1] === 0 && next[2] === 0) delete bands[hslBand];
else bands[hslBand] = next;
onUpdateAdjustments({ hslBands: Object.keys(bands).length ? bands : undefined });
};
// Move toàn ảnh: một cửa cho cả ba knob, nên ruler và field nó ghi luôn là cùng
// một giá trị dù mở từ đâu.
const setHslGlobal = (which: 0 | 1 | 2, v: number) => {
const key = which === 0 ? 'hslHue' : which === 1 ? 'hslSat' : 'hslLum';
onUpdateAdjustments({ [key]: Math.max(-10, Math.min(10, Math.round(v))) } as Partial<ColorAdjustments>);
};
```
`pickColor` (App đưa xuống Viewfinder):
```tsx
// Báo cáo của eyedropper: in màu đọc được, trỏ ruler về dải của màu đó, treo
// panel lên đúng điểm nó được đọc, rồi cất dụng cụ đi — một lần pick một màu.
const pickColor = (rgb: { r: number; g: number; b: number }, at: { fx: number; fy: number }) => {
setHslSample(rgb);
setHslBand(nearestHslBand(rgbToHsl(rgb.r, rgb.g, rgb.b).h));
setHslPickedAt(at);
setPicking(false);
};
```
### 8.3 `src/components/AdjustmentPanel.tsx`
1. `type TabId` (dòng 20) thêm `| 'hsl'`.
2. `paramDefs: Record<TabId, ParamDef[]>` thêm `hsl: []` **hoặc** xử lý riêng trong `chipRow` (xem dưới). Ba knob toàn ảnh **không** vào `PARAM_DEFS` — chúng đọc/ghi field của `adjustments` nhưng label là `HUE IMAGE`/`SAT IMAGE`/`LUM IMAGE`, và mở row qua cùng cơ chế `openParam` với `PARAM_GROUP` để nút back đóng row.
Cách gọn nhất, khớp cấu trúc sẵn có: mở rộng `paramDefs.hsl` bằng 3 `ParamDef` với `key` là `'hsl.h' | 'hsl.s' | 'hsl.l'` và `onChange: (v) => setHslGlobal(which, v)`. Khi đó `openParamDef` + row slider sẵn có tự hoạt động, chip hiện value, RESET/back hoạt động.
```tsx
// Ba knob toàn ảnh của mixer không nằm trong PARAM_DEFS: chúng đọc/ghi move
// của CHÍNH ẢNH chứ không phải một field phẳng của adjustments, và khác panel
// trên ảnh, chúng không gắn với dải mà mixer đang trỏ.
hsl: [0, 1, 2].map((i) => {
const own = [a.hslHue ?? 0, a.hslSat ?? 0, a.hslLum ?? 0];
const key = i === 0 ? 'hsl.h' : i === 1 ? 'hsl.s' : 'hsl.l';
const name = i === 0 ? 'HUE' : i === 1 ? 'SAT' : 'LUM';
return {
key,
label: `${name} IMAGE`,
value: own[i],
default: 0,
min: -10,
max: 10,
step: 1,
display: (v: number) => (v > 0 ? `+${v}` : String(v)),
onChange: (v: number) => onSetHslGlobal(i as 0 | 1 | 2, v),
};
}),
```
3. Thêm `case 'hsl'` vào switch `chipRow`:
```tsx
case 'hsl': {
// PICK arm eyedropper; 8 chip dải chọn dải mà ruler sửa; ruler do ba
// knob bên dưới mở. Ba knob đó là move toàn ảnh, không phải của dải:
// chip dải chọn màu cho panel trên ảnh, còn HUE/SAT/LUM dưới divider
// move mọi hue trong khung cùng lúc.
return chipRow([
{ key: 'hsl-pick', label: 'PICK', active: picking, onPress: () => onSetPicking(!picking) },
...HSL_BANDS.map((b): ChipDef => {
const band = a.hslBands?.[b.id];
const moved = !!band && (band[0] !== 0 || band[1] !== 0 || band[2] !== 0);
return {
key: `hsl-band-${b.id}`,
label: b.label,
// Chip gọi tên một màu nên nó hiện đúng màu đó.
color: hslToHex(b.hue, 70, 50),
active: hslBand === b.id,
amberValue: hslBand !== b.id && moved,
onPress: () => onSetHslBand(b.id),
};
}),
// Divider giữ ba slider toàn ảnh ra ngoài hàng màu: hàng trên chọn
// một màu, ba cái này move tất cả.
{ key: 'hsl-image', label: 'IMAGE', disabled: true, active: false, onPress: () => {} },
...paramChips(paramDefs.hsl),
], 8);
}
```
4. `ChipDef` cần thêm `color?: string` (chip màu). Trong `renderChip`, khi `c.color` có mặt, vẽ một dot màu trước label (Android dùng `View` với `backgroundColor`):
```tsx
{c.color ? <View style={{ width: 10, height: 10, borderRadius: 999, backgroundColor: c.color, marginRight: 6 }} /> : null}
```
Chip màu vẫn giữ text label (RED/ORANGE/…) — dot là phụ, không thay chữ.
5. Readout khi `activeTab === 'hsl'`: Android không có cột phụ như web. Đặt một **dòng readout mỏng ngay trên hàng chip** (chỗ `openParamDef` row đang render), chỉ khi tab hsl:
```tsx
{activeTab === 'hsl' && (
<View className="flex-row items-center gap-2 px-3 py-1">
{hslSample ? (
<>
<View style={{ width: 22, height: 22, borderRadius: 4, borderWidth: 1, borderColor: '#3f3f46',
backgroundColor: `rgb(${hslSample.r}, ${hslSample.g}, ${hslSample.b})` }} />
<Text className="font-mono text-[11px] text-zinc-400">
R {hslSample.r} · G {hslSample.g} · B {hslSample.b}
</Text>
<Text className="font-mono text-[11px] text-zinc-400">
{Math.round(rgbToHsl(hslSample.r, hslSample.g, hslSample.b).h)}° ·{' '}
{Math.round(rgbToHsl(hslSample.r, hslSample.g, hslSample.b).s * 100)}% ·{' '}
{Math.round(rgbToHsl(hslSample.r, hslSample.g, hslSample.b).l * 100)}%
</Text>
<Text className="font-mono text-[11px] text-amber-400">{bandName}</Text>
</>
) : (
<Text className="font-mono text-[10px] tracking-widest text-zinc-500">
BẤM PICK RỒI CHỌN MỘT MÀU TRÊN ẢNH
</Text>
)}
</View>
)}
```
`bandName = HSL_BANDS.find((b) => b.id === hslBand)?.label ?? ''`.
### 8.4 `hslToHex` (dùng cho chip màu)
Copy nguyên từ web (`docker/frontend/src/App.tsx` dòng 108) vào `src/utils/colorUtils.ts` hoặc ngay trong `AdjustmentPanel.tsx`:
```ts
// HSL -> #rrggbb. Chip gọi tên một màu nên nó phải hiện đúng màu đó; hex là
// format duy nhất RN nhận thẳng từ chuỗi.
export function hslToHex(h: number, s: number, l: number): string {
const k = (n: number) => (n + h / 30) % 12;
const a = (s / 100) * Math.min(l / 100, 1 - l / 100);
const chan = (n: number) =>
Math.max(0, Math.min(255, Math.round(255 * (l / 100 - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))))))
.toString(16)
.padStart(2, '0');
return `#${chan(0)}${chan(8)}${chan(4)}`;
}
```
### 8.5 Eyedropper + panel nổi — `src/components/Viewfinder.tsx`
Đây là phần **chưa có tương ứng Android**, phải viết mới.
**Props thêm:**
```tsx
picking?: boolean;
onPickColor?: (rgb: { r: number; g: number; b: number }, at: { fx: number; fy: number }) => void;
hslPanel?: React.ReactNode;
hslPanelAt?: { fx: number; fy: number } | null;
```
**Lớp pick.** Web đặt một `div` phủ đúng **box của ảnh** (`box` = rect ảnh sau zoom/pan, không phải cả canvas). Android đã có `imageFitRect` và sẵn một touch layer trong library mode (`onLibTouchStart` với toạ độ fraction). Cách port đúng và ít code nhất:
- Khi `picking`, thêm một `Pressable` (hoặc `View` với `onStartShouldSetResponder`) phủ **đúng `imageFitRect`**, chặn touch để pan/zoom không khởi động.
- Toạ độ fraction tính từ rect **của lớp đó**, không phải của canvas:
```tsx
const fx = (e.nativeEvent.locationX) / rectW;
const fy = (e.nativeEvent.locationY) / rectH;
if (!(fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1)) return;
```
- Hiện một icon eyedropper đi theo ngón tay (`pick-icon` của web là SVG; Android dùng `lucide-react-native` đã có sẵn: `<Pipette size={22} color="#fff" />`).
- Chỉ arm trong **library mode**. Camera mode không có pixel để sample (và preview là luồng sống).
**Lấy màu.** Web sample chính **ảnh preview đã render** (`previewUrl`), không phải file gốc — để màu đọc được đúng là màu đang thấy. Android tương đương: snapshot canvas đang vẽ. Đường ngắn nhất:
```tsx
// canvasRef = useCanvasRef() trên canvas library đang vẽ `graded`.
const shot = canvasRef.current?.makeImageSnapshot(); // ảnh đã render
const px = await shot.readPixels(Math.floor(fx * shot.width()), Math.floor(fy * shot.height()), {
width: 1, height: 1, colorType: ColorType.RGBA_8888, alphaType: AlphaType.Unpremul,
});
onPickColor({ r: px[0], g: px[1], b: px[2] }, { fx, fy });
```
Đọc **1 pixel** từ snapshot là đủ và rẻ; cache snapshot trong lúc `picking` để mỗi lần chạm không phải snapshot lại.
> **ponytail:** snapshot canvas mỗi lần pick là đủ cho một lần chạm/ảnh; nếu độ trễ cảm nhận được trên máy thật, chuyển sang decode `skiaImage` một lần và `readPixels` trên đó — nhưng khi đó màu sample là màu **gốc**, không phải màu đã grade, và hue có thể lệch khỏi thứ người dùng nhìn thấy nếu các knob khác đã move. Chỉ hạ cấp khi có số đo.
**Panel nổi.** Web đặt panel tại `left = fx*100%`, `top = fy*100%` **trong box ảnh**, với `transform: translate(-50%, 14px)`, hoặc `translate(-50%, calc(-100% - 14px))` khi `fy > 0.55` (điểm nằm thấp thì panel treo lên trên để không rơi khỏi ảnh). Android absolute-position trong chính box đó:
```tsx
{hslPanel && hslPanelAt && (
<View
pointerEvents="box-none"
style={{
position: 'absolute',
left: 0, top: 0, width: rectW, height: rectH,
}}
>
<View
style={{
position: 'absolute',
left: hslPanelAt.fx * rectW,
top: hslPanelAt.fy * rectH,
transform: [
{ translateX: -PANEL_HALF_W }, // translate(-50%)
{ translateY: hslPanelAt.fy > 0.55 ? -(PANEL_H + 14) : 14 },
],
}}
>
{hslPanel}
</View>
</View>
)}
```
Panel dừng pointer để không rơi xuống lớp pan của ảnh.
### 8.6 Panel nội dung (`hslPanel`)
Web dựng `pickPanel` ở `App.tsx`; Android dựng trong `AdjustmentPanel` (nó đã có `Slider` + haptics) rồi App truyền node xuống Viewfinder, hoặc dựng thẳng trong App. Nội dung:
- **Swatch** — màu của sample sau khi áp knob của dải đang chọn:
```tsx
const picked = hslSample ? rgbToHsl(hslSample.r, hslSample.g, hslSample.b) : null;
const band = adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
const mixedHex = picked
? hslToHex(
picked.h + band[0],
Math.max(0, Math.min(100, picked.s * 100 + band[1])),
Math.max(0, Math.min(100, picked.l * 100 + band[2])),
)
: null;
```
- **Tên dải + hex**, nút đóng `✕` (`onPress={() => setHslPickedAt(null)}`).
- **Ba MiniSlider** `HUE` / `SAT` / `LUM`, mỗi cái `value = band[i]`, `onValueChange = (v) => setBandKnob(i, v)`, và reset về 0 (web: double-click; Android: thêm một `TouchableOpacity` nhỏ hoặc `onSlidingComplete` khi |v| < 0.5 ⇒ 0). Dùng `Slider` (`@react-native-community/slider`) với `minimumValue={-10} maximumValue={10} step={1}`.
### 8.7 `resetDirty` — `App.tsx`
`differs()` là **shallow**. Với `setBandKnob` trả `hslBands: undefined` khi rỗng (8.2), `differs(adjustments, DEFAULT_ADJUSTMENTS)` đã bắt đúng cả `hslBands` lẫn `hslHue/hslSat/hslLum`. **Không cần thêm clause** — miễn là đừng để `hslBands: {}`.
Nếu vì lý do nào đó muốn giữ `{}`, thì phải thêm `|| Object.keys(adjustments.hslBands ?? {}).length > 0` (đúng như web) — nhưng cách `undefined` sạch hơn.
### 8.8 Camera mode + PRO gate — quyết định
- **Camera mode:** mixer tự động chạy trong preview camera (7.1 sửa xong `toneSync`), nên tab HSL cứ hiện ở cả hai mode. **PICK + panel chỉ có nghĩa ở library** — ở camera mode cho chip `PICK` ở trạng thái `disabled` (chip xám, không mở). Đây là điểm khác web (web library-only) và là chỗ dễ tranh cãi nhất — **xác nhận với người dùng nếu muốn khác**.
- **PRO gate:** web **không** gate HSL (`proLookInUse()` chỉ phủ `PRO_FRAMES` + GPS + HDF; tab HSL mở cho guest). Port giữ nguyên: **HSL là tính năng LITE**, không thêm vào `entitlement.ts`.
- **`RecipeCreateModal`:** không cần thêm dòng HSL. Web cũng không có HSL trong form CREATE — mixer chỉ chỉnh trên ảnh, recipe lưu qua `adjustments` khi SAVE.
---
## 9. Kiểm tra
### 9.1 Trên Android (self-check + unit)
1. **`HSL_BANDS` / `hslBandGaps`** — bất biến partition of unity:
```ts
// Với mọi hue 0..359, tổng 8 tent weight phải = 1 (±1e-6).
const gaps = hslBandGaps();
for (let h = 0; h < 360; h += 1) {
const sum = gaps.reduce((s, b) => {
const d = ((h - b.hue + 540) % 360) - 180;
const w = d <= 0 ? Math.max(0, 1 + d / b.left) : Math.max(0, 1 - d / b.right);
return s + w;
}, 0);
if (Math.abs(sum - 1) > 1e-6) throw new Error(`weights != 1 at ${h}: ${sum}`);
}
```
Và `hslBandGaps()` trả `red.left = 40`, `red.right = 30` (wrap đúng).
2. **`sanitizeHslBands`**:
- `{ red: [99, -99, 0] }` → `{ red: [10, -10, 0] }`
- `{ red: [0,0,0] }` → `undefined`
- `{ red: [1,2] }`, `{ nope: [1,2,3] }` → bỏ
- `null` / `'x'` → `undefined`
3. **`rgbToHsl` ↔ `nearestHslBand`**: đỏ thuần `(255,0,0)` → `red`; `(255,128,0)` → `orange`; xám `(128,128,128)` → `s = 0`, `nearestHslBand(0) = 'red'` (rồi shader gate nó đi).
4. **Shader compile** — `Skia.RuntimeEffect.Make(TONE_SKSL)` không throw. Bắt buộc, vì `${BAND_BLOCK}` nội suy vào chuỗi.
5. **`toneUniformArray`** — `length === 40`; `hslOn` ở index 12; `gh/gs/gl` ở 37/38/39.
6. **`toneIsActive`** — `{ ...all zero, hslOn: 1 }` → `true`; `{ ...all zero }` → `false`.
7. **`getToneUniforms`** — `baseFilter: 'monochrome'` + `hslBands` có giá trị ⇒ `hslOn === 0`; `colour` + `hslLum: 10` ⇒ `gl === 1`, `hslOn === 1`.
8. **Round-trip `.recipe`** — recipe có `hslBands: { blue: [3,-2,5], red: [0,0,0] }` export rồi import: `red` biến mất, `blue` giữ nguyên; `hslHue/Sat/Lum` sống qua vòng.
9. **Máy / emulator** (kiểm tra số, không nhìn ảnh):
- Preview camera với `+10` dải RED và ảnh có mảng đỏ: sample pixel đỏ trước/sau bằng `readPixels` trên cùng toạ độ ⇒ hue trôi ~+30°.
- `-10` SAT dải RED trên vùng đỏ ⇒ saturation về ~0; `+10` LUM ⇒ lightness tăng ~0.25.
- `hslLum = +10` trên một ảnh đã `saturation = -10`: lightness vẫn phải tăng (kiểm tra `gl` không bị gate).
- Monochrome stock: uniform HSL toàn 0 bất kể `hslBands` có gì.
- Export ra file: pixel cuối phải khớp pixel preview (cùng một `toneUniformArray`).
### 9.2 Tham chiếu hành vi từ web
Các script e2e của web là **hợp đồng hành vi** để đối chiếu, nằm trong scratchpad:
```
/home/locpham/.penguin/data/default_project/agents/default_agent/scratchpad/session-2026-09-06-18-30-42-c24c8c7b/
hsl-test.cjs # chips dải, setBandKnob, xoá dải toàn 0
hsl-panel-test.cjs # PICK -> pickColor -> panel nổi -> knob panel
hsl-shader-test.cjs # partition of unity + kết quả shader theo hue
hsl-bundle.cjs # build standalone
hsl-entry.ts # entry
```
Web đã deploy (`docker/frontend`) là bản chạy được để so từng bước: mở tab `hsl`, PICK một màu, kéo HUE/SAT/LUM trên panel và trên ruler `IMAGE`, đối chiếu `R n · G n · B n` / `h° · s% · l%`.
---
## 10. Thứ tự thực hiện (đề xuất)
1. `types` → 2. `colorUtils` (HSL_BANDS … sanitize) → 3. `toneShader` (4.1–4.8) → 4. self-check 9.1.1–9.1.7 chạy pass → 5. `defaultRecipes` + `recipeShare` → 6. `Viewfinder` buffer (7.1) — **làm trước UI**, nếu không preview camera sẽ vỡ → 7. `ToolRail` + `AdjustmentPanel` chip/readout → 8. `Viewfinder` eyedropper + panel + state App → 9. máy thật.
-123
View File
@@ -1,123 +0,0 @@
# Ghi chú port web → Android: sim `CLASSIC VIVIDIPES` + chip FX `MONOCHROME`
Nguồn: web studio `docker/frontend` (nhánh `recipes-web`), commit `d7d7be2` (sim mới) và
`4d0d441` (chip FX). Việc dưới đây là **port sang app Android** (`src/`, nhánh này).
Đối chiếu: `src/` giữa `recipes-web` và `feat/vision-camera-v5` **chỉ khác**
`src/components/RecipeCreateModal.tsx` (+9 dòng) — nên port gần như là chép y nguyên.
Số dòng dưới đây tính theo nhánh này.
---
## 1. Sim mới `CLASSIC VIVIDIPES`
Ghép tại dòng blue: **giữ nguyên dòng blue của `classic-chrome`**, lấy **dòng R/G của `velvia`**.
Sửa 4 file:
1. `src/types/index.ts:65` — `BaseFilter`: thêm `'classic-vivid'`.
2. `src/utils/colorUtils.ts` — thêm nhánh **ngay sau** nhánh `classic-chrome` (dòng 111):
```ts
} else if (baseFilter === 'classic-vivid') {
// Classic Vivid: the Classic Chrome blue row kept verbatim — skies stay the
// same muted teal/cyan — with the red and green rows taken from Velvia, so
// everything that is not blue reads loud.
matrix = [
1.3895, -0.2540, -0.0256, 0, -0.0550, // R — velvia
-0.0772, 1.2355, -0.0261, 0, -0.0661, // G — velvia
0.0652, 0.3973, 0.5575, 0, -0.0100, // B — classic-chrome (nguyên bản)
0, 0, 0, 1, 0,
];
```
3. `src/utils/defaultRecipes.ts:43` — `FILM_SIMS`, thêm ngay sau `sim-classic-chrome`:
```ts
{ id: 'sim-classic-vivid', name: 'CLASSIC VIVIDIPES', baseFilter: 'classic-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS } },
```
4. `src/utils/toneShader.ts:190` — `FILM_TONE`, cạnh `'classic-chrome'`:
```ts
'classic-vivid': { sh: -0.28 },
```
(Dìm shadow thuộc về stock chứ không thuộc dòng ma trận → đi kèm. Bỏ dòng này nếu muốn
VIVIDIPES không dìm shadow.)
**Không phải sửa gì thêm**: chip `PHOTO STYLE` (`AdjustmentPanel.tsx` `groupDefs.style`), TopBar
(`activeRecipe.name`) và picker trong `RecipeCreateModal.tsx` đều map từ `FILM_SIMS`.
**Kiểm chứng**: strip PHOTO STYLE có **10** sim; chọn `CLASSIC VIVIDIPES` → look khác **cả**
`CLASSIC CHRIPES` lẫn `VELVIPES`; `CLASSIC CHRIPES` vẫn nguyên vẹn cạnh nó.
---
## 2. Chip `MONOCHROME` ở tab FX (`filters`)
Đây là **công tắc**, không phải một look: bật thì đổi `baseFilter` sang `monochrome`, tắt thì
trả lại đúng cái ảnh đang mặc (kể cả tên sim). **Không được đụng `adjustments`** — knob chỉnh
trong lúc đang bật phải sống sót.
Web: `docker/frontend/src/App.tsx` — `monoPrev` ref + `toggleMono`, chip `mono` đứng đầu
`case 'fx'`, `active: monoOn` với `monoOn = recipe.baseFilter === 'monochrome'`.
Android cần 2 chỗ:
**a. `src/components/AdjustmentPanel.tsx`** — `case 'filters'` (dòng 888):
```ts
case 'filters':
return chipRow([
{ key: 'mono', label: 'MONOCHROME', active: monoOn, amberValue: monoOn, onPress: onToggleMono },
...paramChips(paramDefs.filters),
]);
```
+ thêm 2 prop vào `interface AdjustmentPanelProps`: `monoOn: boolean;` và `onToggleMono: () => void;`
+ truyền từ `App.tsx` chỗ render `<AdjustmentPanel … />` (`App.tsx:1929` có `currentBaseFilter`,
`onSelectRecipe` — đặt cạnh đó).
**b. `App.tsx`** — Android giữ `adjustments` là state **riêng** (không nằm trong `selectedRecipe`),
nên công tắc chỉ chạm `selectedRecipe`:
```ts
// FX's MONOCHROME switch: not a look of its own — it swaps the base filter for
// the mono stock and swaps right back. Adjustments are never touched.
const monoOn = selectedRecipe?.baseFilter === 'monochrome';
const monoPrevRef = useRef<Recipe | null>(null);
const handleToggleMono = () => {
commit(true, () => {
if (selectedRecipe?.baseFilter === 'monochrome') {
const back = monoPrevRef.current ?? filmSimToRecipe(FILM_SIMS.find((f) => f.id === 'sim-provia')!);
setSelectedRecipe(back);
} else {
monoPrevRef.current = selectedRecipe;
setSelectedRecipe({
...(selectedRecipe ?? filmSimToRecipe(FILM_SIMS[0])),
id: 'mono',
name: 'MONOCHROME',
baseFilter: 'monochrome',
});
}
});
};
```
Lưu ý:
- `commit(true, …)` (dòng 739) để công tắc vào **undo stack** như mọi edit khác — `handleResetAll`
dùng đúng cách này.
- `Look` đã có `recipe` → UNDO tự trả lại đúng `baseFilter` cũ, không cần state phụ nào khác.
- `id: 'mono'` không trùng sim nào nên khi bật, chip PHOTO STYLE **không** còn sáng tên sim cũ
(`groupDefs.style.value` tính từ `currentRecipeId` so với `FILM_SIMS`).
- Tắt khi `monoPrevRef` rỗng (ví dụ vào monochrome bằng cách chọn sim ACRIPES, không qua công tắc)
→ rơi về PROVIA, tức là "tắt là có màu", không phải về đen trắng.
**Kiểm chứng**: bật → ảnh xám (mọi pixel `R=G=B`; chỉ lệch khi WB khác 5500K hoặc tint khác 0);
tắt → ảnh trở lại **y hệt** trước khi bật (so hash pixel); đổi `CONTRAST` trong lúc bật/tắt →
giá trị vẫn giữ; UNDO một nhịp → về màu, chip trở lại trạng thái tắt.
---
## 3. Test web đã chạy (tham chiếu — nằm trong scratchpad của agent, không nằm trong repo)
- `sim-vivid-test.cjs` — **9/0**: chip có mặt, strip đủ 10 sim, chọn được, render khác cả
CHRIPES lẫn VELVIPES.
- `fx-mono-test.cjs` — **15/0**: đo trực tiếp kênh màu trên pixel preview (spread `>25` khi màu,
`<=6` khi mono), hash quay lại y hệt khi tắt, CONTRAST sống sót, UNDO gỡ mono.
- Regression web quanh 2 thay đổi: `panel`, `layout`, `fonts 27/0`, `create 52/0`, `share 22/0`,
`photo-save 19/0`, `redo 30/0`, `accent-brand` — tất cả xanh.
-2
View File
@@ -82,8 +82,6 @@ Exit: user QA đạt → mặc định native; legacy chỉ fallback.
- Nhắc: nếu npm install chạy lại → node_modules vision-camera về pristine, áp lại patch:
`cd RecipesCam && git apply patches/vision-camera-shutter-silent.patch`
(artifact = diff `HybridPhotoOutput.kt`: shutter OFF thật silent — bỏ OR mustPlayShutterSound).
`cd RecipesCam && git apply patches/vision-camera-isp-off.patch`
(artifact = 3 hunk, 2 file: `HybridPhotoOutput.kt` cho mọi still JPEG, `modules/recipescam-ultrawide/.../UltraWideModule.kt` cho đường 0.5x — cùng cặp cờ NOISE_REDUCTION_MODE + EDGE_MODE của ISP = OFF).
## Rủi ro / ghi chú
- Local module phải bắt chước đúng cấu trúc expo package (đã đọc expo-media-library: `plugins { id 'com.android.library'; id 'expo-module-gradle-plugin' }`, expo-module.config.json khai báo class Kotlin; autolinking quét `./modules`).
-64
View File
@@ -1,64 +0,0 @@
# Play release notes
Text for Google Play Console → Release → "What's new". One entry per version, newest
first. Plain text only (the Console renders no markdown), 500 characters max per
language — each block below is measured and fits. Paragraphs are one line each so a
copy-paste lands in the Console unwrapped.
Release name field: `1.2.7 (9)`
## 1.2.7 (versionCode 9)
```
Photos taken in dim light and at high ISO came out much noisier than the preview showed. Every shot was being sharpened in the background by a fixed amount the viewfinder never applied, and it amplified sensor noise about three times over.
That hidden sharpening is gone, so high-ISO shots now come back as clean as the preview promised. The SHARPENING slider still sharpens exactly as before when you move it above 0.
```
419 characters (1.2.7).
Artifact: `android/app/build/outputs/bundle/liteRelease/app-lite-release.aab`
(appId `com.locphamtran.recipescamera`, versionCode 9, versionName 1.2.7).
The pro APK built from the same commit is `app-pro-release.apk`, versionName
`1.2.7-pro` - it is the paid build handed out directly, not uploaded here.
## 1.2.6 (versionCode 8)
```
New film simulation B&W HIGH CONTRAST, next to ACRIPES: a warm-filter black-and-white mix, with deeper blacks and lifted whites, so the greys take a harder push.
Its contrast comes from the stock itself, so the EXPOSURE and CONTRAST sliders stay at 0 while the shot renders pushed.
```
281 characters (1.2.6).
Artifact: `android/app/build/outputs/bundle/liteRelease/app-lite-release.aab`
(appId `com.locphamtran.recipescamera`, versionCode 8, versionName 1.2.6).
The pro APK built from the same commit is `app-pro-release.apk`, versionName
`1.2.6-pro` - it is the paid build handed out directly, not uploaded here.
## 1.2.5 (versionCode 7)
```
Film simulations are now pure colour and tone: each one is the stock's own colour matrix and curve, with no grain, clarity, exposure or saturation baked in - those are your edits to make.
LC STREETLIFE VIVID keeps its brightness from the stock itself, so the EXPOSURE slider stays at 0 while the shot comes out lifted.
```
Artifact: `android/app/build/outputs/bundle/liteRelease/app-lite-release.aab`
(appId `com.locphamtran.recipescamera`, versionCode 7, versionName 1.2.5).
The pro APK built from the same commit is `app-pro-release.apk`, versionName
`1.2.5-pro` - it is the paid build handed out directly, not uploaded here.
## 1.2.4 (versionCode 6)
```
HSL selective colour is here: eight hue bands (red, orange, yellow, green, aqua, blue, purple, magenta), each with its own HUE, SAT and LUM, plus HUE/SAT/LUM IMAGE knobs that move every colour at once.
In the library, tap PICK and touch any colour on the photo: a panel opens on that spot with the three knobs of the band it belongs to.
The tool rail is reordered: presets, favorites, white balance, light, effects, HSL, frame.
```
427 characters (1.2.4).
Artifact: `android/app/build/outputs/bundle/liteRelease/app-lite-release.aab`
(appId `com.locphamtran.recipescamera`, versionCode 6, versionName 1.2.4).
The pro APK built from the same commit is `app-pro-release.apk`, versionName
`1.2.4-pro` — it is the paid build handed out directly, not uploaded here.
+78
View File
@@ -0,0 +1,78 @@
# RecipesCam
RecipesCam is a camera and photo-editing app built around **recipes** — reusable
looks that carry a film simulation plus a full set of adjustments. You shoot or
open a photo, dial in a look, and keep it as a recipe you can apply again, share
as a `.recipe` file, or save to your account.
It ships twice from one repository: a **React Native (Expo) app** for iOS and
Android, and a **self-contained web build** under `docker/` that runs the same
render pipeline in the browser.
## What it does
- **Shoot with a recipe applied.** Live viewfinder, GPS-tagged captures, and the
recipe baked into the saved file.
- **Film simulations.** Built-in looks — PROVIPES, VELVIPES, CLASSIC CHRIPES,
CLASSIC NEGIPES, ASTIPES, ETERNIPES, ACRIPES, B&W HIGH CONTRAST and the LC
STREETLIFE pair — each with its own grain and tone curve.
- **The full adjustment set.** Exposure, contrast, highlights and shadows,
saturation, colour temperature, clarity, grain, and an HSL mixer with a
colour picker that samples straight off the photo.
- **Geometry.** Crop to a fixed ratio or free-form, quarter turns, and a
straighten ruler, plus printed frames (classic border, retro instant, wall
frame portrait/landscape).
- **Finishing.** Watermark and GPS stamp, EXIF carried through the export, JPEG
written with a proper 300 DPI JFIF header.
- **On-device upscaling.** A Real-ESRGAN pass runs locally when an export asks
for more pixels than the source has — no server sees the photo.
- **Recipes.** Save, favourite, export and import `.recipe` files; the web
build keeps them in your account, the phone build also keeps them on device.
## The two builds
| Build | Where | Stack |
| --- | --- | --- |
| iOS / Android | repo root | Expo + React Native, `@shopify/react-native-skia` for the render pipeline, NativeWind for styling |
| Web | `docker/` | Vite + React, CanvasKit (`canvaskit-wasm`) for the same pipeline, Fastify + SQLite API for accounts and recipes |
The render engine is shared by design: the web build compiles the app's own
`src/utils/*` and type definitions unchanged, with `@shopify/react-native-skia`
aliased to a CanvasKit shim (`docker/frontend/src/engine/skiaShim.ts`). A look
looks the same on both because it is the same code.
## Running the web build
```bash
cd docker
cp .env.example .env
docker compose up -d --build
# → http://localhost:8090
```
Photos never leave the browser: grading, framing, watermarking and JPEG
encoding all run in the visitor's tab; the API only stores accounts and recipe
JSON. See `docker/README.md` for the layout and the proxy setup.
## Running the app
```bash
npm install
npx expo start # Expo Go / dev client
npx expo run:android # or run:ios for a native build
```
## Repository layout
```
App.tsx, src/ the Expo app: screens, tool rail, viewfinder, shaders
docker/ the web build (frontend + API + compose file)
frontend/shared/ vendored copies of the app's types and utils
frontend/src/engine/ CanvasKit shim, export engine, super-resolution
docs/ privacy policy
THIRD_PARTY_NOTICES.md licences of the bundled fonts, models and libraries
```
## Licence
See `LICENSE` and `THIRD_PARTY_NOTICES.md`.
-47
View File
@@ -1,52 +1,5 @@
# Release notes
## 1.2.7 (versionCode 9)
Export
- Every camera capture came out of the exporter through a full-resolution unsharp mask the viewfinder never showed. App.tsx passed `sharpen: true`, and with the SHARPENING knob at its default 0 both engines read that flag as an *amount* of 0.5 - a value that existed only as the boolean's fallback. The 3x3 kernel `[0,-a,0; -a,1+4a,-a; 0,-a,0]` at a=0.5 has a noise gain of 3.167x at low frequency and 5.0x at Nyquist, so a dim, high-ISO frame was pushed back several times noisier than the preview promised.
- The knob is now the only source of output sharpening. The boolean is gone from ExportOptions and NativeExportOptions, the 0.5 fallback is gone with it, and screenSharpenImage no longer carries `amount = 0.5` as a default argument - a second door onto the same value. A knob above 0 sharpens exactly as before, the negative knob still means none, and the Kotlin-side `adjust["sharpen"]` amount is untouched.
- Measured on a Xiaomi thor, 3000x4000 captures in one unchanged scene. At 1x / ISO ~330 the noise sigma drops 4.52 -> 2.35, the dark-quartile sigma 3.21 -> 1.56 and a flat 64px tile 0.599 -> 0.349; at 10x / ISO ~1900 the same three fall 1.22 -> 1.09, 0.75 -> 0.54 and 0.464 -> 0.225. With no edit applied, the JPEGs shrink 26% and 35% - the mask had also been spending a third of every file on amplified noise.
- Stills now match the preview, which always rendered without the mask.
Notes
- qualityPrioritization was re-tested as the other suspect and cleared: MAXIMIZE_QUALITY ('quality') moved neither noise figure at either ISO, so the shipped 'balanced' capture mode stays.
- Zoom to 10x in the same room is the high-ISO bench for this device: AE picks ISO ~1900-2100 there against ~330 at 1x.
## 1.2.6 (versionCode 8)
Presets
- New film sim B&W HIGH CONTRAST, next to ACRIPES. Acros' grey ramp taken through a warm filter mix - red 0.38 / green 0.56 / blue 0.06, the three rows equal and summing to 1.00, so a neutral stays the neutral the sensor recorded and nothing clips at either end - and then pushed at both ends: the toe goes to -0.32 against Acros' -0.12, and the shoulder LIFTS to +0.26 instead of rolling off. Midtones see neither mask, so the long smooth stretch between the two ends survives, which is what keeps that hard a push off the posterised look.
- The stock's own slope (+4 of Contrast) is not in the sim's adjustments: the panel still reads EXPOSURE 0 / CONTRAST 0, and FX still reads GRAIN 0 / CLARITY 0, while the render comes out pushed. It rides STOCK_BIAS in colorUtils.ts, keyed by baseFilter - the old STOCK_EXPOSURE table folded in, one place for the tone a stock carries by itself (LC STREETLIFE VIVID exposure +2, B&W HIGH CONTRAST contrast +4).
- Two black-and-white stocks exist now, so every "is this a colour stock?" test goes through the new isMonochromeBase(): saturation, white balance and the R/B fine-tune in the matrix, Color Chrome in the tone shader. A second B&W stock can no longer end up half excluded - a stray Kelvin gain on a grey ramp shows up as a tint.
- Measured against the compiled tables: a 0.25 grey goes 0.2500 -> 0.2250 (the +4 slope pivoting on mid-grey 0.5), a full red goes 0.213 -> 0.368 while blue drops to 0.016, and colour-stage knobs moved hard (saturation +8, white balance 9000K, wbR +5 / wbB -5) leave both B&W matrices byte-identical.
- On a Xiaomi thor: the chip sits right after ACRIPES, its preview reads mean chroma 0.00 against 17.48 for a colour sim, and the frame's luma spread widens as designed (sd 92.3 / p95 245 against ACRIPES' 88.9 / 238).
## 1.2.5 (versionCode 7)
Presets
- A film sim is a stock, not an edit: every entry in FILM_SIMS now carries the neutral defaults verbatim, and its whole look comes from the stock's own colour matrix plus its FILM_TONE curve. The effects that had been riding along are gone - grain on ACRIPES, clarity+grain on LC STREETLIFE CLASSIC, and exposure/saturation/clarity/shadow/highlight on LC STREETLIFE VIVID.
- LC STREETLIFE VIVID is brighter because the stock is brighter, not because a slider was moved: the +2 exposure lives in STOCK_EXPOSURE (colorUtils.ts), keyed by baseFilter, where the preview and both exporters already read it. It never reaches the panel, so EXPOSURE still reads 0 on that sim.
- The naming rule is recorded in the FILM_SIMS header: colour and tone only, plus a reference table of the real stock each sim stands for. Reference only - the UI keeps the sim names.
- Measured against the compiled tables: a mid grey goes 0.5000 -> 0.5435 under LC STREETLIFE VIVID, a ratio of 1.1008 against exposure +2's own 1.1000, while the sim's own exposure value stays 0.
## 1.2.4 (versionCode 6)
HSL
- New HSL tab in the tool rail: the web studio's 8-band selective colour mixer. Each band (RED, ORANGE, YELLOW, GREEN, AQUA, BLUE, PURPLE, MAGENTA) carries hue, saturation and lightness knobs, -10..+10; -10 on SAT is grey, full HUE is a 30 deg turn, full LUM is +0.25.
- Band weights are linear tents anchored on the 8 hues and summing to 1 at every hue, so neighbouring bands hand over at exactly half strength and no hue falls in a gap. Pixels under 8% saturation are gated out of the weights (rgb2hsl calls them red, and an ungated neutral would ride the RED band), while the whole-image LUM move is added after the gate, so a picture pulled to grey by -SAT still answers +LUM.
- The mixer runs last in TONE_SKSL, after split-tone/kv and vibrance, so a band edit is judged on the colour the user actually sampled.
- Library mode gains an eyedropper: PICK arms a layer over the picture, a tap reads the rendered pixel, points the ruler at the nearest band and hangs a panel with that band's three knobs. In camera mode PICK is greyed out - a live preview has no pixel to read - but the mixer still runs in the preview.
- HUE / SAT / LUM IMAGE move every hue at once, behind an IMAGE divider in the same chip row.
Rail
- Tool rail order: PRESETS, FAVORITED, WB, LIGHT, FX, HSL, FRAME.
Notes
- HSL is a LITE feature: both builds get it, no PRO gate.
- MONOCHROME disables the mixer outright - a mono stock has no hue to select.
- The camera worklet's tone buffer grew from 10 to 40 slots; a missed slot would have pushed NaN into every uniform and broken the whole tone pass, not just HSL.
- Measured on a Xiaomi thor against an 8-band chart: +10 whole-image HUE rotates all eight bands by exactly +30 deg, -10 on a band's SAT flattens that band to grey while the other seven stay put, and a whole-image -SAT drops the camera preview's mean chroma from 13.7 to 0.4 with +LUM still lifting it 116 -> 166.
## 1.2.3 (versionCode 5)
Shutter
+249
View File
@@ -0,0 +1,249 @@
# WEB_PLAN — RecipesCam Web (Docker, branch `recipes-web`)
Trạng thái: kế hoạch đã chốt yêu cầu, chưa code. Ngày: 2026-09-06.
Nguồn đối chiếu: app RN hiện tại (`App.tsx`, `src/components/*`, `src/utils/*`).
## Mục tiêu
Một web app chạy bằng Docker, gồm 2 phần:
1. **Landing page** (`/`, responsive cả desktop lẫn mobile) — giới thiệu app RecipesCam: hero, tính năng, ảnh before/after, review người dùng, badge Google Play / App Store, CTA **DÙNG THỬ DEMO** và **ĐĂNG KÝ**.
2. **WebUI chỉnh ảnh** (`/app`) — tái hiện giao diện app điện thoại:
- **Cột trái**: rail 6 tab (PRESETS / FAVORITED / LIGHT / WB / FX / FRAME); chip công cụ của tab đang chọn nằm trong cột đó.
- **Giữa**: khung ảnh lớn, **kéo-thả ảnh từ máy tính** vào để nạp (kèm chọn file + paste clipboard). Trên mobile: nút chọn ảnh / camera roll.
- Đầy đủ tool như app → **EXPORT / RENDER** ra JPEG (DPI 300) tải về.
- **Add-on BATCH EXPORT**: nạp nhiều ảnh, áp cùng 1 recipe (của người dùng hoặc bundle), export ra cùng một tông màu.
3. **Tài khoản**: bắt buộc đăng nhập để dùng bản đầy đủ; vẫn cho **demo không cần đăng nhập**.
4. Toàn bộ xử lý ảnh chạy **client-side** (không upload ảnh lên server) — server chỉ phục vụ file tĩnh + API tài khoản/recipe.
## Quyết định đã chốt (2026-09-06)
1. **Cổng/domain**: chạy `IP:port` (mặc định `8090`), đổi được qua `docker-compose.yml`; domain thật gắn sau bằng **Nginx Proxy Manager** bên ngoài → không cần TLS trong container, chỉ cần `X-Forwarded-*` đúng.
2. **Auth/multi-user**: có. Đăng ký/đăng nhập email + mật khẩu, session cookie; **vẫn cho dùng demo** không cần tài khoản.
3. **Batch export**: có, là công cụ add-on cho người dùng đã đăng nhập — nhiều ảnh dùng chung 1 recipe, render tuần tự có tiến trình.
4. **Responsive**: hỗ trợ cả desktop và mobile browser.
5. **EXIF GPS**: đọc GPS từ ảnh nạp vào để tự điền watermark; nếu ảnh không có GPS thì cho nhập tay (place name + toạ độ).
6. **Review trên landing**: nội dung tĩnh trong repo (`web/src/content/reviews.json`) — sửa file + rebuild; không cần CRUD/admin ở v1.
`ponytail:` seed tĩnh là đủ vì review đổi rất chậm; nâng lên bảng DB + admin API khi cần tự đăng review.
7. **Theme**: nhiều bộ theme — `light` / `dark` + các nhóm màu accent (xem mục Theme).
8. **Đa ngôn ngữ**: có — tiếng Việt + tiếng Anh ở v1 (xem mục i18n).
### Phân hạng tính năng
| Hạng | Điều kiện | Quyền |
|---|---|---|
| Guest | không đăng nhập | Landing, xem review, **DEMO**: nạp 1 ảnh, dùng tool (cấu hình ở `web/src/config/tiers.ts`), export **tối đa 2048px + watermark "RecipesCam demo"**, không lưu recipe |
| User | đã đăng nhập | Toàn bộ tool như app, export **full-res không watermark**, lưu/favorite/share recipe (đồng bộ server), **BATCH EXPORT** |
Bảng hạng để trong 1 file config duy nhất để đổi chính sách mà không sửa UI.
## Theme (light/dark + nhóm màu)
- **Cơ chế**: CSS custom properties trên `:root`, chuyển bằng thuộc tính `data-theme` + `data-accent` trên `<html>`; mọi màu trong UI đi qua token, không hardcode. Đổi theme = set 2 attribute, không re-render React.
- **Theme nền**: `light`, `dark` (mặc định theo `prefers-color-scheme`, người dùng chọn được thì ghi đè). Dark dùng nền đen thuần theo `web-design`.
- **Nhóm màu accent**: `amber` (mặc định — đúng chất RecipesCam), `blue`, `emerald`, `rose`, `violet`. Accent chỉ đổi token `--accent*`, không đổi layout.
- **Lưu**: `localStorage` (`theme`, `accent`); đồng bộ sang `<meta name="theme-color">` cho mobile browser.
- **UI chọn**: popover trong header (icon bảng màu) trên cả landing lẫn workspace; landing cũng đổi theme được.
- `ponytail:` 2 theme nền + 5 accent là cấu hình dữ liệu trong `web/src/theme/themes.ts`; thêm theme mới = thêm 1 object, không sửa component.
## Đa ngôn ngữ (i18n)
- **Ngôn ngữ v1**: `vi` (mặc định theo `navigator.language` nếu là tiếng Việt) + `en`. Thêm ngôn ngữ = thêm 1 file từ điển.
- **Cách làm**: tự viết, **không thêm thư viện** — từ điển `web/src/i18n/vi.ts` / `en.ts` (object lồng nhau, có kiểu từ `vi` để `en` thiếu key là lỗi typecheck), `t(key, params)` + `I18nProvider` giữ `lang` trong `localStorage`. Đổi ngôn ngữ = đổi state, không reload.
- **Phạm vi dịch**: toàn bộ nhãn UI (rail tab, chip, slider, tooltip, nút, thông báo lỗi, landing/review/auth form), tiêu đề trang + SEO (`<html lang>`, OG locale).
- **Không dịch**: tên film sim (`PROVIPES`, `LC STREETLIFE CLASSIC`…), tên recipe người dùng đặt, nội dung watermark người dùng nhập — là danh từ riêng/thương hiệu, giữ nguyên ở mọi ngôn ngữ.
- `ponytail:` từ điển là file TS được bundle; chỉ tách lazy-load theo ngôn ngữ khi bundle vượt ~50KB text.
## Kiến trúc
```
docker-compose.yml
├── web nginx:alpine 8090:80 static SPA (Vite dist) + proxy /api → api
└── api node:20-alpine 3000 Fastify + better-sqlite3, volume ./data
```
- **Frontend**: Vite + React + TS + react-router (`/`, `/app`, `/login`, `/signup`).
- **Engine ảnh**: **CanvasKit (canvaskit-wasm)** trong Web Worker — nhưng **bắt buộc trên surface WebGL** (`MakeWebGLCanvasSurface(new OffscreenCanvas(w,h))`), không phải raster CPU: xem W0. Preview bản thu nhỏ (~2048px), render full-res khi export.
- **API**: Fastify + better-sqlite3 (1 file DB, không cần container DB riêng). Session cookie `httpOnly` + `SameSite=Lax`, mật khẩu hash `argon2`. Bảng: `users`, `sessions`, `recipes`, `favorites`.
- **Recipe bundle** lấy thẳng từ `src/utils/defaultRecipes.ts` (client-side) — server chỉ lưu recipe người dùng tạo.
- Nginx Proxy Manager bên ngoài lo TLS/domain; container chỉ nghe HTTP.
### Vì sao CanvasKit
Tái dùng gần như nguyên vẹn `src/utils/exportEngine.ts`: CanvasKit có `RuntimeEffect` (SkSL y nguyên `TONE_SKSL`, `CINEMA_SKSL`), `ColorFilter.MakeMatrix`, `ImageFilter.MakeBlur`/`MakeCompose`, `BlendMode.Overlay`, `drawImageRect`, `MakeFreeTypeFaceFromData`, `encodeToBytes(JPEG, 95)` → parity cao nhất với app, code ít nhất.
Hai chỗ CanvasKit khác Skia JSI (đã xử lý ở W0): encode JPEG cần build `canvaskit-wasm/full`; không có `ImageFilter.MakeMatrixConvolution` → CLARITY/sharpening đi qua `CLARITY_SKSL`.
**Không** dùng `react-native-web`/Expo web: app phụ thuộc `react-native-vision-camera` + Skia JSI, web sẽ vỡ.
## Tái sử dụng (không viết lại)
| Nguồn trong repo | Dùng cho web |
|---|---|
| `src/utils/colorUtils.ts` | `getSkiaColorMatrix`, `applyExposureGain`, `kelvinToRGB` — nguyên văn |
| `src/utils/toneShader.ts` | `TONE_SKSL` + `getToneUniforms` |
| `src/utils/cinemaShader.ts` | `CINEMA_SKSL` + `getCinemaUniforms` |
| `src/utils/frameUtils.ts` | `FRAMES`, `polaroidLayout`, `wallframeLayout`, `drawFrameOnCanvas`, hằng số POLAROID/WALLFRAME |
| `src/utils/paramDefs.ts` | `PARAM_DEFS` (slider bounds/label/format) → render chip + slider |
| `src/utils/defaultRecipes.ts` | `FILM_SIMS`, `DEFAULT_ADJUSTMENTS`, recipe bundle |
| `src/utils/jpegDpi.ts` | `patchJpegDpi(bytes, 300)` — chạy trong worker |
| `src/utils/recipeShare.ts` | IMPORT/EXPORT recipe chia sẻ giữa app ↔ web (cùng định dạng) |
| `src/types/index.ts` | `ColorAdjustments`, `Recipe`, `FrameId`, `AspectRatio`, `CropRatio`, `CropRect`, `ASPECT_RATIO_W_H`, `CROP_W_H` |
| `assets/Cousine-Regular.ttf`, `assets/NotoEmoji-GPS.ttf`, `wallframe.png` | font + artwork frame |
Viết mới: `web/src/engine/skiaShim.ts` (adapter API Skia trên CanvasKit) + `web/src/engine/exportEngine.ts` (port từ `src/utils/exportEngine.ts`, bỏ `expo-file-system`/MediaLibrary → Blob/download).
## Bản đồ UI app → web
| Khu vực app | Web |
|---|---|
| `ToolRail` (rail ngang dưới màn hình) | Cột dọc bên trái, 6 tab; mobile: rail ngang dưới đáy |
| `AdjustmentPanel` chip row + strip | Cột thứ 2 cạnh rail (~280–320px): chip của tab + strip/slider mở tại chỗ |
| `Viewfinder` | Canvas giữa, nhận drag&drop file ảnh |
| Drag-on-image để đổi slider | Giữ: kéo dọc trên ảnh = chỉnh param đang mở |
| PEEK (giữ để so sánh) | Giữ chuột phải hoặc phím `\` |
| `TopBar` | Header: tên recipe, UNDO/RESET, SAVE AS, EXPORT, IMPORT/EXPORT recipe, tài khoản, **đổi theme**, **đổi ngôn ngữ**; ẩn mục camera-only |
| `PhotoViewerModal` | Strip thumbnail kết quả export ở đáy |
| `SettingsModal` | Popover settings; bỏ mục camera-only; giữ PHOTO RATIO |
### Danh sách tool đầy đủ theo tab (lấy từ code hiện tại)
- **PRESETS**: PHOTO STYLE (`provia`, `velvia`, `classic-chrome`, `classic-neg`, `astia`, `eterna`, `monochrome`, `leica`, `leica-vivid`) · RECIPES (bundle + user, có star) · CREATE · IMPORT.
- **FAVORITED**: recipe đã star.
- **LIGHT (iq)**: EXPOSURE · EV · CONTRAST · COLOR · VIBRANCE · HIGHLIGHT · SHADOW · D.RANGE (AUTO/DR100/200/400).
- **WB**: TEMP (AUTO / DAYLIGHT / DAYLIGHT -3R / CLOUDY / SHADE / TUNGSTEN / FLUOR + slider COLOR TEMP) · TINT · COLOR CHROME · CHROME BLUE.
- **FX (filters)**: NOISE REDUCTION · CLARITY · SHARPENING · MONOCHROME GRAIN · HDF EFFECT · VIGNETTING.
- **FRAME**: NO FRAME / CLASSIC BORDER / RETRO INSTANT (polaroid) / WALL FRAME (+ WALL LANDSCAPE) · CROP (none/free/1:1/2:3/3:2/3:4/4:3/16:9 + APPLY) · ROTATE (0/90/180/270 + STRAIGHTEN + AUTO STRAIGHTEN) · WATERMARK (GPS WATERMARK ON/OFF, PLACE NAME, TIME, TEXT COLOR/SIZE/FONT/ROTATE, CUSTOM WATERMARK) · PHOTO RATIO (FULL/4:3/3:2).
### Metadata khi export — phải khớp app (300 ppi + EXIF)
Đường export mặc định của app (`src/utils/exportEngine.ts`, tức `EXPO_PUBLIC_NATIVE_EXPORT != 1`) ghi JPEG đúng 3 bước, **theo thứ tự này**:
1. `encodeToBytes(JPEG, 95)` — Skia re-encode **làm mất toàn bộ EXIF nguồn**.
2. `patchJpegDpi(bytes, 300)` — JFIF density `units=1 X=Y=300` (kèm `XResolution`/`YResolution` nếu JPEG có EXIF).
3. `writeJpegExif(bytes, stamp, sourceBytes)` — dựng lại APP1/EXIF: **copy từ ảnh nguồn mọi tag app không sở hữu** (hãng máy, model, lens, phơi sáng, ISO…), **bỏ IFD1 thumbnail + MakerNote**, rồi đóng dấu `Software = "RecipesCam 1.2"`, `DateTimeOriginal`, GPS, tên địa điểm (0x889d), blob thiết bị (0x889e), kích thước pixel, dpi.
Web phải làm y hệt, cùng thứ tự, **giữ nguyên kích thước pixel và 300 ppi**:
- `src/utils/exifWrite.ts` và `src/utils/jpegDpi.ts` **không có import nào** → tái dùng/port **nguyên văn**, chạy trên `Uint8Array` trong worker; web đã có sẵn bytes ảnh nguồn nên không cần `readPhotoBytes`.
- Chỉ `src/utils/photoMeta.ts` mới dính Expo (`getDeviceIdentity`) → web truyền `device` từ nguồn khác hoặc để `null` (mọi field của `ExifStamp` đều optional).
- Trạng thái hiện tại: `web/src/engine/exportEngine.ts` mới có bước 1+2 (đã kiểm chứng: JFIF `x=300 y=300`) — **bước 3 chưa port** → làm ở W4. Nhớ `patchJpegDpi` phải chạy **trước** `writeJpegExif`, và truyền `dpi` vào stamp để `XResolution`/`YResolution` khớp JFIF.
### Khác biệt web (bắt buộc xử lý)
- **GPS watermark**: đọc GPS từ EXIF ảnh nạp vào (`exifr`); ảnh không có GPS → cho nhập tay place name + toạ độ. Reverse-geocode tên địa điểm là tuỳ chọn (gọi Nominatim), mặc định tắt. Cùng GPS đó cũng đi vào EXIF xuất ra (bước 3 ở trên), không chỉ vào watermark.
- **Crop/rotate/straighten**: thao tác chuột/chạm trực tiếp trên canvas.
- **Export**: CanvasKit `encodeToBytes(JPEG, 95)` → `patchJpegDpi(300)` → `writeJpegExif(...)` → tải `recipescam_<tên>_<timestamp>.jpg`; batch → ZIP (`fflate`, ~8KB) hoặc tải lần lượt. Không re-encode lần hai, không đổi kích thước pixel.
- **Trạng thái**: `localStorage` thay `AsyncStorage`; recipe đã lưu đồng bộ server khi đã đăng nhập.
## Cấu trúc thư mục đề xuất
```
web/
Dockerfile nginx.conf docker-compose.yml
package.json vite.config.ts tsconfig.json index.html
api/ # Fastify + better-sqlite3 (build riêng, chạy trong container api)
server.ts auth.ts recipes.ts db.ts Dockerfile
src/
main.tsx App.tsx router.tsx config/tiers.ts config/storeLinks.ts
content/reviews.json content/features.json
theme/ (tokens.css, themes.ts, ThemeProvider.tsx, ThemePopover.tsx)
i18n/ (vi.ts, en.ts, I18nProvider.tsx, t.ts)
pages/ (Landing, Login, Signup, Workspace)
ui/ (ToolRail, ChipColumn, ImageStage, SliderRow, StripRow, ThumbStrip, SettingsPopover, BatchPanel, AccountMenu)
engine/ (skiaShim.ts, exportEngine.ts, worker.ts, previewEngine.ts, exifGps.ts)
state/ (recipeStore.ts, authStore.ts)
assets/ (fonts, wallframe.png — import từ ../assets)
```
Docker build context = repo root (cần `src/utils`, `assets/`), `-f web/Dockerfile`.
## Phases
### W0 — Spike CanvasKit (parity gate) ← đã chạy, còn cổng parity
Chuỗi 1 ảnh: decode → crop → matrix → tone → cinema → denoise/clarity → HDF → grain → vignette → frame → watermark → JPEG 95 + DPI 300.
Exit: chạy trong Node trên ảnh mẫu; so sánh với ảnh export từ app (meanAbs/gt12 như probe P6); đo thời gian 12MP; xác nhận FreeType font `Cousine` + `NotoEmoji-GPS` render (emoji không ra → fallback Canvas2D overlay).
Rủi ro lớn nhất của cả kế hoạch → làm trước.
#### Kết quả đo được (2026-09-06, spike trong `web/spike/`)
Bản port đã chạy hết chuỗi trên ảnh 12MP và ra JPEG; 4 phát hiện làm đổi kế hoạch:
1. **JPEG encode**: build `canvaskit-wasm` mặc định **không** encode được JPEG (`encodeToBytes(JPEG)` → null; PNG vẫn được). → dùng build `canvaskit-wasm/full` (8.2MB wasm so với 7.3MB). Shim import `canvaskit-wasm/full`.
2. **`ImageFilter.MakeMatrixConvolution` không tồn tại** trong CanvasKit (chuỗi wasm có nhưng không có binding JS). → thay bằng `CLARITY_SKSL` (lấy từ `src/utils/toneShader.ts`, vốn đã dùng cho preview RN) chạy như shader pass, `px=(1,1)` ở tỉ lệ 1:1. Không phải viết thuật toán mới.
3. **Font ✓**: `Typeface.MakeFreeTypeFaceFromData` + `Canvas.drawText` chạy. `Cousine-Regular.ttf` **thiếu** 📍/📷 (glyphID 0) nhưng `NotoEmoji-GPS.ttf` có (glyphID 1,2) và vẽ ra 1731 pixel → **không cần fallback Canvas2D emoji**.
4. **Raster CPU không dùng được, buộc phải render trên surface WebGL**. Đo trên 4000×3000 (`spike/bench.ts`, SkVM interpreter):
| Bước | 4000×3000 | 2048 |
|---|---|---|
| draw | 5ms | — |
| tone SkSL pass | **3205ms** | 836ms |
| clarity SkSL 5-tap | **13745ms** | 3581ms |
| blur σ=40 | **3024ms** | — |
| JPEG q95 | 246ms | 54ms |
| decode | 233ms | — |
Cùng pipeline trên surface WebGL (`spike/gpu.html`, headless Chromium, ANGLE/SwiftShader — **software GL**, GPU thật sẽ nhanh hơn hoặc bằng):
- `MakeWebGLCanvasSurface(new OffscreenCanvas(w,h))` OK, tạo surface 15–22ms.
- tone / clarity / blur pass: **0.1–1ms** (submit bất đồng bộ).
- **JPEG q95: 205ms @12MP, 54ms @2048, 14ms @1024**; decode 12MP 280ms.
Đầu ra kiểm chứng trên ảnh 12MP (raster CPU, `spike/w0.ts`): 3 file JPEG 4.1–5.1MB, JFIF `units=1 x=300 y=300` (DPI 300 đúng), byte-mean ~125 → **ảnh không trắng, pipeline chạy hết chuỗi**.
→ Chốt: engine **bắt buộc** chạy trên WebGL surface (`MakeWebGLCanvasSurface`), raster chỉ là fallback cho test Node. Preview ~2048px, full-res khi export.
Các API khác đã xác nhận có trong CanvasKit: `MakeCompose`, `MakeBlur`, `MakeRadialGradient`, `RuntimeEffect.Make`, `makeShaderWithChildren`, `MakeSurface`, `Font`, `TypefaceFontProvider`, `MakeOffscreen`, `MakeWebGLCanvasSurface`.
Gotcha khi viết shim: `new CK.Paint()` (không phải `CK.Paint()`); `img.readPixels(srcX, srcY, imageInfo)`; `CK.ImageFormat` là enum object; phải `flush(surface)` trước mỗi snapshot.
**Còn lại của W0 (chặn parity)**: cần **1 cặp ảnh mẫu từ người dùng** — ảnh gốc + đúng ảnh app đã export từ ảnh đó — để chạy diff meanAbs/gt12 như P6. Cặp `app.jpg`/`native.jpg` đang có trong scratchpad không rõ nguồn nên không dùng làm chuẩn được. Sau đó chạy lại 1 lần end-to-end trên WebGL để lấy số 12MP thật trong browser.
### W1 — Scaffold + layout + theme + i18n + nạp ảnh + responsive
Vite/React/TS/router; 3 vùng desktop / 1 cột mobile; rail 6 tab; chip column rỗng; canvas giữa; nạp ảnh bằng drop/chọn file/paste; preview downscale 2048px (worker); zoom/pan.
Theme: `tokens.css` (light/dark + 5 accent), `ThemeProvider`, popover chọn theme; i18n: `I18nProvider` + từ điển vi/en + nút đổi ngôn ngữ, mọi nhãn đi qua `t()`.
Exit: thả ảnh từ desktop → hiển thị; đổi theme/ngôn ngữ tức thì và giữ sau reload; `npm run build` + typecheck sạch; mở đúng trên khổ mobile.
### W2 — Tool phi cấu trúc ảnh
PRESETS, FAVORITED, LIGHT, WB, FX; slider/chip/strip (một row mở tại thời điểm); undo/reset; PEEK; lưu localStorage. Từ điển vi/en phủ hết nhãn của các tab này.
Exit: mọi tham số đổi tức thì trên preview; reload giữ trạng thái; không còn chuỗi tiếng Anh hardcode ngoài tên film sim.
### W3 — FRAME / CROP / ROTATE / WATERMARK / RATIO + EXIF GPS
4 frame + wall landscape; free crop kéo rect; rotate/straighten/auto; watermark GPS (EXIF + nhập tay) + custom text; photo ratio.
Exit: preview đúng layout từng frame; ảnh có GPS tự điền, ảnh không GPS cho nhập tay.
### W4 — EXPORT / RENDER full-res
Worker render full-res + tiến trình + hủy; `encodeToBytes(JPEG, 95)` → `patchJpegDpi(300)` → `writeJpegExif(...)` (port `src/utils/exifWrite.ts`, giữ tag ảnh nguồn, bỏ IFD1/MakerNote, stamp software/date/GPS/dims/dpi); download; thumbnail strip.
Exit: file tải về đúng **300 ppi** (`JFIF units=1 X=Y=300`) và **giữ EXIF như app** (camera/lens/ISO của ảnh nguồn + tag app tự ghi); kích thước pixel khớp app với cùng input; cùng ảnh + cùng recipe → EXIF web và app giống nhau từng tag app sở hữu.
### W5 — API + auth
Fastify + SQLite: `POST /api/auth/signup|login|logout`, `GET /api/auth/me`, CRUD `/api/recipes`; session cookie; trang Login/Signup; đồng bộ recipe + favorite; chế độ **demo** (không cần tài khoản, giới hạn theo `tiers.ts`).
Exit: đăng ký → đăng nhập → lưu recipe → đăng nhập lại ở máy khác thấy recipe; demo chạy không cần tài khoản và bị giới hạn đúng cấu hình.
### W6 — Landing page
Hero, tính năng, before/after, review (`reviews.json`), badge Google Play/App Store (`storeLinks.ts`), CTA demo + đăng ký; responsive; SEO cơ bản (`<title>`, OG tags, `<html lang>` theo ngôn ngữ đang chọn); landing cũng đổi được theme + ngôn ngữ.
Exit: trang mở đẹp ở cả 360px và 1440px ở cả 2 theme; link store + CTA đúng đích; nội dung landing có đủ vi/en.
### W7 — BATCH EXPORT (add-on)
Panel riêng: nạp nhiều ảnh, chọn recipe (bundle hoặc của người dùng), hàng đợi render tuần tự có tiến trình, tải từng file hoặc ZIP. Yêu cầu đăng nhập.
Exit: 10 ảnh + 1 recipe → 10 file cùng tông màu, không tràn RAM.
### W8 — Parity & perf
Chạy cùng bộ ảnh trên app (native/Skia) và web; so diff + mắt; tối ưu (worker pool, preview cache, dùng lại bitmap khi export).
Exit: diff trong ngưỡng đã chấp nhận ở P6; render 12MP web < ~3s.
### W9 — Docker + deploy + docs
`docker compose up -d --build`; kiểm tra qua `IP:8090`; hướng dẫn gắn Nginx Proxy Manager (domain, TLS, `X-Forwarded-*`); `docker/README.md` (RUNBOOK: port, domain, backup `./data`, đổi `TIER` config); commit + push.
Exit: mở webUI từ máy khác trong LAN; đăng ký/đăng nhập/demo/export chạy; container tự lên lại sau reboot.
**Trạng thái**: `docker/` đã tự chứa và chạy được — `docker/docker-compose.yml` (service `frontend` + `api`), `docker/backend/` (Fastify + SQLite), `docker/frontend/` (Vite + CanvasKit + nginx). Chạy `docker compose up -d --build` trong `docker/`, UI ở `:8090`; đã kiểm chứng health/signup/login/recipes và export ảnh thật end-to-end trong Chromium headless. Còn lại: gắn domain/TLS qua Nginx Proxy Manager, cập nhật `README.md` gốc.
## Rủi ro
- ~~**CanvasKit không có emoji màu**~~ → **đã loại ở W0**: `NotoEmoji-GPS.ttf` cung cấp đủ 📍/📷, không cần fallback Canvas2D.
- ~~**Skia API shim** (`MakeMatrixConvolution` khác chữ ký)~~ → **đã loại ở W0**: không tồn tại trong CanvasKit, thay bằng `CLARITY_SKSL`.
- ~~**Hiệu năng**: raster CPU chậm gấp ~1000 lần WebGL~~ → **đã chốt**: engine dùng GPU surface, nhưng **một `GrDirectContext` dùng chung** + `MakeRenderTarget` cho mọi surface. `MakeWebGLCanvasSurface` gọi nhiều lần tạo nhiều WebGL context riêng → snapshot của surface này vẽ sang surface kia ra **đen thui** (đo được: 12MP JPEG + output sharpen, GL mean 0.0 vs raster 107.0). Không có WebGL thì fallback raster `MakeSurface` — đúng pipeline, chỉ chậm hơn.
- **wasm ~8MB** (build `full`, gzip ~3MB): lazy-load, nginx cache dài hạn.
- **RAM 12MP** trong browser (bitmap ~48MB + surface) — giới hạn worker pool 1–2, giải phóng bitmap sau export; batch render tuần tự, không song song.
- **HDF/vignette** dùng gradient + blend — kiểm tra tương đương CanvasKit.
- Không xoá/đổi code app RN: web chỉ **đọc** `src/utils/*` (nếu phải sửa util để dùng chung → giữ tương thích app, typecheck lại).
- Import TS ngoài root `web/`: cấu hình `server.fs.allow` + alias; Docker context phải là repo root.
- **Parity chưa chốt**: thiếu cặp ảnh gốc + ảnh app export cùng nguồn (xem cuối W0) → rủi ro phát hiện lệch màu muộn ở W8.
- **Metadata**: engine web **phải** giữ đúng 300 ppi và dựng lại EXIF như app; quên bước `writeJpegExif` thì ảnh vẫn ra nhưng mất camera/lens/ISO/ngày chụp.
- **Hai đường export của app không đồng nhất**: đường Kotlin native (`RecipescamExportModule.kt`, `Bitmap.compress(JPEG)`) **chỉ** gọi `patchJpegDpi`, **không** dựng lại EXIF → nếu P6 flip default sang native thì app cũng sẽ mất metadata. Cần chốt: port `writeJpegExif` sang Kotlin, hoặc giữ đường Skia làm mặc định. Không ảnh hưởng web, nhưng ảnh hưởng mốc "khớp app".
- **Giá trị cần xác nhận khi làm W6**: URL Google Play / App Store thật (đang để placeholder trong `storeLinks.ts`), tên miền cuối cùng gắn qua Nginx Proxy Manager.
## Checklist
- [x] Tạo branch `recipes-web`
- [x] Chốt yêu cầu (port, auth, batch, responsive, EXIF GPS)
- [x] Bổ sung theme (light/dark + accent) + i18n (vi/en) vào kế hoạch
- [x] Chốt metadata export: 300 ppi (`patchJpegDpi`) + dựng lại EXIF (`writeJpegExif`), giữ nguyên kích thước pixel
- [ ] W0 spike CanvasKit — pipeline ✓, font ✓, build `full` ✓, WebGL ✓; **còn**: cặp ảnh parity từ người dùng + số end-to-end trên WebGL
- [ ] W1 scaffold + drag&drop + responsive
- [ ] W2 tool phi cấu trúc ảnh
- [ ] W3 frame/crop/rotate/watermark/ratio + EXIF GPS
- [ ] W4 export/render full-res
- [ ] W5 API + auth + demo
- [ ] W6 landing page
- [ ] W7 batch export
- [ ] W8 parity & perf
- [x] `docker/` tự chứa + chạy được (code FE/BE nằm trong `docker/`, compose + Dockerfile + `.env.example` + README) — đã verify end-to-end
- [ ] W9 Docker + deploy + docs
+2 -2
View File
@@ -92,8 +92,8 @@ android {
applicationId 'com.locphamtran.recipescamera'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 9
versionName "1.2.7"
versionCode 5
versionName "1.2.3"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
+1 -7
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "RecipesCam",
"slug": "recipescamera",
"version": "1.2.7",
"version": "1.2.3",
"orientation": "portrait",
"icon": "./RecipesCamIcon.png",
"userInterfaceStyle": "light",
@@ -49,12 +49,6 @@
"isAccessMediaLocationEnabled": true
}
],
[
"expo-camera",
{
"cameraPermission": "Allow RecipesCam to scan a recipe QR code."
}
],
"expo-sharing"
],
"extra": {
+25
View File
@@ -0,0 +1,25 @@
# Host port the web UI is published on. Everything else is internal: the API is
# only reachable through nginx at /api/.
WEB_PORT=8090
# Comma-separated emails allowed to moderate the landing strip (/admin).
# Leave empty to make nobody an admin.
ADMIN_EMAILS=
# The mail relay that sends the address-verification mail — the 6-digit code
# plus the link. A new account is a guest until it proves the address with one
# of the two, so a deployment without a relay can only ever hand out guest
# access.
#
# Leave SMTP_HOST empty and both the code and the link are written to the api
# container's log instead (`docker compose logs api`), which is enough for local
# work.
SMTP_HOST=
# 587 upgrades to TLS (STARTTLS); 465 is TLS from the first byte. Set
# SMTP_SECURE=true to force the latter on an unusual port.
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
# What the mail says it is from. Defaults to SMTP_USER, then a noreply address.
SMTP_FROM=
SMTP_SECURE=
+2
View File
@@ -0,0 +1,2 @@
.env
data/
+91
View File
@@ -0,0 +1,91 @@
# RecipesCam web — self-contained stack
A Docker-hosted web build of RecipesCam. Everything it needs is in this folder:
move it to another machine, run two commands, and the app is up. It does **not**
need the React Native project around it.
```bash
cp .env.example .env
docker compose up -d --build
# → http://localhost:8090
```
## What runs where
| Service | Image | Role |
| --- | --- | --- |
| `frontend` | `nginx:1.27-alpine` (built by `frontend/Dockerfile`) | Static SPA + `/api/` reverse proxy |
| `api` | `node:22-slim` (built by `backend/Dockerfile`) | Accounts + saved recipes, SQLite on `./data` |
`frontend` resolves `api` through Docker's embedded DNS and proxies `/api/*` to
it — that is why the API container is named `api` and why it is not published on
the host. Only `${WEB_PORT:-8090}` is exposed.
Photos never leave the browser. The CanvasKit render pipeline (grade, frame,
watermarks, JPEG encode) runs in the visitor's tab; the API only stores recipes
as JSON.
## Layout
```
docker-compose.yml the stack
.env.example WEB_PORT
data/ SQLite (created on first run, gitignored)
backend/ Fastify + better-sqlite3 API, own Dockerfile
frontend/ Vite + React + CanvasKit SPA, own Dockerfile + nginx.conf
shared/ vendored copies of the app's types + utils (see below)
src/engine/ skiaShim.ts (CanvasKit) + exportEngine.ts (render pipeline)
+ session.ts (localStorage/IndexedDB studio persistence)
```
### Vendored files
`frontend/shared/{types/index.ts,utils/*.ts}` are byte-identical copies of
`src/types/index.ts` and ten `src/utils/*.ts` files from the React Native
project (`@shopify/react-native-skia` is aliased to `src/engine/skiaShim.ts` in
`vite.config.ts` + `tsconfig.json`, so those files compile unchanged):
`cinemaShader` `colorUtils` `defaultRecipes` `exifWrite` `frameUtils` `jpegDpi`
`paramDefs` `recipeShare` `skiaImage` `toneShader`.
The landing page needs no CDN: `frontend/public/assets/fonts/*.woff2` are the
seven self-hosted faces behind the three font groups the Themes menu offers
(Plus Jakarta Sans / Inter / JetBrains Mono, Fraunces / Be Vietnam Pro /
Courier Prime, Be Vietnam Pro / Space Mono — all SIL OFL, pulled from Google
Fonts, vietnamese + latin + latin-ext subsets), and
`frontend/public/assets/samples/s*.jpg` are the six placeholder negatives the
film strip, preset tester and QR card show (swap them for real graded stills
whenever we have them). Its one foreign request is the QR image from
`api.qrserver.com`, which degrades to an empty slot offline.
When the app changes one of them, copy it back in — the renderer is only
"parity" for as long as these stay in sync:
```bash
cd <repo>/docker/frontend/shared/utils
cp <repo>/src/utils/<name>.ts .
```
## Operations
```bash
docker compose logs -f api # API log
docker compose restart api # after backend/src changes (rebuild: --build)
docker compose down # stop; ./data survives
```
Backup is the `./data` folder — that is the whole database.
## Checks
```bash
curl -s http://localhost:8090/api/health # {"ok":true}
curl -sI http://localhost:8090/ # 200, index.html
```
Then open the UI, drop a photo in, and confirm the preview shows the picture and
EXPORT downloads a JPEG that opens. The preview going solid black while the
export still reports a plausible size is the one failure mode worth knowing: it
means the CanvasKit GPU surfaces lost their shared `GrDirectContext` (see
`frontend/src/engine/skiaShim.ts`), and with no GPU the raster fallback renders
the same pipeline correctly, just slower.
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
data
*.log
Dockerfile
.dockerignore
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+20
View File
@@ -0,0 +1,20 @@
# RecipesCam API — self-contained build (context = this folder).
# Builder compiles TypeScript; runtime carries production deps only.
FROM node:22-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build && npm prune --omit=dev
FROM node:22-slim
ENV NODE_ENV=production \
PORT=3000 \
DATA_DIR=/data
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]
+1674
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "recipescam-api",
"version": "1.0.0",
"private": true,
"description": "RecipesCam web API - accounts + user recipes (server never receives photos)",
"main": "dist/server.js",
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "tsx watch src/server.ts",
"test": "node test/security.mjs"
},
"dependencies": {
"better-sqlite3": "^12.11.1",
"fastify": "^5.12.5",
"nodemailer": "^7.0.13"
},
"devDependencies": {
"@types/better-sqlite3": "^9.6.0",
"@types/node": "^22.20.3",
"@types/nodemailer": "^8.0.2",
"tsx": "^4.23.13",
"typescript": "^5.9.3"
}
}
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
import nodemailer, { type Transporter } from 'nodemailer';
// The API sends exactly one kind of mail: the link that proves an address. The
// relay is declared in the deployment's .env, because a mail server is
// infrastructure, not a constant.
//
// With no SMTP_HOST there is nothing to connect to, so the link is written to
// the log instead. That keeps a dev box — or this repo's own test suite — able
// to finish a signup without a mail server, and whoever reads the log is
// already the person running the database.
const SMTP_HOST = (process.env.SMTP_HOST ?? '').trim();
const SMTP_PORT = Number(process.env.SMTP_PORT || 587);
const SMTP_USER = (process.env.SMTP_USER ?? '').trim();
const SMTP_PASS = process.env.SMTP_PASS ?? '';
const SMTP_FROM = (process.env.SMTP_FROM ?? '').trim() || SMTP_USER || 'no-reply@recipescam.local';
// 465 is TLS from the first byte; 587 starts in the clear and upgrades. Only a
// port the operator actually chose should be second-guessed.
const SMTP_SECURE = process.env.SMTP_SECURE ? process.env.SMTP_SECURE === 'true' : SMTP_PORT === 465;
export const mailConfigured = SMTP_HOST !== '';
const transporter: Transporter | null = mailConfigured
? nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_SECURE,
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
// A relay that never answers must not hold a request open.
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 20_000,
})
: null;
// Both languages, because the account's language is not known before it exists.
// The code comes first and the link second: the code is the one that works
// without leaving the page the visitor signed up on, so it is what most of them
// will use — but the link stays, for the phone that owns the address, and
// because a letter that only carries digits is a letter a spam filter can
// decide is a phishing attempt.
const body = (url: string, code: string) =>
[
'RecipesCam — xác thực địa chỉ email / verify your email address',
'',
`Mã xác thực của bạn / your verification code: ${code}`,
'',
'Mã có hiệu lực 15 phút. Nhập mã vào trang đăng ký để bật các tính năng PRO.',
'The code is valid for 15 minutes. Type it into the signup page to unlock PRO.',
'',
'Hoặc mở liên kết này / or open this link (hiệu lực 24 giờ / valid 24 hours):',
url,
'',
'Nếu bạn không đăng ký, hãy bỏ qua thư này. / If you did not sign up, ignore this mail.',
].join('\r\n');
// Fire and forget: the account already exists, so a relay that is slow, out of
// quota or misconfigured may not fail the signup that asked for it. The owner
// can ask for another link from the studio; the operator sees the error here.
export function sendVerificationMail(to: string, url: string, code: string, log: (msg: string) => void): void {
if (!transporter) {
log(`[verify] SMTP not configured — code ${code} and verification link for ${to}: ${url}`);
return;
}
transporter
.sendMail({ from: SMTP_FROM, to, subject: `RecipesCam — ${code} is your verification code`, text: body(url, code) })
.then(() => log(`[verify] code and link sent to ${to}`))
.catch((err: unknown) => log(`[verify] could not mail ${to}: ${String(err)}`));
}
+61
View File
@@ -0,0 +1,61 @@
// Place names for a coordinate. The phone app asks the OS geocoder, and a
// browser has no twin of that, so the lookup lives here: one HTTP call behind a
// cache, and the same naming rule the app uses, so a stamp reads the same on
// both.
import { findPlace, savePlace } from './db';
// ~11m at the equator: fixes from the same spot share a cache row.
const key = (lat: number, lng: number) => `${lat.toFixed(4)},${lng.toFixed(4)}`;
// Nominatim (OpenStreetMap). Its usage policy asks for one request per second,
// a caller that identifies itself, and answers that are kept — hence the queue
// below and the SQLite row every answer leaves behind.
const ENDPOINT = 'https://nominatim.openstreetmap.org/reverse';
const AGENT = 'RecipesCam/1.2 (https://recipescam.labz.io.vn)';
const GAP_MS = 1100;
const TIMEOUT_MS = 8000;
// One lookup at a time, never closer together than GAP_MS.
let queue: Promise<unknown> = Promise.resolve();
// Android answers with the commune in `subregion` and the city in `region`;
// Nominatim says the same thing with `suburb` and `city`. Same rule as the app's
// localityName: "COMMUNE, CITY", and a name that fills both slots prints once.
export function nameOf(address: Record<string, string | undefined>): string | null {
const district =
address.suburb || address.city_district || address.district || address.quarter || address.neighbourhood || address.county;
const city = address.city || address.town || address.municipality || address.village || address.state;
const parts = district && district !== city ? [district, city] : [district || city];
const name = parts.filter(Boolean).join(', ');
return name ? name.toUpperCase() : null;
}
export async function placeName(lat: number, lng: number): Promise<string | null> {
const cached = findPlace(key(lat, lng));
if (cached !== undefined) return cached || null;
const call = queue.then(async () => {
await new Promise((r) => setTimeout(r, GAP_MS));
const url = `${ENDPOINT}?format=jsonv2&zoom=14&addressdetails=1&lat=${lat}&lon=${lng}`;
const res = await fetch(url, {
headers: { 'user-agent': AGENT, accept: 'application/json' },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) throw new Error(`geocoder HTTP ${res.status}`);
const body = (await res.json()) as { address?: Record<string, string> };
return body.address ? nameOf(body.address) : null;
});
// The queue must not inherit a rejection, or every later lookup would fail
// with it; the caller gets the failure through `call` itself.
queue = call.catch(() => undefined);
try {
const name = await call;
// Only an answer that came back is worth keeping — a timeout or an outage
// must not pin "no name here" on the coordinate for good.
savePlace(key(lat, lng), name ?? '');
return name;
} catch {
return null;
}
}
+58
View File
@@ -0,0 +1,58 @@
// The `.recipe` file the app writes on EXPORT and reads back on IMPORT. It is a
// small XML envelope around a scrambled hex payload, and the landing page's QR
// card hands a photo's look back as exactly this file, so the server has to
// write the app's own format. The source of truth is the web app's
// `shared/utils/recipeShare.ts`; this module and the phone app's
// `src/utils/recipeShare.ts` are its copies, and `test/security.mjs` pins the
// envelope's shape. Change all three together, or the QR stops importing.
//
// ponytail: obfuscation, not cryptography — the key ships inside the app. Same
// caveat as the app's copy: swap in a real cipher at these two functions if the
// envelope ever has to resist a determined reader.
const KEY = 'RecipesCam::recipe-share::v1';
const ALGORITHM = 'xor16-v1';
const hash = (s: string): number => {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
};
// xorshift32: one 16-bit word of keystream per call.
const stream = (seed: number) => {
let s = seed >>> 0 || 0x9e3779b9;
return () => {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return s & 0xffff;
};
};
// 16-bit code units, hex-encoded: no encoder needed, every UTF-16 char (names
// with Vietnamese diacritics included) survives the round trip.
const scramble = (text: string, salt: number): string => {
const k = stream(hash(`${KEY}|${salt}`));
let out = '';
for (let i = 0; i < text.length; i++) out += (text.charCodeAt(i) ^ k()).toString(16).padStart(4, '0');
return out;
};
// The recipe as the app stored it, wrapped in the envelope. The payload is the
// stored JSON whole: the parser on the other side keeps the fields it knows and
// drops the rest, so this side needs no opinion about what a recipe contains.
export function recipeFile(recipeJson: string): string {
const salt = Math.floor(Math.random() * 0xffffffff) >>> 0;
return [
'<?xml version="1.0" encoding="UTF-8"?>',
`<recipescam-recipe version="1" algorithm="${ALGORITHM}" salt="${salt.toString(16)}">`,
` <payload>${scramble(recipeJson, salt)}</payload>`,
'</recipescam-recipe>',
'',
].join('\n');
}
File diff suppressed because it is too large Load Diff
+740
View File
@@ -0,0 +1,740 @@
// Security self-check for the API. Boots the real server against a throwaway
// DATA_DIR and exercises the boundaries that matter: who may write, what may be
// written, who may read a photo back, and who may moderate the strip.
//
// npm test (from docker/backend/)
//
// Node only — no test framework, no network beyond loopback.
import { spawn } from 'node:child_process';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const PORT = Number(process.env.TEST_PORT || 3411);
const BASE = `http://127.0.0.1:${PORT}/api`;
const ADMIN_EMAIL = 'admin@test.local';
const DATA_DIR = mkdtempSync(join(tmpdir(), 'recipescam-sec-'));
// A 1x1 PNG, and the first bytes of a JPEG (all the sniffer looks at).
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
const JPEG_HEAD = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(64)]);
let pass = 0;
let fail = 0;
const check = (name, ok, detail = '') => {
if (ok) {
pass++;
console.log(`PASS ${name}`);
} else {
fail++;
console.log(`FAIL ${name}${detail ? ` :: ${detail}` : ''}`);
}
};
// One cookie jar per actor, so "signed in as A" cannot leak into B.
function actor() {
let cookie = '';
return {
get cookie() {
return cookie;
},
async req(path, init = {}) {
const headers = { ...(init.headers ?? {}) };
if (cookie) headers.cookie = cookie;
const res = await fetch(BASE + path, { ...init, headers });
const set = res.headers.getSetCookie?.() ?? (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
for (const line of set) {
const value = line.split(';')[0];
if (value.startsWith('rc_session=')) cookie = value.endsWith('=') ? '' : value;
}
const text = await res.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = text;
}
return { status: res.status, headers: res.headers, setCookie: set.join(' | '), body };
},
signup(email, password = 'supersecret1') {
return this.req('/auth/signup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
});
},
upload(bytes, type) {
return this.req('/photos', { method: 'POST', headers: { 'content-type': type }, body: bytes });
},
avatar(bytes, type) {
return this.req('/auth/avatar', { method: 'POST', headers: { 'content-type': type }, body: bytes });
},
};
}
// A fresh signup proves nothing until the address it gave is confirmed: an
// unverified account is served at the guest tier (see the PRO gate below). The
// suite cannot read the mail, but the token is in the throwaway database and
// the link is the API's own route, so it is followed here for the accounts that
// are exercising something other than the gate.
const Database = (await import('better-sqlite3')).default;
function tokenFor(email) {
const db = new Database(join(DATA_DIR, 'recipescam.db'), { readonly: true });
const row = db
.prepare('SELECT token FROM email_verifications WHERE user_id = (SELECT id FROM users WHERE email = ?)')
.get(email);
db.close();
return row?.token;
}
async function followVerifyLink(email) {
const res = await fetch(`${BASE}/auth/verify?token=${tokenFor(email)}`, { redirect: 'manual' });
if (res.status !== 303) throw new Error(`verify link for ${email} answered ${res.status}`);
}
async function activeSignup(a, email) {
const res = await a.signup(email);
await followVerifyLink(email);
return res;
}
// The same letter carries a code, and the suite reaches it the same way — the
// database is the only reader of the mail here.
function codeFor(email) {
const db = new Database(join(DATA_DIR, 'recipescam.db'), { readonly: true });
const row = db
.prepare('SELECT code FROM email_verifications WHERE user_id = (SELECT id FROM users WHERE email = ?)')
.get(email);
db.close();
return row?.code;
}
// The code's own clock starts at `created_at`, so an old one is made here
// rather than waited for.
function ageCode(email, minutes) {
const db = new Database(join(DATA_DIR, 'recipescam.db'));
db.prepare(
'UPDATE email_verifications SET created_at = ? WHERE user_id = (SELECT id FROM users WHERE email = ?)',
).run(new Date(Date.now() - minutes * 60_000).toISOString(), email);
db.close();
}
// Run the sources, not a possibly stale build: the point of this suite is to
// test the code as written.
const tsx = join(ROOT, 'node_modules/.bin/tsx');
const entry = existsSync(tsx) ? [tsx, 'src/server.ts'] : ['dist/server.js'];
const server = spawn(process.execPath, entry, {
cwd: ROOT,
env: { ...process.env, PORT: String(PORT), DATA_DIR, ADMIN_EMAILS: ADMIN_EMAIL, NODE_ENV: 'test' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let serverLog = '';
server.stdout.on('data', (d) => (serverLog += d));
server.stderr.on('data', (d) => (serverLog += d));
async function waitForServer() {
for (let i = 0; i < 100; i++) {
try {
const res = await fetch(`${BASE}/health`);
if (res.ok) return true;
} catch {
/* not up yet */
}
await new Promise((r) => setTimeout(r, 100));
}
return false;
}
try {
if (!(await waitForServer())) throw new Error(`server never came up:\n${serverLog}`);
const stamp = Date.now();
const admin = actor();
const user = actor();
const other = actor();
// ---- accounts -----------------------------------------------------------
check('health responds', (await fetch(`${BASE}/health`)).ok);
const badEmail = await user.signup('not-an-email');
check('signup rejects a malformed email', badEmail.status === 400, `got ${badEmail.status}`);
const badPw = await user.signup(`short${stamp}@test.local`, 'short');
check('signup rejects a short password', badPw.status === 400, `got ${badPw.status}`);
const adminSignup = await admin.signup(ADMIN_EMAIL);
check('admin account signs up', adminSignup.status === 201, `got ${adminSignup.status}`);
const userSignup = await activeSignup(user, `contributor${stamp}@test.local`);
check('contributor account signs up', userSignup.status === 201, `got ${userSignup.status}`);
await activeSignup(other, `other${stamp}@test.local`);
const cookie = userSignup.setCookie;
check('session cookie is HttpOnly', /HttpOnly/i.test(cookie), cookie);
check('session cookie is SameSite', /SameSite=Lax/i.test(cookie), cookie);
check('session cookie is not Secure over plain http', !/;\s*Secure/i.test(cookie), cookie);
check('session token is 256 bits of hex', /rc_session=[0-9a-f]{64}/.test(cookie), cookie);
// The proxy hands nginx the public scheme; a TLS visitor must get Secure.
const tls = actor();
const tlsSignup = await tls.req('/auth/signup', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-proto': 'https' },
body: JSON.stringify({ email: `tls${stamp}@test.local`, password: 'supersecret1' }),
});
check('session cookie is Secure behind https', /;\s*Secure/i.test(tlsSignup.setCookie), tlsSignup.setCookie);
// Documented, not fixed: the 409 is a deliberate UX choice and doubles as an
// account-existence oracle.
const dup = await actor().signup(ADMIN_EMAIL);
check('duplicate signup is a 409 (known user-enumeration oracle)', dup.status === 409, `got ${dup.status}`);
const noUser = await actor().req('/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: `ghost${stamp}@test.local`, password: 'supersecret1' }),
});
check('unknown email and wrong password look identical', noUser.status === 401, `got ${noUser.status}`);
const anonMe = await actor().req('/auth/me');
check('signed out is a 200 with no user', anonMe.status === 200 && anonMe.body?.user === null, JSON.stringify(anonMe.body));
const ownMe = await user.req('/auth/me');
check('/auth/me reports the signed-in account', ownMe.body?.user?.email === `contributor${stamp}@test.local`, JSON.stringify(ownMe.body));
// ---- the PRO gate: an unproven address is a guest -----------------------
// Signing up is not what earns the tier — the address is. Until its link is
// followed the account is a guest with a name: every write and every personal
// listing answers 403, which is what tells the studio to ask for the mail
// rather than for a password.
const jsonHdr = { 'content-type': 'application/json' };
const unproven = actor();
const unprovenEmail = `unproven${stamp}@test.local`;
const unprovenSignup = await unproven.signup(unprovenEmail);
check('a fresh signup is unverified', unprovenSignup.body?.user?.verified === false, JSON.stringify(unprovenSignup.body));
check(
'an unverified account may still ask for its link',
(await unproven.req('/auth/resend-verification', { method: 'POST' })).status === 200,
);
check('an unverified account cannot upload', (await unproven.upload(PNG, 'image/png')).status === 403);
check('an unverified account cannot list a folder', (await unproven.req('/photos/mine')).status === 403);
check(
'an unverified account cannot save a recipe',
(await unproven.req('/recipes', { method: 'POST', headers: jsonHdr, body: JSON.stringify({ name: 'x', recipe: {} }) })).status === 403,
);
check('an unverified account cannot wear an avatar', (await unproven.avatar(PNG, 'image/png')).status === 403);
check('a signed-out caller still gets a 401, not a 403', (await actor().req('/photos/mine')).status === 401);
const unknownLink = await fetch(`${BASE}/auth/verify?token=${'0'.repeat(64)}`, { redirect: 'manual' });
check(
'an unknown link verifies nothing',
unknownLink.status === 303 && unknownLink.headers.get('location')?.endsWith('/?verified=0'),
String(unknownLink.headers.get('location')),
);
const link = tokenFor(unprovenEmail);
check('signup leaves one verification link in the database', typeof link === 'string' && link.length === 64, String(link));
const followed = await fetch(`${BASE}/auth/verify?token=${link}`, { redirect: 'manual' });
check(
'the mailed link verifies the account',
followed.status === 303 && followed.headers.get('location')?.endsWith('/?verified=1'),
String(followed.headers.get('location')),
);
check('the account is verified from then on', (await unproven.req('/auth/me')).body?.user?.verified === true);
check('a verified account may upload', (await unproven.upload(PNG, 'image/png')).status === 201);
const replay = await fetch(`${BASE}/auth/verify?token=${link}`, { redirect: 'manual' });
check('a spent link cannot be followed twice', replay.headers.get('location')?.endsWith('/?verified=0'), String(replay.headers.get('location')));
check('the allowlisted admin needs no letter', (await admin.req('/auth/me')).body?.user?.verified === true);
// ---- the code in the same letter ----------------------------------------
// The link is not the only way to prove an address any more: the letter also
// carries six digits the visitor types into the dialog they signed up on. The
// code is the shorter-lived of the two proofs and the one worth guessing, so
// the checks below are mostly about what a wrong guess costs.
const typed = actor();
const typedEmail = `typed${stamp}@test.local`;
await typed.signup(typedEmail);
const code = codeFor(typedEmail);
check('signup leaves a six-digit code beside the link', /^\d{6}$/.test(String(code)), String(code));
const wrongCode = code === '000000' ? '111111' : '000000';
const type = (a, value) =>
a.req('/auth/verify-code', { method: 'POST', headers: jsonHdr, body: JSON.stringify({ code: value }) });
check('a wrong code verifies nothing', (await type(typed, wrongCode)).status === 400);
check('and leaves the account unproven', (await typed.req('/auth/me')).body?.user?.verified === false);
check('a code of the wrong shape is refused', (await type(typed, '12345')).status === 400);
check('a signed-out caller cannot type a code', (await type(actor(), code)).status === 401);
check('the mailed code verifies the account', (await type(typed, code)).status === 200);
check('the account is verified from then on', (await typed.req('/auth/me')).body?.user?.verified === true);
check('the code being spent spends the link with it', tokenFor(typedEmail) === undefined, String(tokenFor(typedEmail)));
const locked = actor();
const lockedEmail = `locked${stamp}@test.local`;
await locked.signup(lockedEmail);
const lockedCode = codeFor(lockedEmail);
const otherCode = lockedCode === '000000' ? '111111' : '000000';
for (let i = 0; i < 5; i++) await type(locked, otherCode);
const afterLock = await type(locked, lockedCode);
check('five wrong guesses lock the code out', afterLock.status === 429, `got ${afterLock.status}`);
check('and the right code no longer helps', (await locked.req('/auth/me')).body?.user?.verified === false);
await locked.req('/auth/resend-verification', { method: 'POST' });
const freshCode = codeFor(lockedEmail);
check('a resent letter hands out a fresh code', freshCode !== lockedCode && /^\d{6}$/.test(String(freshCode)));
check('that fresh code is not locked out by the old guesses', (await type(locked, freshCode)).status === 200);
const stale = actor();
const staleEmail = `stale${stamp}@test.local`;
await stale.signup(staleEmail);
const staleCode = codeFor(staleEmail);
ageCode(staleEmail, 16); // past CODE_TTL_S
check('a code older than its quarter hour is refused', (await type(stale, staleCode)).status === 400);
check('and leaves the account unproven', (await stale.req('/auth/me')).body?.user?.verified === false);
// ---- rate limiting ------------------------------------------------------
const brute = actor();
const bruteEmail = `brute${stamp}@test.local`;
let limited = 0;
for (let i = 0; i < 21; i++) {
const res = await brute.req('/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: bruteEmail, password: 'wrong-password' }),
});
if (res.status === 429) limited++;
}
check('login attempts are rate limited', limited === 1, `429s: ${limited}`);
// ---- upload boundary ----------------------------------------------------
check('guest cannot upload', (await actor().upload(PNG, 'image/png')).status === 401);
check(
'non-image content-type is refused',
(await user.upload(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>'), 'image/svg+xml')).status === 415,
);
check(
'svg bytes under an image content-type is refused',
(await user.upload(Buffer.from('<svg onload="alert(1)"/>'), 'image/jpeg')).status === 415,
);
check(
'a declared type that disagrees with the bytes is refused',
(await user.upload(JPEG_HEAD, 'image/png')).status === 415,
);
check('empty body is refused', (await user.upload(Buffer.alloc(0), 'image/png')).status === 400);
const tooBig = Buffer.concat([PNG, Buffer.alloc(13 * 1024 * 1024)]);
check('an over-limit body is refused', (await user.upload(tooBig, 'image/png')).status === 413);
const created = await user.upload(PNG, 'image/png');
check('a signed-in member can upload a real PNG', created.status === 201, JSON.stringify(created.body));
// ---- what the public may read ------------------------------------------
const list = await actor().req('/photos');
const listed = list.body?.photos ?? [];
check('the strip is readable anonymously', list.status === 200);
check(
'the public strip leaks no owner',
listed.length > 0 && !('userId' in listed[0]) && !('email' in listed[0]) && !('file' in listed[0]),
JSON.stringify(listed[0]),
);
const id = created.body?.photo?.id;
const served = await fetch(`${BASE}/photos/${id}/file`);
check('a contributed photo is served', served.status === 200);
check('the served photo keeps its sniffed type', served.headers.get('content-type') === 'image/png');
check('the served photo is nosniff', served.headers.get('x-content-type-options') === 'nosniff');
check('the served photo is sandboxed', (served.headers.get('content-security-policy') ?? '').includes('sandbox'));
check('the bytes round-trip intact', Buffer.from(await served.arrayBuffer()).equals(PNG));
check('an unknown id is a 404', (await fetch(`${BASE}/photos/999999/file`)).status === 404);
check('a non-numeric id is a 404', (await fetch(`${BASE}/photos/abc/file`)).status === 404);
check(
'an id cannot escape the uploads directory',
(await fetch(`${BASE}/photos/..%2f..%2fetc%2fpasswd/file`)).status === 404,
);
// ---- the member's own folder --------------------------------------------
check('a guest has no folder', (await actor().req('/photos/mine')).status === 401);
const folder = await user.req('/photos/mine');
check(
'a member lists their own photos',
folder.status === 200 && (folder.body?.photos ?? []).some((p) => p.id === id),
JSON.stringify(folder.body).slice(0, 120),
);
const stranger = actor();
await activeSignup(stranger, `stranger${stamp}@test.local`);
check("a fresh account's folder is empty", ((await stranger.req('/photos/mine')).body?.photos ?? []).length === 0);
// The strip's own labels ride the query string: the body is the image.
const labels = { tag: '#KODAK_PORTRA_400', title: 'Golden Hour Portrait', meta: 'ISO 400 · GRAIN 35 · WARMTH +18' };
const labelled = await user.req(
`/photos?tag=${encodeURIComponent(labels.tag)}&title=${encodeURIComponent(labels.title)}&meta=${encodeURIComponent(labels.meta)}`,
{ method: 'POST', headers: { 'content-type': 'image/png' }, body: PNG },
);
check(
'an upload carries its strip labels',
labelled.status === 201 &&
labelled.body?.photo?.tag === labels.tag &&
labelled.body?.photo?.title === labels.title &&
labelled.body?.photo?.meta === labels.meta,
JSON.stringify(labelled.body),
);
const labelledId = labelled.body?.photo?.id;
const publicRow = ((await actor().req('/photos')).body?.photos ?? []).find((p) => p.id === labelledId);
check('the labels reach the public strip', publicRow?.tag === labels.tag && publicRow?.meta === labels.meta);
const capped = await user.req(
`/photos?tag=${'x'.repeat(200)}&meta=${encodeURIComponent('bad\u0007line')}`,
{ method: 'POST', headers: { 'content-type': 'image/png' }, body: PNG },
);
check('a label is length-capped', capped.body?.photo?.tag?.length === 64, `len ${capped.body?.photo?.tag?.length}`);
check('a label is control-stripped', capped.body?.photo?.meta === 'bad line', JSON.stringify(capped.body?.photo?.meta));
check(
'a member cannot delete a photo they do not own',
(await stranger.req(`/photos/${labelledId}`, { method: 'DELETE' })).status === 404,
);
check('the stranger’s delete leaves the file alone', (await fetch(`${BASE}/photos/${labelledId}/file`)).status === 200);
const ownDelete = await user.req(`/photos/${labelledId}`, { method: 'DELETE' });
check('a member deletes their own photo', ownDelete.status === 204, `got ${ownDelete.status}`);
check('the deleted file is gone from disk', (await fetch(`${BASE}/photos/${labelledId}/file`)).status === 404);
check(
'the row leaves the folder',
!((await user.req('/photos/mine')).body?.photos ?? []).some((p) => p.id === labelledId),
);
// The curator removes anyone's through the same route — the moderation screen
// keeps its own two admin endpoints, this one just shares the job.
const spare = (await user.upload(PNG, 'image/png')).body?.photo;
check('an admin deletes through the member route too', (await admin.req(`/photos/${spare?.id}`, { method: 'DELETE' })).status === 204);
check('a deleted photo leaves the strip', !((await actor().req('/photos')).body?.photos ?? []).some((p) => p.id === spare?.id));
// ---- moderation ---------------------------------------------------------
check('a guest cannot moderate', (await actor().req('/admin/photos')).status === 401);
const forbidden = await user.req('/admin/photos');
check('a plain member is 403, not 200', forbidden.status === 403, `got ${forbidden.status}`);
const adminList = await admin.req('/admin/photos');
const rows = adminList.body?.photos ?? [];
check('an admin lists contributions', adminList.status === 200 && rows.length > 0);
check('the admin listing carries the owner', rows.some((r) => /@test\.local$/.test(r.email ?? '')));
check(
'a fresh upload lands in the strip section',
JSON.stringify(rows.find((r) => r.id === id)?.slots) === JSON.stringify(['strip']),
);
check('a plain member cannot delete', (await user.req(`/admin/photos/${id}`, { method: 'DELETE' })).status === 403);
// ---- placement ----------------------------------------------------------
const patch = (path, body) =>
admin.req(path, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
check(
'a plain member cannot place a photo',
(await user.req(`/admin/photos/${id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ slots: ['qr'] }),
})).status === 403,
);
const placed = await patch(`/admin/photos/${id}`, { slots: ['tester', 'creator'] });
check(
'an admin puts a photo in several sections at once',
placed.status === 200 && JSON.stringify(placed.body?.slots) === JSON.stringify(['tester', 'creator']),
JSON.stringify(placed.body),
);
check(
'the sections are public, the owner is not',
JSON.stringify(((await actor().req('/photos')).body?.photos ?? []).find((r) => r.id === id)?.slots) ===
JSON.stringify(['tester', 'creator']),
);
const one = await patch(`/admin/photos/${id}`, { slots: ['strip', 'strip', 'qr'] });
check(
'a repeated section is stored once',
JSON.stringify(one.body?.slots) === JSON.stringify(['strip', 'qr']),
JSON.stringify(one.body),
);
// An empty set is the curator's removal: no landing section draws the row,
// but the uploader keeps it in their folder.
const off = await patch(`/admin/photos/${id}`, { slots: [] });
check('an admin takes a photo off the landing', off.status === 200 && off.body?.slots?.length === 0, JSON.stringify(off.body));
check(
'an off photo is on no landing section',
((await actor().req('/photos')).body?.photos ?? []).find((r) => r.id === id)?.slots?.length === 0,
);
check('its owner still has it in the folder', ((await user.req('/photos/mine')).body?.photos ?? []).some((p) => p.id === id));
check('an unknown slot is refused', (await patch(`/admin/photos/${id}`, { slots: ['nope'] })).status === 400);
check('a bare slot string is refused', (await patch(`/admin/photos/${id}`, { slots: 'strip' })).status === 400);
check('placing an unknown photo is a 404', (await patch('/admin/photos/999999', { slots: ['qr'] })).status === 404);
// ---- preset link --------------------------------------------------------
// The look a photo was uploaded with is the landing QR card's payload: the
// `.recipe` file the app reads back on IMPORT. The curator's `qr` tick is the
// whole permission — nothing else is a link, and the listing exposes only
// whether a look exists, never the look.
const look = { name: 'QR LOOK', baseFilter: 'velvia', adjustments: { contrast: 7 }, frameId: 'none' };
const withLook = await user.req(`/photos?recipe=${encodeURIComponent(JSON.stringify(look))}`, {
method: 'POST',
headers: { 'content-type': 'image/png' },
body: PNG,
});
const presetId = withLook.body?.photo?.id;
check('an upload that carried a look says so', withLook.body?.photo?.hasPreset === true);
check('a photo with no look says no', rows.find((r) => r.id === id)?.hasPreset === false);
const listedRow = ((await actor().req('/photos')).body?.photos ?? []).find((p) => p.id === presetId);
check('the public listing counts looks, never ships them', listedRow?.hasPreset === true && !('recipe' in listedRow));
check('an un-curated photo is not a link', (await actor().req(`/photos/${presetId}/preset.recipe`)).status === 404);
check('a lookless photo is not a link either', (await actor().req(`/photos/${id}/preset.recipe`)).status === 404);
check('an unknown row is a 404', (await actor().req('/photos/999999/preset.recipe')).status === 404);
check('a nonsense id is a 404, not a crash', (await actor().req('/photos/abc/preset.recipe')).status === 404);
await patch(`/admin/photos/${presetId}`, { slots: ['qr'] });
const presetFile = await fetch(`${BASE}/photos/${presetId}/preset.recipe`);
const presetXml = await presetFile.text();
check(
'a curated photo serves its look as a download',
presetFile.status === 200 && /^application\/xml/.test(presetFile.headers.get('content-type') ?? ''),
`got ${presetFile.status}`,
);
check(
'the download carries the row it came from',
presetFile.headers.get('content-disposition') === `attachment; filename="recipescam-${presetId}.recipe"`,
presetFile.headers.get('content-disposition') ?? '',
);
check(
'the file is the app’s own envelope',
/^<\?xml version="1\.0" encoding="UTF-8"\?>\n<recipescam-recipe version="1" algorithm="xor16-v1" salt="[0-9a-f]+">\n {2}<payload>[0-9a-f]+<\/payload>\n<\/recipescam-recipe>\n$/.test(
presetXml,
),
);
const again = await (await fetch(`${BASE}/photos/${presetId}/preset.recipe`)).text();
check(
'every download gets its own salt',
/salt="([0-9a-f]+)"/.exec(again)?.[1] !== /salt="([0-9a-f]+)"/.exec(presetXml)?.[1],
);
// Taking the photo out of the `qr` section takes the link away with it.
await patch(`/admin/photos/${presetId}`, { slots: [] });
check('an un-curated photo loses its link again', (await actor().req(`/photos/${presetId}/preset.recipe`)).status === 404);
await user.req(`/photos/${presetId}`, { method: 'DELETE' });
const deleted = await admin.req(`/admin/photos/${id}`, { method: 'DELETE' });
check('an admin deletes a contribution', deleted.status === 204, `got ${deleted.status}`);
check('the deleted file is gone from disk', (await fetch(`${BASE}/photos/${id}/file`)).status === 404);
check('the deleted row is gone from the listing', !((await admin.req('/admin/photos')).body?.photos ?? []).some((r) => r.id === id));
// ---- quota --------------------------------------------------------------
const quota = actor();
await activeSignup(quota, `quota${stamp}@test.local`);
let last = 0;
for (let i = 0; i < 13; i++) last = (await quota.upload(PNG, 'image/png')).status;
check('uploads are capped per account', last === 429, `13th upload: ${last}`);
// The cap is a member fair-use rule; the curator stocks the landing page from
// one account, so it must not apply to the allowlist.
let adminLast = 0;
for (let i = 0; i < 13; i++) adminLast = (await admin.upload(PNG, 'image/png')).status;
check('the admin is exempt from the member quota', adminLast === 201, `13th admin upload: ${adminLast}`);
const cleared = await admin.req('/admin/photos', { method: 'DELETE' });
check('an admin clears the strip in one call', cleared.status === 200 && cleared.body?.removed > 0, JSON.stringify(cleared.body));
check('the strip is empty afterwards', ((await actor().req('/photos')).body?.photos ?? []).length === 0);
// ---- profile: an account edits itself ----------------------------------
const JSON_HDR = { 'content-type': 'application/json' };
const edit = (a, body) => a.req('/auth/me', { method: 'PATCH', headers: JSON_HDR, body: JSON.stringify(body) });
const member = actor();
await activeSignup(member, `profile${stamp}@test.local`);
const anonEdit = await edit(actor(), { password: 'another-secret-1', currentPassword: 'supersecret1' });
check('a profile edit needs a session', anonEdit.status === 401, `got ${anonEdit.status}`);
const badCurrent = await edit(member, { password: 'another-secret-1', currentPassword: 'not-the-password' });
check('a profile edit needs the current password', badCurrent.status === 403, `got ${badCurrent.status}`);
const takenEmail = await edit(member, { email: ADMIN_EMAIL, currentPassword: 'supersecret1' });
check('a profile edit refuses a taken email', takenEmail.status === 409, `got ${takenEmail.status}`);
const shortNew = await edit(member, { password: 'short', currentPassword: 'supersecret1' });
check('a profile edit refuses a short password', shortNew.status === 400, `got ${shortNew.status}`);
const newEmail = `renamed${stamp}@test.local`;
const renamed = await edit(member, { email: newEmail, currentPassword: 'supersecret1' });
check('an admin-visible profile edit changes the email', renamed.status === 200 && renamed.body?.user?.email === newEmail, JSON.stringify(renamed.body));
// The tier follows the address that earned it: the new one is unproven until
// its own letter is followed, so the account drops back to the guest tier.
check('a changed address is unproven again', renamed.body?.user?.verified === false, JSON.stringify(renamed.body));
check('a changed address loses the writes', (await member.req('/recipes')).status === 403);
await followVerifyLink(newEmail);
check('following the new letter restores the tier', (await member.req('/auth/me')).body?.user?.verified === true);
const login = (email, password) =>
actor().req('/auth/login', { method: 'POST', headers: JSON_HDR, body: JSON.stringify({ email, password }) });
check('the account logs in under the new email', (await login(newEmail, 'supersecret1')).status === 200);
check('the old email no longer logs in', (await login(`profile${stamp}@test.local`, 'supersecret1')).status === 401);
// The session that made the edit is the same row, so it also changes the password.
const newPassword = 'second-secret-1';
const rekeyed = await edit(member, { password: newPassword, currentPassword: 'supersecret1' });
check('an account changes its own password', rekeyed.status === 200, `got ${rekeyed.status}`);
check('the old password stops working', (await login(newEmail, 'supersecret1')).status === 401);
check('the new password works', (await login(newEmail, newPassword)).status === 200);
// ---- admin: the account list -------------------------------------------
const anonUsers = await actor().req('/admin/users');
check('the user list is not public', anonUsers.status === 401, `got ${anonUsers.status}`);
const memberUsers = await member.req('/admin/users');
check('a member cannot read the user list', memberUsers.status === 403, `got ${memberUsers.status}`);
const adminUsers = await admin.req('/admin/users');
const adminRow = (adminUsers.body?.users ?? []).find((u) => u.email === ADMIN_EMAIL);
check('an admin reads the user list', adminUsers.status === 200 && Array.isArray(adminUsers.body?.users), `got ${adminUsers.status}`);
check('the list flags the allowlisted account', adminRow?.admin === true, JSON.stringify(adminRow));
check('the list counts each account’s photos', typeof adminRow?.photos === 'number', JSON.stringify(adminRow));
// ---- avatar: the picture beside the name -------------------------------
const memberId = renamed.body?.user?.id;
const noSession = await actor().avatar(PNG, 'image/png');
check('an avatar upload needs a session', noSession.status === 401, `got ${noSession.status}`);
const badAvatar = await member.avatar(Buffer.from('<svg onload="alert(1)"/>'), 'image/png');
check('an avatar upload sniffs the bytes', badAvatar.status === 415, `got ${badAvatar.status}`);
const gaveAvatar = await member.avatar(PNG, 'image/png');
const avatarUrl = gaveAvatar.body?.user?.avatar;
check('a member uploads an avatar', gaveAvatar.status === 200 && typeof avatarUrl === 'string', JSON.stringify(gaveAvatar.body));
check('the avatar URL points at the account', new RegExp(`^/api/users/${memberId}/avatar\\?v=[0-9a-f]{32}$`).test(String(avatarUrl)), String(avatarUrl));
const servedAvatar = await fetch(`http://127.0.0.1:${PORT}${avatarUrl}`);
check('an avatar is served without a session', servedAvatar.status === 200, `got ${servedAvatar.status}`);
check('an avatar carries its image type', servedAvatar.headers.get('content-type') === 'image/png', String(servedAvatar.headers.get('content-type')));
check('an avatar is cacheable for a long time', (servedAvatar.headers.get('cache-control') ?? '').includes('immutable'), String(servedAvatar.headers.get('cache-control')));
check('the avatar bytes round-trip intact', Buffer.from(await servedAvatar.arrayBuffer()).equals(PNG));
const replaced = await member.avatar(JPEG_HEAD, 'image/jpeg');
const replacedUrl = replaced.body?.user?.avatar;
check('a second avatar replaces the first', replaced.status === 200 && replacedUrl !== avatarUrl, JSON.stringify(replaced.body));
check('the replaced avatar file is gone', !existsSync(join(DATA_DIR, 'avatars', `${String(avatarUrl).split('?v=')[1]}.png`)));
check('the new avatar resolves', (await fetch(`http://127.0.0.1:${PORT}${replacedUrl}`)).status === 200);
const ghost = await actor().req('/users/999999/avatar');
check('an unknown account has no avatar', ghost.status === 404, `got ${ghost.status}`);
// The moderation list has to show the same face beside the email.
const listedUsers = (await admin.req('/admin/users')).body?.users ?? [];
const memberRow = listedUsers.find((u) => u.id === memberId);
check('the user list carries each account’s picture', memberRow?.avatar === replacedUrl, JSON.stringify(memberRow));
// ---- moderation: block, remove, delete an account -----------------------
const target = actor();
await activeSignup(target, `moderated${stamp}@test.local`);
const targetId = (await target.req('/auth/me')).body?.user?.id;
const targetPhoto = (await target.upload(PNG, 'image/png')).body?.photo;
const targetPhotoUrl = `http://127.0.0.1:${PORT}/api/photos/${targetPhoto?.id}/file`;
const moderate = (a, id, body) => a.req(`/admin/users/${id}`, { method: 'PATCH', headers: JSON_HDR, body: JSON.stringify(body) });
check('a fresh account is moderatable', Number.isInteger(targetId) && Number.isInteger(targetPhoto?.id), `${targetId}/${targetPhoto?.id}`);
const anonModerate = await moderate(actor(), targetId, { blocked: true });
check('moderating needs a session', anonModerate.status === 401, `got ${anonModerate.status}`);
const memberModerate = await moderate(member, targetId, { blocked: true });
check('a member cannot moderate', memberModerate.status === 403, `got ${memberModerate.status}`);
const selfModerate = await moderate(admin, adminRow?.id, { blocked: true });
check('an allowlisted account cannot be moderated', selfModerate.status === 403, `got ${selfModerate.status}`);
const ghostModerate = await moderate(admin, 999999, { blocked: true });
check('an unknown account is a 404', ghostModerate.status === 404, `got ${ghostModerate.status}`);
const badModerate = await moderate(admin, targetId, { blocked: 'yes' });
check('a moderation body is type-checked', badModerate.status === 400, `got ${badModerate.status}`);
const blocked = await moderate(admin, targetId, { blocked: true });
check('an admin blocks an account', blocked.status === 200 && blocked.body?.user?.blocked === true, JSON.stringify(blocked.body));
const blockedLogin = await login(`moderated${stamp}@test.local`, 'supersecret1');
check('a blocked account cannot sign in', blockedLogin.status === 403 && blockedLogin.body?.error === 'account blocked', JSON.stringify(blockedLogin.body));
check('a blocked session stops being a user', (await target.req('/auth/me')).body?.user === null, JSON.stringify((await target.req('/auth/me')).body));
const blockedRow = ((await admin.req('/admin/users')).body?.users ?? []).find((u) => u.id === targetId);
check('the list flags a blocked account', blockedRow?.blocked === true, JSON.stringify(blockedRow));
const unblocked = await moderate(admin, targetId, { blocked: false });
check('an admin unblocks an account', unblocked.status === 200 && unblocked.body?.user?.blocked === false, JSON.stringify(unblocked.body));
check('an unblocked account signs in again', (await login(`moderated${stamp}@test.local`, 'supersecret1')).status === 200);
check('its photo is back on the strip', ((await actor().req('/photos')).body?.photos ?? []).some((p) => p.id === targetPhoto?.id));
const removed = await moderate(admin, targetId, { removed: true });
check('an admin removes an account', removed.status === 200 && removed.body?.user?.removed === true, JSON.stringify(removed.body));
const removedLogin = await login(`moderated${stamp}@test.local`, 'supersecret1');
check('a removed account cannot sign in', removedLogin.status === 403 && removedLogin.body?.error === 'account removed', JSON.stringify(removedLogin.body));
check('a removed account’s photos leave the strip', !((await actor().req('/photos')).body?.photos ?? []).some((p) => p.id === targetPhoto?.id));
const removedRow = ((await admin.req('/admin/users')).body?.users ?? []).find((u) => u.id === targetId);
check('the list flags a removed account', removedRow?.removed === true, JSON.stringify(removedRow));
const restored = await moderate(admin, targetId, { removed: false });
check('an admin restores an account', restored.status === 200 && restored.body?.user?.removed === false, JSON.stringify(restored.body));
check('a restored account signs in again', (await login(`moderated${stamp}@test.local`, 'supersecret1')).status === 200);
const hardDelete = await admin.req(`/admin/users/${targetId}`, { method: 'DELETE' });
check('an admin deletes an account outright', hardDelete.status === 204, `got ${hardDelete.status}`);
check('a deleted account leaves the list', !((await admin.req('/admin/users')).body?.users ?? []).some((u) => u.id === targetId));
check('a deleted account cannot sign in', (await login(`moderated${stamp}@test.local`, 'supersecret1')).status === 401);
check('a deleted account’s photo file is unlinked', (await fetch(targetPhotoUrl)).status === 404);
// ---- film-strip ratings -------------------------------------------------
// Public and one-per-visitor: two addresses are two voters, and a second vote
// from the same address replaces the first instead of adding to it.
const vote = (ip, key, stars) =>
fetch(`${BASE}/ratings`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-for': ip },
body: JSON.stringify({ key, stars }),
});
const readRatings = async (ip) =>
((await (await fetch(`${BASE}/ratings`, { headers: { 'x-forwarded-for': ip } })).json()).ratings ?? {});
check('a guest may rate a frame', (await vote('10.9.9.1', 'look:TEST_LOOK', 5)).status === 200);
await vote('10.9.9.2', 'look:TEST_LOOK', 3);
const tally = (await readRatings('10.9.9.1'))['look:TEST_LOOK'];
check('the tally averages every vote', tally?.avg === 4 && tally?.n === 2, JSON.stringify(tally));
check('a visitor reads back their own vote', tally?.mine === 5);
check('a stranger has no vote of their own', (await readRatings('10.9.9.3'))['look:TEST_LOOK']?.mine === 0);
await vote('10.9.9.1', 'look:TEST_LOOK', 1);
const changed = (await readRatings('10.9.9.1'))['look:TEST_LOOK'];
check('a second vote replaces the first', changed?.avg === 2 && changed?.n === 2 && changed?.mine === 1, JSON.stringify(changed));
check('a six-star score is refused', (await vote('10.9.9.1', 'look:TEST_LOOK', 6)).status === 400);
check('a zero-star score is refused', (await vote('10.9.9.1', 'look:TEST_LOOK', 0)).status === 400);
check('a malformed key is refused', (await vote('10.9.9.1', 'bad key!', 3)).status === 400);
check('an empty key is refused', (await vote('10.9.9.1', '', 3)).status === 400);
const throwaway = await user.upload(PNG, 'image/png');
const throwawayId = throwaway.body?.photo?.id;
check('the throwaway upload lands', Number.isInteger(throwawayId), JSON.stringify(throwaway.body));
await vote('10.9.9.4', `photo:${throwawayId}`, 5);
// ---- the hero's award windows -------------------------------------------
// The landing's award column reads the same votes over a window: what was
// cast today, and what was cast this week. A vote cast a moment ago is in
// both. A subject that is not a photo — a built-in look — is never a frame.
const highlights = (await (await fetch(`${BASE}/highlights`)).json()).highlights ?? {};
const day = (highlights.day ?? []).find((r) => r.id === throwawayId);
const week = (highlights.week ?? []).find((r) => r.id === throwawayId);
check('a vote lands in today’s window', day?.avg === 5 && day?.n === 1, JSON.stringify(day));
check('and in this week’s window too', week?.avg === 5 && week?.n === 1, JSON.stringify(week));
check(
'a look that is not a photo is never an award',
[...(highlights.day ?? []), ...(highlights.week ?? [])].every((r) => Number.isInteger(r.id)),
JSON.stringify(highlights),
);
await user.req(`/photos/${throwawayId}`, { method: 'DELETE' });
check(
'a deleted photo takes its votes with it',
!Object.hasOwn(await readRatings('10.9.9.4'), `photo:${throwawayId}`),
);
const afterDelete = (await (await fetch(`${BASE}/highlights`)).json()).highlights ?? {};
check(
'and off the award column',
![...(afterDelete.day ?? []), ...(afterDelete.week ?? [])].some((r) => r.id === throwawayId),
JSON.stringify(afterDelete),
);
// ---- pre-existing guarantees still hold ---------------------------------
const foreignRecipe = await user.req('/recipes/1', { method: 'DELETE' });
check("another account's recipe is not deletable", foreignRecipe.status === 404, `got ${foreignRecipe.status}`);
const missing = await actor().req('/nope');
check('unknown routes keep the single error shape', missing.status === 404 && missing.body?.error === 'not_found');
} catch (err) {
fail++;
console.log(`FAIL harness :: ${err && err.stack ? err.stack : err}`);
console.log(serverLog.slice(-2000));
} finally {
server.kill('SIGTERM');
rmSync(DATA_DIR, { recursive: true, force: true });
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail === 0 ? 0 : 1);
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"lib": ["ES2022"],
"types": ["node"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": false,
"sourceMap": false
},
"include": ["src/**/*.ts"]
}
+38
View File
@@ -0,0 +1,38 @@
# RecipesCam web — the whole stack. Copy this folder anywhere, then:
# cp .env.example .env
# docker compose up -d --build
name: recipescam-web
services:
# Accounts + saved recipes. Never sees a photo: every pixel of the render
# pipeline runs in the visitor's browser.
api:
build: ./backend
restart: unless-stopped
environment:
DATA_DIR: /data
# Who may moderate the contributed strip: a comma-separated email
# allowlist. Empty means nobody is an admin, which is the safe default.
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
# The relay that mails the "prove this address" link. Leave SMTP_HOST
# empty and the link is written to this container's log instead — a dev
# box needs no mail server, and the operator can read / copy it.
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-}
SMTP_SECURE: ${SMTP_SECURE:-}
volumes:
# SQLite (WAL) lives on the host so a rebuild never loses accounts.
- ./data:/data
# Static SPA + /api reverse proxy. "api" is the DNS name nginx.conf proxies to,
# so this service name is load-bearing.
frontend:
build: ./frontend
restart: unless-stopped
depends_on:
- api
ports:
- "${WEB_PORT:-8090}:80"
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
public/wasm
*.log
.env
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
public/wasm/
+14
View File
@@ -0,0 +1,14 @@
# Build stage. Node 22 (glibc) so nothing here depends on the host.
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# tsc --noEmit && vite build; the prebuild hook copies canvaskit.wasm into public/
RUN npm run build
# Runtime: static files only. The photo pipeline runs in the visitor's browser.
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+49
View File
@@ -0,0 +1,49 @@
<!doctype html>
<html lang="vi" data-theme="dark" data-accent="amber" data-fonts="studio">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0a0a0a" />
<link rel="icon" type="image/png" href="/assets/RecipesCamIcon.png" />
<link rel="apple-touch-icon" href="/assets/RecipesCamIcon.png" />
<!-- The installed app. The manifest is a static file in public/ (see
public/manifest.json and scripts/make-icons.mjs for its icons), so a build
needs no plugin to ship it. iOS ignores most of the manifest and reads these
three metas instead: 'capable' is what makes a home-screen bookmark open
without Safari's chrome, 'black-translucent' gives it the app's black bars,
and 'title' is the name under the icon. -->
<link rel="manifest" href="/manifest.json" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="RecipesCam" />
<title>RecipesCam — web</title>
<meta name="description" content="RecipesCam web — grade photos with the same film recipes as the app." />
<link rel="canonical" href="/" />
<!-- Share card. The URLs are relative on purpose: a crawler resolves them
against whatever host serves the page, so the same build previews
correctly on any deployment. og:image is the 1200x630 hero shot in
public/assets/og-cover.png (see the og-cover script in the repo notes). -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="RecipesCam" />
<meta property="og:title" content="RecipesCam — Color Recipes & Film Camera Studio" />
<meta property="og:description" content="Grade ảnh bằng công thức màu phim Kodak & Fuji — tone curve, hạt phim và halation. Cùng một pipeline trên điện thoại và web studio, ảnh không rời khỏi máy bạn." />
<meta property="og:url" content="/" />
<meta property="og:image" content="/assets/og-cover.png" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="RecipesCam landing page — capture and grade photos with exclusive color recipes" />
<meta property="og:locale" content="vi_VN" />
<meta property="og:locale:alternate" content="en_US" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="RecipesCam — Color Recipes & Film Camera Studio" />
<meta name="twitter:description" content="Grade ảnh bằng công thức màu phim Kodak & Fuji — tone curve, hạt phim và halation. Cùng một pipeline trên điện thoại và web studio." />
<meta name="twitter:image" content="/assets/og-cover.png" />
<meta name="twitter:image:alt" content="RecipesCam landing page — capture and grade photos with exclusive color recipes" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+117
View File
@@ -0,0 +1,117 @@
# Static SPA + API proxy. TLS/domain are terminated outside (Nginx Proxy
# Manager), so this container only ever speaks plain HTTP on port 80.
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Static only, plus the API proxy below; a stricter CSP needs a per-app tune
# (canvas wasm, the QR image host) and belongs here once it is done.
# Cross-origin isolation: super-resolution runs onnxruntime's threaded wasm,
# which needs a SharedArrayBuffer, which the browser only hands over when the
# page is isolated. Both headers are required, and both must also sit on the
# script responses a nested worker fetches — see the two locations below.
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types text/css application/javascript application/json application/wasm image/svg+xml;
# nginx's stock mime.types has no .mjs, and the browser refuses to run a module
# the server hands over as octet-stream: onnxruntime's glue is one of those.
# First match wins among regex locations, so this one stands before the
# /wasm/ block that would otherwise swallow the file.
# (Both locations below restate the isolation headers from above: an add_header
# in a location drops every add_header inherited, and a worker script is fetched
# under the embedder's COEP — Chromium blocks it as
# `coep-frame-resource-needs-coep-header` without them.)
location ~* \.mjs$ {
types { application/javascript mjs; }
expires 30d;
add_header Cache-Control "public, immutable";
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
try_files $uri =404;
}
# canvas.wasm is ~8MB and the super-resolution model is ~5MB: immutable, hashed
# or not, they never change under a given build. Fonts and the wall-frame
# artwork are likewise static.
location ~* ^/(wasm|models|assets)/ {
expires 30d;
add_header Cache-Control "public, immutable";
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
try_files $uri =404;
}
# The two files whose names never change but whose contents must: a cached worker
# keeps answering with the previous build's shell long after the deploy that
# replaced it, and a cached manifest keeps pointing at the icon set it shipped with.
# Nothing else would ever flush them, which is exactly why they opt out of the
# long-lived caching above. The worker is a script under the embedder's COEP like
# the .mjs files, so it restates the two isolation headers for the same reason.
location = /sw.js {
add_header Cache-Control "no-cache";
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
try_files $uri =404;
}
location = /manifest.json {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
# The one route that carries a whole data dir back in (see /api/admin/restore
# — who may call it is the API's own admin check, done before it reads a byte).
# The upload cap that holds everywhere else would reject it, and unpacking the
# archive plus the restart takes longer than the stock read timeout. nginx
# takes the longest matching prefix, so this one holds for that route while
# /api/ below keeps the rest.
location /api/admin/restore {
resolver 127.0.0.11 valid=10s ipv6=off;
set $api_upstream http://api:3000;
proxy_pass $api_upstream$request_uri;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
client_max_body_size 0;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
location /api/ {
# Resolved per request through Docker's embedded DNS, so the frontend can
# start before the API container without nginx refusing to boot.
resolver 127.0.0.11 valid=10s ipv6=off;
set $api_upstream http://api:3000;
proxy_pass $api_upstream$request_uri;
proxy_http_version 1.1;
# $http_host, not $host: keep the port when the visitor hits this container
# directly (localhost:8090), since the API builds the verification link from
# the forwarded host.
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Hand the API the scheme the visitor actually used (TLS is terminated by
# Nginx Proxy Manager in front of this container) so it can mark the session
# cookie Secure. Falls back to nothing when that header is absent.
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
# Room for a full-size upload (the API caps photo bodies at 12m itself).
client_max_body_size 16m;
}
# SPA fallback: /app, /login and /signup are all index.html.
location / {
try_files $uri $uri/ /index.html;
}
}
+2038
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "recipescam-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"prepare-wasm": "node scripts/copy-wasm.mjs",
"predev": "npm run prepare-wasm",
"dev": "vite",
"prebuild": "npm run prepare-wasm",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview --port 4173"
},
"dependencies": {
"canvaskit-wasm": "^0.42.0",
"exifr": "^7.1.3",
"libraw-wasm": "^1.6.0",
"onnxruntime-web": "^1.30.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^22.20.3",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.7"
}
}
+72
View File
@@ -0,0 +1,72 @@
<!doctype html>
<meta charset="utf-8">
<title>probe: develop a real RAW</title>
<body>
<pre id="out" style="font:12px monospace">running…</pre>
<img id="shot" style="max-width:100%">
<script type="module">
const log = (...a) => {
const s = a.map((x) => (typeof x === 'string' ? x : JSON.stringify(x))).join(' ');
document.getElementById('out').textContent += s + '\n';
console.log(s);
};
const b64 = (bytes) => {
let s = '';
for (let i = 0; i < bytes.length; i += 0x8000) {
s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
}
return btoa(s);
};
try {
const RAW_URL = new URLSearchParams(location.search).get('f') || 'http://127.0.0.1:5300/DSC03453.ARW';
const { initSkia } = await import('/src/engine/skiaShim.ts');
await initSkia({ locateFile: (file) => `/wasm/${file}` });
log('canvaskit up');
const { developRaw } = await import('/src/engine/rawDevelop.ts');
const bytes = new Uint8Array(await (await fetch(RAW_URL)).arrayBuffer());
log('raw bytes', bytes.length, RAW_URL);
// The develop hands its buffer to LibRaw's worker, which detaches it — one
// copy each, since the plane below is read out of a second decode.
const forPlane = bytes.slice();
const t0 = performance.now();
const jpeg = await developRaw(bytes);
log('develop ms', Math.round(performance.now() - t0), 'jpeg bytes', jpeg.length);
window.__dev = b64(jpeg);
document.getElementById('shot').src = URL.createObjectURL(new Blob([jpeg], { type: 'image/jpeg' }));
// The plane and the colour data behind that develop, so the shader can be
// replayed offline a stage at a time.
const { default: LibRaw } = await import('libraw-wasm');
const raw = new LibRaw();
// LibRaw hands the buffer to its worker, which detaches it — a copy, since
// the develop above already passed the original through.
await raw.open(forPlane, {
halfSize: true, outputBps: 16, outputColor: 0, noAutoScale: true,
useCameraWb: false, noAutoBright: true, gamm: [1, 1], userQual: 3, highlight: 0,
});
const meta = await raw.metadata(true);
const img = await raw.imageData();
const cd = meta.color_data;
const plane = new Uint8Array(img.data.buffer, img.data.byteOffset, img.data.byteLength);
log('plane', img.width, 'x', img.height, 'bytes', plane.length);
log('black', cd.black, 'maximum', cd.maximum, 'mul', cd.cam_mul, 'model', meta.normalized_model);
window.__dump = {
b64: b64(plane),
width: img.width, height: img.height,
black: cd.black, maximum: cd.maximum,
cam_mul: cd.cam_mul, rgb_cam: cd.rgb_cam,
model: meta.normalized_model, raw_bps: meta.raw_bps ?? null,
};
const thumb = await raw.thumbnailData().catch(() => null);
if (thumb?.data?.length) window.__thumb = b64(new Uint8Array(thumb.data));
log('thumb', thumb?.format, thumb?.width, 'x', thumb?.height);
raw.dispose();
log('DONE');
} catch (e) {
log('ERROR', e?.message ?? String(e), (e?.stack ?? '').split('\n').slice(0, 6).join(' | '));
log('FAILED');
}
</script>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

+37
View File
@@ -0,0 +1,37 @@
{
"name": "RecipesCam — Color Recipes & Film Camera Studio",
"short_name": "RecipesCam",
"description": "Grade ảnh bằng công thức màu phim Kodak & Fuji — tone curve, hạt phim và halation. Cùng một pipeline trên điện thoại và web studio, ảnh không rời khỏi máy bạn.",
"lang": "vi",
"start_url": "/app",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0a0a0a",
"theme_color": "#0a0a0a",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Thư viện",
"short_name": "Thư viện",
"url": "/library"
}
]
}
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
// The app shell as a service worker, so an installed RecipesCam opens with no
// network at all. Not bundled on purpose: through vite/rollup it would stop being
// readable in DevTools, the one place a worker gets debugged. VERSION is the cache
// name and the whole update story — bump it when the shell changes, and `activate`
// drops every older cache.
const VERSION = 'recipescam-v1';
// nginx answers all three with the same index.html (SPA fallback), so they are one
// document under three keys: an offline navigation finds it whichever key it asks.
const SHELL = ['/', '/app', '/library'];
// Cache-first paths: vite hashes every filename in its own bundle, and the wasm,
// the models and the icons never change under a given build.
const STATIC = /^\/(assets|wasm|models|icons)\//;
// Only a complete same-origin 200 is worth keeping — an opaque or error body
// stored here comes back as a failure on the next visit, and nothing in the worker
// can tell the two apart by then.
const cachedFetch = (request) =>
fetch(request).then((response) => {
if (response.ok) {
const copy = response.clone();
caches.open(VERSION).then((cache) => cache.put(request, copy));
}
return response;
});
// Offline, the studio the app is for; the landing page for a build without /app.
const offlineShell = async () =>
(await caches.match('/app')) ?? (await caches.match('/')) ?? Response.error();
self.addEventListener('install', (event) => {
// Best effort, one route at a time: a deploy caught mid-flight must not leave the
// worker uninstalled, so each route fails on its own.
event.waitUntil(
caches.open(VERSION)
.then((cache) => Promise.all(SHELL.map((url) => cache.add(url).catch(() => {}))))
.then(() => self.skipWaiting()),
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((names) => Promise.all(names.filter((n) => n !== VERSION).map((n) => caches.delete(n))))
.then(() => self.clients.claim()),
);
});
self.addEventListener('fetch', (event) => {
const { request } = event;
// A worker is a cache, not a proxy: a POST, the API, another origin's font — a
// request that is not a plain same-origin read — goes straight out.
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
// A navigation is network-first even when it is cached: a shell answered from the
// cache while the network holds a newer build pins the visitor to the old one. It
// is also the path an installed app opens through with no network.
if (request.mode === 'navigate') {
event.respondWith(fetch(request).catch(offlineShell));
return;
}
// ponytail: a navigation is never written back, the precached entry is what
// answers offline. Add a put() here once a route must survive on its own.
if (STATIC.test(url.pathname)) {
event.respondWith(caches.match(request).then((hit) => hit ?? cachedFetch(request)));
return;
}
// Everything else — a font, the manifest, an uploaded photo — is network-first,
// and answered from the cache only when that fails.
event.respondWith(fetch(request).catch(async () => (await caches.match(request)) ?? Response.error()));
});
+119
View File
@@ -0,0 +1,119 @@
// AUTO's three readings are pure arithmetic on the binned ramp, so they can be
// checked here rather than in a browser: hand the functions a histogram whose
// answer is known by construction and read it back. The module is TypeScript
// (and a .tsx), so it is transpiled on the fly out of the installed compiler and
// the React/i18n imports — which only the overlay component needs — are dropped
// before it is evaluated (same convention as preview-match-check.mjs).
//
// node scripts/auto-tone-check.mjs
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import ts from 'typescript';
const transpile = (path) =>
ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
compilerOptions: {
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2022,
jsx: ts.JsxEmit.ReactJSX,
},
}).outputText;
const dir = mkdtempSync(join(tmpdir(), 'auto-check-'));
// colorUtils only imports types, so the compiler drops that line on its own.
writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts'));
writeFileSync(
join(dir, 'histogram.mjs'),
transpile('../src/ui/Histogram.tsx')
.replace(
/^import .*from ['"]\.\.\/\.\.\/shared\/utils\/colorUtils['"];$/m,
'import { kelvinToRGB } from "./colorUtils.mjs";',
)
// The overlay's own imports: the component is never rendered here, so React
// and the i18n provider are dead weight node cannot resolve.
.replace(/^import .*from ['"]react[^'"]*['"];$/gm, '')
.replace(/^import .*from ['"]\.\.\/i18n\/I18nProvider['"];$/m, ''),
);
const { autoExposureStops, autoTone, autoWhiteBalance, AUTO_EV_MAX } = await import(
pathToFileURL(join(dir, 'histogram.mjs')).href
);
const BINS = 256;
// One pixel per bin: the whole 0..255 ramp, evenly, so every quantile of it is
// known in closed form (quantile q of a flat ramp is q).
const flat = () => new Array(BINS).fill(1);
// Every pixel at one level: a frame with a single tone in it, and nothing else.
const spike = (v, n = 1000) => {
const bins = new Array(BINS).fill(0);
bins[v] = n;
return bins;
};
// --- Exposure: the mean luma against the 0.48 a grey card lands on, in stops.
const stops = (lum) => autoExposureStops(lum);
assert.equal(stops(spike(255)), Math.log2(0.48 / 1), 'white is pulled back by log2(0.48)');
assert.equal(stops(spike(0)), AUTO_EV_MAX, "black is pushed to the knob own ceiling");
// Half a stop down from the target is 0.48 / 2^0.5 = 0.339 -> bin 87.
assert.equal(Math.round(stops(spike(87)) * 10) / 10, 0.5, 'a dark frame is asked for +0.5 stop');
// The flat ramp averages exactly mid-grey, which is 0.0589 stop under target.
assert.ok(Math.abs(stops(flat()) + 0.0589) < 5e-4, 'a flat ramp sits just under mid-grey');
assert.equal(stops(spike(20, 0)), AUTO_EV_MAX, 'an empty histogram takes the ceiling, not infinity');
// --- Highlight / Shadow: the 1% tails, thresholds 0.9 / 0.02, ramp to ±5.
assert.deepEqual(autoTone(spike(255)), { highlight: -5, shadow: 0 }, 'blown frame only pulls highlights');
assert.deepEqual(autoTone(spike(0)), { highlight: 0, shadow: 5 }, 'black frame only opens shadows');
// The flat ramp is the interesting one: its own top 1% really is clipped and its
// own bottom 1% really is on the floor, so both ends move. p99 = 253/255 = 0.992
// (5 units), p01 = 2/255 = 0.008 (3 units).
assert.deepEqual(autoTone(flat()), { highlight: -5, shadow: 3 }, 'an even ramp has both ends in play');
// 1% of the frame at white over an otherwise mid ramp: the spec's small blown
// window, and the shadow side stays put because the ramp's floor is not black.
const blown = flat();
blown[255] += 3;
assert.deepEqual(autoTone(blown), { highlight: -5, shadow: 3 }, 'a blown 1% is enough to pull the top');
// p99 landed just over the threshold: 989 pixels mid-ramp and 11 at bin 243 puts
// p99 at 0.953, 0.053 over 0.9 -> 2.65 units -> 3. Proof the pull is graded.
const shoulder = spike(100, 989);
shoulder[243] = 11;
assert.deepEqual(autoTone(shoulder), { highlight: -3, shadow: 0 }, 'p99 just over 0.9 pulls a third of the way');
// 2% of pixels two bins off the floor: p01 = 0.008, so the shadow side moves —
// but only 3 units, because the tail is barely under the 0.02 threshold.
const crushed = spike(128, 980);
crushed[2] = 20;
assert.deepEqual(autoTone(crushed), { highlight: 0, shadow: 3 }, 'a crushed 2% opens the shadows part way');
// 0.4% on the floor is NOT the bottom 1%: the tenth-darkest pixel is the mid
// ramp, so the threshold is not crossed and the knob stays put.
const speck = spike(128, 996);
speck[0] = 4;
assert.deepEqual(autoTone(speck), { highlight: 0, shadow: 0 }, 'a speck of black is not a crushed frame');
assert.deepEqual(autoTone(spike(0, 0)), { highlight: 0, shadow: 0 }, 'an empty histogram is left alone');
// --- White Balance: gray-world with G as the anchor, matched against the
// engine's own kelvinToRGB, so a neutral frame must come back on 5500K / 0.
assert.deepEqual(autoWhiteBalance(flat(), flat(), flat()), { temperature: 5500, tint: 0 }, 'neutral stays put');
assert.deepEqual(
autoWhiteBalance(spike(0), spike(0), spike(0)),
{ temperature: 5500, tint: 0 },
'a frame with no light in it has no cast to read',
);
// A mildly warm frame (R above G above B) is a frame lit too warmly, so the
// ruler is asked to cool it — below 5500K. The cast sits on the planckian
// locus, so no green/magenta trim is needed to reach it.
const warm = autoWhiteBalance(spike(160), spike(150), spike(140));
assert.ok(warm.temperature < 5500, `a warm frame cools: got ${warm.temperature}K`);
assert.equal(warm.tint, 0, `and stays on the locus: got tint ${warm.tint}`);
// A cast far warmer than the Kelvin ruler's travel cannot be cancelled, and the
// two axes overlap once the temperature pins at its end — so this only asks for
// the direction and for the trim staying small.
const veryWarm = autoWhiteBalance(spike(180), spike(150), spike(120));
assert.ok(veryWarm.temperature < 3000, `a harder warm cast cools further: got ${veryWarm.temperature}K`);
assert.ok(Math.abs(veryWarm.tint) <= 5, `without a wild trim: got ${veryWarm.tint}`);
// A frame that is only green is the one the TINT axis exists for: green is
// trimmed toward magenta (positive), which no Kelvin move can do.
const green = autoWhiteBalance(spike(120), spike(160), spike(120));
assert.ok(green.tint > 5, `a green frame is trimmed toward magenta: got ${green.tint}`);
console.log('auto-tone-check ok');
+31
View File
@@ -0,0 +1,31 @@
// canvaskit and onnxruntime resolve their wasm at runtime, not through the
// bundler, so the binaries have to be served assets. 20MB of wasm in the repo is
// worse than 20MB copied out of node_modules at build time (the Docker build
// stage does the same).
import { copyFileSync, mkdirSync, existsSync } from 'node:fs';
import { dirname } from 'node:path';
const copies = [
['node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', 'public/wasm/canvaskit.wasm'],
// onnxruntime-web's default entry (its own bundled glue) looks for the jsep
// build of the runtime in `env.wasm.wasmPaths`; that build carries both the
// WebGPU and the plain WebAssembly execution provider, so one file is enough.
[
'node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.jsep.wasm',
'public/wasm/ort/ort-wasm-simd-threaded.jsep.wasm',
],
[
'node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.jsep.mjs',
'public/wasm/ort/ort-wasm-simd-threaded.jsep.mjs',
],
];
for (const [from, to] of copies) {
if (!existsSync(from)) {
console.error(`missing ${from} — run npm install first`);
process.exit(1);
}
mkdirSync(dirname(to), { recursive: true });
copyFileSync(from, to);
console.log(`copied ${from} -> ${to}`);
}
+58
View File
@@ -0,0 +1,58 @@
// The RAW develop hands its band plane to Skia as half (src/engine/halfFloat.ts)
// because the GPU backend quantises an F32 image to the 1/255 grid — which is
// exactly the grid the shadows live under. This checks the conversion itself:
// the named encodings, and that no plane value in the sensor's range moves more
// than the 16-bit plane step it came from.
//
// node scripts/half-check.mjs
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import ts from 'typescript';
const src = readFileSync(new URL('../src/engine/halfFloat.ts', import.meta.url), 'utf8');
const js = ts.transpileModule(src, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
const { f32ToF16 } = await import(`data:text/javascript,${encodeURIComponent(js)}`);
function encode(...values) {
const src32 = Float32Array.from(values);
const out = new Uint16Array(values.length);
f32ToF16(src32, out, values.length);
return out;
}
// Independent decoder, from the IEEE 754 binary16 layout.
function decode(h) {
const sign = h & 0x8000 ? -1 : 1;
const exp = (h >> 10) & 0x1f;
const man = h & 0x3ff;
if (exp === 0x1f) return sign * Infinity;
if (exp === 0) return sign * man * 2 ** -24;
return sign * (1 + man / 1024) * 2 ** (exp - 15);
}
const named = [
[0, 0x0000], [1, 0x3c00], [0.5, 0x3800], [2, 0x4000], [-1, 0xbc00],
[2 ** -24, 0x0001], [1e-9, 0x0000], [70000, 0x7c00], [65504, 0x7bff],
];
for (const [v, bits] of named) {
assert.equal(encode(v)[0], bits, `${v} -> expected ${bits.toString(16)}`);
}
// Every value the plane can carry — a 14-bit sensor from its black floor to its
// white level — has to come back within half precision's 2^-11 relative error,
// and the shadows within a far finer absolute one, since that is where the
// develop's black level lives. 1e-3 relative is a tenth of an output level at
// white; the absolute bound covers the values whose relative error says nothing.
let worstRel = 0, worstAbs = 0;
for (let n = 0; n <= 16312; n++) {
const v = n / 65535;
const back = decode(encode(v)[0]);
worstAbs = Math.max(worstAbs, Math.abs(back - v));
if (v > 0) worstRel = Math.max(worstRel, Math.abs(back - v) / v);
}
assert.ok(worstRel < 1e-3, `worst relative error ${worstRel}`);
assert.ok(worstAbs < 1e-4, `worst absolute error ${worstAbs} at raw ${Math.round(worstAbs * 65535)}`);
console.log(`plane 0..16312: worst ${worstRel.toExponential(2)} relative, ${worstAbs.toExponential(2)} absolute`);
console.log('half-check ok');
@@ -0,0 +1,86 @@
// Highlight roll-off, both ends of the pipeline, as one soft knee:
//
// L' = L , L < T
// L' = T + (L - T) / (1 + 2 S (L - T)) , L >= T
//
// The develop draws it on the sensor's own levels (T = 0.7, S = 1 / (2 (1 - T)),
// which puts the asymptote on 1.0) so the two stops the sensor holds above its
// white level are COMPRESSED into the frame instead of being thrown away by the
// old fade-to-white; the tone pass draws the same curve in linear light on the
// value the develop and the camera match left, where -HL is the knob (T = 0.5,
// S = |hl|). Before this, a blown sky left the develop on exactly 1.0 in all
// three channels and HL had a flat white to pull on: measured on DSC03453.ARW,
// where the camera's own preview is clipped the develop's luma was 253.4 with a
// standard deviation of 2.4, against 251.2 / 10.0 through the knee.
//
// Both are SkSL, so the shape is pinned on the source; the curve itself is
// checked as arithmetic, with the constants the source is asserted to carry.
//
// node scripts/highlight-knee-check.mjs
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const develop = readFileSync(new URL('../src/engine/rawDevelop.ts', import.meta.url), 'utf8');
const tone = readFileSync(new URL('../shared/utils/toneShader.ts', import.meta.url), 'utf8');
// The develop: knee on the sensor's max channel, the channel ratios kept, so the
// hue and the saturation of a blown area survive the pull-down.
const dev = develop.match(/if \(mx > 0\.7\) \{[\s\S]*?\n \}/)?.[0];
assert.ok(dev, 'the develop knee is gone — a blown sky is flat 1.0 again');
assert.match(dev, /float over = mx - 0\.7;/);
assert.match(dev, /rgb \*= \(0\.7 \+ over \/ \(1\.0 \+ over \* 3\.3333\)\) \/ mx;/);
assert.doesNotMatch(develop, /mix\(rgb \/ mx, float3\(1\.0\)/, 'the fade-to-white is back');
// The tone pass: the knee runs in LINEAR light and before the luma is read, the
// lift keeps its headroom weight — and the recovery must not also ride the
// additive term, which would darken the white the knee protects.
assert.match(tone, /if \(hl < 0\.0\) \{/);
assert.match(tone, /vec3 lin = toLinear\(rgb\);/);
assert.match(tone, /float l0 = dot\(lin, vec3\(0\.2126, 0\.7152, 0\.0722\)\);/);
assert.match(tone, /lin \*= \(0\.5 \+ over \/ \(1\.0 \+ S \* over \* 2\.0\)\) \/ l0;/);
assert.match(tone, /rgb = clamp\(toEncoded\(lin\), 0\.0, 1\.0\);/);
assert.match(tone, /float o = t \+ max\(hl, 0\.0\) \* hlMask \* \(1\.0 - t\) \+ sh \* 0\.34 \* shMask;/);
// The transfer pair has to be the accurate one, or the knee is drawn in a space
// that is not linear at all.
assert.match(tone, /return mix\(c \/ 12\.92, pow\(\(c \+ 0\.055\) \/ 1\.055, vec3\(2\.4\)\), step\(vec3\(0\.04045\), c\)\);/);
// The arithmetic. T = 0.7 / S = 1 / (2 (1 - T)) is the develop's pair (S is what
// puts the asymptote on 1.0: T + 1/(2S) = 1); T = 0.5 with S = 1 is the top of
// the tone pass's knob.
const knee = (l, T, S) => (l < T ? l : T + (l - T) / (1 + 2 * S * (l - T)));
for (const [T, S] of [
[0.7, 1 / (2 * (1 - 0.7))],
[0.5, 0.25],
[0.5, 1],
]) {
// Below the knee the frame is untouched, and the curve is continuous and C1 at
// T — slope 1 on both sides — so there is no seam for a later pass to mask.
assert.equal(knee(T - 0.2, T, S), T - 0.2);
assert.equal(knee(T, T, S), T);
const slope = (x) => (knee(x + 1e-6, T, S) - knee(x, T, S)) / 1e-6;
assert.ok(Math.abs(slope(T) - 1) < 1e-3, `seam at T=${T}: slope ${slope(T)}`);
// Monotone, and never a brightening: a recovery slider that lifted a highlight
// would be a lift in disguise, and an inverted pair of pixels is a visible edge.
let prev = -Infinity;
for (let l = 0; l <= 2; l += 1 / 512) {
assert.ok(slope(l) > 0, `inverted at ${l} (T=${T}, S=${S})`);
assert.ok(knee(l, T, S) <= l + 1e-9, `brightened ${l} -> ${knee(l, T, S)}`);
assert.ok(knee(l, T, S) >= prev);
prev = knee(l, T, S);
}
// The asymptote: everything the sensor held above the knee lands under it.
// The develop's pair puts it exactly on 1.0, the tone pass's S = 1 on 0.75.
assert.ok(Math.abs(knee(1e6, T, S) - (T + 1 / (2 * S))) < 1e-4);
}
assert.ok(Math.abs(0.7 + 1 / (2 * (1 / (2 * (1 - 0.7)))) - 1) < 1e-9, 'the develop plateau left 1.0');
// ...and the same pair in the encoded domain, which is the domain the develop
// hands over: mx = 1.0 (the white level) lands on 237, the sensor's own plateau
// (1.93, see the gain above) on 248 — a ramp of a dozen code values where the
// old fade-to-white left nothing above 250 at all.
const enc = (x) => (x <= 0.0031308 ? x * 12.92 : 1.055 * x ** (1 / 2.4) - 0.055);
const DEV_S = 1 / (2 * (1 - 0.7));
assert.equal(Math.round(enc(knee(1.0, 0.7, DEV_S)) * 255), 237);
assert.equal(Math.round(enc(knee(1.93, 0.7, DEV_S)) * 255), 248);
console.log('highlight-knee-check ok');
+226
View File
@@ -0,0 +1,226 @@
// The catalogue, checked where it actually lives: in a browser. A directory
// handle, IndexedDB, a grid of tiles and a handover into the studio have no
// meaning outside one, so this is a Playwright run rather than a pure-node
// assert.
//
// Only Chromium can hand a page a folder, and only a person can answer the
// picker. The check stands in for both: it serves two real frames (one JPEG, one
// RAW) off a local server and answers `showDirectoryPicker` with a folder made of
// them. The stand-in handles carry their methods as *non-enumerable* properties,
// which is what lets them go through IndexedDB's structured clone at all (a
// clone copies enumerable own keys and drops the rest, and a function anywhere
// else is a DataCloneError); the methods are put back where the catalogue reads
// them, because a cloned handle is a plain object with no `getFile`.
//
// npm run build && node scripts/library-check.mjs
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> node scripts/library-check.mjs
//
// Playwright is not a dependency of this package (a browser run needs it, a
// build does not), so a run without it says SKIP and exits 0.
import { spawn } from 'node:child_process';
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
const SAMPLES = process.env.SAMPLE_DIR ?? path.resolve(FRONTEND, '..');
const APP_PORT = Number(process.env.APP_PORT ?? 4185);
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4319);
const JPG = 'P1010256.JPG';
const RAW = 'P1010256.RW2';
let failures = 0;
function step(name, ok, detail = '') {
if (!ok) failures++;
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
return ok;
}
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
if (!playwright) {
console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE=<path to its index.mjs>');
process.exit(0);
}
// --- the two frames, off a local server (the page fetches them by CORS) ------
const sampleServer = createServer(async (req, res) => {
const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0]));
if (name !== JPG && name !== RAW) {
res.writeHead(404).end();
return;
}
const bytes = await readFile(path.join(SAMPLES, name));
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': bytes.length,
'Access-Control-Allow-Origin': '*',
});
res.end(bytes);
});
await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve));
// `--host 127.0.0.1`: without it vite's preview binds the name `localhost`, which
// on a dual-stack box can be ::1 alone and every 127.0.0.1 fetch below fails.
const app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
cwd: FRONTEND,
stdio: 'ignore',
});
const base = `http://127.0.0.1:${APP_PORT}`;
let up = false;
for (let i = 0; i < 60 && !up; i++) {
up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false);
if (!up) await new Promise((r) => setTimeout(r, 500));
}
if (!up) {
app.kill('SIGTERM');
sampleServer.close();
console.log(`FAIL no preview on ${base} — run \`npm run build\` first (vite preview serves dist/)`);
process.exit(1);
}
// --- the browser ------------------------------------------------------------
const browser = await playwright.chromium.launch({
// Playwright's own Chromium when it has one; CHROME points at any other.
executablePath: process.env.CHROME || undefined,
args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
});
const context = await browser.newContext();
// The folder the picker would have returned, plus the revival of its handles on
// the way back out of IndexedDB.
await context.addInitScript(
({ origin, names, folder }) => {
const bytes = new Map();
const load = async (name) => {
if (!bytes.has(name)) bytes.set(name, fetch(`${origin}/${name}`).then((r) => r.arrayBuffer()));
return bytes.get(name);
};
const fileHandle = (name) => {
const handle = { kind: 'file', name, __fake: true };
Object.defineProperty(handle, 'getFile', {
value: async () => new File([await load(name)], name, { type: name.endsWith('.JPG') ? 'image/jpeg' : 'application/octet-stream' }),
});
return handle;
};
const dirHandle = () => {
const handle = { kind: 'directory', name: folder };
Object.defineProperties(handle, {
values: { value: () => names.map(fileHandle)[Symbol.iterator]() },
queryPermission: { value: async () => 'granted' },
requestPermission: { value: async () => 'granted' },
});
return handle;
};
window.showDirectoryPicker = async () => dirHandle();
const revive = (row) => {
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
row.handle = row.handle.kind === 'directory' ? dirHandle() : fileHandle(row.handle.name);
}
return row;
};
for (const method of ['get', 'getAll']) {
const original = IDBObjectStore.prototype[method];
IDBObjectStore.prototype[method] = function (...args) {
const request = original.apply(this, args);
const descriptor = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result');
Object.defineProperty(request, 'result', {
configurable: true,
get() {
const value = descriptor.get.call(this);
return Array.isArray(value) ? value.map(revive) : revive(value);
},
});
return request;
};
}
},
{ origin: `http://127.0.0.1:${SAMPLE_PORT}`, names: [JPG, RAW], folder: 'CheckRoll' }
);
const page = await context.newPage();
const catalogue = () =>
page.evaluate(async () => {
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open('recipescam-library');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
const all = (store) =>
new Promise((resolve, reject) => {
const req = db.transaction(store).objectStore(store).getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
const [photos, edits] = await Promise.all([all('photos'), all('edits')]);
const thumbs = [];
for (const photo of photos) {
const head = photo.thumb ? new Uint8Array(await photo.thumb.slice(0, 2).arrayBuffer()) : null;
thumbs.push({
id: photo.id,
bytes: photo.thumb?.size ?? 0,
jpeg: head ? head[0] === 0xff && head[1] === 0xd8 : false,
});
}
return { thumbs, edits: edits.map((e) => ({ id: e.photoId, filter: e.recipe?.baseFilter, knobs: Object.keys(e.recipe?.adjustments ?? {}).length })) };
});
// --- the run ----------------------------------------------------------------
await page.goto(`${base}/library`);
await page.click('[data-key="lib-add"]');
const tiles = '[data-key^="lib-photo-"]';
await page.waitForSelector(tiles, { timeout: 120_000 });
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 2, tiles, { timeout: 120_000 });
step('both frames indexed as tiles', true, `2 tiles from ${JPG} + ${RAW}`);
const indexed = await catalogue();
for (const name of [JPG, RAW]) {
const row = indexed.thumbs.find((t) => t.id.endsWith(`/${name}`));
step(`thumbnail for ${name}`, !!row && row.bytes > 1000 && row.jpeg, `${row?.bytes ?? 0} bytes, jpeg=${row?.jpeg}`);
}
// One tap on a tile hands the frame to the studio. The RAW is the harder half —
// it has to be read back off the (stand-in) handle and developed — so it is the
// one that gets opened.
const rawId = indexed.thumbs.find((t) => t.id.endsWith(`/${RAW}`)).id;
await page.click(`[data-key="lib-open-${rawId}"]`);
await page.waitForURL((url) => url.pathname.startsWith('/app'), { timeout: 120_000 });
await page.waitForFunction(() => !document.querySelector('.dropzone'), null, { timeout: 180_000 });
step('studio developed the frame from its handle', true, 'stage no longer on the drop zone');
// The handover is an address, not a state: the studio puts the frame into its
// own store and drops the query, so a reload reopens it like any other photo.
step('the address was handed back', !page.url().includes('lib='), `url=${page.url()}`);
const editsIn = () =>
page.evaluate(async () => {
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open('recipescam-library');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return await new Promise((resolve) => {
const req = db.transaction('edits').objectStore('edits').getAll();
req.onsuccess = () => resolve(req.result);
});
});
// The recipe is written 600ms after the last change (a slider drag fires it per
// frame), so the row arrives a beat after the frame does.
let filed = [];
for (let i = 0; i < 40 && !filed.length; i++) {
filed = await editsIn();
if (!filed.length) await new Promise((r) => setTimeout(r, 250));
}
step('the look was filed back against the frame', filed.length === 1, JSON.stringify(filed));
await page.goto(`${base}/library`);
await page.waitForSelector(`[data-key="lib-photo-${rawId}"]`);
const meta = await page.locator(`[data-key="lib-photo-${rawId}"] .adm-meta`).innerText();
step('the tile says the frame is edited', /đã chỉnh|edited/.test(meta), meta.replace(/\n/g, ' · '));
await browser.close();
sampleServer.close();
app.kill('SIGTERM');
console.log(failures ? `\n${failures} failed` : '\nall checks passed');
process.exit(failures ? 1 : 0);
+71
View File
@@ -0,0 +1,71 @@
// The manifest needs its icons at fixed sizes, and the only artwork there is ships
// at 256x256 (public/assets/RecipesCamIcon.png). Skia is already a dependency — the
// engine grades through it — so it does the resize here rather than a new image
// tool or a runtime fetch of an oversized PNG. One-off, run by hand: the output is
// committed, so a build never has to decode and rescale anything.
//
// node scripts/make-icons.mjs
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import CanvasKitInit from 'canvaskit-wasm/bin/full/canvaskit.js';
const SOURCE = 'public/assets/RecipesCamIcon.png';
const OUT = 'public/icons';
const CanvasKit = await CanvasKitInit({
locateFile: () =>
fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
});
// Buffers are typed arrays already, but Skia's binding wants one it can read as a
// plain byte view, not node's pooled slab.
const art = CanvasKit.MakeImageFromEncoded(new Uint8Array(readFileSync(SOURCE)));
if (!art) throw new Error(`${SOURCE} is not an image Skia can decode`);
// `scale` is the art's share of the square. The plain icons bleed to the edge,
// exactly as the source does. The maskable one cannot: the launcher supplies the
// mask, and most of them are circles, so the art has to stay inside the 80%
// safe zone the spec draws — the background around it is the app's own #0a0a0a
// rather than nothing at all, because a masked icon with transparent corners
// shows whatever the launcher paints behind it.
const icons = [
{ file: 'icon-192.png', size: 192, scale: 1, background: false },
{ file: 'icon-512.png', size: 512, scale: 1, background: false },
{ file: 'maskable-512.png', size: 512, scale: 0.8, background: true },
];
mkdirSync(OUT, { recursive: true });
for (const { file, size, scale, background } of icons) {
const surface = CanvasKit.MakeSurface(size, size);
if (!surface) throw new Error(`could not make a ${size}x${size} surface`);
const canvas = surface.getCanvas();
if (background) {
const paint = new CanvasKit.Paint();
paint.setColor(CanvasKit.Color(10, 10, 10));
canvas.drawPaint(paint);
paint.delete();
}
const side = size * scale;
const offset = (size - side) / 2;
// Mitchell cubic (B = C = 1/3): the 256 -> 192 step is a downscale, where
// nearest or bilinear would drop detail off the camera's thin outlines.
canvas.drawImageRectCubic(
art,
CanvasKit.XYWHRect(0, 0, art.width(), art.height()),
CanvasKit.XYWHRect(offset, offset, side, side),
1 / 3,
1 / 3,
null,
);
surface.flush();
const png = surface.makeImageSnapshot().encodeToBytes();
surface.dispose();
if (!png) throw new Error(`could not encode ${file}`);
writeFileSync(`${OUT}/${file}`, png);
console.log(`${OUT}/${file} ${size}x${size} ${png.length} bytes`);
}
art.delete();
@@ -0,0 +1,102 @@
// The per-file colour fit is pure arithmetic, so it can be checked here rather
// than in a browser: hand fitMatch a grid and the same grid through a known 3x3,
// and it has to hand that 3x3 back. Done that way because the function only ever
// runs inside the RAW develop, which needs LibRaw and CanvasKit — neither of
// which a node check has. The module is TypeScript, so it is transpiled on the
// fly out of the installed compiler (the repo's convention for checks: see
// raw-develop-check.mjs, which reads the shader source instead).
//
// node scripts/preview-match-check.mjs
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import ts from 'typescript';
const file = new URL('../src/engine/previewMatch.ts', import.meta.url);
const js = ts.transpileModule(readFileSync(file, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
const { fitMatch, MATCH_GRID } = await import(
`data:text/javascript;base64,${Buffer.from(js).toString('base64')}`
);
// A grid with colour in it: three independent ramps, none of them clipped and
// none at the floor, so every block carries something to fit. Independent on
// purpose — three correlated channels leave the 3x3 ill-conditioned, and a fit
// that comes back a percent off on such a scene says nothing about the code.
function scene() {
let s = 1;
const rnd = () => ((s = (s * 1103515245 + 12345) & 0x7fffffff) % 4096) / 4096;
const g = new Uint8Array(MATCH_GRID * MATCH_GRID * 4);
for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) {
for (let c = 0; c < 3; c++) g[i * 4 + c] = 25 + Math.round(rnd() * 190);
g[i * 4 + 3] = 255;
}
return g;
}
const M = [1.06, -0.05, 0.01, 0.02, 0.98, 0.03, -0.04, 0.06, 1.02];
const dev = scene();
const ref = new Uint8Array(dev.length);
for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) {
const e = [dev[i * 4], dev[i * 4 + 1], dev[i * 4 + 2]];
for (let r = 0; r < 3; r++) {
const v = M[r * 3] * e[0] + M[r * 3 + 1] * e[1] + M[r * 3 + 2] * e[2];
ref[i * 4 + r] = Math.max(0, Math.min(255, Math.round(v)));
}
ref[i * 4 + 3] = 255;
}
const fit = fitMatch(dev, ref);
assert.ok(fit, 'a fitted grid has to fit');
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) {
assert.ok(Math.abs(fit.m[r * 3 + c] - M[r * 3 + c]) < 0.01, `recovered ${fit.m[r * 3 + c]} for ${M[r * 3 + c]}`);
}
}
// This scene was made by a 3x3 and nothing else, so the curve fitted on what the
// 3x3 leaves has to come back flat — the identity, up to the rounding the scene
// carries.
assert.equal(fit.tone.length, 12);
for (let i = 0; i < 12; i++) {
const flat = i % 4 === 1 ? 1 : 0;
assert.ok(Math.abs(fit.tone[i] - flat) < 0.15, `tone ${i} came back ${fit.tone[i]}, not ${flat}`);
}
// Nothing to fit against: the camera clipped the whole frame, so no block says
// anything about its rendering, and the caller develops as the sensor left it.
assert.equal(fitMatch(dev, new Uint8Array(dev.length).fill(255)), null);
// A near-neutral frame through a matrix whose rows carry a negative element — what
// the bodies the fit used to refuse looked like (X-T3 -0.04, X100V -1.67 on rows
// that also carry +1.05 and +1.63). Three channels that share nearly all their
// content leave the normal equations near-singular, and the plain solve came back
// with a diagonal element at or below zero, which the caller read as a degeneracy
// and dropped the whole fit for. It is a colour: the ridge lifts the system back
// to definite and the fit has to come back a matrix.
function neutral() {
let s = 7;
const rnd = () => ((s = (s * 1103515245 + 12345) & 0x7fffffff) % 4096) / 4096;
const g = new Uint8Array(MATCH_GRID * MATCH_GRID * 4);
for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) {
const x = 40 + Math.round(rnd() * 180);
for (let c = 0; c < 3; c++) g[i * 4 + c] = Math.max(0, Math.min(255, x + Math.round((rnd() - 0.5) * 8)));
g[i * 4 + 3] = 255;
}
return g;
}
const N = [1.05, -1.67, 1.63, 0.02, 0.95, 0.03, -0.04, 0.06, 1.02];
const nDev = neutral();
const nRef = new Uint8Array(nDev.length);
for (let i = 0; i < MATCH_GRID * MATCH_GRID; i++) {
const e = [nDev[i * 4], nDev[i * 4 + 1], nDev[i * 4 + 2]];
for (let r = 0; r < 3; r++) {
const v = N[r * 3] * e[0] + N[r * 3 + 1] * e[1] + N[r * 3 + 2] * e[2];
nRef[i * 4 + r] = Math.max(0, Math.min(255, Math.round(v)));
}
nRef[i * 4 + 3] = 255;
}
const nFit = fitMatch(nDev, nRef);
assert.ok(nFit, 'a near-neutral frame has to fit, not come back null');
for (const v of nFit.m) assert.ok(Number.isFinite(v) && Math.abs(v) <= 4, `degenerate fit ${v}`);
console.log('preview-match-check ok');
+86
View File
@@ -0,0 +1,86 @@
// Install support is not visible in the source. It is the manifest the browser
// reads, the worker it registers, and what that worker still holds once the network
// is gone — so unlike the other checks here, which read a module and assert on it,
// this one has to drive a real browser at a real build. Four assertions, one line
// each, against a preview server:
//
// npx vite build && npx vite preview --port 4183 &
// node scripts/pwa-check.mjs
//
// The browser is Playwright's own, installed by whoever runs this — there is no
// test runner in this repo, and software GL is what the studio needs to paint at
// all. PLAYWRIGHT_CORE points at another copy, CHROME at another browser.
const BASE = process.argv[2] ?? 'http://localhost:4183';
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
if (!playwright) {
console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE=<path to its index.mjs>');
process.exit(0);
}
const { chromium } = playwright;
const browser = await chromium.launch({
// Playwright's own Chromium when it has one; CHROME points at any other.
executablePath: process.env.CHROME || undefined,
args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
});
const context = await browser.newContext();
// Every assertion prints, rather than throwing on the first one: which of the four
// works and which does not is the whole answer.
let failed = 0;
const check = (ok, label) => {
console.log(`${ok ? 'ok ' : 'FAIL'} ${label}`);
if (!ok) failed++;
};
const page = await context.newPage();
await page.goto(`${BASE}/app`);
// (a) The manifest, as the browser fetches it before it offers anything.
const manifest = await page.evaluate(async () => await (await fetch('/manifest.json')).json());
check(Array.isArray(manifest.icons) && manifest.icons.length === 3, `/manifest.json parses with ${manifest.icons?.length} icons`);
// (b) The worker registration main.tsx makes on load. `ready` only ever resolves
// with a registration that has an `active` worker; that worker's own state string
// is 'activated' (the ServiceWorkerState enum has no 'active' in it).
const registration = await page.evaluate(async () => {
const reg = await navigator.serviceWorker.ready;
return { scope: reg.scope, state: reg.active?.state ?? null };
});
check(registration.state === 'activated', `serviceWorker.ready resolves — scope ${registration.scope}, active.state '${registration.state}'`);
// (c) What it put away. Read after a reload, because the first load is the one that
// installs it and the reload is the first one it is in control of.
await page.waitForFunction(async () => (await caches.match(new URL('/app', location.origin).href)) !== undefined);
await page.reload();
const cached = await page.evaluate(async () => {
const out = {};
for (const name of await caches.keys()) {
out[name] = (await (await caches.open(name)).keys()).map((r) => new URL(r.url).pathname);
}
return out;
});
const names = Object.keys(cached);
const urls = Object.values(cached).flat().sort();
check(
urls.includes('/') && urls.includes('/app') && urls.some((u) => /^\/assets\/index-/.test(u)),
`worker cache ${names.join(', ')} after reload: ${urls.join(' ')}`,
);
// (d) The point of all of it: the network gone, the shell still paints.
await context.setOffline(true);
let brand = 'nothing';
try {
await page.reload();
brand = await page.evaluate(() => document.querySelector('header.header .brand')?.textContent ?? null);
} catch (err) {
brand = `reload failed: ${err.message.split('\n')[0]}`;
}
check(/RecipesCam/.test(brand ?? ''), `offline reload of /app paints the app's own header: ${brand}`);
await browser.close();
if (failed) {
console.error(`pwa-check: ${failed} of 4 failed`);
process.exit(1);
}
console.log('pwa-check ok');
@@ -0,0 +1,122 @@
// LibRaw subtracts the black level itself, whatever `noAutoScale` says, so the
// develop must not do it again: a second subtraction drained red and blue — the
// channels `cam_mul` lifts most — and turned every Sony ARW green. This pins that
// down on the source, since the develop itself only runs in a browser (LibRaw
// worker + CanvasKit).
//
// node scripts/raw-develop-check.mjs
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const src = readFileSync(new URL('../src/engine/rawDevelop.ts', import.meta.url), 'utf8');
const sksl = src.match(/const RAW_DEVELOP_SKSL = `([\s\S]*?)`;/)?.[1];
assert.ok(sksl, 'RAW_DEVELOP_SKSL not found');
// The prose above the code talks about the black level, so drop the comments.
const code = sksl.replace(/\/\/[^\n]*/g, '');
// The plane comes in already black-subtracted, so its only scale is the white
// level less the black level, handed over as one inverse.
assert.match(code, /uniform float gain;/);
assert.doesNotMatch(code, /uniform float4 black;/);
assert.doesNotMatch(code, /-\s*black/, 'the shader subtracts the black level again');
assert.match(code, /float3 n = max\(p\.rgb \* gain, 0\.0\);/);
assert.match(code, /float3 lin = n \* mul\.rgb;/);
// The WB gains, applied where the sensor has already clipped, are what leaves the
// blown areas magenta, so the shader has to read the sensor's own levels — before
// the gains — and desaturate the pixel towards its own value as the clip is
// approached. Measured on the FX30 ARW: the camera's own JPEG has an all-white top
// percentile (R/G 0.995, B/G 0.999), the develop without this line came back at
// R/G 1.017, B/G 0.862 — a warm tint on every blown area.
//
// The clip to read is the one the gains make as well as the sensor's own: on a body
// whose gains lift red and blue (the Ricoh GR: `cam_mul` [2.64, 1, 1.73]) a blown
// sky crosses the white level at 0.38 of the raw range in red and 0.58 in blue while
// green only crosses at 1.0, so a gate read on the sensor's levels alone stayed shut
// across the whole sky. Measured on that frame in the app against the camera's own
// JPEG: mean dRGB +1.2, -5.9, -6.1 with the sensor's clip alone, +0.8, +1.0, +1.1
// with both, mean |dL| 21.5 against 11.1.
assert.match(
code,
/float hi = max\(max\(n\.r, n\.g\), n\.b\);\s*hi = max\(hi, max\(max\(lin\.r, lin\.g\), lin\.b\)\);\s*rgb = mix\(rgb, float3\(mx\), smoothstep\(0\.95, 1\.0, hi\)\);/
);
// The inverse is built on the CPU side and has to be the first uniform of the
// buffer the shader reads as `gain`, and its divisor has to be the white level
// the frame itself ran out at — `maximum` alone left every frame a stop bright,
// and a fixed factor two only fitted the two bodies it was measured on. The
// probe and its own check live in src/engine/sensorWhite.ts.
assert.match(src, /^ *uniforms\[0\] = SAMPLE_MAX \/ sensorWhite\(data, cd\.maximum, cd\.black\);$/m);
assert.match(src, /^import \{ sensorWhite \} from '\.\/sensorWhite';$/m);
// On the FX30 that inverse is 2.065 (65535 / (2 (16380 - 512))), against the
// 4.13 the white level alone gives. The camera's own JPEG lands on the 2.065:
// 0.05% of its pixels at pure white against the develop's 0.76% at 4.13 — which
// is also what the probe has to fall back to on a frame with nothing blown.
assert.ok(65535 / (2 * (16380 - 512)) - 2.065 < 0.001, 'the white level factor dropped out');
const probe = readFileSync(new URL('../src/engine/sensorWhite.ts', import.meta.url), 'utf8');
assert.match(
probe,
/if \(top < maximum - black\) return legacy;/,
'a frame with nothing blown no longer falls back to the fixed factor'
);
// What a RAW opens as is the develop of its own sensor data, fitted to the
// preview the camera wrote into the file — so the file opens at the colour the
// body chose and at the resolution its sensor has. The preview is the reference
// and the fallback, never the frame: a file with no preview of its own is not
// fitted, and there is no body table behind the fit any more (a table fitted on
// one develop stops matching when the develop changes under it, and it did: the
// highlight knee left every body that had one with a cast).
assert.match(src, /preview = await cameraPreview\(raw\);/);
assert.match(src, /const match = blocks && ref \? fitMatch\(blocks, ref\) : null;/);
assert.doesNotMatch(src, /cameraMatch/, 'the body table is back');
assert.doesNotMatch(code, /uniform float4 w0;/, 'the body table is back in the shader');
// The fit is drawn by the develop that was fitted, so the frame goes through it
// twice: once on the sensor alone, to fit against the preview, and then again
// with the fit in the shader.
assert.match(src, /const first = develop\(null\);/);
assert.match(src, /const matched = match \? develop\(match\) : null;/);
assert.match(src, /const jpeg = \(matched \?\? first\)\.encodeToBytes/);
assert.match(src, /if \(preview\) return preview;/);
assert.match(src, /if \(thumb\?\.format !== 'jpeg' \|\| !thumb\.data\?\.length\) return null;/);
// The fit has to land on the encoded value in float, not as an 8-bit colour filter
// painted over the frame afterwards: the fit carries an exposure, and a channel it
// lifted past the white level was cut where it stood — blue first, the channel the
// WB gains lift most — which is what left the bright end of the frame short of
// blue. Measured on the A5100 frame, pixels at 255: 5.25% blue, 2.10% of them blue
// alone through the filter, against 2.46% / 2.44% through this path.
//
// No rolloff after the fit. The fit is luma-preserving, not white-preserving —
// its rows sum to ~0.79 / 1.07 / 0.92, so white leaves it past 1.0 in green only —
// and dividing by that max pulled red and blue down with it: highlights came out
// cyan and not one cell of the frame reached white on all three channels (0.0%)
// against 2.3% now and 3.3% in the camera's preview, dE00 7.1 against 6.6.
assert.match(code, /uniform float4 f0;/);
assert.doesNotMatch(src, /drawMatched|colorMatrix/, 'the fit is painted through a colour filter again');
assert.match(code, /float3 q = clamp\(float3\(dot\(f0\.xyz, e\), dot\(f1\.xyz, e\), dot\(f2\.xyz, e\)\), 0\.0, 1\.0\);/);
assert.doesNotMatch(code, /float mq = /, 'the rolloff after the fit is back');
// A 3x3 can only scale a channel and the gap to the camera is mostly a shape, so
// the frame leaves through the per-channel curve the fit carries as well. Without
// it the shadows stayed bright and green: measured on the A5100 frame, dE00 6.6
// against 4.4, and at the bottom of the lightness range dL +13.8 with green
// +0.128 against +4.5 and +0.017 through the curve (both against the camera's own
// JPEG).
assert.match(code, /float tone\(float4 w, float x\) \{/);
assert.match(code, /return half4\(half3\(tone\(t0, q\.r\), tone\(t1, q\.g\), tone\(t2, q\.b\)\), 1\.0\);/);
assert.match(code, /uniform float4 t0;/);
// ...and it is the tail of the uniform buffer, one float4 per channel, which the
// second develop overwrites on the buffer it already built.
assert.match(src, /^ *uniforms\.set\(fit\?\.tone \?\? FLAT_TONE, 33\);$/m);
assert.match(src, /const uniforms = new Float32Array\(45\);/);
assert.match(src, /^ *uniforms\[21\] = f\[0\]; uniforms\[22\] = f\[1\]; uniforms\[23\] = f\[2\]; uniforms\[24\] = 0;$/m);
// A RAW opens at the sensor's own resolution, not at the quarter the half-size
// demosaic reports: the GR's DNG came back 3010x2012 against the 6000x4000 of the
// camera's own JPEG beside it. With the flag off the same develop returns
// 6020x4024, and every one of the eight bodies checked doubled its frame.
assert.match(src, /^ *halfSize: false,$/m, 'the develop is half-size again');
console.log('raw-develop-check ok');
+85
View File
@@ -0,0 +1,85 @@
# The GPU path runs the upscaler in half precision. The chip is given 2.3MB of
# weights instead of 4.9MB, and where its shaders can multiply in fp16 (the
# adapter's `shader-f16`) it does twice the work per pass.
#
# The export itself is unchanged: the tensor the app hands over is still float32,
# and the two Cast nodes below are the model's own edge, so nothing in
# superRes.ts or App.tsx has to know which copy it got.
#
# python3 realesr-fp16.py ../public/models/realesr-general-x4v3.onnx \
# ../public/models/realesr-general-x4v3-f16.onnx
#
# The source is the PReLU-rewritten model realesr-gpu.py writes — this runs
# after that one, never instead of it.
#
# onnxconverter-common's `keep_io_types` rewrites the input's own consumers and
# misses the one that does not go 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 the two.
# The consumer is rewired onto the cast here, which is all `keep_io_types` should
# have done.
#
# Its second miss is Resize's `scales`, which ONNX defines as a float input
# whatever the graph around it is doing, and which came out half precision. A
# runtime that loads the file at all rejects the whole graph for it — "Type
# 'tensor(float16)' of input parameter (/Constant_output_0) of operator (Resize)
# is invalid" — on the GPU as much as on the processor. Both are put right here.
import sys
import numpy as np
import onnx
from onnx import numpy_helper
from onnxconverter_common import float16
# Resize's own float inputs: the region of interest and the scales. Neither is
# part of the picture, so neither has any business in half precision.
FLOAT_OPERAND = {("Resize", 1), ("Resize", 2)}
def convert(src, dst):
model = onnx.load(src)
half = float16.convert_float_to_float16(model, keep_io_types=True, disable_shape_infer=True)
graph = half.graph
io = graph.input[0].name
cast = next(n for n in graph.node if n.op_type == "Cast" and list(n.input) == [io])
rewired = 0
for node in graph.node:
for i, name in enumerate(node.input):
if name == io and node is not cast:
node.input[i] = cast.output[0]
rewired += 1
widened = 0
producer = {o: n for n in graph.node for o in n.output}
for node in graph.node:
for i, name in enumerate(node.input):
if (node.op_type, i) not in FLOAT_OPERAND or not name:
continue
src_node = producer.get(name)
if src_node is None or src_node.op_type != "Constant":
continue
t = src_node.attribute[0].t
if t.data_type != onnx.TensorProto.FLOAT16:
continue
arr = numpy_helper.to_array(t).astype(np.float32)
src_node.attribute[0].t.CopyFrom(numpy_helper.from_array(arr, t.name))
widened += 1
# The tensor the app builds is float32 and has to stay that way: a model whose
# input is fp16 would need the pixels converted in JavaScript, where there is
# no half-float array to convert them into.
assert graph.input[0].type.tensor_type.elem_type == onnx.TensorProto.FLOAT
assert graph.output[0].type.tensor_type.elem_type == onnx.TensorProto.FLOAT
assert rewired >= 1, "the input's direct consumer is gone: is this still the same model?"
assert widened >= 1, "Resize's scales are still fp16: the runtime will reject the graph"
ons = sum(1 for i in graph.initializer if i.data_type == onnx.TensorProto.FLOAT16)
assert ons == len(graph.initializer), f"{ons}/{len(graph.initializer)} weights are fp16"
onnx.save(half, dst)
print(f"{len(graph.node)} nodes, {rewired} graph input consumer(s) rewired, {widened} Resize operand(s) widened back to float, {ons} fp16 weights -> {dst}")
if __name__ == "__main__":
convert(sys.argv[1], sys.argv[2])
+73
View File
@@ -0,0 +1,73 @@
# The WebGPU execution provider has no PReLU kernel. The upscaler is 34 convolutions
# with a PReLU after every one of them, so the runtime splits the graph 33 times:
# each activation travels off the GPU to the processor and the result travels back,
# a 64-channel map in both directions, per tile. A machine with a good graphics chip
# was therefore not exporting any faster for having it.
#
# Relu, Neg, Mul and Sub it does have, and
# PRelu(x) = Relu(x) - slope * Relu(-x)
# holds exactly, negative slopes included, so this rewrites the activations into
# those four and leaves the weights untouched.
#
# python3 realesr-gpu.py <downloaded>.onnx public/models/realesr-general-x4v3.onnx
#
# The source is the x4v3 model of https://github.com/xinntao/Real-ESRGAN
# (realesr-general-x4v3.pth, converted to ONNX at opset 17).
import sys
import numpy as np
import onnx
from onnx import TensorProto, helper, numpy_helper
def rewrite(src, dst):
model = onnx.load(src)
graph = model.graph
rank = len(graph.input[0].type.tensor_type.shape.dim)
by_name = {i.name: i for i in graph.initializer}
nodes, made = [], 0
for node in graph.node:
if node.op_type != "PRelu":
nodes.append(node)
continue
x, slope = node.input
# The slope comes out of PyTorch as (C,1,1) and has to line up with a
# (1,C,H,W) activation, so give it the batch axis it is missing.
s = numpy_helper.to_array(by_name[slope])
if s.shape != (1,) + tuple(s.shape):
s = s.reshape((1,) + tuple(s.shape))
name = f"{slope}__broadcast"
if name not in by_name:
graph.initializer.append(numpy_helper.from_array(s, name))
by_name[name] = graph.initializer[-1]
slope = name
p, n, q, scaled = f"{node.output[0]}.pos", f"{node.output[0]}.neg", f"{node.output[0]}.relu", f"{node.output[0]}.scaled"
nodes += [
helper.make_node("Relu", [x], [p], name=p),
helper.make_node("Neg", [x], [n], name=n),
helper.make_node("Relu", [n], [q], name=q),
helper.make_node("Mul", [q, slope], [scaled], name=scaled),
helper.make_node("Sub", [p, scaled], [node.output[0]], name=node.name),
]
made += 1
del graph.node[:]
graph.node.extend(nodes)
# The slopes the PReLU nodes used to read are now unreachable, and the runtime
# warns about every one of them while it loads unless they go.
used = {name for node in graph.node for name in node.input}
kept = [i for i in graph.initializer if i.name in used]
print(f"dropping {len(graph.initializer) - len(kept)} unused initializers")
del graph.initializer[:]
graph.initializer.extend(kept)
onnx.checker.check_model(model)
onnx.save(model, dst)
print(f"rewrote {made} PReLU nodes -> {dst}")
assert made, "no PReLU node found: wrong model?"
assert rank >= 2
if __name__ == "__main__":
rewrite(sys.argv[1], sys.argv[2])
@@ -0,0 +1,80 @@
// Where the develop's white level comes from (src/engine/sensorWhite.ts). The
// probe reads the frame instead of trusting a constant: the plane's ceiling is
// 1.89-2.00x `maximum - black` and which one depends on the body, and a frame
// that sat below the desaturation's 0.95 start came out tinted (Fuji X-Trans,
// measured 1.892). These pin the two states it has to tell apart — a blown
// plateau (spike plus gap) and a bright smooth sky (a top bin no bigger than the
// ones under it) — and the fall-backs on frames with nothing to measure.
//
// node scripts/white-level-check.mjs
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import ts from 'typescript';
const src = readFileSync(new URL('../src/engine/sensorWhite.ts', import.meta.url), 'utf8');
const js = ts.transpileModule(src, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
const { sensorWhite } = await import(`data:text/javascript,${encodeURIComponent(js)}`);
const N = 1_000_000;
const MAXIMUM = 16383;
const BLACK = 512;
const LEGACY = 2 * (MAXIMUM - BLACK);
const FLOOR = 8192; // an unremarkable mid-tone the frame is mostly made of
// A frame of `N` mid-tone samples plus `counts` of them sitting in the nine bins
// from top-8 up to top: counts[8] is the top bin, counts[0] the one eight below.
function plane(top, counts) {
const data = new Uint16Array(N);
data.fill(FLOOR);
let i = 0;
for (let d = 0; d < 9; d++) for (let k = 0; k < counts[d]; k++) data[i++] = top - 8 + d;
return data;
}
// The sensor's own plateau: a pile at the top with nine near-empty bins under it.
assert.equal(sensorWhite(plane(30995, [1, 1, 1, 1, 1, 1, 1, 1, 3000]), MAXIMUM, BLACK), 30995, 'X-Trans plateau missed');
assert.equal(sensorWhite(plane(31779, [1, 1, 1, 1, 1, 1, 1, 1, 3000]), MAXIMUM, BLACK), 31779, 'Sony plateau missed');
// ...and the body that really does sit at twice the white level is read as such,
// not as the constant it used to be. Same answer either way, which is the point:
// the probe does not need to know which body it has.
assert.equal(sensorWhite(plane(LEGACY, [1, 1, 1, 1, 1, 1, 1, 1, 3000]), MAXIMUM, BLACK), LEGACY);
// A smooth sky fills the bins around the top far more than a clipped one does,
// and its top bin is a tail, not a step: no gap, no clip, no move of the white.
assert.equal(
sensorWhite(plane(30995, [400, 900, 1500, 2000, 2400, 2600, 2600, 2400, 2000]), MAXIMUM, BLACK),
LEGACY,
'a smooth top read as a clip'
);
// A bright flat wall sits exactly on one value with a big pile — but the bins
// under it are empty too, so that one IS a clip and is meant to move the white.
assert.equal(sensorWhite(plane(30995, [0, 0, 0, 0, 0, 0, 0, 0, 5000]), MAXIMUM, BLACK), 30995);
// Nothing to measure: a frame that never reached the plane's ceiling, and one
// whose only pile is a handful of hot pixels, both stay on the fixed factor two.
assert.equal(sensorWhite(plane(9000, [1, 1, 1, 1, 1, 1, 1, 1, 3000]), MAXIMUM, BLACK), LEGACY, 'an unblown frame moved the white');
assert.equal(sensorWhite(plane(30995, [0, 0, 0, 0, 0, 0, 0, 0, 5]), MAXIMUM, BLACK), LEGACY, 'hot pixels moved the white');
// A hot pixel above the plateau is not the ceiling: the window is hung off the
// pile, not off the frame's largest sample. Measured on a Panasonic DMC-LX10 RW2
// — a pile of 3.1M at 13855 with one sample at 15993 over it — reading the stray
// as the top left the nine bins under it empty, the frame fell back on the factor
// two (7908 against its own 13855) and opened 0.8 of a stop bright with its
// highlights clipped flat.
const strayed = new Uint16Array(N);
strayed.fill(FLOOR);
for (let i = 0; i < 400_000; i++) strayed[i] = 30995;
strayed[600_000] = 34000;
strayed[700_000] = 32400;
assert.equal(sensorWhite(strayed, MAXIMUM, BLACK), 30995, 'a stray sample above the plateau moved the white');
// The pile is a fraction of the plane, not a fixed count, so a small sensor with
// the same clipping is read the same way as a large one.
const small = new Uint16Array(100_000);
small.fill(FLOOR);
for (let i = 0; i < 40; i++) small[i] = 30995;
assert.equal(sensorWhite(small, MAXIMUM, BLACK), 30995, 'a small plane moved the threshold');
console.log('white-level-check ok');
+279
View File
@@ -0,0 +1,279 @@
export type FrameId =
| 'none'
| 'polaroid'
| 'classic-white'
| 'cinematic'
| 'wallframe' // WALL FRAME PORTRAIT — the artwork hung 90° CW
| 'wallframe-landscape'; // the same artwork upright: WALL FRAME LANDSCAPE
// Viewfinder/composition aspect-ratio selector. Convention is an UPRIGHT
// (portrait) picture: "4:3" = standing 3:4 photo (w/h = 3/4), "3:2" = standing
// 2:3 photo (w/h = 2/3). "full" = current fullscreen behaviour (no ratio crop).
// Wall Frame ignores the ratio (frame + export keep fullscreen as before).
export type AspectRatio = 'full' | '4:3' | '3:2';
// Full-frame metering behaviour. Evaluative = the camera's default whole-scene
// metering (offset 0). Highlight-weighted protects bright areas by underexposing
// (~ -0.7EV) — the camera lib exposes no real metering-mode API, so this is the
// closest honest approximation via the existing EV path.
export type MeterMode = 'evaluative' | 'highlight';
// Camera shutter sound. OS policy may force the click on in some regions;
// there is no native volume control (OFF/ON only).
export type ShutterSound = 'on' | 'off';
// LED flash for photo capture (never a live torch): 'auto' lets the capture
// pipeline decide from the metered scene, 'on' always fires, 'off' never does.
export type FlashMode = 'off' | 'auto' | 'on';
export type StartupMode = 'default' | 'last';
// One photo in the in-app session history (recent captures + library exports).
// uri = app-cache JPEG; assetId (when savedToLibrary) = the MediaLibrary asset
// so the trash button can remove the gallery copy too.
export interface PhotoEntry {
id: string;
uri: string;
recipeName: string;
savedToLibrary: boolean;
assetId?: string | null;
}
// w/h of the final upright photo for each non-full choice.
export const ASPECT_RATIO_W_H: Record<Exclude<AspectRatio, 'full'>, number> = {
'4:3': 3 / 4,
'3:2': 2 / 3,
};
// FRAME-tab CROP. Ratio convention is w:h (so 2:3 is an upright crop, 16:9 a
// wide one); 'free' is a user-dragged rectangle instead of a fixed shape.
export type CropRatio = 'none' | 'free' | '1:1' | '2:3' | '3:2' | '3:4' | '4:3' | '16:9';
// Keep-rectangle as 0..1 fractions of the (post-rotation) source photo.
export interface CropRect {
x: number;
y: number;
w: number;
h: number;
}
// w/h of the kept area for each fixed crop choice.
export const CROP_W_H: Record<Exclude<CropRatio, 'none' | 'free'>, number> = {
'1:1': 1,
'2:3': 2 / 3,
'3:2': 3 / 2,
'3:4': 3 / 4,
'4:3': 4 / 3,
'16:9': 16 / 9,
};
// FREE crop starts as a visible inset rectangle the user can immediately grab.
export const DEFAULT_CROP_RECT: CropRect = { x: 0.1, y: 0.1, w: 0.8, h: 0.8 };
// Smallest FREE-crop side, in source fractions.
export const MIN_CROP_FRAC = 0.08;
// Cinematic seasonal atmosphere grade. Not a fake re-light: it reads the
// photo's EXISTING exposure — warm tint + soft glow on areas already lit,
// cool tint in shade, plus a seasonal atmospheric haze/fog veil.
export type CinemaSeason = 'spring' | 'summer' | 'autumn' | 'winter';
// `monochrome` is Acros' grey ramp; `mono-high-contrast` is the B&W HIGH
// CONTRAST stock's own — both are black-and-white, so anything that asks "is
// this a colour stock?" must ask it through isMonochromeBase (colorUtils.ts)
// rather than comparing against one of these two strings.
export type BaseFilter = 'classic-neg' | 'classic-chrome' | 'classic-vivid' | 'provia' | 'velvia' | 'astia' | 'eterna' | 'monochrome' | 'mono-high-contrast' | 'leica' | 'leica-vivid' | 'none';
// The eight hue bands of the selective-colour mixer, in the order they wrap
// around the wheel. HSL_BANDS (shared/utils/colorUtils.ts) carries the anchor
// hue each id stands for; the eyedropper picks one from the sampled colour.
export type HslBandId = 'red' | 'orange' | 'yellow' | 'green' | 'aqua' | 'blue' | 'purple' | 'magenta';
// One band's edit: [hue, saturation, lightness], each -10..+10 like every other
// knob. A band that is all zero is dropped rather than stored, so the object
// stays empty until the mixer is actually used.
export type HslBand = [number, number, number];
// LIGHT tab > TONE CURVE. One editable curve per channel, each a list of
// control points as [input, output] pairs in 0..1 — the first pinned to x=0 and
// the last to x=1, so every curve answers for black and for white. The composite
// ('rgb') curve is applied AFTER the three channels (Photoshop's order), which is
// what makes the same points print the same numbers in every renderer.
//
// Only the channels the user actually moved are stored, so an untouched curve
// costs nothing, an old recipe stays valid, and the recipe JSON the API keeps
// carries it without a migration.
export type ToneCurvePoint = [number, number];
export type ToneCurveChannel = 'rgb' | 'r' | 'g' | 'b';
export type ToneCurve = Partial<Record<ToneCurveChannel, ToneCurvePoint[]>>;
// FX tab > HEAL. One repaired spot: the circle the brush painted on the photo
// and the patch it borrowed, both as fractions of the rendered photo — x/y/sx/sy
// of its width and height, r of its width. Fractions rather than pixels because
// the same spot has to land on the preview and on the full-size export, and a
// fraction of the width keeps the circle round on any photo shape.
export interface HealSpot {
x: number;
y: number;
r: number;
sx: number;
sy: number;
}
// FX tab > MOSAIC. One hidden spot: the circle the brush painted on the photo,
// as fractions of the rendered photo — x/y of its width and height, r of its
// width. The same measurement HEAL stores, minus the patch: a mosaic shows
// nothing of what is under it, so there is nothing borrowed to record.
export interface MosaicSpot {
x: number;
y: number;
r: number;
}
// FX tab > LINEAR / RADIAL GRADIENT. One local adjustment: a shape the user
// dragged on the photo, the three knobs it carries, and the alpha the render
// reads them at — Lightroom's gradient masks, with the maths of the GLSL spec
// they were asked for (scratchpad gradient_mask.md) ported to SkSL.
//
// Everything positional is a fraction of the render, the same measurement HEAL
// and MOSAIC store and for the same reason: the preview and the export render
// the same photo at two sizes, and only a fraction lands a shape on both. The
// semi-axes are fractions of the WIDTH, so an ellipse is the same ellipse on a
// portrait photo as on a landscape one, exactly as HEAL's circle is.
//
// LINEAR: (x, y) and (ex, ey) are the two ends of the drag, and the alpha runs
// from 0 at the start to 1 at the end — the drag IS the feather, which is what
// makes the same gesture a wide fade or a hard edge. RADIAL: (x, y) is the
// centre, (rx, ry) the semi-axes, `angle` the ellipse's rotation in radians and
// `feather` the fraction of the axis the alpha fades over, inward from the rim.
export interface GradientMask {
kind: 'linear' | 'radial';
x: number;
y: number;
ex: number;
ey: number;
rx: number;
ry: number;
angle: number;
feather: number;
// The three local knobs. Exposure is in EV, the -5..+5 the spec's blend shader
// is written for (its own `pow(2.0, e)`); contrast and saturation are the
// spec's -1..+1 as the app's own -10..+10 knobs, so 0 is every one of them and
// nothing moves until the user moves it.
exposure: number;
contrast: number;
saturation: number;
// The spec's section 4 knobs, the frame-wide ones the mask can restrict to
// its own area: HIGHLIGHT and SHADOW through its smoothstep soft masks, WHITE
// and BLACK at the two ends of the histogram, and the two spatial moves —
// CLARITY against the frame's own blurred reference and DEHAZE through the dark
// channel, both signed. The same -10..+10 as the app's own rulers, and the same
// moves; absent on a mask stored before they existed, and then the shader reads
// 0 for it.
highlights?: number;
shadows?: number;
whites?: number;
blacks?: number;
clarity?: number;
dehaze?: number;
}
export interface ColorAdjustments {
exposure: number; // -10 to +10 (mapped to matrix multiplier or offset)
contrast: number; // -10 to +10
saturation: number; // -10 to +10
temperature: number; // 2500 to 10000 (Kelvin)
tint: number; // -10 to +10
wbRed?: number; // Fuji WB fine-tune, Red axis (-9..9) — extra red-channel gain
wbBlue?: number; // Fuji WB fine-tune, Blue axis (-9..9) — extra blue-channel gain
highlight: number; // -10 to +10 (- pulls highlights down / roll, + brightens)
shadow: number; // -10 to +10 (+ lifts shadows, - deepens)
whites?: number; // -10 to +10 (WB white point: per channel, the shoulder)
blacks?: number; // -10 to +10 (WB black point: per channel, the toe)
denoise: number; // -10 to +10 (+ = blur sigma; - = add film grain back)
clarity: number; // -10 to +10 (mapped to matrix convolution / bloom)
// FX tab > DEHAZE (raw_parameter_processing_gradient_mask_algorithms.md §3.2):
// -10 to +10, the Dark Channel Prior pass — the scattered light the haze puts
// in front of the subject, taken back out above zero and put back below it.
// Absent on an older recipe = 0.
dehaze?: number;
grain: number; // 0 to 10 (mapped to noise turbulence opacity/scale)
grainSize?: number; // 50 to 200 (percent of the stock's own grain cell; 100 = the stock's own)
colorChrome: 'none' | 'weak' | 'strong'; // Chrome effect
colorChromeBlue: 'none' | 'weak' | 'strong'; // Chrome effect (Blue)
dynamicRange: 'auto' | 100 | 200 | 400; // Fuji-style DR tone curve (auto/100 = no extra curve)
sharpening: number; // -10 to +10 (+ = screen sharpen; - = soften the frame)
hdf?: number; // 0 to 10 (Highlight Diffusion Filter: glow on the highlights only)
vignette?: number; // 0 to 10 (darken the four corners; 0 = off)
vibrance?: number; // -10 to +10 (chroma-masked saturation: vivid colours move least)
// Selective colour by hue band. Only the bands the user actually moved are
// present, so an untouched mixer costs nothing and an old recipe stays valid.
hslBands?: Partial<Record<HslBandId, HslBand>>;
// The mixer's overall move: one hue turn, one saturation scale and one
// lightness offset for the WHOLE image, applied to every hue before the band
// edits above. -10..+10 like every other knob; absent/0 = no move at all.
hslHue?: number;
hslSat?: number;
hslLum?: number;
// TONE CURVE, the last word on tone: applied after everything above. Absent or
// flat = no move at all.
toneCurve?: ToneCurve;
// FX tab > HEAL. The spots the dust brush repaired, in the order they were
// laid down. Absent = nothing was healed, and the pass is not built at all.
heal?: HealSpot[];
// FX tab > MOSAIC. The spots the hiding brush covered, in the order they were
// laid down. Absent = nothing was hidden, and the pass is not built at all.
mosaic?: MosaicSpot[];
// FX tab > LINEAR / RADIAL GRADIENT. The masks the user drew, in the order
// they were laid down: each is applied to what the one before it left, the
// way a stack of local adjustments reads in Lightroom. Absent = no local
// adjustments at all, and no pass is built.
masks?: GradientMask[];
// -3 to +3 EV. Camera: AE bias (hardware, so the render adds none).
// Library: a real 2^EV gain on LINEAR light (EXPOSURE_SKSL) — not a matrix row.
exposureCompensation: number;
}
export interface Recipe {
id: string;
name: string;
isCustom?: boolean;
baseFilter: BaseFilter;
adjustments: ColorAdjustments;
frameId?: FrameId; // e.g. 'none', 'polaroid', 'classic-white', 'cinematic', 'wallframe'
cinema?: CinemaSeason | null; // seasonal atmosphere grade (optional)
useGeotag: boolean;
}
export interface GPSInfo {
latitude: number;
longitude: number;
locality?: string;
country?: string;
timestamp: number;
source?: 'exif' | 'mediaLibrary' | 'device'; // where the GPS data came from
}
// Custom text watermark (WATERMARK tab -> CUSTOM WATERMARK chip): enabled flag,
// the typed text, and its anchor position as 0..1 fractions of the photo area
// (0.5/0.5 = centered). The anchor is the text's BASELINE plus a small 0.35em
// drop in both renderers — keep the formula in sync across Viewfinder +
// exportEngine.
export interface CustomWatermark {
enabled: boolean;
text: string;
x: number;
y: number;
// Styling set by the COLOR / SIZE / FONT chips. Absent on marks saved before
// those controls existed — the renderers then fall back to amber, 1x and the
// bundled Cousine face, i.e. exactly the previous look.
color?: string;
size?: number; // multiplier on the base 3.2%-of-width text size
font?: string | null; // OS font family; null/absent = bundled font
}
// GPS watermark placement (WATERMARK tab drag + pinch): x/y are 0..1 fractions
// of the SAME area the custom mark uses (frame card / photo rect / screen), and
// the anchor is the top-left corner of the two-line block. size multiplies the
// base 3.2%-of-width size. Defaults reproduce the old fixed top-left look.
export interface GpsWatermark {
x: number;
y: number;
size: number;
// Styling set by the WATERMARK tab's PLACE NAME / TIME / COLOR chips. Absent
// on marks saved before those controls existed — the renderers then fall back
// to amber text with both lines shown, i.e. exactly the previous look.
color?: string;
showName?: boolean;
showTime?: boolean;
}
+26
View File
@@ -0,0 +1,26 @@
// The brush's own physics, shared by FX's two tools: HEAL copies a patch of the
// photo over what it paints, MOSAIC hides what it paints, and both lay circular
// spots from the same gesture — a circle that rides the cursor, a stroke laid
// down as the pointer travels, and a wheel that sizes it. What a spot MEANS is
// each tool's own business (heal.ts, mosaic.ts); how big it is and how far apart
// a stroke lays them is this file's.
//
// The bounds are fractions of the photo's width. Past BRUSH_MAX_R a spot is a
// quarter of the frame — a repair would be borrowing a patch from off the
// photo, and a mosaic block would be enormous — and below BRUSH_MIN_R the circle
// is finer than the pixels it is drawn on at preview scale, which is a size the
// user cannot see well enough to aim.
export const BRUSH_MIN_R = 0.003;
export const BRUSH_MAX_R = 0.25;
// How far apart a drag lays its spots, in radii: any closer and the run is the
// same spot written twice, which is work for nothing.
export const BRUSH_SPACING = 0.6;
// The brush radius after a wheel notch. Multiplicative, so the brush grows by
// the same proportion wherever it starts, and bounded as above — a trackpad's
// small deltas and a mouse's 100px notch are then the same gesture at two
// speeds.
export function wheelBrushR(r: number, deltaY: number): number {
const next = r * Math.exp(-deltaY * 0.0015);
return next < BRUSH_MIN_R ? BRUSH_MIN_R : next > BRUSH_MAX_R ? BRUSH_MAX_R : next;
}
@@ -0,0 +1,138 @@
import { CinemaSeason } from '../types';
// Cinematic seasonal atmosphere grade. Deliberately NOT a fake re-light: a
// still photo has no depth, so "moving the sun" would look plastic. Instead
// this reads the photo's EXISTING exposure and pushes it a certain way:
// - lit areas get the season's warm/golden tint + a soft sun glow (halation)
// - shadow side gets a complementary cool tint
// - a seasonal haze/fog veil blends toward a fog color (strongest in winter)
// That reads as "sunlight / morning mist" without inventing new light.
//
// Uniforms are 16 flat floats (scalar form keeps the JSX <Shader> uniforms
// object and the worklet/export flat arrays in the exact same order):
// strength, contrast, saturation, haze,
// warmR, warmG, warmB, warmAmt,
// coolR, coolG, coolB, coolAmt,
// fogR, fogG, fogB, fogAmt
export const CINEMA_SKSL = `
uniform shader src;
uniform float strength;
uniform float contrast;
uniform float saturation;
uniform float haze;
uniform float warmR;
uniform float warmG;
uniform float warmB;
uniform float warmAmt;
uniform float coolR;
uniform float coolG;
uniform float coolB;
uniform float coolAmt;
uniform float fogR;
uniform float fogG;
uniform float fogB;
uniform float fogAmt;
vec4 main(vec2 xy) {
vec4 c = src.eval(xy);
float t = clamp(dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0);
vec3 r = c.rgb;
// Filmic-ish gentle contrast on the luma (keeps hue).
r = mix(r, clamp((r - 0.5) * (1.0 + contrast * strength) + 0.5, 0.0, 1.0), strength);
// Saturation.
float l = dot(r, vec3(0.2126, 0.7152, 0.0722));
r = mix(vec3(l), r, mix(1.0, saturation, strength));
// Season tints follow the EXISTING light: warm on lit areas, cool in shade.
float lit = smoothstep(0.30, 1.0, t);
float shad = 1.0 - smoothstep(0.0, 0.45, t);
vec3 warm = vec3(warmR, warmG, warmB);
vec3 cool = vec3(coolR, coolG, coolB);
r *= 1.0 + (warm - 1.0) * lit * warmAmt * strength;
r *= 1.0 + (cool - 1.0) * shad * coolAmt * strength;
// Soft sun glow at the brightest points (halation of existing highlights).
float glow = smoothstep(0.65, 1.0, t);
r += warm * glow * warmAmt * 0.45 * strength;
// Atmospheric haze/fog veil (lightest shadow side => misty air).
vec3 fog = vec3(fogR, fogG, fogB);
r = mix(r, fog, fogAmt * strength * (0.3 + 0.7 * shad));
return vec4(clamp(r, 0.0, 1.0), c.a);
}
`;
export interface CinemaUniformSet {
season: CinemaSeason;
// 16 flat floats — order matches CINEMA_SKSL declaration order above.
flat: number[];
// Name-keyed, for the JSX <Shader uniforms={...}> form.
uniforms: Record<string, number>;
}
const ZERO: Record<string, number> = {};
const SEASONS: Record<CinemaSeason, { name: string; values: number[] }> = {
// Soft fresh light, gentle warm haze, barely-there cool in shade.
spring: {
name: 'CINEMA SPRING',
values: [
0.6, 0.08, 1.04, 0.18,
1.02, 0.998, 0.95, 0.3,
0.97, 0.995, 1.03, 0.12,
0.95, 0.96, 0.98, 0.16,
],
},
// Golden hour-ish high sun: strong warm on lit, bluish shade, crisp.
summer: {
name: 'CINEMA SUMMER',
values: [
0.75, 0.2, 1.12, 0.1,
1.08, 1.04, 0.88, 0.4,
0.93, 0.97, 1.06, 0.2,
1.0, 0.97, 0.88, 0.05,
],
},
// Amber low sun + warm atmospheric haze.
autumn: {
name: 'CINEMA AUTUMN',
values: [
0.8, 0.16, 1.1, 0.24,
1.12, 1.02, 0.78, 0.42,
1.02, 0.98, 0.88, 0.16,
1.0, 0.93, 0.75, 0.18,
],
},
// Cold winter fog: desaturated, blue-grey, heavy mist, no warm sun.
winter: {
name: 'CINEMA WINTER',
values: [
0.65, -0.02, 0.88, 0.38,
0.99, 0.995, 1.02, 0.06,
0.94, 0.97, 1.08, 0.3,
0.9, 0.93, 0.97, 0.45,
],
},
};
const KEYS = [
'strength', 'contrast', 'saturation', 'haze',
'warmR', 'warmG', 'warmB', 'warmAmt',
'coolR', 'coolG', 'coolB', 'coolAmt',
'fogR', 'fogG', 'fogB', 'fogAmt',
] as const;
export function getCinemaUniforms(season: CinemaSeason | null | undefined): CinemaUniformSet | null {
if (!season) return null;
const s = SEASONS[season];
if (!s) return null;
const uniforms: Record<string, number> = {};
KEYS.forEach((k, i) => (uniforms[k] = s.values[i]));
return { season, flat: s.values, uniforms };
}
// Zeroed 16-float block — used to turn the cinema pass OFF in the worklet
// synchronizable (strength === 0 means inactive there).
export function cinemaOffFlat(): number[] {
return [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
}
export function cinemaIsActive(flat: number[] | null | undefined): boolean {
return !!flat && flat.length === 16 && flat[0] > 0;
}
+493
View File
@@ -0,0 +1,493 @@
import { ColorAdjustments, BaseFilter, HslBand, HslBandId } from '../types';
// The eight bands of the selective-colour mixer, and the anchor hue each one
// stands for (degrees on the HSL wheel). The anchors are unevenly spaced on
// purpose: red/orange/yellow sit 30° apart because the eye separates them
// finely, while the blue side is one 60° stretch. TONE_SKSL builds the same
// eight weights from this table, so the anchor a band is edited at and the
// anchor the shader masks at can never drift apart.
//
// `label` is what the chip prints — the phone's own strings there too, so it is
// not in the i18n dictionary.
export const HSL_BANDS: { id: HslBandId; hue: number; label: string }[] = [
{ id: 'red', hue: 0, label: 'RED' },
{ id: 'orange', hue: 30, label: 'ORANGE' },
{ id: 'yellow', hue: 60, label: 'YELLOW' },
{ id: 'green', hue: 120, label: 'GREEN' },
{ id: 'aqua', hue: 180, label: 'AQUA' },
{ id: 'blue', hue: 240, label: 'BLUE' },
{ id: 'purple', hue: 280, label: 'PURPLE' },
{ id: 'magenta', hue: 320, label: 'MAGENTA' },
];
// Hue (degrees) the two neighbours of each band sit at, as TONE_SKSL needs
// them. Derived here so the two tables cannot disagree: the tent weight of a
// band falls to 0 at its neighbours' anchors, which makes the eight weights a
// partition of unity on the wheel (they sum to 1 at every hue, so a pixel is
// never counted twice and never falls in a gap).
export function hslBandGaps(): { id: HslBandId; hue: number; left: number; right: number }[] {
const n = HSL_BANDS.length;
return HSL_BANDS.map((b, i) => {
const prev = HSL_BANDS[(i - 1 + n) % n].hue;
const next = HSL_BANDS[(i + 1) % n].hue;
// Red (0°) wraps: its left neighbour is magenta at 320°, 40° back.
return { id: b.id, hue: b.hue, left: (b.hue - prev + 360) % 360, right: (next - b.hue + 360) % 360 };
});
}
// 0..255 sRGB to HSL. Hue in degrees, saturation and lightness 0..1 — the
// reverse of hslToRgb below, and what the eyedropper readout prints.
export function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
const R = r / 255;
const G = g / 255;
const B = b / 255;
const mx = Math.max(R, G, B);
const mn = Math.min(R, G, B);
const l = (mx + mn) / 2;
const d = mx - mn;
if (d < 1e-6) return { h: 0, s: 0, l };
const s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
let h: number;
if (mx === R) h = ((G - B) / d + (G < B ? 6 : 0)) * 60;
else if (mx === G) h = ((B - R) / d + 2) * 60;
else h = ((R - G) / d + 4) * 60;
return { h, s, l };
}
// The band a sampled hue belongs to: the nearest anchor. Bands overlap in the
// shader, so this only decides which one the ruler is pointed at.
export function nearestHslBand(hue: number): HslBandId {
const wrapped = ((hue % 360) + 360) % 360;
let best = HSL_BANDS[0];
let bestD = 361;
for (const band of HSL_BANDS) {
const d = Math.abs(((wrapped - band.hue + 540) % 360) - 180);
if (d < bestD) {
bestD = d;
best = band;
}
}
return best.id;
}
// A band triple as stored: integers in -10..10, and an all-zero band is dropped
// so the recipe only carries what the user moved.
const clamp10 = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? Math.max(-10, Math.min(10, Math.round(v))) : 0);
export function sanitizeHslBands(raw: unknown): Partial<Record<HslBandId, HslBand>> | undefined {
if (!raw || typeof raw !== 'object') return undefined;
const out: Partial<Record<HslBandId, HslBand>> = {};
for (const band of HSL_BANDS) {
const v = (raw as Record<string, unknown>)[band.id];
if (!Array.isArray(v) || v.length !== 3) continue;
const triple: HslBand = [clamp10(v[0]), clamp10(v[1]), clamp10(v[2])];
if (triple[0] || triple[1] || triple[2]) out[band.id] = triple;
}
return Object.keys(out).length ? out : undefined;
}
// Kelvin to RGB compensation for White Balance.
// The WB setting names the light the camera is told to expect, so its gain is
// the INVERSE of the Planckian (blackbody) colour at K: set 10000K under a
// daylight scene (5500K) and the camera thinks the light is bluer than it is
// and warms the picture up; set 2500K and it goes cool/blue. Pinned to unity at
// 5500K, the app's neutral reference.
//
// Two things a hand-drawn linear ramp gets wrong, both of them visible on skin:
// - moving the three channels independently (the old one lifted green 20% at
// 10000K) shifts LUMINANCE as well as colour, so a warm shot went yellow-
// green rather than orange. Here every gain is divided by its own Rec.709
// luma, so a neutral pixel keeps its level and only the cast moves.
// - the excursion. It was the ratio of the sRGB-ENCODED blackbody colours,
// square-rooted, which measured R x1.06 / B x0.89 from 5500K to 10000K: a
// shift a swatch shows and a sunset does not. White balance is a gain on
// LIGHT, so the ratio is taken in linear light now (planckianLinear) and
// tamed by KELVIN_TAME: the same 10000K moves the frame R x1.16 / B x0.76,
// and 2500K its mirror, the way a camera does when its WB is set 4500K off
// the scene. One constant scales the whole ruler, and both platforms carry
// the same one, so a Kelvin picked on either lands the same cast.
export function kelvinToRGB(kelvin: number): { r: number; g: number; b: number } {
const k = Math.min(10000, Math.max(2500, kelvin));
const light = planckianLinear(k);
const ref = planckianLinear(5500);
return normalizeGainLuma([
Math.pow(ref.r / Math.max(light.r, 1e-4), KELVIN_TAME),
Math.pow(ref.g / Math.max(light.g, 1e-4), KELVIN_TAME),
Math.pow(ref.b / Math.max(light.b, 1e-4), KELVIN_TAME),
]);
}
// How hard that excursion is allowed to bite. The physical ratio is brutal (the
// blue channel carries 12x at 2500K) and it lands on sRGB-encoded, already
// tone-mapped pixels, where the raw value clips the ends flat. 0.5 is the
// square root of it — a cast that survives a sunset and a print. Toward 1 is a
// more violent ruler, toward 0.33 a gentler one; change it in BOTH copies
// (src/utils/colorUtils.ts and docker/frontend/shared/utils/colorUtils.ts).
const KELVIN_TAME = 0.5;
// sRGB-encoded colour of a blackbody radiator at K, 0..1 (Tanner Helland's fit
// of the Planckian locus; exact enough from 1000K up, and exactly neutral where
// the app pins unity). Blue collapses to 0 below ~1900K, hence the guard in the
// caller.
function planckianSRGB(kelvin: number): { r: number; g: number; b: number } {
const t = kelvin / 100;
const r = t <= 66 ? 255 : 329.698727446 * Math.pow(t - 60, -0.1332047592);
const g =
t <= 66
? 99.4708025861 * Math.log(t) - 161.1195681661
: 288.1221695283 * Math.pow(t - 60, -0.0755148492);
const b = t >= 66 ? 255 : t <= 19 ? 0 : 138.5177312231 * Math.log(t - 10) - 305.0447927307;
const norm = (v: number) => Math.min(1, Math.max(0, v / 255));
return { r: norm(r), g: norm(g), b: norm(b) };
}
// The same colour in LINEAR light, which is the domain a white-balance gain
// belongs in: a camera's WB is a multiplier on the light, not on the encoded
// pixel, so the ratio has to be taken before the sRGB transfer curve.
function planckianLinear(kelvin: number): { r: number; g: number; b: number } {
const srgb = planckianSRGB(kelvin);
const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
return { r: toLinear(srgb.r), g: toLinear(srgb.g), b: toLinear(srgb.b) };
}
// Divide a set of channel gains by their own luma: the CAST moves, the exposure
// does not. Channels still clip where they must — this only keeps a neutral
// pixel sitting at its neutral level.
function normalizeGainLuma(gain: number[]): { r: number; g: number; b: number } {
const luma = 0.2126 * gain[0] + 0.7152 * gain[1] + 0.0722 * gain[2];
const s = luma > 0 ? 1 / luma : 1;
return { r: gain[0] * s, g: gain[1] * s, b: gain[2] * s };
}
// Sim-owned exposure bias: brightness a stock needs that is part of the stock
// itself, never a user knob. Kept beside the matrices it compensates so the
// values are one hop from the rows they lift. Only LC STREETLIFE VIVID carries
// one — its matrix is a pure separation with no lift, and without this it reads
// a full step darker than the other sims.
//
// In STOPS (EV), on the same scale as exposureStops below, because that is the
// domain the gain is applied in now. The old entry was +2 Exposure units, whose
// 1.04x scale + 0.03 offset measured out at roughly this much light.
const SIM_EXPOSURE_BIAS_EV: Partial<Record<BaseFilter, number>> = {
'leica-vivid': 0.25,
};
// What one unit of the EXPOSURE knob is worth, in stops. Lightroom's slider is
// ±5 EV; this one is ±10 units, so ±2.5 EV at the ends — the same reach AUTO is
// allowed to ask for (AUTO_EV_MAX), so the knob can always express what the
// button decides. Raise it toward 0.5 for Lightroom's full travel, and accept
// that every recipe already saved with a non-zero EXPOSURE gets brighter with
// it.
const EV_PER_UNIT = 0.25;
// The EXPOSURE knob and the sim bias, in stops, for the linear-light pass in
// exportEngine. Separate from the colour matrix on purpose: the matrix works on
// sRGB-ENCODED values, where multiplying by 2 is a gamma-space move that lifts a
// mid-grey to a blown 1.0 instead of the 0.73 a real stop of light gives.
export function exposureStops(adj: ColorAdjustments, baseFilter?: BaseFilter): number {
const units = adj.exposure ?? 0;
return units * EV_PER_UNIT + (SIM_EXPOSURE_BIAS_EV[(baseFilter ?? 'none') as BaseFilter] ?? 0);
}
// A second brightness a stock owns, in Contrast-slider units. The knob itself
// stays at 0 (a sim is colour only), so the extra slope is added here, where
// the knob's own gain/offset mapping already lives.
const SIM_CONTRAST_BIAS: Partial<Record<BaseFilter, number>> = {
'mono-high-contrast': 4,
};
// Black-and-white stocks. Their grey ramp is the whole look, so every
// colour-only stage below — saturation, white balance, the R/B fine-tune — is
// skipped for them, and the tone pass leaves the hue mixer alone. One list,
// asked through one function, so a second B&W stock cannot end up half
// excluded (a stray Kelvin gain on a grey ramp shows up as a tint).
const MONO_BASES: BaseFilter[] = ['monochrome', 'mono-high-contrast'];
export const isMonochromeBase = (baseFilter: BaseFilter | undefined): boolean =>
!!baseFilter && MONO_BASES.includes(baseFilter);
// Generate a 4x5 ColorMatrix (array of 20 floats) based on base style and
// adjustments. EV, the EXPOSURE knob and the sim exposure bias are all
// intentionally NOT part of the matrix: a stop of light is a gain on LIGHT, and
// a matrix runs on sRGB-encoded values, where the same multiply is a gamma-space
// move (a mid-grey at +1 EV would clip instead of landing at 0.73). They go
// through EXPOSURE_SKSL in exportEngine instead. dynamicRange/highlight/shadow
// are in the same place for the same kind of reason — see toneShader.ts.
export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments): number[] {
const contrast = adj.contrast + (SIM_CONTRAST_BIAS[baseFilter] ?? 0);
const saturation = adj.saturation;
const temperature = adj.temperature;
const tint = adj.tint;
// colorChrome/colorChromeBlue are deliberately unused here — the chrome
// effects run in the tone shader (per-pixel chroma), not the matrix.
// 1. Start with Identity Matrix
let matrix = [
1, 0, 0, 0, 0,
0, 1, 0, 0, 0,
0, 0, 1, 0, 0,
0, 0, 0, 1, 0,
];
// 2. Base Filters
if (baseFilter === 'classic-neg') {
// Classic Neg (Superia-like): restrained colour, higher contrast around
// mid-grey, reds a touch deeper. Its signature split tone — green/cyan
// darks drifting to warm brights — cannot live in a linear matrix (one cast
// would hit both ends), so it ships in FILM_TONE (toneShader.ts) instead.
matrix = [
0.9961, 0.0773, 0.0078, 0, -0.0406,
0.0226, 1.0298, 0.0076, 0, -0.0300,
0.0224, 0.0750, 0.9520, 0, -0.0247,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'provia') {
// Provia (Standard): the reference stock — balanced, faithful, easy on the
// eye. Mild saturation and a shallow contrast lift around mid-grey, with no
// channel cast, so neutrals stay neutral and only the chroma deepens.
matrix = [
1.1492, -0.0901, -0.0091, 0, -0.0250,
-0.0268, 1.0859, -0.0091, 0, -0.0250,
-0.0268, -0.0901, 1.1669, 0, -0.0250,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'velvia') {
// Velvia (Vivid): the loud one — saturation pushed very hard onto green,
// blue and red alike, on top of strong contrast with deep, dense darks.
// Saturated reds, skies and greens are meant to ride the clip; that hard
// edge is the look.
matrix = [
1.3895, -0.2540, -0.0256, 0, -0.0550,
-0.0772, 1.2355, -0.0261, 0, -0.0661,
-0.0775, -0.2603, 1.4756, 0, -0.0689,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'classic-chrome') {
// Classic Chrome: magazine-reportage look. Saturation is pulled well down
// while the G/B rows are crossed so blue skies lean teal/cyan (green comes
// up towards blue, with blue kept high). The heavy shadow crush that goes
// with it is the `sh` entry in FILM_TONE — a matrix alone cannot deepen one
// end of the curve without dragging the rest.
matrix = [
0.7792, 0.2188, 0.0220, 0, -0.0100,
0.0652, 0.7186, 0.2362, 0, -0.0100,
0.0652, 0.3973, 0.5575, 0, -0.0100,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'classic-vivid') {
// Classic Vivid: the Classic Chrome blue row kept verbatim — skies stay the
// same muted teal/cyan — with the red and green rows taken from Velvia, so
// everything that is not blue reads loud. Two stocks spliced at the blue
// row, which is exactly the point: quiet skies over saturated colour.
matrix = [
1.3895, -0.2540, -0.0256, 0, -0.0550,
-0.0772, 1.2355, -0.0261, 0, -0.0661,
0.0652, 0.3973, 0.5575, 0, -0.0100,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'astia') {
// Astia (Soft): gentler than standard — contrast below neutral (shadows
// lift, highlights ease off) and colour held back, with a faint warm/pink
// bias that flatters skin. The outdoor-portrait stock.
matrix = [
0.9162, 0.0201, 0.0020, 0, 0.0428,
0.0058, 0.9030, 0.0020, 0, 0.0386,
0.0059, 0.0197, 0.8944, 0, 0.0400,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'eterna') {
// Eterna: cinema — flat (low contrast), muted color, gentle highlight rolloff.
matrix = [
0.94, 0.02, 0.02, 0, 0.02,
0.02, 0.96, 0.02, 0, 0.02,
0.02, 0.02, 0.94, 0, 0.02,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'leica') {
// LC STREETLIFE CLASSIC — the rangefinder character rather than a saturation
// push. What makes this file read as one is the separation BETWEEN the
// hues, so the deepening lives in the off-diagonal terms, not in a global
// chroma gain:
// - the three rows sum to 1.00/1.00/0.995, so greys stay exactly grey and
// every saturated colour gains ~5% through channel separation; Velvia
// pushes 39%, this stays quiet ("dậm đà, không gắt").
// - a small uniform offset deepens the darks: solid shadows that keep their
// texture, no lifted black and no crushed toe.
// - red up / blue down with green held at 1.00 is the warm European cast:
// it lands on skin as warmth, never as the yellow-green a warm gain on
// all three channels would give.
matrix = [
1.060, -0.045, -0.010, 0, -0.010,
-0.030, 1.055, -0.025, 0, -0.008,
-0.025, -0.020, 1.040, 0, -0.005,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'leica-vivid') {
// LC STREETLIFE VIVID — the same slanted channel separation pushed into the
// digital era: ~20% of chroma gain (still well under Velvia's 39%, so it
// sparkles instead of shouting) on the same warm R>G>B bias CLASSIC has, so
// VIVID is a brighter read of one look rather than a second, neutral one.
// No positive lift in the rows themselves, so the matrix stays inside the
// 0..1 heads: the brightness this stock needs rides SIM_EXPOSURE_BIAS above
// (+2), which keeps white out of a hard clip and keeps the sim's
// `adjustments` neutral so the Exposure knob still reads 0.
matrix = [
1.125, -0.100, -0.010, 0, -0.015,
-0.055, 1.110, -0.055, 0, -0.005,
-0.050, -0.070, 1.100, 0, -0.005,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'monochrome') {
// Acros: pure BT.709 grayscale, zero colour leakage, and NO gain or offset.
// The weights sum to 1.0 and the offset is 0, so this matrix is a straight
// luminance readout: nothing clips at either end and every grey step the
// sensor recorded survives. (An earlier 1.10x / -0.05 version plugged every
// black below ~5% and blew every white above ~95% — exactly the fine
// graduation this stock is prized for.) The deep black and the highlight
// shoulder live in FILM_TONE (toneShader.ts), where the curve can be shaped
// one end at a time without touching the midtones. (Grain/sharpness stay
// the user's call — a film sim here is the colour grade only.)
const r = 0.2126;
const g = 0.7152;
const b = 0.0722;
matrix = [
r, g, b, 0, 0,
r, g, b, 0, 0,
r, g, b, 0, 0,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'mono-high-contrast') {
// B&W HIGH CONTRAST — Acros taken through a warm filter, then pushed at the
// ends (the push itself lives in FILM_TONE, and its midtone slope in
// SIM_CONTRAST_BIAS above).
//
// The mix is the B&W MIX step: the channel weights are the matrix's whole
// job here, and they are deliberately not BT.709. Red is nearly doubled
// against its luminance share, orange and yellow hold or gain slightly
// (their rows carry the red weight plus a green one), while blue and green
// fall — so a red roof reads bright, a blue sky and foliage read deep, and
// the separation between them is what gives the ramp its contrast before
// any curve is applied.
//
// ponytail: the weights ride the CHANNELS, while the brief that asked for
// this stock names HUE groups (red 1.15, orange 1.20, blue 0.70). A 4x5
// matrix cannot be hue-selective; if those exact factors are ever wanted,
// that is a per-pixel HSL pass in the tone shader (the mixer block already
// has the tent weights to do it), not a row edit here.
//
// The three rows are identical and sum to 1.00, so a neutral stays the
// neutral the sensor recorded and nothing clips at either end.
const r = 0.38;
const g = 0.56;
const b = 0.06;
matrix = [
r, g, b, 0, 0,
r, g, b, 0, 0,
r, g, b, 0, 0,
0, 0, 0, 1, 0,
];
}
// 3. Apply Saturation adjustment (standard color matrix transformation)
if (!isMonochromeBase(baseFilter) && saturation !== 0) {
const s = 1 + (saturation / 10) * 0.5; // -10 maps to 0.5x, +10 maps to 1.5x saturation
const invS = 1 - s;
const r = 0.213 * invS;
const g = 0.715 * invS;
const b = 0.072 * invS;
const satMat = [
r + s, g, b, 0, 0,
r, g + s, b, 0, 0,
r, g, b + s, 0, 0,
0, 0, 0, 1, 0,
];
matrix = multiplyMatrices(satMat, matrix);
}
// 4. Color Chrome / Color Chrome FX Blue are NOT here: both deepen only the
// already-chromatic pixels, which needs per-pixel chroma (see TONE_SKSL) — a
// matrix gain on R/B is a global saturation push that also moves greys, and a
// blue-only gain tints the white point. `colorChrome`/`colorChromeBlue` are
// consumed by getToneUniforms instead.
// 5. White Balance (Kelvin Temperature and Tint) — skip for monochrome to prevent color leakage
if (!isMonochromeBase(baseFilter)) {
const rgbTemp = kelvinToRGB(temperature);
// Tint is the green↔magenta axis, and it is symmetric: + pushes magenta
// (red and blue up, green down), - pushes green (green up, red and blue
// down). The old version only ever ADDED a channel (magenta lifted R+B,
// green lifted G), so the knob brightened the frame instead of tinting it.
// Normalising the product keeps that at a tint shift, not an exposure one.
const tintMagenta = (tint / 10) * 0.08;
const wbGain = normalizeGainLuma([
rgbTemp.r * (1 + tintMagenta),
rgbTemp.g * (1 - tintMagenta),
rgbTemp.b * (1 + tintMagenta),
]);
const wbMat = [
wbGain.r, 0, 0, 0, 0,
0, wbGain.g, 0, 0, 0,
0, 0, wbGain.b, 0, 0,
0, 0, 0, 1, 0,
];
matrix = multiplyMatrices(wbMat, matrix);
}
// 5b. Fuji WB fine-tune Red/Blue axis (the CREATE modal's R/B offsets). These
// axes are independent channel gains on top of the Kelvin/tint preset: each
// step nudges its channel ~1.1% toward/away from neutral, matching the
// perceptible size of one Fuji fine-tune step (-9..+9).
const wbRed = adj.wbRed ?? 0;
const wbBlue = adj.wbBlue ?? 0;
if (!isMonochromeBase(baseFilter) && (wbRed !== 0 || wbBlue !== 0)) {
const shiftMat = [
1 + (wbRed / 9) * 0.1, 0, 0, 0, 0,
0, 1, 0, 0, 0,
0, 0, 1 + (wbBlue / 9) * 0.1, 0, 0,
0, 0, 0, 1, 0,
];
matrix = multiplyMatrices(shiftMat, matrix);
}
// 6. Contrast — applied to ALL base filters (monochrome included) so both keep
// affecting mono shots. Contrast scales around mid-grey 0.5, in the encoded
// domain, which is where a contrast move belongs (it is a shape on the printed
// ramp, not a gain on light).
if (contrast !== 0) {
const conScale = 1 + (contrast / 10) * 0.25; // -10 → 0.75x, +10 → 1.25x
const conOffset = 0.5 * (1 - conScale);
const adjMat = [
conScale, 0, 0, 0, conOffset,
0, conScale, 0, 0, conOffset,
0, 0, conScale, 0, conOffset,
0, 0, 0, 1, 0,
];
matrix = multiplyMatrices(adjMat, matrix);
}
return matrix;
}
// 4x5 Matrix multiplication utility: A * B
function multiplyMatrices(a: number[], b: number[]): number[] {
const result = new Array(20).fill(0);
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
result[r * 5 + c] =
a[r * 5 + 0] * b[0 * 5 + c] +
a[r * 5 + 1] * b[1 * 5 + c] +
a[r * 5 + 2] * b[2 * 5 + c] +
a[r * 5 + 3] * b[3 * 5 + c];
}
// Handle the 5th column translation
result[r * 5 + 4] =
a[r * 5 + 0] * b[0 * 5 + 4] +
a[r * 5 + 1] * b[1 * 5 + 4] +
a[r * 5 + 2] * b[2 * 5 + 4] +
a[r * 5 + 3] * b[3 * 5 + 4] +
a[r * 5 + 4];
}
return result;
}
@@ -0,0 +1,326 @@
import { Recipe, ColorAdjustments } from '../types';
// Fallback values for every adjustable knob. Old recipes/sessions persisted in
// AsyncStorage lack the newer fields — merge this under stored JSON to keep
// every adjustment well-defined (see storageUtils.getAllRecipes / App session).
export const DEFAULT_ADJUSTMENTS: ColorAdjustments = {
exposure: 0,
contrast: 0,
saturation: 0,
temperature: 5500,
tint: 0,
wbRed: 0,
wbBlue: 0,
highlight: 0,
shadow: 0,
denoise: 0,
clarity: 0,
grain: 0,
colorChrome: 'none',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
hslHue: 0,
hslSat: 0,
hslLum: 0,
exposureCompensation: 0,
};
// Film simulations. These are the fixed "look" presets at the front of the
// PRESETS chip row, and each one is COLOUR AND TONE ONLY.
//
// A sim carries exactly two things: its `baseFilter` colour matrix
// (colorUtils.ts) and, where the stock needs to shape one end of the curve, its
// FILM_TONE entry (toneShader.ts). Everything the sim does to the picture is in
// those two places — including the brightness a stock owns: that is folded into
// the matrix through SIM_EXPOSURE_BIAS (colorUtils.ts), never into
// `adjustments`. `adjustments` is therefore the neutral default for every sim —
// no grain, no clarity, no saturation push, no exposure/highlight/shadow move —
// because those are the user's knobs, not the stock's. A sim is a clean starting
// point, never a baked recipe; any further look is added afterwards.
//
// Name → the stock's colour and tone it must stand for. This table is the
// REFERENCE ONLY: the string at the left is just the app's label for the chip
// (the legacy 'PES' spelling), and what matters is that the sim behind it grades
// the picture the way the stock at the right does. So a change to a label must
// never touch the grading, and a change to a grading never renames the chip:
// PROVIPES Provia / Standard
// VELVIPES Velvia / Vivid
// CLASSIC CHRIPES Classic Chrome
// CLASSIC VIVIDIPES Velvia spliced with Classic Chrome at the blue row
// CLASSIC NEGIPES Classic Negative (Superia-like)
// ASTIPES Astia / Soft
// ETERNIPES Eterna (cinema)
// ACRIPES Acros (monochrome)
// B&W HIGH CONTRAST Acros through a filter, ends pushed hard
// LC STREETLIFE CLASSIC Leica Authentic Classic
// LC STREETLIFE VIVID Leica Vivid
export interface FilmSim {
id: string; // Recipe-like id used only while the sim is the current selection
name: string;
baseFilter: Recipe['baseFilter'];
adjustments: ColorAdjustments;
}
export const FILM_SIMS: FilmSim[] = [
{ id: 'sim-provia', name: 'PROVIPES', baseFilter: 'provia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-velvia', name: 'VELVIPES', baseFilter: 'velvia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-classic-chrome', name: 'CLASSIC CHRIPES', baseFilter: 'classic-chrome', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-classic-vivid', name: 'CLASSIC VIVIDIPES', baseFilter: 'classic-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-classic-neg', name: 'CLASSIC NEGIPES', baseFilter: 'classic-neg', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-astia', name: 'ASTIPES', baseFilter: 'astia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-eterna', name: 'ETERNIPES', baseFilter: 'eterna', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-acros', name: 'ACRIPES', baseFilter: 'monochrome', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-bw-high-contrast', name: 'B&W HIGH CONTRAST', baseFilter: 'mono-high-contrast', adjustments: { ...DEFAULT_ADJUSTMENTS } },
// The rangefinder digital look (M/Q/SL) is not a film stock, so it ships as its own
// pair instead of one look that has to compromise. Both are still colour and
// tone only: CLASSIC is Leica Authentic (the matrix carries the channel
// separation and the warm European cast, nothing else), VIVID is Leica Vivid
// (the same separation opened up — again in the matrix, with the one
// brightness step the stock owns folded in through SIM_EXPOSURE_BIAS in
// colorUtils, never as an `adjustments` value).
{ id: 'sim-leica', name: 'LC STREETLIFE CLASSIC', baseFilter: 'leica', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-leica-vivid', name: 'LC STREETLIFE VIVID', baseFilter: 'leica-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS } },
];
// Film sim → full Recipe (never persisted; sims are applied on the fly).
export function filmSimToRecipe(sim: FilmSim): Recipe {
return {
id: sim.id,
name: sim.name,
baseFilter: sim.baseFilter,
adjustments: { ...sim.adjustments },
frameId: 'none',
useGeotag: true,
};
}
export const DEFAULT_RECIPES: Recipe[] = [
{
id: 'classic-neg-default',
name: 'CLASSIC NEGIPES.',
baseFilter: 'classic-neg',
adjustments: {
exposure: 2,
contrast: 1,
saturation: -1,
temperature: 6300,
tint: 2,
highlight: -2,
shadow: 1,
denoise: 1,
clarity: 2,
grain: 4,
colorChrome: 'weak',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
},
frameId: 'none',
useGeotag: true,
},
{
id: 'velvia-default',
name: 'VELVIPES VIVID',
baseFilter: 'velvia',
adjustments: {
exposure: 0,
contrast: 2,
saturation: 3,
temperature: 5500,
tint: 0,
highlight: 1,
shadow: -1,
denoise: 2,
clarity: 4,
grain: 1,
colorChrome: 'strong',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
},
frameId: 'none',
useGeotag: true,
},
{
id: 'provia-default',
name: 'PROVIPES STD',
baseFilter: 'provia',
adjustments: {
exposure: 1,
contrast: 0,
saturation: 0,
temperature: 5000,
tint: 0,
highlight: 0,
shadow: 0,
denoise: 2,
clarity: 1,
grain: 2,
colorChrome: 'none',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
},
frameId: 'none',
useGeotag: true,
},
{
id: 'acros-default',
name: 'ACRIPES MONO',
baseFilter: 'monochrome',
adjustments: {
exposure: 0,
// Low: the deep black comes from FILM_TONE's toe and the micro-contrast
// from CLARITY, both of which add separation WITHOUT flattening the grey
// ramp. The old 4 / highlight 3 / shadow -2 stack was three global
// contrast moves on top of a matrix that was already clipping — it
// bought its deep black by throwing away every step below 9% and every
// one above 91%.
contrast: 2,
saturation: 0,
temperature: 5000,
tint: 0,
highlight: 0,
shadow: 0,
denoise: 0,
clarity: 3,
grain: 5,
colorChrome: 'none',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
},
frameId: 'none',
useGeotag: true,
},
{
id: 'retro-amber-default',
name: 'RETRO AMBER 🏮',
baseFilter: 'classic-neg',
adjustments: {
exposure: 3,
contrast: 2,
saturation: 1,
temperature: 7500,
tint: -2,
highlight: -3,
shadow: 2,
denoise: 3,
clarity: -2,
grain: 5,
colorChrome: 'strong',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
},
frameId: 'classic-white',
useGeotag: true,
},
{
id: 'cinema-spring-default',
name: 'CINEMA SPRING',
baseFilter: 'none',
adjustments: {
exposure: 0,
contrast: 1,
saturation: 1,
temperature: 5800,
tint: 0,
highlight: -1,
shadow: 1,
denoise: 2,
clarity: 1,
grain: 3,
colorChrome: 'none',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
},
frameId: 'none',
cinema: 'spring',
useGeotag: true,
},
{
id: 'cinema-summer-default',
name: 'CINEMA SUMMER',
baseFilter: 'none',
adjustments: {
exposure: 0,
contrast: 2,
saturation: 2,
temperature: 6000,
tint: -1,
highlight: 1,
shadow: -1,
denoise: 1,
clarity: 2,
grain: 2,
colorChrome: 'weak',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
},
frameId: 'none',
cinema: 'summer',
useGeotag: true,
},
{
id: 'cinema-autumn-default',
name: 'CINEMA AUTUMN',
baseFilter: 'none',
adjustments: {
exposure: 0,
contrast: 1,
saturation: 2,
temperature: 7200,
tint: -2,
highlight: -2,
shadow: 1,
denoise: 2,
clarity: 0,
grain: 4,
colorChrome: 'weak',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
},
frameId: 'none',
cinema: 'autumn',
useGeotag: true,
},
{
id: 'cinema-winter-default',
name: 'CINEMA WINTER',
baseFilter: 'none',
adjustments: {
exposure: 1,
contrast: -1,
saturation: -2,
temperature: 4500,
tint: 2,
highlight: -2,
shadow: 2,
denoise: 3,
clarity: -2,
grain: 3,
colorChrome: 'none',
colorChromeBlue: 'weak',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
},
frameId: 'none',
cinema: 'winter',
useGeotag: true,
},
];
+497
View File
@@ -0,0 +1,497 @@
// Pure JPEG/EXIF writer — no imports, so the codec can be exercised directly.
//
// Skia re-encodes the photo, which drops the original EXIF block (see
// jpegDpi.ts). This rebuilds the APP1/EXIF segment: it reads the SOURCE
// photo's TIFF, keeps every tag it does not own, and stamps the fields this app
// is responsible for — software + version, capture date + zone, GPS, pixel
// dimensions — plus the baseline tags a conforming EXIF block must carry
// (ExifVersion, FlashPixVersion, ComponentsConfiguration, ColorSpace, Interop).
// Same in-place byte approach as jpegDpi.ts: no dependency, no native module.
//
// ponytail: IFD1 (the embedded thumbnail) and MakerNote are dropped. A carried
// thumbnail would show the PRE-export pixels, and MakerNote holds absolute
// offsets that a relocated TIFF invalidates. Read them back when a use case
// needs them.
// Stamped into every file this app writes (kept in step with app.json).
export const SOFTWARE = 'RecipesCam 1.2';
export interface ExifStamp {
software?: string;
// Shutter instant. null/undefined = keep the loaded photo's own
// DateTimeOriginal and only fill it in when the source has none.
dateTime?: Date | null;
gps?: { latitude: number; longitude: number } | null;
// Place name shown on the Gallery frame (EXIF 0x889d). Stock photos carry it
// whenever the capture was geotagged; ours have to state it from the same
// info the watermark prints.
locationName?: string | null;
width?: number | null;
height?: number | null;
dpi?: number | null;
// Device identity for the Gallery watermark blob (ref. watermarkBlob). Only
// used when the source photo carries no blob of its own.
device?: DeviceIdentity | null;
// What the studio would once have burned into the pixels: the frame's tagline
// as ImageDescription (0x010e) and the whole look as JSON in UserComment
// (0x9286). Kept in metadata only — an exported file must stay clean.
imageDescription?: string | null;
userComment?: string | null;
}
/** Raw device identity for the HyperOS Gallery watermark tag (0x889e). */
export interface DeviceIdentity {
device: string; // ro.product.device codename, e.g. "aurora"
marketName: string; // ro.product.marketname, e.g. "Xiaomi 14 Ultra"
manufacturer: string; // Build.MANUFACTURER, e.g. "Xiaomi"
model: string; // Build.MODEL — fallback when marketName is empty
}
interface Entry {
tag: number;
type: number;
count: number;
data: Uint8Array;
ref?: 'exif' | 'gps' | 'interop'; // IFD pointer entry — value patched during layout
}
const BYTE = 1, ASCII = 2, SHORT = 3, LONG = 4, RATIONAL = 5, UNDEFINED = 7, SLONG = 9, SRATIONAL = 10;
const TYPE_SIZE: Record<number, number> = {
[BYTE]: 1, [ASCII]: 1, [SHORT]: 2, [LONG]: 4, [RATIONAL]: 8,
[UNDEFINED]: 1, [SLONG]: 4, [SRATIONAL]: 8,
};
// Tags this module owns, or that would dangle once the TIFF is rebuilt.
const DROP = new Set<number>([
0x0112, // Orientation — the exported pixels are already upright (stamped back as 1)
0x0100, 0x0101, // ImageWidth / ImageLength (stamped from the real export size)
0x011a, 0x011b, 0x0128, // XResolution / YResolution / ResolutionUnit (stamped when dpi is set)
0x0131, 0x0132, // Software / DateTime
0x8769, 0x8825, 0xa005, 0x014a, // Exif / GPS / Interop / SubIFD pointers (rebuilt)
0x0200, 0x0201, 0x0202, // thumbnail offset/length
0x927c, // MakerNote — internal offsets do not survive relocation
]);
// --- little-endian writers -------------------------------------------------
function u16w(b: Uint8Array, p: number, v: number): void {
b[p] = v & 0xff;
b[p + 1] = (v >> 8) & 0xff;
}
function u32w(b: Uint8Array, p: number, v: number): void {
b[p] = v & 0xff;
b[p + 1] = (v >> 8) & 0xff;
b[p + 2] = (v >> 16) & 0xff;
b[p + 3] = (v >> 24) & 0xff;
}
// --- entry builders --------------------------------------------------------
// ASCII-typed byte string, NUL-terminated as EXIF requires. The bytes are UTF-8:
// a place name ("TAM KỲ, ĐÀ NẴNG") or a market name can carry accents, and the
// charCodeAt & 0xff shortcut would mangle them into latin-1. Pure-ASCII text
// encodes byte-for-byte the same as before.
function asciiEntry(tag: number, text: string): Entry {
const bytes: number[] = [];
for (const ch of text) {
const cp = ch.codePointAt(0) as number;
if (cp < 0x80) bytes.push(cp);
else if (cp < 0x800) bytes.push(0xc0 | (cp >> 6), 0x80 | (cp & 0x3f));
else if (cp < 0x10000) bytes.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
else bytes.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
}
bytes.push(0);
return { tag, type: ASCII, count: bytes.length, data: new Uint8Array(bytes) };
}
function shortEntry(tag: number, v: number): Entry {
const data = new Uint8Array(2);
u16w(data, 0, v);
return { tag, type: SHORT, count: 1, data };
}
// UNDEFINED value: a raw byte string whose length IS the count (ExifVersion etc).
function undefinedEntry(tag: number, text: string): Entry {
const data = new Uint8Array(text.length);
for (let i = 0; i < text.length; i++) data[i] = text.charCodeAt(i) & 0xff;
return { tag, type: UNDEFINED, count: data.length, data };
}
// UserComment (0x9286) must open with an 8-byte character-code header; the rest
// is UTF-8, which every reader that accepts "ASCII" here decodes correctly.
function userCommentEntry(text: string): Entry {
const body = new TextEncoder().encode(text);
const data = new Uint8Array(8 + body.length);
data.set([0x41, 0x53, 0x43, 0x49, 0x49, 0, 0, 0]); // "ASCII\0\0\0"
data.set(body, 8);
return { tag: 0x9286, type: UNDEFINED, count: data.length, data };
}
function longEntry(tag: number, v: number): Entry {
const data = new Uint8Array(4);
u32w(data, 0, v);
return { tag, type: LONG, count: 1, data };
}
function rationalEntry(tag: number, num: number, den: number, type = RATIONAL): Entry {
const data = new Uint8Array(8);
u32w(data, 0, num);
u32w(data, 4, den);
return { tag, type, count: 1, data };
}
// GPS coordinate: 3 RATIONALs (degrees, minutes, seconds), denominator 10000
// for the seconds so ~0.1 m survives.
function gpsCoordEntry(tag: number, value: number): Entry {
const abs = Math.abs(value);
let deg = Math.floor(abs);
let min = Math.floor((abs - deg) * 60);
let sec = Math.round(((abs - deg) * 60 - min) * 60 * 10000);
if (sec >= 600000) { sec = 0; min += 1; }
if (min >= 60) { min = 0; deg += 1; }
const data = new Uint8Array(24);
u32w(data, 0, deg); u32w(data, 4, 1);
u32w(data, 8, min); u32w(data, 12, 1);
u32w(data, 16, sec); u32w(data, 20, 10000);
return { tag, type: RATIONAL, count: 3, data };
}
// A big-endian source TIFF hands us big-endian value bytes; they are re-emitted
// into a little-endian TIFF, so every numeric value must be swapped first
// (ASCII/BYTE/UNDEFINED are byte strings and stay as they are).
function swapToLittleEndian(data: Uint8Array, type: number): Uint8Array {
const unit = type === SHORT ? 2 : type === LONG || type === SLONG ? 4 : type === RATIONAL || type === SRATIONAL ? 4 : 0;
if (!unit) return data;
// `new Uint8Array(data)` (not data.slice()) — the input may be a Node Buffer,
// whose slice() aliases the source and would corrupt the caller's bytes.
const out = new Uint8Array(data);
for (let p = 0; p + unit <= out.length; p += unit) {
for (let i = 0; i < unit >> 1; i++) {
const a = out[p + i];
out[p + i] = out[p + unit - 1 - i];
out[p + unit - 1 - i] = a;
}
}
return out;
}
function asciiValue(e: Entry | undefined): string | null {
if (!e || e.type !== ASCII || e.data.length === 0) return null;
let s = '';
for (let i = 0; i < e.data.length && e.data[i] !== 0; i++) s += String.fromCharCode(e.data[i]);
return s || null;
}
// "YYYY:MM:DD HH:MM:SS" — the EXIF date format (colons, not dashes).
function exifDate(d: Date): string {
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}:${p(d.getMonth() + 1)}:${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
// "±HH:MM" — the OffsetTime* tag format. Framing tools read it to place a
// capture correctly, and it is what makes the date unambiguous without GPS.
function tzOffset(d: Date): string {
const mins = -d.getTimezoneOffset();
const a = Math.abs(mins);
const p = (n: number) => String(n).padStart(2, '0');
return `${mins < 0 ? '-' : '+'}${p(Math.floor(a / 60))}:${p(a % 60)}`;
}
// The watermark blob HyperOS Gallery reads back (EXIF tag 0x889e) before it
// will open its watermark editor: without it, a photo edited here fails with
// "cannot recognize the parameters". Only the stock camera app writes this tag
// — CameraX/HAL never does — so a camera capture has to synthesize it from the
// same props the stock app derives it from. Shape matches the stock bytes:
// {"buildDevice":"aurora",...,"waterLogo":"XIAOMI","waterName":"14 Ultra"}.
function watermarkBlob(d: DeviceIdentity): string {
const brand = d.manufacturer.trim();
const market = d.marketName.trim();
// "Xiaomi 14 Ultra" -> "14 Ultra": Gallery prints waterName, not the brand.
const waterName = market.toLowerCase().startsWith(`${brand.toLowerCase()} `)
? market.slice(brand.length + 1)
: market || d.model;
return JSON.stringify({
buildDevice: d.device,
customize: '',
cvLensName: '',
filterName: '',
livephotoInfo: '',
version: 2,
waterLogo: brand.toUpperCase(),
waterName,
});
}
// --- parse -----------------------------------------------------------------
interface ParsedTiff {
ifd0: Entry[];
exif: Entry[];
gps: Entry[];
interop: Entry[];
}
function parseTiff(tiff: Uint8Array): ParsedTiff {
const none: ParsedTiff = { ifd0: [], exif: [], gps: [], interop: [] };
if (tiff.length < 8) return none;
const le = tiff[0] === 0x49 && tiff[1] === 0x49;
const be = tiff[0] === 0x4d && tiff[1] === 0x4d;
if (!le && !be) return none;
const u16 = (p: number): number => (le ? tiff[p] | (tiff[p + 1] << 8) : (tiff[p] << 8) | tiff[p + 1]);
const u32 = (p: number): number =>
le
? (tiff[p] | (tiff[p + 1] << 8) | (tiff[p + 2] << 16) | (tiff[p + 3] << 24)) >>> 0
: ((tiff[p] << 24) | (tiff[p + 1] << 16) | (tiff[p + 2] << 8) | tiff[p + 3]) >>> 0;
const read = (off: number): { list: Entry[]; ptr: Record<number, number> } => {
const out: { list: Entry[]; ptr: Record<number, number> } = { list: [], ptr: {} };
if (off <= 0 || off + 2 > tiff.length) return out;
const n = u16(off);
for (let i = 0; i < n; i++) {
const p = off + 2 + i * 12;
if (p + 12 > tiff.length) break;
const tag = u16(p);
const type = u16(p + 2);
const count = u32(p + 4);
if (tag === 0x8769 || tag === 0x8825 || tag === 0xa005) {
out.ptr[tag] = u32(p + 8); // sub-IFD pointers are rebuilt, never copied
continue;
}
const size = TYPE_SIZE[type];
if (!size || DROP.has(tag)) continue;
const len = size * count;
if (len <= 0 || len > 4 * 1024 * 1024) continue; // ignore absurd/overflowing counts
if (len <= 4) {
out.list.push({ tag, type, count, data: be ? swapToLittleEndian(tiff.slice(p + 8, p + 8 + len), type) : tiff.slice(p + 8, p + 8 + len) });
} else {
const o = u32(p + 8);
if (o + len > tiff.length) continue;
const raw = tiff.slice(o, o + len);
out.list.push({ tag, type, count, data: be ? swapToLittleEndian(raw, type) : raw });
}
}
return out;
};
const a = read(u32(4));
const exifRead = a.ptr[0x8769] ? read(a.ptr[0x8769]) : { list: [], ptr: {} };
return {
ifd0: a.list,
exif: exifRead.list,
gps: a.ptr[0x8825] ? read(a.ptr[0x8825]).list : [],
interop: exifRead.ptr[0xa005] ? read(exifRead.ptr[0xa005]).list : [],
};
}
// Locate the APP1/EXIF segment: start/end bound the whole segment, tiff the
// payload after the "Exif\0\0" signature.
function findExifSegment(jpeg: Uint8Array): { start: number; end: number; tiff: Uint8Array } | null {
if (jpeg.length < 4 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8) return null;
let i = 2;
while (i + 1 < jpeg.length && jpeg[i] === 0xff) {
const start = i;
while (jpeg[i] === 0xff) i++;
const marker = jpeg[i];
i++;
if (marker === 0xd9 || marker === 0xda) return null; // EOI / SOS — headers only
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
if (i + 2 > jpeg.length) return null;
const len = (jpeg[i] << 8) | jpeg[i + 1];
if (len < 2 || i + len > jpeg.length) return null;
const dataStart = i + 2;
if (
marker === 0xe1 && len >= 8 &&
jpeg[dataStart] === 0x45 && jpeg[dataStart + 1] === 0x78 && jpeg[dataStart + 2] === 0x69 &&
jpeg[dataStart + 3] === 0x66 && jpeg[dataStart + 4] === 0x00 && jpeg[dataStart + 5] === 0x00
) {
return { start, end: i + len, tiff: jpeg.subarray(dataStart + 6, i + len) };
}
i += len;
}
return null;
}
// --- serialize -------------------------------------------------------------
function serializeTiff(ifd0: Entry[], exif: Entry[], gps: Entry[], interop: Entry[]): Uint8Array {
const ifdLen = (n: number) => 2 + n * 12 + 4;
const outLen = (es: Entry[]) => {
let s = 0;
for (const e of es) if (e.data.length > 4) s += (e.data.length + 1) & ~1;
return s;
};
const o0 = 8;
const d0 = o0 + ifdLen(ifd0.length);
const oE = d0 + outLen(ifd0);
const dE = oE + ifdLen(exif.length);
const oG = dE + outLen(exif);
const dG = oG + ifdLen(gps.length);
const oI = dG + outLen(gps);
const dI = oI + ifdLen(interop.length);
const t = new Uint8Array(dI + outLen(interop));
t[0] = 0x49; t[1] = 0x49; t[2] = 0x2a; t[3] = 0x00; // "II", 42
u32w(t, 4, o0);
const writeIfd = (off: number, es: Entry[], dataOff: number): void => {
u16w(t, off, es.length);
let c = dataOff;
es.forEach((e, i) => {
const p = off + 2 + i * 12;
u16w(t, p, e.tag);
u16w(t, p + 2, e.type);
u32w(t, p + 4, e.count);
if (e.ref === 'exif') { u32w(t, p + 8, oE); return; }
if (e.ref === 'gps') { u32w(t, p + 8, oG); return; }
if (e.ref === 'interop') { u32w(t, p + 8, oI); return; }
if (e.data.length <= 4) { t.set(e.data, p + 8); return; }
u32w(t, p + 8, c);
t.set(e.data, c);
c += (e.data.length + 1) & ~1; // values start on a word boundary
});
u32w(t, off + 2 + es.length * 12, 0); // no IFD1
};
writeIfd(o0, ifd0, d0);
writeIfd(oE, exif, dE);
writeIfd(oG, gps, dG);
writeIfd(oI, interop, dI);
return t;
}
/**
* Rebuilds the JPEG's APP1/EXIF block: `sourceJpeg` (the loaded photo) donates
* every tag this module does not own; `stamp` sets the ones the app owns.
* Returns a new array; input is never modified. A JPEG that cannot take the
* segment (not a JPEG, EXIF larger than 64 KB) comes back untouched.
*/
export function writeJpegExif(jpeg: Uint8Array, stamp: ExifStamp, sourceJpeg?: Uint8Array | null): Uint8Array {
if (jpeg.length < 4 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8) return jpeg;
// An unreadable source (content:// uri, IO error) must not wipe the tags the
// jpeg already carries, so fall back to the jpeg's own EXIF block.
const src = findExifSegment(sourceJpeg && sourceJpeg.length > 4 ? sourceJpeg : jpeg);
const parsed = src ? parseTiff(src.tiff) : { ifd0: [], exif: [], gps: [], interop: [] };
const ifd0 = new Map(parsed.ifd0.map((e) => [e.tag, e]));
const exif = new Map(parsed.exif.map((e) => [e.tag, e]));
const gps = new Map(parsed.gps.map((e) => [e.tag, e]));
const interop = new Map(parsed.interop.map((e) => [e.tag, e]));
const now = exifDate(stamp.dateTime ?? new Date());
const keepSourceDate = !stamp.dateTime;
const sourceDate = asciiValue(ifd0.get(0x0132)) ?? asciiValue(exif.get(0x9003));
const dateStr = keepSourceDate ? sourceDate ?? now : now;
if (!keepSourceDate || !ifd0.has(0x0132)) ifd0.set(0x0132, asciiEntry(0x0132, dateStr));
if (!keepSourceDate || !exif.has(0x9003)) exif.set(0x9003, asciiEntry(0x9003, dateStr));
if (!keepSourceDate || !exif.has(0x9004)) exif.set(0x9004, asciiEntry(0x9004, dateStr));
if (!keepSourceDate) {
// We own the timestamp for camera shots, so we also state its zone.
const off = asciiEntry(0x9010, tzOffset(stamp.dateTime as Date));
ifd0.set(0x9010, off); // OffsetTime (0th IFD)
exif.set(0x9011, { ...off, tag: 0x9011 }); // OffsetTimeOriginal
exif.set(0x9012, { ...off, tag: 0x9012 }); // OffsetTimeDigitized
}
ifd0.set(0x0131, asciiEntry(0x0131, stamp.software ?? SOFTWARE));
ifd0.set(0x0112, shortEntry(0x0112, 1)); // upright — the pixels are already straight
// The frame's own labels, metadata-only: ImageDescription names the look and
// UserComment carries the settings so the file reopens as it was saved.
const desc = stamp.imageDescription?.trim();
if (desc) ifd0.set(0x010e, asciiEntry(0x010e, desc));
const comment = stamp.userComment?.trim();
if (comment) exif.set(0x9286, userCommentEntry(comment));
// Baseline tags a conforming EXIF block is expected to carry. Framing apps
// reject a file that omits them, and HALs (the emulator's especially) do not
// always supply them, so fill the gaps and keep whatever the source had.
if (!exif.has(0x9000)) exif.set(0x9000, undefinedEntry(0x9000, '0230')); // ExifVersion
if (!exif.has(0xa000)) exif.set(0xa000, undefinedEntry(0xa000, '0100')); // FlashPixVersion
if (!exif.has(0x9101)) exif.set(0x9101, { tag: 0x9101, type: UNDEFINED, count: 4, data: new Uint8Array([1, 2, 3, 0]) }); // YCbCr
if (!exif.has(0xa001)) exif.set(0xa001, shortEntry(0xa001, 65535)); // ColorSpace = uncalibrated
if (!interop.has(0x0001)) interop.set(0x0001, asciiEntry(0x0001, 'R98')); // InteropIndex
if (!interop.has(0x0002)) interop.set(0x0002, undefinedEntry(0x0002, '0100')); // InteropVersion
if (stamp.width && stamp.height) {
ifd0.set(0x0100, longEntry(0x0100, stamp.width));
ifd0.set(0x0101, longEntry(0x0101, stamp.height));
exif.set(0xa002, longEntry(0xa002, stamp.width)); // PixelXDimension
exif.set(0xa003, longEntry(0xa003, stamp.height)); // PixelYDimension
}
if (stamp.dpi && stamp.dpi > 0) {
ifd0.set(0x011a, rationalEntry(0x011a, stamp.dpi, 1));
ifd0.set(0x011b, rationalEntry(0x011b, stamp.dpi, 1));
ifd0.set(0x0128, shortEntry(0x0128, 2)); // resolution unit = inch
}
// HyperOS Gallery refuses to open its watermark editor without 0x889e, and a
// CameraX capture never carries it. Never overwrite a blob the source has.
const xiaomi = !!stamp.device && /xiaomi|redmi|poco/i.test(stamp.device.manufacturer);
if (stamp.device && xiaomi && !exif.has(0x889e)) {
exif.set(0x889e, asciiEntry(0x889e, watermarkBlob(stamp.device)));
}
// Stock Xiaomi photos name the device in 0x9a00 and in Model (0x0110) as the
// MARKET name ("Xiaomi 14 Ultra"); the framing tool matches those against its
// known-device list and reports an unknown device otherwise. A CameraX capture
// only has the raw codename, so state the market name. A source that already
// carries 0x9a00 is a stock photo of some other device — leave it alone.
if (stamp.device && xiaomi && !ifd0.has(0x9a00)) {
const market = (stamp.device.marketName || stamp.device.model).trim();
if (market) {
ifd0.set(0x9a00, asciiEntry(0x9a00, market));
ifd0.set(0x0110, asciiEntry(0x0110, market));
}
}
// Place name the frame prints (0x889d) — stock writes it in both IFDs.
const place = stamp.locationName?.trim();
if (place) {
if (!ifd0.has(0x889d)) ifd0.set(0x889d, asciiEntry(0x889d, place));
if (!exif.has(0x889d)) exif.set(0x889d, asciiEntry(0x889d, place));
}
if (stamp.gps) {
const { latitude: lat, longitude: lon } = stamp.gps;
gps.set(0x0001, asciiEntry(0x0001, lat >= 0 ? 'N' : 'S'));
gps.set(0x0002, gpsCoordEntry(0x0002, lat));
gps.set(0x0003, asciiEntry(0x0003, lon >= 0 ? 'E' : 'W'));
gps.set(0x0004, gpsCoordEntry(0x0004, lon));
}
const list0 = [...ifd0.values()];
if (exif.size > 0) list0.push({ tag: 0x8769, type: LONG, count: 1, data: new Uint8Array(4), ref: 'exif' });
if (gps.size > 0) list0.push({ tag: 0x8825, type: LONG, count: 1, data: new Uint8Array(4), ref: 'gps' });
list0.sort((a, b) => a.tag - b.tag);
const listE = [...exif.values()].sort((a, b) => a.tag - b.tag);
const listG = [...gps.values()].sort((a, b) => a.tag - b.tag);
if (interop.size > 0) listE.push({ tag: 0xa005, type: LONG, count: 1, data: new Uint8Array(4), ref: 'interop' });
listE.sort((a, b) => a.tag - b.tag);
const listI = [...interop.values()].sort((a, b) => a.tag - b.tag);
const tiff = serializeTiff(list0, listE, listG, listI);
const payload = 2 + 6 + tiff.length;
if (payload > 0xffff) return jpeg; // APP1 length is a 16-bit field
const app1 = new Uint8Array(2 + payload);
app1[0] = 0xff; app1[1] = 0xe1;
// APP1 length is a big-endian 16-bit field (the TIFF block after it is little-endian)
app1[2] = (payload >> 8) & 0xff;
app1[3] = payload & 0xff;
app1.set([0x45, 0x78, 0x69, 0x66, 0x00, 0x00], 4); // "Exif\0\0"
app1.set(tiff, 10);
const old = findExifSegment(jpeg);
const head = old ? jpeg.subarray(0, old.start) : jpeg.subarray(0, 2);
const tail = old ? jpeg.subarray(old.end) : jpeg.subarray(2);
const out = new Uint8Array(head.length + app1.length + tail.length);
out.set(head, 0);
out.set(app1, head.length);
out.set(tail, head.length + app1.length);
return out;
}
+204
View File
@@ -0,0 +1,204 @@
import { SkCanvas, SkPaint, Skia, BlendMode } from '@shopify/react-native-skia';
import { FrameId } from '../types';
export interface FrameDefinition {
id: FrameId;
name: string;
}
export const FRAMES: FrameDefinition[] = [
{ id: 'none', name: 'NO FRAME' },
{ id: 'classic-white', name: 'CLASSIC BORDER' },
{ id: 'polaroid', name: 'RETRO INSTANT' },
{ id: 'wallframe', name: 'WALL FRAME' },
];
// Real Polaroid print proportions (88 x 107 mm card, 79 x 79 mm image print).
// All fractions are relative to the CARD WIDTH so the layout scales to any
// pixel size while keeping the physical look.
export const POLAROID_CARD = {
// Horizontal + top margin: (88 - 79)/2 / 88 mm each side.
sideMargin: (88 - 79) / 2 / 88, // ≈ 0.05114
// Bottom deck: remaining height of the 107 mm card below the 79 mm print,
// minus the top margin: (107 - 79 - 4.5) / 88 mm.
bottomDeck: (107 - 79 - (88 - 79) / 2) / 88, // ≈ 0.26705
} as const;
// Image window width as a fraction of card width (79 / 88 mm).
export const POLAROID_WIN_W = 1 - 2 * POLAROID_CARD.sideMargin; // ≈ 0.89773
export interface PolaroidLayout {
card: { x: number; y: number; w: number; h: number };
window: { x: number; y: number; w: number; h: number };
}
// Wall frame: the PNG artwork lives at the project root (wallframe.png,
// 4000x3117 RGBA, transparent window cols 675..3322 x rows 675..2441). Only the
// geometry lives here; the bitmap itself is drawn by the caller. The phone is
// portrait, so the app shows the artwork ROTATED 90° CW (original top edge →
// right edge): a portrait frame whose transparent window is a fixed 2:3
// vertical opening. The photo is cover-cropped (center) into that opening — the
// mat of a real frame hides the print edges. WALLFRAME_W/H and WALLFRAME_WIN
// are the rotated display orientation; the affine in wallframeLayout maps the
// source PNG pixels onto the frame rect so the drawn opening and the photo
// window always line up.
export const WALLFRAME_PNG_W = 4000; // source PNG width
export const WALLFRAME_PNG_H = 3117; // source PNG height
export const WALLFRAME_W = 3117; // rotated display width (= PNG height)
export const WALLFRAME_H = 4000; // rotated display height (= PNG width)
// Transparent window as fractions of the rotated image, derived from the same
// affine used to draw the bitmap: X = w - (y/3117)w, Y = (x/4000)h.
export const WALLFRAME_WIN = {
left: 676 / 3117, // ≈ 0.21688
top: 675 / 4000, // 0.16875
w: 1766 / 3117, // ≈ 0.56657
h: 2647 / 4000, // 0.66175
} as const;
// Landscape hang: the PNG's OWN orientation, so nothing is rotated — the
// canvas is the artwork (4000x3117) and the opening is the source window as
// fractions of it. Same 3:2 mat opening, hung the other way round.
export const WALLFRAME_LAND_W = WALLFRAME_PNG_W; // 4000
export const WALLFRAME_LAND_H = WALLFRAME_PNG_H; // 3117
export const WALLFRAME_LAND_WIN = {
left: 675 / WALLFRAME_PNG_W, // 0.16875
top: 675 / WALLFRAME_PNG_H, // ≈ 0.21688
w: 2647 / WALLFRAME_PNG_W, // 0.66175
h: 1766 / WALLFRAME_PNG_H, // ≈ 0.56657
} as const;
export interface WallframeLayout {
frame: { x: number; y: number; w: number; h: number };
window: { x: number; y: number; w: number; h: number };
// Row-major Skia matrix that rotates the source PNG (WALLFRAME_PNG_W/H) 90° CW
// into the frame rect. Feed it to <Group matrix> (viewfinder) or concat it on
// the canvas (export) before drawing the bitmap.
drawMatrix: number[];
}
// Geometry of the wall-frame inside an arbitrary container. The frame is
// contain-fitted ('contain', centered, letterboxed) for the live viewfinder,
// or fills the container exactly ('fill', export canvas already at frame size).
// `landscape` hangs the artwork the other way round: same PNG, no rotation.
export function wallframeLayout(
contW: number,
contH: number,
mode: 'contain' | 'fill' = 'contain',
landscape = false
): WallframeLayout {
const FW = landscape ? WALLFRAME_LAND_W : WALLFRAME_W;
const FH = landscape ? WALLFRAME_LAND_H : WALLFRAME_H;
const WIN = landscape ? WALLFRAME_LAND_WIN : WALLFRAME_WIN;
let frameW: number;
let frameH: number;
if (mode === 'fill') {
frameW = contW;
frameH = contH;
} else {
const s = Math.min(contW / FW, contH / FH);
frameW = FW * s;
frameH = FH * s;
}
const fx = (contW - frameW) / 2;
const fy = (contH - frameH) / 2;
const scale = frameW / FW; // == frameH / FH
return {
frame: { x: fx, y: fy, w: frameW, h: frameH },
window: {
x: fx + WIN.left * frameW,
y: fy + WIN.top * frameH,
w: WIN.w * frameW,
h: WIN.h * frameH,
},
// Portrait: the source PNG must be turned 90° CW onto the canvas.
// Landscape: it already sits the right way up — scale and centre it.
drawMatrix: landscape
? [scale, 0, fx, 0, scale, fy, 0, 0, 1]
: [0, -scale, fx + frameW, scale, 0, fy, 0, 0, 1],
};
}
// Geometry of a retro-polaroid card inside an arbitrary container. The photo is
// NEVER cropped: the image window keeps the photo aspect ratio (photoW/photoH)
// and the card grows/shrinks around it, so the whole picture always fits the
// window. 'contain' fits the full card inside the container (centered, with
// letterbox bars); 'fill' sizes the container exactly to the natural card
// proportions (used at export where the canvas IS the card).
export function polaroidLayout(
contW: number,
contH: number,
photoAspect: number, // photo width / photo height (>1 = landscape)
mode: 'contain' | 'fill' = 'contain'
): PolaroidLayout {
const S = POLAROID_CARD;
// Height of the card once the photo window (width WIN_W of the card width,
// height = that / photoAspect) is stacked below the top margin and above the
// bottom deck: cardH = cardW*(sideMargin + bottomDeck) + cardW*WIN_W/photoAspect
const naturalAspect = S.sideMargin + S.bottomDeck + POLAROID_WIN_W / photoAspect; // cardH/cardW
let cardW: number;
let cardH: number;
if (mode === 'fill') {
cardW = contW;
cardH = contH;
} else {
cardW = Math.min(contW, contH / naturalAspect);
cardH = cardW * naturalAspect;
}
const cx = (contW - cardW) / 2;
const cy = (contH - cardH) / 2;
const top = S.sideMargin * cardW;
const side = S.sideMargin * cardW;
let winW = POLAROID_WIN_W * cardW;
let winH = winW / photoAspect;
// Container aspect may leave less vertical room than the natural card (e.g.
// phone camera feed) — shrink the window to the available space instead of
// overflowing the card.
const maxWinH = cardH - top - S.bottomDeck * cardW;
if (winH > maxWinH) {
winH = maxWinH;
winW = winH * photoAspect;
}
return {
card: { x: cx, y: cy, w: cardW, h: cardH },
window: { x: cx + (cardW - winW) / 2, y: cy + top, w: winW, h: winH },
};
}
export function drawFrameOnCanvas(
canvas: SkCanvas,
width: number,
height: number,
frameId: FrameId
) {
if (frameId === 'none') return;
const paint = Skia.Paint();
if (frameId === 'classic-white') {
// A clean white border around the picture
paint.setColor(Skia.Color('#ffffff'));
paint.setStyle(0); // Fill
const borderWidth = Math.min(width, height) * 0.05; // 5% border
// Top border
canvas.drawRect(Skia.XYWHRect(0, 0, width, borderWidth), paint);
// Bottom border
canvas.drawRect(Skia.XYWHRect(0, height - borderWidth, width, borderWidth), paint);
// Left border
canvas.drawRect(Skia.XYWHRect(0, 0, borderWidth, height), paint);
// Right border
canvas.drawRect(Skia.XYWHRect(width - borderWidth, 0, borderWidth, height), paint);
} else if (frameId === 'cinematic') {
// Cinematic black bars on top and bottom
paint.setColor(Skia.Color('#000000'));
paint.setStyle(0); // Fill
const barHeight = height * 0.12; // 12% cinematic letterbox height
// Top letterbox
canvas.drawRect(Skia.XYWHRect(0, 0, width, barHeight), paint);
// Bottom letterbox
canvas.drawRect(Skia.XYWHRect(0, height - barHeight, width, barHeight), paint);
}
}
@@ -0,0 +1,304 @@
import type { GradientMask } from '../types';
import {
CLARITY_GAIN,
DEHAZE_FLOOR_T,
DEHAZE_MAX_OMEGA,
DEHAZE_PATCH_STEP,
DEHAZE_PATCH_TAPS,
} from './toneShader';
// FX tab > LINEAR / RADIAL GRADIENT — Lightroom's two gradient masks, the local
// adjustments that are a SHAPE rather than a whole-frame knob.
//
// HEAL and MOSAIC repair and hide; these two grade. A shape is dragged on the
// photo, and inside it (through the alpha the shape's own maths produces) the
// three knobs below move the pixels and nothing outside them does. A linear
// gradient is the shape whose alpha is a straight ramp across the drag; a radial
// one is an ellipse whose alpha holds full until its feather and dies at its
// rim. Two shapes, one shader: the kind is a number in the mask's own uniform
// and the block that reads it branches, because that is the whole difference
// between them.
//
// The maths is the GLSL spec these were asked for (its scratchpad md, sections
// 1 and 2 for the masks and the blend shader for the colour): the linear one
// projects the pixel onto the drag with a dot product and smoothsteps the
// result; the radial one moves the pixel into the ellipse's own frame, rotates
// it back by -angle, divides by the semi-axes and smoothsteps inward from the
// rim. Written in SkSL because that is what this renderer is (Canvaskit, see
// skiaShim.ts), stage for stage: the one pass the spec asks for per mask, in
// the order the user drew them, each reading what the one before it left.
//
// The spec's section 4 — "the system needs to restrict all the above effects to
// operate only within the mask's area" — is the rest of the block below: the
// smoothstep soft masks that carry HIGHLIGHT and SHADOW, the two ends WHITE and
// BLACK move, and the two spatial ones (CLARITY against the frame's own blur,
// DEHAZE through the dark channel) that the caller hands in the blurred
// reference for and the atmospheric light for. Every one of them rides the same
// alpha the shape produces and lands through the same `mix`, so a mask at half
// strength is half of the move.
export const MASK_KIND = { linear: 0, radial: 1 } as const;
// Exposure is stored as the EV itself — the spec's own -5..+5 — because that is
// what `pow(2.0, e)` reads, and a stop is a stop whatever the app's slider units
// are elsewhere. The other two are -10..+10 like every other knob, and are turned
// into the spec's -1..+1 on the way to the shader.
export const MASK_EXPOSURE_MAX = 5;
// How much of the semi-axis a fresh ellipse fades over. Half: the edge is soft
// enough to be a gradient mask rather than a cut-out, and every pixel of the
// ellipse is still on the shape's own side of the rim.
export const MASK_DEFAULT_FEATHER = 0.5;
// The smallest drag that is a shape rather than a press: a hundredth of the
// width. Below this the mask is dropped instead of laid down — the same floor
// readMasks refuses a stored one at.
export const MASK_MIN = 0.01;
const num = (v: unknown, fallback: number) => {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
};
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
const clampA = (v: number) => (v < -10 ? -10 : v > 10 ? 10 : v);
const clampEV = (v: number) => (v < -MASK_EXPOSURE_MAX ? -MASK_EXPOSURE_MAX : v > MASK_EXPOSURE_MAX ? MASK_EXPOSURE_MAX : v);
// The stored masks, made readable: numbers, inside the frame, one kind — the
// same guard HEAL's and MOSAIC's lists get, so a hand-written or older file
// cannot produce a shape the overlay and the renderer disagree about. A mask
// with no shape (a linear drag of no length, an ellipse of no radius) is
// dropped: it would paint nothing and could never be taken hold of on the photo.
// What readMasks hands back: the stored mask with every knob resolved to a
// number. The two spatial ones and the four tonal ones are optional in the
// stored type (a mask saved before they existed has none), so the reader's
// return type is the one that says they are there.
export type ReadMask = GradientMask & {
highlights: number;
shadows: number;
whites: number;
blacks: number;
clarity: number;
dehaze: number;
};
export function readMasks(masks: GradientMask[] | undefined): ReadMask[] {
if (!Array.isArray(masks)) return [];
return masks
.filter((m) => m?.kind === 'linear' || m?.kind === 'radial')
.map((m) => ({
kind: m.kind,
x: clamp01(num(m.x, 0.5)),
y: clamp01(num(m.y, 0.5)),
ex: clamp01(num(m.ex, 0.5)),
ey: clamp01(num(m.ey, 0.5)),
rx: Math.max(0, num(m.rx, 0)),
ry: Math.max(0, num(m.ry, 0)),
angle: num(m.angle, 0),
feather: clamp01(num(m.feather, MASK_DEFAULT_FEATHER)),
exposure: clampEV(num(m.exposure, 0)),
contrast: clampA(num(m.contrast, 0)),
saturation: clampA(num(m.saturation, 0)),
// The spec's section 4 knobs: the two tone soft masks, the two ends, and
// the two spatial moves. All -10..+10 like every other knob here, all 0 on
// a mask stored before they existed.
highlights: clampA(num(m.highlights, 0)),
shadows: clampA(num(m.shadows, 0)),
whites: clampA(num(m.whites, 0)),
blacks: clampA(num(m.blacks, 0)),
clarity: clampA(num(m.clarity, 0)),
dehaze: clampA(num(m.dehaze, 0)),
}))
.filter((m) => (m.kind === 'linear' ? Math.hypot(m.ex - m.x, m.ey - m.y) > MASK_MIN : m.rx > 0 && m.ry > 0));
}
// The two spatial knobs read what the pass cannot build on its own: CLARITY the
// frame's own blurred reference, handed in as a second child, and both of them
// the frame's atmospheric light. One question, asked in one place, so the
// renderer builds the blur for exactly the masks with a spatial knob on them and
// the effect is cached for the same answer, and so the shader is built with the
// spatial block (DEHAZE's own dark channel lives in it).
export function masksHaveSpatial(masks: GradientMask[]): boolean {
return readMasks(masks).some((m) => m.clarity !== 0 || m.dehaze !== 0);
}
// The uniform block the shader for `n` masks reads: the shapes, the ellipse
// parameters, the knobs with the kind, the tone soft masks, the spatial pair,
// then the frame the fractions are of — and, when the caller has one, the
// atmospheric light the dehaze reads. Declaration order, arrays expanded — one
// buffer is one upload per render, the same shape healUniforms and
// mosaicUniforms use. Its length is a function of the list, not a fixed
// capacity, because the shader carries exactly the masks the recipe holds.
export function maskUniforms(
masks: GradientMask[],
width: number,
height: number,
air?: [number, number, number] | null
): Float32Array {
const list = readMasks(masks);
const n = list.length;
const u = new Float32Array((5 * n + 1 + (air ? 1 : 0)) * 4);
for (let i = 0; i < n; i++) {
const m = list[i];
u.set([m.x, m.y, m.ex, m.ey], i * 4);
u.set([m.rx, m.ry, m.angle, m.feather], (n + i) * 4);
// The knobs are handed over in the renderer's own units: EV for exposure,
// -1..1 for the other two, which is what the spec's blend shader reads.
u.set(
[m.exposure, m.contrast / 10, m.saturation / 10, MASK_KIND[m.kind]],
(2 * n + i) * 4
);
// The tone soft masks and the two ends, then the spatial pair.
u.set([m.highlights / 10, m.shadows / 10, m.whites / 10, m.blacks / 10], (3 * n + i) * 4);
u.set([m.clarity / 10, m.dehaze / 10, 0, 0], (4 * n + i) * 4);
}
u.set([width, height, 0, 0], 5 * n * 4);
if (air) u.set([air[0], air[1], air[2], 0], (5 * n + 1) * 4);
return u;
}
// The colour inside a mask, in the spec's own order and, for the knobs the app
// also has frame-wide, in the app's own formulas: exposure first (a power of
// two, so a stop is a stop), then contrast about the middle, then saturation as
// a mix away from the pixel's own REC-709 luma. Then the spec's section 2 and 3
// on top — HIGHLIGHT and SHADOW through the two smoothstep soft masks its own
// formula names, WHITE and BLACK as the per-channel point moves TONE_SKSL
// makes, and the two spatial ones against the blurred reference the caller hands
// in. Every one of them means inside the mask what it means on the whole frame
// (toneShader.ts is the reference the four tonal ones are written from), because
// the same name on the same knob should not be two different moves. The result
// is clamped to the range a file can hold — the spec's own guard, and it is
// `mix`ed back over the base by the mask's alpha, so a mask at half strength is
// half of the move rather than the whole of it.
//
// `blur` is the frame's bilateral reference (the same one CLARITY uses
// frame-wide) and `air` the atmospheric light; both are the constants 0 when the
// shader was built without the second child, and then the two spatial knobs are
// left out of the pass — the caller only builds that child for masks that ask
// for them (masksHaveSpatial). `dark` is the patch's dark channel for DEHAZE,
// read in the block below (the frame's own pixels are only in reach there).
const adjustFn = (spatial: boolean) => `
half3 maskAdjust(half3 c, float4 a, float4 tone, float4 fx, half dark${spatial ? ', half3 blur, float3 air' : ''}) {
c = c * half(pow(2.0, a.x));
c = (c - half(0.5)) * half(1.0 + a.y) + half(0.5);
half l = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722));
// The tonal four, in TONE_SKSL's own formulas: a mask's HIGHLIGHT is meant to
// be the same move as the whole-frame HIGHLIGHT on a smaller area, not a
// second opinion about what the name means. HIGHLIGHT and SHADOW are additive
// shifts of the luma, HIGHLIGHT weighted by the headroom left (1 - l) so it
// cannot drag a blown white to grey, and the colour difference rides along at
// a damped gain (TONE_SKSL's own cg) so a lift or a pull cannot collapse a
// colour. WHITE and BLACK are per-channel point moves, cubic in each channel's
// own distance from the end it owns: the toe and the shoulder move, the
// midtones do not, and a white that is lowered stays white.
// The spec's soft masks, computed in float and narrowed: smoothstep on half is
// one more type the shader does not have to guess at.
float lf = clamp(float(l), 0.0, 1.0);
float mh = smoothstep(0.50, 1.00, lf);
float ms = 1.0 - smoothstep(0.00, 0.55, lf);
half lifted = half(clamp(lf + tone.x * mh * (1.0 - lf) + tone.y * 0.34 * ms, 0.0, 1.0));
half cg = clamp(lifted / max(l, half(0.0004)), half(0.55), half(1.35));
c = clamp(half3(lifted) + (c - half3(l)) * cg, half3(0.0), half3(1.0));
half3 dk = half3(1.0) - c;
c = clamp(c + half(tone.w * 0.18) * dk * dk * dk + half(tone.z * 0.18) * c * c * c, half3(0.0), half3(1.0));
half nl = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722));
c = mix(half3(nl), c, half(1.0 + a.z));
${spatial ? ` // DEHAZE before CLARITY, the frame-wide order and for the frame-wide reason:
// sharpening haze only makes it read as detail.
if (fx.y != 0.0) {
// DEHAZE (doc section 3.2), DEHAZE_SKSL's own formula on the mask's pixels:
// dark is the patch's dark channel, the MINIMUM of min(r,g,b)/A over a
// neighbourhood — read by the block below, which is where the frame's pixels
// are — so a patch with anything genuinely dark in it is not haze and is left
// alone (the patch average this used to read called every patch hazy). Signed
// like the frame-wide knob: below zero t rises above 1 and the same
// expression puts the scattered light back.
half3 aa = half3(half(max(air.x, 0.05)), half(max(air.y, 0.05)), half(max(air.z, 0.05)));
half t = clamp(half(1.0 - fx.y * ${DEHAZE_MAX_OMEGA} * clamp(dark, half(0.0), half(1.0))), half(${DEHAZE_FLOOR_T}), half(${1 + DEHAZE_MAX_OMEGA}));
c = clamp((c - aa) / t + aa, half3(0.0), half3(1.0));
}
// CLARITY (doc section 3.1): the pixel against its own blurred surroundings.
// Positive sharpens. Negative SOFTENS toward that same reference — a masked
// CLARITY -10 is a soften of the mask's own detail, which is the property the
// knob is named for, and not the inverted unsharp it used to be (that only
// sank the mask's whites and left its contrast where it was).
// The reference is the bilateral one (CLARITY_BLUR_SPAN, 6% of the frame) and
// the frame-wide negative CLARITY mixes toward that same reference with the
// same factor (CLARITY_BLEND_SKSL), so the two readings of the knob are one
// move on two different areas.
if (fx.x > 0.0) {
c = clamp(c + half3(half(fx.x * ${CLARITY_GAIN.toFixed(1)})) * (c - blur), half3(0.0), half3(1.0));
} else if (fx.x < 0.0) {
c = mix(c, blur, half(clamp(-fx.x, 0.0, 1.0)));
}
` : ''} return clamp(c, half3(0.0), half3(1.0));
}
`;
// One unrolled block per mask, for the reason heal.ts unrolls its own: SkSL
// indexes a uniform array by constant only, so the shader is built for the count
// it is handed rather than for a capacity, and the renderer caches them by count
// (exportEngine's maskEffectFor). A linear mask's alpha is the spec's dot
// product, and its zero-length guard is the filter in readMasks rather than a
// branch here: a mask with no length is not a mask. A radial mask's alpha is
// 1 inside the feather and 0 at the rim.
const maskBlock = (i: number, spatial: boolean) => `
{
float a = 0.0;
if (adj[${i}].w < 0.5) {
float2 d = (rects[${i}].zw - rects[${i}].xy) * size.xy;
float len2 = dot(d, d);
float t = dot(pos - rects[${i}].xy * size.xy, d) / len2;
a = smoothstep(0.0, 1.0, clamp(t, 0.0, 1.0));
} else {
float2 st = (pos - rects[${i}].xy * size.xy) / size.x;
float ca = cos(-rads[${i}].z);
float sa = sin(-rads[${i}].z);
float2 r = float2(st.x * ca - st.y * sa, st.x * sa + st.y * ca);
float d = length(r / max(rads[${i}].xy, float2(1e-5)));
a = 1.0 - smoothstep(max(0.0, 1.0 - rads[${i}].w), 1.0, d);
}
if (a > 0.0) {
${spatial ? ` // The patch's dark channel for DEHAZE, one tap per DEHAZE_PATCH_STEP of
// the frame's width — the same fraction the frame-wide pass reads, so the
// mask's DEHAZE and the frame's are the same neighbourhood on any size of
// render. Skipped when the mask has no DEHAZE, so a CLARITY-only mask
// pays nothing for it.
half dark = half(1.0);
if (fx[${i}].y != 0.0) {
half3 aa = half3(half(max(air.x, 0.05)), half(max(air.y, 0.05)), half(max(air.z, 0.05)));
float stp = max(1.0, size.x * ${DEHAZE_PATCH_STEP});
for (int j = -${DEHAZE_PATCH_TAPS}; j <= ${DEHAZE_PATCH_TAPS}; j++) {
for (int k = -${DEHAZE_PATCH_TAPS}; k <= ${DEHAZE_PATCH_TAPS}; k++) {
half3 p = clamp(img.eval(pos + float2(float(k), float(j)) * stp).rgb, half3(0.0), half3(1.0));
dark = min(dark, min(min(p.r / aa.r, p.g / aa.g), p.b / aa.b));
}
}
}` : ' half dark = half(0.0);'}
c.rgb = mix(c.rgb, maskAdjust(c.rgb, adj[${i}], tone[${i}], fx[${i}], dark${spatial ? ', blurred.eval(pos).rgb, air.xyz' : ''}), half(a));
}
}
`;
// The pass. It reads the pixels the pipeline has already built — the grade, the
// grain, the vignette — and writes the local adjustments back over them, one
// mask at a time, so a mask over another mask reads the first one's result
// exactly as a stack of local adjustments does. It runs BEFORE HEAL on purpose:
// a repair laid inside a mask then borrows pixels that already carry the mask's
// light, which is what makes it match its surroundings, and a MOSAIC over a mask
// hides what the mask left. The frame the masks are of comes in as `size`, the
// same uniform HEAL and MOSAIC take. `spatial` adds the two things only CLARITY
// and DEHAZE read: the frame's own blurred reference as a second child, whose
// image is in the same coordinates the pass runs in, and the atmospheric light.
export function gradientMaskSkSL(count: number, spatial = false): string {
return `
uniform shader img;
uniform float4 rects[${count}];
uniform float4 rads[${count}];
uniform float4 adj[${count}];
uniform float4 tone[${count}];
uniform float4 fx[${count}];
uniform float4 size;
${spatial ? 'uniform shader blurred;\nuniform float4 air;' : ''}
${adjustFn(spatial)}
half4 main(float2 pos) {
half4 c = img.eval(pos);${Array.from({ length: count }, (_, i) => maskBlock(i, spatial)).join('')}
return c;
}
`;
}
+367
View File
@@ -0,0 +1,367 @@
// 35mm & 120 FILM GRAIN — the structures the marketing card promises, per
// stock: "authentic grain structures plus halation bloom, tuned per stock
// rather than one global overlay."
//
// The phone's grainShader.ts rolls the film and sizes the cell; this is the
// same film pulled through the lab's stock table. One grain field is still
// printed for the whole picture, but WHICH field — how coarse, how clumpy, and
// how much the highlights bleed back — is the emulsion's and its format's, not
// a constant.
//
// FORMAT. A 35mm frame is 24x36mm; a 120 frame is 6x6 or bigger — two to four
// times the linear area for the same print. So at one picture width the 35mm
// negative's grain prints COARSER and a 120 negative's FINER and smoother: the
// same emulsion is two different textures. `cell` is the format's share of the
// 1080-wide reference the GRAIN knob was tuned at — 1.0 is the 35mm baseline
// that ships today, so an existing 35mm recipe keeps the grain it has.
//
// STOCK. Within a format the emulsion sets the rest: how hard the grain clumps
// (`spread` and the octave `mix`), and how far the highlights bleed back as
// halation. Halation is RED because red is the light that passes the emulsion,
// bounces off the anti-halation backing and returns: the colour negative, whose
// red-sensitive layer sits deepest, halates hardest. Slides halate less. B&W has
// no colour layer to bleed, so it gets a weak, barely-warm grey. A sensor has no
// backing at all — LEICA's two looks are digital, so they carry none.
//
// The GRAIN knob stays the only control, exactly as it is today: its amount
// scales BOTH the grain's alpha and the halation's, so GRAIN OFF is still a
// clean frame and the OFF/WEAK/STRONG chips still mean 0/3/6. Nothing here
// moves a stock's look until grain is dialled in.
import type { BaseFilter } from '../types';
export type FilmFormat = '35mm' | '120';
export interface GrainStock {
format: FilmFormat;
/** The emulsion this entry stands for — the record behind the numbers. */
film: string;
/** Grain cell, as a share of the 1080-wide reference (see GRAIN_REF). */
cell: number;
/** Contrast of the noise, i.e. how hard the grain clumps. */
spread: number;
/** Octave weights of the grain field, fine → coarse. */
mix: [number, number, number];
/** Halation strength at GRAIN 10, 0..1. */
halation: number;
/** Halo radius in 1080-reference units (picture-relative, see grainCell). */
halo: number;
/** What the halo is made of: the highlight, tinted. */
haloRGB: [number, number, number];
}
// The baseline field the GRAIN knob was calibrated against, and the one the
// phone still prints: a fine octave carrying most of the structure, then its
// clumps at 2x and 4x. Coarser and clumpier (35mm), or the base octave opened
// up and the clumps pulled back (120's finer, smoother texture).
const CLUMPY: [number, number, number] = [0.55, 0.3, 0.15];
const SMOOTH: [number, number, number] = [0.62, 0.26, 0.12];
// Digital: a sensor has no emulsion and no backing — the grain the knob prints
// is the user's own choice, so it keeps the neutral baseline field and no
// halation. Both LEICA looks land here.
const SENSOR: GrainStock = {
format: '35mm',
film: 'digital sensor',
cell: 1,
spread: 2.95,
mix: CLUMPY,
halation: 0,
halo: 0,
haloRGB: [1, 1, 1],
};
export const GRAIN_STOCKS: Record<BaseFilter, GrainStock> = {
// --- 35mm: the consumer, press and cinema stocks ---
'classic-neg': {
format: '35mm',
film: 'colour negative, 35mm',
cell: 1.15,
spread: 3.1,
mix: CLUMPY,
halation: 0.45,
halo: 7,
haloRGB: [1, 0.18, 0.06],
},
'classic-chrome': {
format: '35mm',
film: 'colour slide, 35mm',
cell: 1,
spread: 2.95,
mix: CLUMPY,
halation: 0.25,
halo: 6,
haloRGB: [1, 0.22, 0.08],
},
'classic-vivid': {
format: '35mm',
film: 'colour slide, vivid, 35mm',
cell: 1,
spread: 2.95,
mix: CLUMPY,
halation: 0.3,
halo: 6,
haloRGB: [1, 0.2, 0.07],
},
eterna: {
format: '35mm',
film: 'motion-picture negative, 35mm',
cell: 1.1,
spread: 3.2,
mix: CLUMPY,
halation: 0.38,
halo: 6.5,
haloRGB: [1, 0.2, 0.07],
},
'mono-high-contrast': {
format: '35mm',
film: 'B&W, high contrast, 35mm',
cell: 1.25,
spread: 3.4,
mix: CLUMPY,
halation: 0.15,
halo: 4.5,
haloRGB: [1, 0.72, 0.55],
},
// --- 120: the pro emulsions whose reputation is the big negative ---
provia: {
format: '120',
film: 'Fuji Provia 100F, 120',
cell: 0.62,
spread: 2.5,
mix: SMOOTH,
halation: 0.18,
halo: 5.5,
haloRGB: [1, 0.24, 0.09],
},
velvia: {
format: '120',
film: 'Fuji Velvia 50, 120',
cell: 0.55,
spread: 2.35,
mix: SMOOTH,
halation: 0.3,
halo: 6,
haloRGB: [1, 0.2, 0.07],
},
astia: {
format: '120',
film: 'Fuji Astia 100F, 120',
cell: 0.62,
spread: 2.5,
mix: SMOOTH,
halation: 0.16,
halo: 5.5,
haloRGB: [1, 0.26, 0.1],
},
monochrome: {
format: '120',
film: 'Fuji Acros 100, 120',
cell: 0.72,
spread: 2.7,
mix: SMOOTH,
halation: 0.12,
halo: 4,
haloRGB: [1, 0.78, 0.62],
},
// --- digital ---
leica: SENSOR,
'leica-vivid': SENSOR,
none: SENSOR,
};
// The stock a base filter prints with. Every id is in the table, so the fallback
// is only there for a recipe that names a filter this build does not know.
export function grainStockFor(base: BaseFilter | string | null | undefined): GrainStock {
return (base && GRAIN_STOCKS[base as BaseFilter]) || SENSOR;
}
// The roll of film: one seed per page load, hashed into the noise's domain, so
// two visitors never print the same clumps while every render inside one
// session — the preview, the compare copy and the file — prints the same one.
export const GRAIN_SEED: readonly [number, number] = [Math.random() * 61.7, Math.random() * 43.9];
// Grain is sized against the PICTURE, never against the screen: one noise cell
// per 1/1080 of the picture's width, the size the GRAIN knob was tuned at. A
// stock scales that cell by its format and its emulsion (see GRAIN_STOCKS).
export const GRAIN_REF = 1080;
// The cell GRAIN_SKSL is handed for a picture of `pictureWidth` px: the stock's
// share of the reference, scaled by the SIZE knob (a percentage of the stock's
// own cell), and floored at one pixel because a cell smaller than the target's
// own pixel cannot be resolved — it prints as static instead of grain, which is
// aliasing, not a finer emulsion. The floor is handed to the shader too, so the
// field's own patch-to-patch mix cannot cross it.
export function grainCell(pictureWidth: number, stock: GrainStock, sizePct = 100, minCell = 1): number {
return Math.max((pictureWidth / GRAIN_REF) * stock.cell * (sizePct / 100), minCell);
}
// The clump count the panel READS OUT: how many clumps the design puts across an
// inch of a 300 dpi print, i.e. 300px of the GRAIN_REF frame. A statement about
// the stock and the SIZE knob, never about one patch of the frame — the field
// mixes two lattices ±ZONE_SWING/2 either side of it, patch by patch (see
// GRAIN_SKSL) — and never about the screen, so the same stock reads the same
// number in the preview and in the file.
export const GRAIN_DPI = 300;
export function grainPerInch(stock: GrainStock, sizePct = 100): number {
return Math.round(GRAIN_DPI / (stock.cell * (sizePct / 100)));
}
// The halo radius in px for the same picture: measured in reference units too,
// so a 120 stock's tighter grain cannot leave it a 35mm-sized halo.
export function halationSigma(pictureWidth: number, stock: GrainStock): number {
return Math.max((pictureWidth / GRAIN_REF) * stock.halo, 0.6);
}
// `u` is the cell, `seed` this session's roll, `mix`/`spread` the stock's own
// structure. The three octaves go COARSER only: a finer one lands under the
// pixel, the clumping is lost (rho(1) 0.275 -> 0, measured) and the field is
// static again. `spread` holds the AMOUNT knob on the spread it was tuned with.
//
// The field is JITTERED CLUMPS, not value noise. Value noise prints the density
// of the cell's four corners, so every clump sits on a knot of one square grid,
// and that grid's own repeat — 13 cells, 44px at the 35mm cell — is what the eye
// reads as diagonal lines. Here a clump lands at a random spot inside its cell
// instead: no two clumps share a grid, and the printed frame's off-origin
// autocorrelation falls to 0.03 from 0.44 (measured) — nothing left to tile. The
// hash behind the jitter is sin-free for the same reason: a float sinus whose
// argument grows with the picture folds back on itself, a lattice of its own.
//
// Emulsion is not ONE grain size across the frame: the coating settles unevenly,
// so the clumps run coarser in patches and tighter in others. The same hash read
// as a SLOW value noise is what draws those patches — one every 1/ZONE_FREQ
// cells, turned off the axes and smoothed, because a step at a patch border
// would print as a seam — and that weight MIXES two lattices, ZONE_SWING/2
// either side of the design cell, instead of warping one. A cell that varies
// with position is what the eye reads as a smear: the phase of a lattice built
// on cell(pos) accumulates as d(phase)/ds = 1/cell - s*cell'/cell^2, and that
// second term grows with the distance s from the picture's own origin, so the
// clumps are stretched wherever the patch's cell runs — measured on one
// classic-neg paint (1024px, cell 1.09, 64 tiles): swing on, the tiles' lag-1
// correlation spans -0.065..0.747, clumps drawn out into smooth blotches;
// swing forced to 0, the SAME field's tiles span -0.057..0.045. Two FIXED
// lattices cannot do that — their phase is linear in the picture, so a patch can
// only change how much of each is printed, never how either is shaped, and
// their beat falls at 1/(1/fine - 1/coarse) = 2.4 cells, 2.6px at the 35mm
// cell: the pixel scale, not a line. Neither the mean nor the spread moves:
// both lattices carry grainClump's own 0.5685 and the mix is renormalised by
// sqrt(w^2+(1-w)^2), so a coarser patch prints bigger clumps — the tiles span
// 3.2x in lag-1 correlation at the preview's cell 1.70, against 1.14x with the
// swing off — not a brighter or a harder one, which is why the panel can read
// out one number while the frame carries a range.
export const ZONE_FREQ = 1 / 96;
export const ZONE_SWING = 0.4;
// ponytail: the field is evaluated once per lattice, so the grain pass costs
// 1.94x what the single warped lattice did (700px field, software CanvasKit,
// measured). One evaluation cannot hold two cell sizes, so this is the price of
// the mix; revisit only if a preview budget asks for the pass back.
export const GRAIN_SKSL = `
uniform float u;
uniform float mincell;
uniform vec2 seed;
uniform vec3 mixw;
uniform float spread;
float grainHash(vec2 q) {
vec3 p3 = fract(vec3(q.x, q.y, q.x) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
// One clump per cell, at a random spot inside it; what is printed is the
// distance to the nearest, so the clump is a smooth mound, not one pixel of
// static.
float grainClump(vec2 p) {
vec2 i = floor(p);
vec2 f = p - i;
float near = 4.0;
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
vec2 g = vec2(float(x), float(y));
vec2 c = g + vec2(grainHash(i + g), grainHash(i + g + vec2(19.19, 7.77))) - f;
near = min(near, dot(c, c));
}
}
return 1.0 - min(sqrt(near), 1.0);
}
// The patches of grain size: the same hash read slowly, smoothed so a border
// between two patches is a slope and not a step.
float grainZone(vec2 p) {
vec2 i = floor(p);
vec2 f = p - i;
f = f * f * (3.0 - 2.0 * f);
return mix(mix(grainHash(i), grainHash(i + vec2(1.0, 0.0)), f.x),
mix(grainHash(i + vec2(0.0, 1.0)), grainHash(i + vec2(1.0, 1.0)), f.x), f.y);
}
// The stock's own field: three octaves, no two of them on the same grid.
// Each is turned to its own angle — 20, 47, 73 degrees — and sits on its own
// rung of the ladder, 1 / 0.53 / 0.29, off the dyadic 1 / 0.5 / 0.25 where
// the coarse octaves' cells land back on the fine one's and stack. q is in
// CELLS, so one text serves both lattices below.
float grainField(vec2 q) {
return grainClump(mat2(0.9397, -0.3420, 0.3420, 0.9397) * q) * mixw.x
+ grainClump(mat2(0.6820, -0.7314, 0.7314, 0.6820) * q * 0.53 + vec2(13.7, 7.3)) * mixw.y
+ grainClump(mat2(0.2924, -0.9563, 0.9563, 0.2924) * q * 0.29 + vec2(4.1, 27.9)) * mixw.z;
}
vec4 main(vec2 pos) {
vec2 p = pos.xy / max(u, 0.0001) + seed;
// This patch's weight, 0..1: the coarse lattice where the coating settled
// heavy, the fine one where it settled tight.
float w = grainZone(mat2(0.9397, -0.3420, 0.3420, 0.9397) * p * ${ZONE_FREQ.toFixed(6)});
// The two lattices the patch mixes, never under the floor — under it the
// clumps are sub-pixel and print as static, which is aliasing, not a finer
// emulsion.
float fine = max(u * ${(1 - ZONE_SWING / 2).toFixed(2)}, mincell);
float coarse = max(u * ${(1 + ZONE_SWING / 2).toFixed(2)}, mincell);
float a = grainField(pos.xy / fine + seed);
float b = grainField(pos.xy / coarse + seed);
// Back onto the field the AMOUNT knob was calibrated on: clumps sit higher
// and tighter than the value noise they replace (mean 0.569 against 0.500,
// sigma 0.123 against 0.081, measured), so the sum is put back on that mean
// and that spread before the knob's own gain is applied. The two lattices
// are independent, so the blend carries sqrt(w^2+(1-w)^2) of one field's
// spread and that is taken back out with it — patch size must not read as
// patch contrast.
float n = (w * (a - 0.5685) + (1.0 - w) * (b - 0.5685))
* inversesqrt(w * w + (1.0 - w) * (1.0 - w)) * 0.52 + 0.5;
return vec4(vec3(clamp((n - 0.5) * spread + 0.5, 0.0, 1.0)), 1.0);
}
`;
// Flat uniform buffer for makeShader — declaration order above: u, mincell,
// seed, mix, spread. `minCell` is the floor grainCell() applied, so the field's
// own patch mix cannot take a lattice under it.
export function grainUniformArray(cell: number, stock: GrainStock, minCell = 1): number[] {
return [cell, minCell, GRAIN_SEED[0], GRAIN_SEED[1], stock.mix[0], stock.mix[1], stock.mix[2], stock.spread];
}
// HALATION: the highlight bleed the backing returns. Threshold first — a
// mid-tone never reaches the emulsion's scatter ceiling, so only what is going
// white bleeds — then the highlight is TINTED (the stock's halo colour, red for
// every colour emulsion) before the caller blurs it wide and screens it back.
export const HALATION_SKSL = `
uniform shader src;
uniform vec3 tint;
uniform float t0;
uniform float t1;
vec4 main(vec2 xy) {
vec4 c = src.eval(xy);
float luma = dot(clamp(c.rgb, 0.0, 1.0), vec3(0.2126, 0.7152, 0.0722));
return vec4(clamp(c.rgb * tint, 0.0, 1.0) * smoothstep(t0, t1, luma), c.a);
}
`;
// The print has to be near white before the backing sees anything: a touch
// higher than the HDF bloom's threshold, so a bright sky does not smear the
// frame the way a specular highlight does.
export const HALATION_T0 = 0.62;
export const HALATION_T1 = 0.92;
// What the halo is worth at GRAIN 10, over the stock's own strength: a screen
// blend of a blurred highlight is a strong move, so a halating stock lands
// under half alpha at full grain.
export const HALATION_MAX = 0.6;
export function halationUniformArray(stock: GrainStock): number[] {
return [...stock.haloRGB, HALATION_T0, HALATION_T1];
}
+324
View File
@@ -0,0 +1,324 @@
import type { HealSpot } from '../types';
// FX tab > HEAL — the dust brush, and the patch search behind it.
//
// A spot is a circle on the rendered photo plus the patch it borrows: the
// renderer copies the pixels at (sx, sy) onto (x, y), feathers the edge, and
// shifts what it pasted onto the light of the place it landed in, so a repair
// is a draw of the same picture rather than a blur over the dust. All five
// numbers are fractions of the render — x/y/sx/sy of its width and height, r of
// its width — which is what makes one set of spots survive the preview and the
// export rendering the same photo at two sizes, and keeps the circle round
// whatever the photo's shape.
//
// There is no ceiling on the list. The shader is built to carry exactly the
// spots the recipe holds (healSkSL), so a new repair can never push an old one
// out: the dust you healed first is the dust that stays healed.
//
// The source is SEARCHED for rather than asked for. Lightroom picks the patch
// and lets you drag the second circle afterwards; the search below is the same
// idea without the second circle, and it is a pure function of a sampler so a
// synthetic picture can hold it to account.
//
// Feather, as a fraction of the radius: inside it the patch is copied, outward
// it fades to nothing, so the circle leaves no rim of its own. It is the outer
// 15% and no more, because that band is the only place the dust being repaired
// is mixed back into the patch — a wide fade keeps the speck's own edge alive
// as a faint ring inside the circle, which is a blur of the dust rather than a
// repair. sub-pixel at the default brush, still a soft edge at a big one.
export const HEAL_FEATHER = 0.85;
// The brush's radius, as a fraction of the photo's width — where this tool
// starts, at the sensor-dust end, where a speck is a few thousandths of the
// frame. The bounds and the wheel that moves it between them belong to the
// brush both FX tools share (brush.ts).
export const HEAL_DEFAULT_R = 0.012;
// How far the search looks, in radii, and how many directions it looks in.
const SEARCH_DISTANCES = [2.6, 4.2, 6.5];
const SEARCH_DIRS = 8;
// The taps that stand for "the light around a spot": a ring just OUTSIDE the
// brush, where the dust being repaired is not — the scale the eye reads a
// spot's surroundings at. Reading the inside instead compares a candidate with
// the very dark the repair is trying to erase, and the patch that matches a
// speck best is then the one carrying a speck of its own — which is how a
// repair ends up moving dust a few pixels instead of removing it.
//
// Twelve taps read as their MEDIAN, not their mean. The ring can only be a
// little way out — far enough out and it would be reading a light the spot does
// not sit in — so a few of its taps land on the speck's own softened edge, or
// on a neighbour's: readings in the minority, which the median drops and the
// mean would drag the whole light down by.
const RING_R = 1.15;
const RING_TAPS: [number, number][] = Array.from({ length: 12 }, (_, i) => {
const a = (i / 12) * Math.PI * 2;
return [Math.cos(a), Math.sin(a)] as [number, number];
});
// The taps that decide whether a candidate patch is clean: the inside of the
// patch, held against the patch's own mean. Dust is an outlier in its own
// neighbourhood; skin, however grainy, is not. The last ring is the patch's own
// edge — the circle is copied at full strength out to HEAL_FEATHER of its
// radius, so that is where a neighbour's dust leaking into the patch shows up,
// and the middle of the patch would never see it.
const INSIDE_TAPS: [number, number][] = [
[0, 0],
[-0.5, 0],
[0.5, 0],
[0, -0.5],
[0, 0.5],
[-0.35, -0.35],
[0.35, -0.35],
[-0.35, 0.35],
[0.35, 0.35],
...Array.from({ length: 8 }, (_, i) => {
const a = (i / 8) * Math.PI * 2;
return [Math.cos(a) * HEAL_FEATHER, Math.sin(a) * HEAL_FEATHER] as [number, number];
}),
];
// How far a candidate's light may sit from the spot's own before it is refused
// outright, in the 0-255 levels the sampler answers in. Sending a patch from a
// different light is exactly what makes a repair show up as a mark of its own —
// a light square where dark ground was, a dark one where light was — so past
// this the search gives up and the caller leaves the speck alone. The user sees
// an untouched speck, which is honest, and tries another size or another spot.
const LIGHT_GATE = 20;
// Light outweighs cleanliness in the score, so the winner is the same light
// first and the cleaner patch second; within the gate the cleanliness term only
// breaks ties between patches the eye would call the same.
const LIGHT_WEIGHT = 3;
const num = (v: unknown, fallback: number) => {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
};
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
// The stored spots, made readable: numbers, inside the frame. Everything below
// reads a recipe through this, so a hand-written or older file cannot produce a
// spot the brush and the renderer disagree about.
export function readHeal(heal: HealSpot[] | undefined): HealSpot[] {
if (!Array.isArray(heal)) return [];
return heal
.map((s) => ({
x: clamp01(num(s?.x, 0)),
y: clamp01(num(s?.y, 0)),
r: Math.max(0, num(s?.r, 0)),
sx: clamp01(num(s?.sx, s?.x ?? 0)),
sy: clamp01(num(s?.sy, s?.y ?? 0)),
}))
.filter((s) => s.r > 0);
}
// The uniform block the shader for `n` spots reads: the circles, the patches,
// then the frame the fractions are of. Declaration order, arrays expanded —
// that is how the runtime effect wants its uniforms, and one buffer is one
// upload per render. Its length is a function of the list, not a fixed
// capacity, because the shader carries exactly the spots the recipe holds.
export function healUniforms(spots: HealSpot[], width: number, height: number): Float32Array {
const list = readHeal(spots);
const n = list.length;
const u = new Float32Array((n * 2 + 1) * 4);
for (let i = 0; i < n; i++) {
const s = list[i];
u.set([s.x, s.y, s.r, 0], i * 4);
u.set([s.sx, s.sy, 0, 0], (n + i) * 4);
}
u.set([width, height, 0, 0], n * 2 * 4);
return u;
}
// The ring the pasted circle is matched at, and how its taps are laid out.
// Measured just OUTSIDE the brush — RING_R of its radius, where the search
// reads a spot's light too — because that is the only ring with nothing of the
// repair in it: inside the circle is the borrowed patch at one radius and the
// speck being covered at another, and a light read off either of those is the
// dust talking rather than the photo. Outside, both sides are photographs — the
// ground the repair has to sit in, and the ground the patch was borrowed from.
// Sixteen taps are spread around it, at the SAME sixteen places on both sides,
// so a tap's pair of readings differ by the light between the two places and
// not by where in the photo they were taken. Sixteen rather than eight because
// a tap that lands on an edge bends its own direction only as far as the next
// ring tap: the finer the ring, the less of the rim one hard pixel of the photo
// can reach around.
const BLEND_TAPS = 16;
// ...and one pixel further out again than RING_R. The ring is read by a pixel
// that lies under the brush, so the reading has to clear the dust's own edge,
// and that edge is soft: a speck r pixels across spreads about a pixel past
// where it is drawn, in the pixels the shader samples. RING_R alone leaves the
// nearest tap or two inside that spread, and a tap that lands on the dust is
// the dust's light — which is exactly the blotch this reading exists to avoid.
// One pixel is the width of the spread, in the same pixels the sampler works
// in, so it is a pixel here rather than a fraction of the radius: a fraction
// would be nothing at all at the sensor-dust end of the brush.
const RING_PAD = 1;
// ...but one light for the whole circle is a light no ring has when the ring is
// not all one thing. A twig against the sky, a hairline, the edge of a table
// under the brush: a few taps of the ring land on the thing and the rest on the
// ground, and the MEAN of them all is a colour that is neither — a level the
// place does not have, pasted over the whole circle. The donor can be of exactly
// the right light and still come out as a blotch, which is the mark the repair
// was supposed to stop leaving. So the light is read once PER DIRECTION, and a
// pixel takes the correction of the two readings it lies between, interpolated
// by its own angle around the spot: the rim then meets the place all the way
// round instead of on average, and the taps that landed on the twig correct the
// part of the rim that is near the twig rather than the whole of it. It is the
// same taps and the same one draw — each pixel reads them at its own angle
// instead of sharing everyone's single number.
// One unrolled block per spot. SkSL indexes a uniform array by constant only
// (see TONE_SKSL's mixer), so the spots are written out rather than looped, and
// the shader is built for the count it is handed rather than for a capacity —
// that is what lets the list be uncapped. A count costs one RuntimeEffect to
// compile, so the renderer caches them by count (exportEngine's healEffectFor).
//
// What is pasted is the patch's own pixels SHIFTED onto the light of the place
// it lands in — the gradient of the borrowed texture, carried at the level of
// the destination: the border is made to match, and everything inside keeps the
// texture it was borrowed for. That is the whole of "seamless" the brush needs:
// at the rim the patch sits within a level or two of the photo around it,
// instead of up to LIGHT_GATE levels off it, so the repair stops reading as a
// soft blotch of its own and the feather has almost nothing left to hide. The
// correction is read per direction around the ring, so the border is matched
// where it is rather than on average, and the pixels of a twig that run through
// the ring bend the part of the rim they are near instead of the whole circle.
//
// ponytail: the correction lives on the rim — every pixel takes the two ring
// readings it lies between and interpolates — so it is the boundary of a Poisson
// edit and not its interior: a repair laid over something the patch cannot
// reproduce still carries the patch's own texture inside, bent to fit the rim.
// The other half of the solve (a correction that relaxes inside the circle, a
// Jacobi ping-pong per spot) lands when a real photo shows an interior the eye
// can find from the rim alone. A direction's level is also one tap pair on each
// side, so the grain the mean used to average away now rides the rim as a wedge
// of a level or two across the circle — a tangential average of three taps per
// direction is the rung for that, when a photo shows the wedge.
const spotBlock = (i: number) => `
{
float4 s = spots[${i}];
if (s.z > 0.0) {
float rad = s.z * size.x;
float d = distance(pos, s.xy * size.xy);
if (d < rad) {
float4 t = srcs[${i}];
float2 pd = s.xy * size.xy;
float2 ps = t.xy * size.xy;
float r = rad * ${HEAL_FEATHER};
float rr = rad * ${RING_R} + ${RING_PAD.toFixed(1)};
// Which way round the spot this pixel sits, one millionth of a pixel off
// the exact centre so the angle is defined there too.
float ang = atan(pos.y - pd.y + 1e-6, pos.x - pd.x + 1e-6);
half3 corr = half3(0.0);
float wsum = 0.0;
for (int k = 0; k < ${BLEND_TAPS}; k++) {
float a = float(k) * 6.283185307 / float(${BLEND_TAPS});
float2 o = float2(cos(a), sin(a)) * rr;
// How far this pixel's angle is from this tap's, the short way round.
float da = ang - a;
da = abs(da - 6.283185307 * floor(da * 0.1591549431 + 0.5));
float w = max(0.0, 1.0 - da * float(${BLEND_TAPS}) * 0.1591549431);
corr += half(w) * (img.eval(pd + o).rgb - img.eval(ps + o).rgb);
wsum += w;
}
corr *= half(1.0 / wsum);
half m = half(1.0 - smoothstep(r, rad, d));
c = mix(c, img.eval(pos + ps - pd) + half4(corr, 0.0), m);
}
}
}
`;
// The pass. It reads the pixels the pipeline has already built (the child is a
// snapshot of the surface) and writes the borrowed patches back over them, so a
// repair is one draw: no blur, no smoothing, and the grain and the frame land
// on top of it afterwards exactly as they land on the rest of the photo.
export function healSkSL(count: number): string {
return `
uniform shader img;
uniform float4 spots[${count}];
uniform float4 srcs[${count}];
uniform float4 size;
half4 main(float2 pos) {
half4 c = img.eval(pos);${Array.from({ length: count }, (_, i) => spotBlock(i)).join('')}
return c;
}
`;
}
// The patch to borrow for a spot at (x, y) of radius r, from a sampler that
// answers fractions of the same photo. The candidates are a ring of offsets in
// eight directions at three distances — the patch has to be far enough that the
// dust is not in it, near enough that the light is the same — plus each one
// mirrored through the spot, which is the pair Lightroom's own auto-source
// leans on.
//
// Each candidate is asked two questions. "Is this the same light?" holds the
// mean of what would be pasted against the mean of the spot's own ring, the
// pixels just outside the dust — the light the repair has to sit in, read where
// the dust is not, because comparing against the spot's own inside compares a
// candidate with the very dark the repair is trying to erase, and the patch
// that matches a speck best is then the one carrying a speck of its own. "Is
// there dust on it?" holds the candidate's inside against its own inside mean:
// dust is an outlier in its own neighbourhood, grain is not. A candidate whose
// light is off by more than LIGHT_GATE is not scored at all — a patch from the
// wrong light is a mark of its own, and moving it a few pixels for a wrong
// clone is not a repair.
//
// Returns null when the frame is too small to hold any candidate, or when every
// candidate is refused: the caller then leaves the speck where it is rather
// than pasting a patch it cannot stand behind.
export function findHealSource(
sample: (fx: number, fy: number) => { r: number; g: number; b: number },
x: number,
y: number,
r: number
): { sx: number; sy: number } | null {
if (!(r > 0)) return null;
const inside = (cx: number, cy: number) => cx - r >= 0 && cx + r <= 1 && cy - r >= 0 && cy + r <= 1;
const rgb = (v: { r: number; g: number; b: number }) => (v.r + v.g + v.b) / 3;
// The light the repair has to sit in: the median of the spot's ring, where
// the dust is not. Measured once, because it is the same number for every
// candidate.
const ring = RING_TAPS.map(([dx, dy]) => rgb(sample(clamp01(x + dx * RING_R * r), clamp01(y + dy * RING_R * r)))).sort(
(a, b) => a - b
);
const target = (ring[RING_TAPS.length / 2 - 1] + ring[RING_TAPS.length / 2]) / 2;
// The score of one candidate: null when it is refused for its light, else how
// far off that light is times its weight, plus how much the patch varies
// inside itself.
const score = (cx: number, cy: number): number | null => {
let content = 0;
const inside: number[] = [];
for (const [dx, dy] of INSIDE_TAPS) {
const v = rgb(sample(clamp01(cx + dx * r), clamp01(cy + dy * r)));
inside.push(v);
content += v;
}
content /= INSIDE_TAPS.length;
const light = Math.abs(target - content);
if (light > LIGHT_GATE) return null;
let dirty = 0;
for (const v of inside) dirty += Math.abs(v - content);
return light * LIGHT_WEIGHT + dirty / INSIDE_TAPS.length;
};
let best: { sx: number; sy: number; score: number } | null = null;
for (let d = 0; d < SEARCH_DIRS; d++) {
const a = (d / SEARCH_DIRS) * Math.PI * 2;
for (const dist of SEARCH_DISTANCES) {
const cx = x + Math.cos(a) * dist * r;
const cy = y + Math.sin(a) * dist * r;
for (const [px, py] of [
[cx, cy],
[2 * x - cx, 2 * y - cy],
]) {
if (!inside(px, py)) continue;
const s = score(px, py);
if (s === null) continue;
// A tie keeps the earlier candidate: the ring is walked from the right,
// so the patch nearest the spot wins — the one most likely to share its
// light.
if (!best || s < best.score) best = { sx: px, sy: py, score: s };
}
}
}
return best ? { sx: best.sx, sy: best.sy } : null;
}

Some files were not shown because too many files have changed in this diff Show More