253 Commits

Author SHA1 Message Date
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
admin 3320513d7a Bundle only the icons the app renders
Lucide ships one module per icon, but the barrel import dragged all ~1600 in.
A local Babel plugin rewrites each named import to its own icon module, so the
bundle drops from 4.66 MB to 3.40 MB and the earliest tap that reliably lands a
photo moves from ~1.8 s to ~1.6 s on the test device.
2026-09-17 10:11:12 +07:00
admin 76cf4c6d7e Cut 1.2.3 with a shutter that never waits on GPS
Bound the geotag fix with a 2 s deadline and prewarm it at launch, queue a
shot pressed before the camera session binds and replay it, pass the options
argument capturePhotoToFile requires, save through the native path so the
media permission is not needed, and release the Skia surface and bitmap after
every export. Store the JS bundle uncompressed and load the export pipeline on
demand. Note in RELEASE_NOTES.md that none of this moved the tap threshold.
2026-09-17 10:02:35 +07:00
admin d07dd69f17 Cut the store build as 1.2.2 (versionCode 4) 2026-09-16 19:03:52 +07:00
admin c469d9be81 Let R8 optimize the release dex, not just shrink it 2026-09-16 18:54:49 +07:00
admin 13ad52a975 Move the flash control into the top bar 2026-09-16 18:21:14 +07:00
admin c40b5565d3 Fire the LED on the 0.5x still
0.5x is a bare Camera2 session, so CameraX never saw the flash setting and the ultra-wide still came off unlit. The native module now reads FLASH_INFO_AVAILABLE off the lens, maps off/auto/on to CONTROL_AE_MODE plus FLASH_MODE_SINGLE, and starts an AE precapture so the HAL can meter the flash before the frame. The viewfinder hands its flashMode down beside the shutter, with a Zap control that only appears where a unit exists.
2026-09-16 18:11:17 +07:00
admin 12d58ff810 Build the bundle Play's billing needs 2026-09-16 17:24:48 +07:00
admin 5a5c4b0d11 Let the store take PRO back, and finish every purchase 2026-09-16 17:24:48 +07:00
admin 7b013fb6f8 Move the CROP band, not the picture
Library CROP now resizes the band in photo fractions, the way FREE always
did, instead of panning a re-scaled photo under a screen-sized rectangle:
what the band encloses is the export, with no zoom transform left to undo
and no aspect that only holds on a square photo. The band is laid out
below the top strip, whose height TopBar now reports, so it can never
peek under the header. The grain hashes a cell of width/1080 instead of
the raw pixel grid, so a 4000px export is no longer ~4x finer than the
preview that tuned it.
2026-09-16 17:17:36 +07:00
admin 8f615f6cd7 Give ACRIPES a straight BT.709 ramp and end-only tone 2026-09-16 17:17:11 +07:00
admin a5e7162561 Relay share intents through a headless activity 2026-09-16 17:17:10 +07:00
admin 78420002d1 Correct what the volume keys do behind a modal 2026-09-16 10:26:38 +07:00
admin e1ce7af76f Shoot from the volume keys, while the camera is up 2026-09-16 10:18:25 +07:00
admin 78630d8a2d Bias the 0.5x lens, since its feed has no frame pipeline
The ultra-wide preview is a bare Camera2 surface: it never goes through
Skia, so the software EV gain that carries exposure on the 1x preview had
nothing to act on and quick EV / the LIGHT-EV slider did nothing at 0.5x.
The lens itself now takes the bias, as CONTROL_AE_EXPOSURE_COMPENSATION on
every preview request (session builder, metering rebuilds, and a setEv call
the viewfinder pushes whenever the value changes).

The still is left alone: capture() pins the compensation back to 0 and
waits on an AE precapture trigger, so the file stays unbiased and the export
applies its software gain exactly once, as it does for the 1x path. The
hardware range clamps the bias where the lens offers less than the app's
+/-3 EV.
2026-09-16 07:41:38 +07:00
admin 39487ffd67 Park the quick-EV readout against its track
The EV value sat 52dp clear of the AE-lock slider it labels, on the far side of the thumb travel. 8dp now.
2026-09-16 07:08:37 +07:00
admin a85ebf0a81 Sell PRO as an in-app unlock, take shared photos, and let FX run negative
- One free Play app (com.locphamtran.recipescamera) with an expo-iap PRO
  unlock, replacing the paid apk for the store channel
- Android SEND/SEND_MULTIPLE share target feeds photos into the editor,
  on cold start and on resume
- BACK button in the TopBar and press-and-hold PEEK replace the chip row
- NOISE REDUCTION and SHARPENING accept -10: a negative SHARPENING softens
  the frame before the grain pass, a negative NOISE REDUCTION re-grains it,
  in the Skia engine, the live preview and the Kotlin export path alike
2026-09-16 06:48:22 +07:00
admin 27a9dfb65f Ship HDF EFFECT to PRO only, and clean the build for Play
Rename LIGHT GLOW to HDF EFFECT and gate it behind PRO: LITE renders the
chip greyed out with a PRO tag and cannot open its slider; restoration of
older settings that carry hdf > 0 still blocks export in LITE.

Play/licence pass:
- drop unused permissions (ACTIVITY_RECOGNITION, READ_MEDIA_AUDIO/VIDEO,
  SYSTEM_ALERT_WINDOW) and requestLegacyExternalStorage
- rename the Leica sims to LC STREETLIFE CLASSIC/VIVID, drop the brand from
  comments; fix app name text, canvaskit copyright, LICENSE owner
- remove expo-image-picker and expo-sensors (no JS import), delete dead
  exports (computeMatrixTint, cinemaSeasonName, base64 helpers,
  formatCoordinate)
- versionCode 3 / versionName 1.2.1, drop the personal email from About
- add docs/privacy-policy.html for the Play listing

Size: R8 + shrinkResources + bundle compression + ABI trim, 161 MB -> 76 MB.
2026-09-15 11:36:52 +07:00
admin 42756ac7c3 Cap LITE at one FAVORITED recipe
Freeing the FAVORITED tab for LITE put no ceiling on the list, and a list
of your own picks is what PRO is selling. One star is free, the second
names the build; un-starring stays open so the list can always come back
under the limit.
2026-09-14 21:20:56 +07:00
admin 06d7e8f384 Give LITE the bundled recipes, three of its own, and its own mark
LITE used to hand back the RECIPESCAM mark instead of the user's own
watermark and treated every saved recipe as PRO. That is the wrong half of
the product: the looks the user dials in are the reason to shoot, the
printing and the paid extras are the reason to pay.

What LITE now owns outright: the eight film sims, the bundled recipes, up
to three recipes of its own, its own watermark text (in one basic face,
the platform families stay PRO), the plain frames, and FAVORITED. What
needs PRO: the three printed frames, the GPS stamp, the other fonts, and
the next recipe past the limit.

- entitlement: `Look` is {frame, gpsWm}; `isFreeRecipe` is gone, the
  recipe gate is a count now, decided in App (`liteSaveBlocked`).
- export: a LITE export draws the custom mark AND the RECIPESCAM mark, so
  `liteMark` joins `watermark` in the options and the stamp block loops
  over both with one set of bounds.
- panel: past the limit the strip's trailing chip is a `+` carrying the
  PRO tag, which names the build instead of opening the form.
2026-09-14 21:01:13 +07:00
admin 499e61644d Tag PRO-only chips in LITE with a corner PRO marker
LITE lets you pick any preset, frame or watermark and preview it; only the
export is held back, and nothing on screen said which chips those were until
you hit the alert. Each such chip now carries a small amber PRO tag at its
top-right corner.

ChipDef gains an optional `pro` flag, set on the three printed frames, on
recipe chips (bundled or user-made, via isFreeRecipe), and on the custom
watermark chip. The tag renders only where `!IS_PRO`, so it is invisible in
the PRO apk.
2026-09-14 20:31:07 +07:00
admin 1b60ca2f22 Drop the in-app unlock key: only the PRO apk opens PRO
The set is two apks and the unlock key was a second, weaker lock on the
same door. A verifier that runs offline has to carry its secret inside
the lite apk, so anyone who unpacked it could mint a key. Nothing in the
lite build can flip the gate now.

- entitlement.ts keeps the gate (which look is PRO, what an export
  carries in lite) and exports IS_PRO straight from the build flag.
- SETTINGS loses the key field; the plan chip still says LITE / PRO
  ACTIVE and the helper text says what lite cannot export.
- The upsell alerts just name the build that has the feature.
- tools/keygen.mjs goes with the verifier it fed.
2026-09-14 20:09:12 +07:00
admin 97949e449d Ship LITE and PRO, and let CLARITY and LIGHT GLOW reach the library
Both flavors build from one JS tree: `lite` hands out the free apk
(com.locphamtran.recipescamera), `pro` sells as-is (same id + .pro, label
"RecipesCam Pro"). Release is signed from android/keystore.properties, both
files gitignored - lose either and no later build can install over this one.
The tier reaches JS through src/provariant.ts, which tools/set-variant.mjs
rewrites before each build (`npm run apk:lite` / `apk:pro`); the gradle flavor
alone would ship a Pro apk that thinks it is Lite.

The dev scaffolding goes: the three src/dev probes and their App effects, the
EXPO_PUBLIC_CAPTURE_MODE [CAPTURE] timing log, and the -PdevtestSuffix
side-by-side install hook. 'balanced' stays the shipped capture mode, which is
what those probes measured on the 12S Ultra.

Looking at the picture: WB now converts kelvin through the Planckian locus into
a luma-normalised gain (unity at 5500K, symmetric tint) instead of the old
one-sided push; LEITZ STREETLIFE splits into CLASSIC and VIVID; selecting a
recipe loads every knob it carries over the defaults and stays editable, and
RESET hands the panel back to the sim's own values. The in-app library picker,
the ultra-wide native module and recipe share/import land here too.

CLARITY and LIGHT GLOW existed only on the camera worklet and the export
engine, so a photo opened from the library ignored both. They now run in all
three library preview branches (libPhoto), and the preview clips them to the
drawn image - the export engine always clipped its bloom to the canvas, the
preview drew it over the whole rect, so the bloom bled past the photo onto the
black letterbox and over the frame's mat.
2026-09-14 19:48:44 +07:00
admin 99405514c6 Library picker: window the grid on the target date instead of walking to it
Dragging the date bar fired a window query every 120ms while one query takes
over a second on a 12S Ultra, so ~20 queries queued behind each other, the
grid froze for the length of the drag and the stale one landed last - the
grid settled on a date the finger had long left.

The window now opens on both sides: a page above the target, so a list parked
mid-library still has content above it and a downward drag does not die on
offset 0, plus the target page and everything older. jumpTo coalesces the
drag into one query and parks the grid on the row the target date sits on;
scrolling back up to the top pages newer photos in and corrects the offset by
hand, because maintainVisibleContentPosition keeps the offset glued to index 0
and walked the grid forward in time as it paged.
2026-09-13 22:14:36 +07:00
admin 1f8b72ec8c Let keygen mint the key for a payload you pick with --from 2026-09-12 13:43:08 +07:00
admin 3e9dcc40fb Rebuild the metering overlay from the peeked values so compare restores the look 2026-09-12 13:43:08 +07:00
admin 59e2719245 Keep LITE exports clean of PRO looks, and gate CREATE, FAVORITED and + SAVE behind PRO
LITE previews every look and feature but export/render is blocked while a PRO
look is in use, and every LITE export carries the RECIPESCAM mark. Keys are
generated offline (FNV-1a checksum over a fixed secret, 16 chars from a
no-ambiguity alphabet) and activate one machine or a thousand: nothing is bound
to a device, so revoking a leaked key needs Play Billing plus server validation.

CREATE, FAVORITED, the star chip and the TopBar + SAVE now answer with a PRO
prompt instead of a silent no-op; CREATE still drafts and applies the look so
LITE can try it, it just cannot persist.

The SETTINGS card also lifts above the keyboard now: RN Modal is its own dialog
window, so the IME never resizes or pans it and the UNLOCK row was sitting under
the keys.
2026-09-12 11:21:14 +07:00
admin a662674478 Show the pre-edit values while the finger holds, and pass the FRAME tab flag
Pressing and holding on the picture with the LIGHT/WB/FX panel open now
rewinds the last edit, and the panel rewinds with it: the chip numbers and
the open slider read the same pre-edit snapshot, so before and after can be
compared by eye and not just by looking at the picture. Only the displayed
values move -- the stored adjustments, RECIPE and the export keep the real
ones, and a gap longer than PEEK_GAP_MS starts a new snapshot for the next
gesture.

Also pass frameTabActive, without which the FRAME preview zoom added in
Viewfinder.tsx could never turn on.
2026-09-12 08:51:43 +07:00
admin 98b8021f3b Show the picture with the last edit undone while the finger holds it
Pressing and holding on the photo with a LIGHT/WB/FX panel open asks App
for a preview drawn without the edit that gesture just made; lifting the
finger asks for the edited look back. Pegged to one gesture, not one
pointer-move: App only re-snapshots the 'before' state after a 700ms
pause, so the whole drag compares against its own starting point.

Preview-only - the sliders, RECIPE and the export keep reading the real
adjustments. Moving past TAP_SLOP, a second finger, or a release all end
the peek, and a peek is not a tap, so tap-to-focus and double-tap zoom
are untouched.
2026-09-12 08:13:21 +07:00
admin 93b5b664d4 Save into DCIM/Camera below API 29 too, not just the expo DCIM root 2026-09-12 07:51:12 +07:00
admin 1d84fe8a4a Zoom the FRAME preview instead of the photo, and save to DCIM/Camera
- FRAME tab now pinches the whole view (frame + photo + watermark) in both
  camera and library; one finger pans that view. Preview only: the exported
  file is identical at 1x and while the view is magnified.
- Watermark pinch/drag stay relative and keep priority while the WATERMARK
  row is open, so they still work after returning from the FRAME tab.
- Exports go through a native MediaStore insert into DCIM/Camera, falling
  back to expo-media-library when the native save fails.
2026-09-11 23:09:26 +07:00
admin 40c91d2a75 feat(gps): a Settings GPS switch that reads the location; the watermark chip only prints it
The watermark chip used to be the only GPS control, so turning the watermark
off also stopped the photo from carrying coordinates. Split the two: a GPS
master switch in Settings decides whether the location is read at all, and the
chip decides whether it is drawn on the photo.

0x889d is also written as UTF-8 now — a place name with accents ("TAM KỲ, ĐÀ
NẴNG") was going out latin-1 and reading back as mojibake.
2026-09-11 20:08:59 +07:00
admin ea9ff01a22 feat(exif): name the capture device and place (0x9a00/0x0110/0x889d) for the Gallery frame
The framing tool matches Make/Model against its known-device list and prints
the place name; a CameraX capture only carried the raw model codename
(2203121C) and no 0x9a00/0x889d, which is what a stock camera photo always
has. State the market name plus the geotag locality, and leave a source that
already carries 0x9a00 untouched so a library photo of another device is
never relabelled.
2026-09-11 19:47:51 +07:00
admin 17ee01b62a feat(exif): write the Xiaomi watermark blob (0x889e) so HyperOS Gallery can frame our captures
HyperOS Gallery reads EXIF 0x889e (a JSON blob) to build its watermark
frame; without it a photo fails with "cannot recognize the parameters".
CameraX/HAL never supplies that vendor tag, so a capture from this app
lacked it while a stock-camera photo carried it.

Read back the same props the stock camera derives the blob from
(ro.product.device / ro.product.marketname / Build.MANUFACTURER) via the
native module, synthesize the blob in exifWrite, and write it only when
the source has none and the device is Xiaomi/Redmi/POCO - a source blob
is never overwritten.
2026-09-11 18:47:44 +07:00
admin 33123d47fd fix(exif): write a complete, standard EXIF block so framing tools can read it
Match the layout the sample camera file uses: a fourth Interoperability IFD
(0xa005 = R98/0100) and the baseline tags the emulator HAL omits (ExifVersion
0230, FlashPixVersion 0100, ComponentsConfiguration, ColorSpace), plus the
OffsetTime/OffsetTimeOriginal/OffsetTimeDigitized tags for shots this app
timestamps. Source-owned tags are still kept as-is.

Also fix swapToLittleEndian: Buffer.slice() aliases, so the big-endian swap was
mutating the caller's bytes (a Xiaomi source read back as ISO 36865 instead of
400). Use new Uint8Array(data).
2026-09-11 18:28:02 +07:00
admin 62b9735867 feat(exif): stamp RC_<ts> filenames + EXIF on capture and export 2026-09-11 17:59:44 +07:00
admin 6faef4aef7 feat(crop): two-step crop with APPLY + hidden amber border, persisted in session
- FRAME/CROP: choosing a ratio shows the amber band (border + 0.55 dim);
  APPLY collapses the preview to the crop rect with an opaque mask and
  removes the amber stroke; RESET returns to 'none'.
- Viewfinder: libCropView (k = min(vw/dw, vh/dh)) + cropScreenPx drive the
  mask/band, libViewMatrix folds the crop transform into the image groups,
  libCropTouchStyle keeps touch mapping aligned.
- Persist cropApplied in the session snapshot and restore it only when it
  still matches cropRatio.
- Add PLAN-2026-09-09.md with the measurements.

tsc --noEmit unchanged at 14 pre-existing errors.
2026-09-11 17:29:51 +07:00
admin 1b632ba78c feat(frame): read AUTO straighten from the photo, not the accelerometer
EXIF Orientation only knows 0/90/180/270, so a library still carries no
record of how the camera was held. The sensor had nothing to offer.

AUTO now measures the dominant line in the picture itself:
src/utils/horizon.ts runs a shear-projection search (coarse 1 deg over
-45..45, then a 0.5 deg refine) on a <=256px thumbnail, bails when no
line's score beats 3x the median, and returns the tilt in degrees.
App applies photoStraighten = -tilt, so preview and export share one
number exactly as the slider did.

Removes expo-sensors wiring, the horizonRoll state, effectiveStraighten
and the bubble-level overlay (autoRoll prop) from App/AdjustmentPanel/
Viewfinder. expo-sensors stays in package.json.
2026-09-11 14:46:28 +07:00
admin d6b3ebda40 fix(frame): take the bubble level off the image when leaving the strip
AUTO turns the amber level line on over the photo, but the line only left
when AUTO itself was switched off: switching to another frame chip, another
parameter or another rail tab kept it painted on the picture.

Gate the line on the ROTATE context instead: AdjustmentPanel reports whether
the ROTATE strip (or its STRAIGHTEN row) is open, App keeps autoRoll non-null
only then, and the frame chips now close the strip like every other chip
does. Verified on emulator-5554: AUTO on with the strip up shows the line
(row 1199-1203, 594 px); tapping LIGHT and coming back leaves it off.
2026-09-11 12:21:09 +07:00
admin da48ceccb5 fix(frame): make the ROTATE strip behave on switching parameters
Four follow-ups on the strip added for the quarter turns / straighten / AUTO.

AUTO is "snap to the sensor horizon": it now drops the manual STRAIGHTEN offset when it is switched on, so enabling it after a hand-set angle really levels the photo instead of leaving the angle untouched (the chip goes from "ROTATE 0 · +22°" to "ROTATE · AUTO").

The "<" back button on a slider row returns to the strip the row was opened from (STRAIGHTEN to ROTATE, COLOR TEMP to TEMP) instead of closing everything, and picking another parameter — a frame chip or the global RESET — closes the open row, so the straighten slider no longer outlives the parameter it belongs to.

Opening another photo from the library resets the turn, the fine straighten angle and AUTO with it: a new photo starts level.
2026-09-11 12:07:18 +07:00
admin 0ce561cb70 feat(frame): level the photo to the horizon with AUTO
The ROTATE strip gains an AUTO chip that reads the device tilt from expo-sensors and feeds it into the straighten angle while the library is open, so the export is levelled to the horizon. It sits right after RESET, combines with the manual STRAIGHTEN offset and with the quarter turns, and both RESET paths clear it.

The accelerator is sampled every 100 ms only while AUTO is on and the library is visible; near-flat and past 45 degrees readings are ignored so the level does not chase noise. AUTO rotates the image by +roll (a +10 degree emulator tilt exports 10 degrees clockwise), which is the sign that matches the preview.

A level line overlay is drawn in the preview: amber within one degree of level, white otherwise. Adds the expo-sensors dependency, so a native rebuild is required.
2026-09-11 11:35:48 +07:00
admin 9ddaeac260 feat(frame): give ROTATE quarter turns plus a fine straighten 2026-09-11 11:17:39 +07:00
admin 6788d51fb4 feat(frame): rename the RETRO POLAROID chip to RETRO INSTANT 2026-09-11 10:39:30 +07:00
admin dcb74a7243 feat(frame): pinch and drag the framed window on the camera
Two fingers scale the live feed inside a polaroid card / wall opening and
one finger drags it, both clamped to the window, and the shutter passes the
result to the export as frameWindowZoom so the printed crop matches what the
viewfinder showed. The crop centre is mapped through the same out-space ->
raw transform as the window rect; feeding it straight to the raw plane
swapped the axes on a rotated sensor.
2026-09-11 10:36:50 +07:00
admin 4131c9f401 feat(frame): move the watermark controls into a FRAME sub-panel 2026-09-11 10:00:10 +07:00
admin ba971f136c feat(panel): add a FAVORITED tab with a star on every recipe 2026-09-11 09:32:21 +07:00
admin ba7e579986 feat(panel): RESET shows a star while the look differs from a clean start 2026-09-11 09:08:37 +07:00
admin 7ed4301055 feat(panel): gather the white balance presets and COLOR TEMP into a TEMP chip 2026-09-11 08:59:49 +07:00
admin d66ae3dc59 feat(panel): collapse the film simulations into a PHOTO STYLE chip 2026-09-11 08:54:38 +07:00
admin 6adf0b5933 feat(sims): rename film simulations and bundled recipes
Sims: PROVIA->PROVIPES, VELVIA->VELVIPES, CLASSIC CHROME->CLASSIC CHRIPES, CLASSIC NEG->CLASSIC NEGIPES, ASTIA->ASTIPES, ETERNA->ETERNIPES, ACROS->ACRIPES, LEICA->LEITZ STREETLIFE.

Bundled recipes follow: PROVIPES STD, VELVIPES VIVID, ACRIPES MONO, CLASSIC NEGIPES. Ids and baseFilters untouched, so saved sessions keep resolving.
2026-09-11 08:44:58 +07:00
admin 7454ea1218 feat(panel): one RESET on every tab, back to the app's defaults
RESET was look-relative: it only put the sliders back on the values the
active recipe ships with, so the look itself (a saved recipe, a film sim)
survived the tap. It now restores the state a clean start leaves — the
PROVIA startup look, every parameter neutral, frame off, the custom and
GPS watermarks back to their defaults — and it sits on every tab, pinned
outside the scrolling chip row so it can never scroll out of reach.

Mode and aspect ratio stay put: they frame the shot being worked on, not
the look.
2026-09-10 22:18:43 +07:00
admin 02bc3aa4da feat(panel): editor keeps the values when the sim changes; one-tap RESET back to the look 2026-09-10 22:06:36 +07:00
admin 868ebfe6e2 feat(recipe): edit opens the prefilled create form; fields select their value on tap 2026-09-10 21:34:32 +07:00
admin b154e50191 feat(recipe): edit button on the top bar, with overwrite-or-save-as on save 2026-09-10 21:16:50 +07:00
admin 5186b2aab1 feat(sim): add a CLASSIC NEG chip to the film-sim presets 2026-09-10 21:16:50 +07:00
admin 91109cba54 feat(recipe): Save and Save As for recipes
Custom recipes can be overwritten in place (TopBar SAVE now offers CANCEL /
SAVE AS / SAVE and prefills the current name); bundled recipes stay read-only
and only offer Save As. Adds updateCustomRecipe to storageUtils.
2026-09-10 20:57:19 +07:00
admin 4bb2bda5c8 fix(sim): rebuild the six film-sim color matrices to match the stock looks
Provia/Velvia/Astia/Classic Chrome/Classic Neg/Acros each get their own
primaries+cross-talk matrix measured against the pipeline, plus a split-tone
stage (shadow/highlight tint uniforms) for Classic Chrome's teal and Classic
Neg's green-cyan shadows vs warm highlights.
2026-09-10 20:57:19 +07:00
admin 93f48537f8 feat(frame): CROP ratios in the library editor, and the framed-photo fixes
CROP chip on the FRAME tab for the library's plain frames: NONE, FREE
(drag the box) and the fixed ratios 1:1, 2:3, 3:2, 3:4, 4:3, 16:9. The
preview draws the keep-rectangle over the photo and the export honours it
pixel-for-pixel, pinching and dragging inside the band to pick the framing.

- Viewfinder: build frameRect from the normalised rect so the Skia canvas
  never sees undefined width/height (nothing to commit until now), keep
  the watermark drag target across the WATERMARK tab so a zoomed photo
  no longer resets, and derive the crop band + export rect from the photo
  fit rect.
- exportEngine: crop by fraction rect (or the ratio fallback) before the
  frame is composited.
- Leaving a plain frame clears the crop, and the ratio strip closes with
  the CROP chip it belongs to.
2026-09-10 20:12:03 +07:00
admin 299d81bade feat(wm): GPS watermark color + place-name/time toggles, "commune, city" locality 2026-09-10 18:05:57 +07:00
admin f13fd8e0f3 fix(library): drop the leftover zoom in the mark editor, re-anchor the framed crop
Two zoom-interaction bugs in the library viewer:

- Opening the WATERMARK editor now resets a photo zoom left over from the
  panel-closed pass. The zoom had shifted the mark's hit box along with the
  matrix and tripped the `libZoom.s <= 1.01` guards, so after visiting another
  tab and zooming, the mark could neither be dragged nor pinched.
- A polaroid/wall photo window that MOVES (deck grows: a chip slider or the
  watermark text field) now re-anchors the photo transform by the same u/v the
  export uses, so the crop inside the frame no longer slides away from the card
  (at 2.5x the content used to drift 2.5x the card's move).

Verified on device (9a6a7277): the mark drags again after tab-switch + zoom;
opening WATERMARK returns the photo to the pre-zoom framing; the framed crop
tracks the card within ~2 px over a 62 px deck-driven move.
2026-09-10 17:40:50 +07:00
admin f4a5b0c683 fix(wm): let the library editor pinch a watermark mark
onLibTouchStart only recorded the pinch baseline when both fingers arrived in
the same touch-start, so a second finger landing later (the normal case) left
wmPinchRef null and the move handler scaled nothing -- pinch-the-mark worked
on the camera viewfinder but not on a library photo. Seed the baseline in the
move handler, as the camera path already does.
2026-09-10 17:15:36 +07:00
admin 614ebb170b fix(gps): stop stamping the phone's current location on a picked photo
The library flow ended in a device-GPS fallback: whenever a photo's own
coordinates could not be read, getCurrentGPS() supplied the phone's present
spot, so the mark named wherever the user was standing instead of where the
photo was taken. Drop that tier entirely -- a photo with no readable location
gets no GPS mark. Also keep the legacy Android picker unconditionally so the
picked file keeps its EXIF GPS whatever the chip state, and prefer the city
over subAdminArea (county/district) as the displayed place name.
2026-09-10 17:08:09 +07:00
admin 423bf533f8 fix(gps): resolve place name when the watermark is switched on
A photo picked while the GPS chip was off kept its coordinates but no
locality (the geocode only ran when the chip was already on), and
handleToggleGeotag skipped GPS loading entirely when coordinates already
existed -- so switching the chip on showed STREET VIEW. Request location
permission inside reverseGeocode (expo-location's Android geocoder throws
LocationUnauthorizedException without it) and re-resolve the name from an
effect whenever the mark has coordinates but no place name. Both paths now
share localityName() so camera and photo stamps cannot disagree.
2026-09-10 17:01:48 +07:00
admin 67a2a52a6f Bump the app to version 1.2
versionName 1.2 / versionCode 2, kept in sync with app.json, package.json and the credits screen.
2026-09-10 16:43:03 +07:00
admin 11ed786758 Let the GPS watermark be placed, sized and hung landscape
Three WATERMARK/FRAME behaviours that share the stamp geometry:

* GPS mark: drag it anywhere on the frame/photo area and pinch it bigger or
  smaller. The 0..1 anchor plus the size multiplier ride along in the export
  options, so a framed file (whose canvas IS the card/frame) burns the mark in
  at exactly the fraction the preview showed.
* The photo's own pinch zoom stays available while the WATERMARK editor is
  closed, so a two-finger zoom is never swallowed by the mark while its panel
  is open.
* WALL FRAME gains a WALL PORTRAIT / WALL LANDSCAPE chip: the artwork hangs
  in its own 4000x3117 orientation instead of the default 90-degree rotation.
2026-09-10 16:35:35 +07:00
admin 47e424789d Revert "Zoom the finished photo with a double tap only"
This reverts commit 29b75ec3d3.
2026-09-10 16:06:23 +07:00
admin 29b75ec3d3 Zoom the finished photo with a double tap only 2026-09-10 15:59:15 +07:00
admin 6595e5cbc1 Let the GPS and custom watermarks be rotated 2026-09-10 15:56:06 +07:00
admin 2911dd57f0 Let the wall-frame photo be dragged inside its opening 2026-09-10 15:36:51 +07:00
admin 2835f46c37 Keep the dialled-in look when swapping film simulations 2026-09-10 15:16:21 +07:00
admin 045f5f2048 Keep a thumbnail of the finished export in library mode 2026-09-10 12:31:54 +07:00
admin e529c608f0 Keep the highlight slider off the greys, and start each mode clean
The highlight knee opened at 0.45, so dropping HIGHLIGHT pulled a mid-grey
down with the true highlights. It now opens at 0.65 (and the coefficient
drops 0.32 -> 0.22 to stay monotonic), so the slider leaves everything up to
a light grey untouched and still rolls the bright end off. Shadow is
untouched — its 0.00..0.55 knee was already right.

Switching between the library and the camera now drops the edits back to
the recipe's own values, no frame and no custom mark. A frame was picked
for the still it sat on and the mark was placed against that very photo, so
neither may follow the user across the switch (or into a live capture),
exactly like the sliders that were tuned on the photo in front of them.
2026-09-10 12:23:56 +07:00
admin bba2d76957 Keep the photo's colour when the highlight and shadow sliders move
Highlight/Shadow scaled R, G and B by one luma gain. That keeps the ratio
but crushes absolute chroma, so -SH turned a saturated blue into near-black
and -HL turned bright colours grey, and both sliders only bit at the very
ends of the range (knees 0.80..1.00 / 0.00..0.30) — they read as dead on
any photo without true whites or blacks.

The curve now moves the luma and carries the colour difference (rgb - luma)
along at clamp(gain, 0.55, 1.35), so hue survives darkening and lifting
instead of collapsing to black or white. Both knobs are pure additive luma
shifts with soft knees over the upper/lower half: the 0.50 midpoint moves
under 3%, and the curve stays monotonic (the old multiplicative form was
not — with hl=-1 a grey 0.73 came out darker than 0.80).

Kotlin applyTone port and the native bench probe gates follow.

Verified on device (4200x2800 probe: grey ramp + colour patches), library
export at ratio 4:3: -10 highlight leaves the darks alone and drops white
243->189 with the sky still blue; -10 shadow keeps blue as dark blue
(0,0,254)->(0,3,146), never black, ramp stays monotonic. Export keeps the
source 4200x2800 as well, so nothing is cropped.
2026-09-10 11:41:43 +07:00
admin 4ad26b514e Show library photos at their own aspect instead of cropping to the ratio band 2026-09-10 11:28:54 +07:00
admin 21e4932249 Reset the custom watermark when returning to the camera
The mark is placed against the library photo it was dragged on, but it
survived the switch to the live camera: the preview kept drawing it and
the next capture burned it in. Camera mode already drops the frame and
the library GPS for the same reason, so drop the accepted watermark too.

Resetting both the draft and the accepted copy keeps the chip honest and
stops the AsyncStorage session snapshot from restoring a camera mode
that still carries a library watermark.
2026-09-10 11:14:09 +07:00
admin e9de4203e9 Gate pinch zoom behind an existing zoom
A two-finger pinch only changes scale once the photo is already zoomed
in; at 100% the gesture is inert, so the paging strip keeps behaving
like a plain gallery. The responder is now held for the whole pinch so
the paging ScrollView cannot steal it mid-gesture.
2026-09-10 11:02:39 +07:00
admin 7c7a244b84 Rail without icons, FX glow chip renamed
The rail chips carried a lucide glyph before the label; drop the icons and
the icon field so the labels stand alone (the row is a plain text rail now,
and each chip is ~50px narrower). Also rename the FX knob from the
lens-vendor term to LIGHT GLOW - same key, same maths, just the label.
2026-09-10 10:44:33 +07:00
admin 6379c025c8 HDF on the live camera preview
Mirror export pass 5b in the onFrame worklet: extract the highlights with
the same 2.5x-1.5 contrast curve, blur that selection, Screen it back at
0.15..0.5 alpha. The selection must be built as an image filter so the
highlight curve runs before the blur (a paint color filter would run after,
smearing the matrix into the selection).

Kept the tone/cinema path intact: the base draw now records the shader it
used and the glow pass reuses it (drawRect) or falls back to the plain
drawImage, so the glow always sits on the same processed pixels.

Measured on the Xiaomi 2203121C: 5 interleaved 0/10 pairs, the HDF=10 mean
luma is +0.70..0.84 above HDF=0 every time (AE metering does not track the
software pass, so the toggle is the only variable), and frame counts are
unchanged (488/489 vs 492/488 frames per 15s; GPU busy rises ~30%, still
inside the 3 ms median GPU frame). No fps loss, so the pass stays.
2026-09-10 10:41:39 +07:00
admin 6c7d5b8f43 Keep the existing COLOR chip label
Reverts the COLOR -> SATURATION rename: the control already existed, so
it stays as it was.
2026-09-10 10:28:38 +07:00
admin 65d0850317 FX vignetting, LIGHT vibrance/saturation, reticle unlock
- FX: new VIGNETTING 0-10 control (chip + slider). Darkens the four
  corners of the photo with a radial gradient whose radius is the photo
  diagonal/2, so the falloff stays circular on any aspect. Applied in
  preview (camera overlay, polaroid/wall window, plain library) and in
  exportEngine pass 6b, always on the picture, never on the mat.
- LIGHT: new VIBRANCE -10..+10 (chroma-masked saturation in the tone
  shader: vivid pixels move least). Renamed the existing saturation
  control chip from COLOR to SATURATION - same param, clearer label.
- Fix: <Shader> takes a RuntimeEffect, not a gradient SkShader; the
  preview vignette threw "Object is not a HostObject" when it got one.
  Use the declarative <RadialGradient> child on the Rect instead.
- Preview: drop the "AE/AF LOCKED - TAP TO UNLOCK" banner; the lock
  reticle is now itself the unlock touch target.
2026-09-10 10:22:55 +07:00
admin d7a8e0681e Add CANCEL to the watermark editor, scope the mark's touch layer to that editor, and stop a library frame following the user onto the camera
The custom watermark captured EVERY image touch whenever the chip was ON
with text, in both modes: after switching from a library photo to the
camera there was no way to give the image back to tap-to-focus, and a
frame picked for the still rode along onto the live preview (and into the
capture).

- AdjustPanel: CANCEL chip next to ACCEPT. It drops the un-accepted draft
  and restores the last ACCEPTED mark (none if nothing was accepted).
- Viewfinder: draws the mark whenever the chip is ON with text, but only
  lets it CAPTURE touches while its editor is open (new wmEditing prop).
  A double-tap ON the mark still re-opens that editor from the library
  viewer.
- App: ACCEPT and CANCEL now close the editor (that closes the placement
  layer with it). Switching mode closes the deck panels, and leaves the
  frame behind: camera mode has no FRAME picker to undo one.

Verified on device (9a6a7277): camera tap locks AE/AF with the watermark
left ON; library polaroid no longer appears on the live preview after the
switch; CANCEL reverts the chip to CUSTOM WATERMARK OFF and closes the
panel; ACCEPT closes it and the next capture embeds the mark (amber text
found at 0.5/0.375 of the 3000x4000 export), while a capture before
ACCEPT carries none.
2026-09-10 09:58:50 +07:00
admin 28e18893cd Add HDF (Highlight Diffusion Filter) to the FX tab
The built-in lens filter, simulated the way it is done in Photoshop: pull the
highlights out with a hard contrast curve (out = 2.5*in - 1.5, black below 0.6),
Gaussian-blur that selection, then Screen it back over the photo at reduced
opacity. Screen against a near-black pixel is a no-op, so areas with no
highlight within the blur radius come back byte-identical while lit areas
spread their light.

One 0..10 knob drives both the radius (4%..1.9% of the width, ~10..50px at
2560 wide) and the opacity (0.15..0.5), matching the recipe's 15-50px radius
range. The extraction composes on top of the recipe colour matrix so a
monochrome look glows white instead of leaking the original colours back.

Runs in the Skia export engine only; the P5 native path is not wired into the
app (dev parity probe only), so it is left alone for now.
2026-09-10 09:45:51 +07:00
admin 3a8b110c1b Edit the watermark in place, rotate the frame photo, place a custom watermark
Watermark editing no longer covers the photo: the preview letterboxes the
image inside its fit rect while the deck is open, and the idle hint is gone.

The FRAME tab can rotate a library photo 90 degrees clockwise. Rotation
swaps the pixel dimensions instead of the frame, so the polaroid/wall window
keeps its aspect and every consumer (cover crop, watermark area, export) picks
up the new orientation from width()/height() with no extra layout branch. The
rotate surface is CPU-backed on purpose - a GPU snapshot loses its pixels
before the next frame and renders the photo black.

Custom watermark text can now be dragged anywhere on the photo, double-tapped
to re-edit, and is only baked into the file once ACCEPT is pressed; the export
engine takes the same position. It also gained COLOR/SIZE/FONT chips, with the
font list read from the OS via Skia's FontMgr so any installed family can be
used.
2026-09-10 09:26:55 +07:00
admin ff06045e02 Pinch/drag library photo inside polaroid & wall frame windows with export parity
The framed windows (polaroid card / wall frame opening) now take the same pinch + double-tap gestures as plain library, clamped so the photo keeps covering the mat opening, and the visible sub-rectangle is handed to the export engine so the file matches the preview.

Wall frame export also stopped crashing: on Android release an image asset resolves to a drawable *name* ('wallframe') and is flagged downloaded, so Asset.downloadAsync short-circuited and readAsStringAsync threw 'Unsupported scheme for location wallframe'. Clearing that state lets the native module copy the drawable into the cache.
2026-09-10 08:02:55 +07:00
admin c0c2f8532f Lift bottom deck above the keyboard so the custom watermark input stays visible
Edge-to-edge (edgeToEdgeEnabled) makes Android ignore the manifest's
adjustResize, so the IME never shrank the window and the text input sat
under the keyboard. Track keyboardDidShow/Hide height and pad the deck.
2026-09-10 07:44:03 +07:00
admin 3eeef6feb9 Persist device/camera settings across restarts independent of startup mode
Restore shutter sound/metering/RAW from a dedicated key under both 'default'
and 'last session' startup (the session file only stores the look and is
written on background events, so it can be stale after a force-kill). Guard
the persist effect until hydration finishes so the mount-time defaults cannot
overwrite the stored values; drop the stale session override of these three.
2026-09-10 07:36:22 +07:00
admin 144b211d35 Add WATERMARK tab with custom draggable text; hide FRAME in camera; fix double-dark after highlight capture 2026-09-10 07:17:14 +07:00
admin 2512e05c92 Rename launcher label to RecipesCam 2026-09-09 21:55:34 +07:00
admin 4261806dc4 Replace app icon with recipes_icon.png for legacy and adaptive layers 2026-09-09 21:53:23 +07:00
admin f2de481708 Keep GPS watermark user-owned across presets; add pinch/double-tap zoom to photo preview; collapse chrome strip on WB selection 2026-09-09 21:40:28 +07:00
admin 5799210949 Add embedded about & open-source credits screen (license audit) 2026-09-09 21:05:59 +07:00
admin 568f06306a Fix preview wedge after flip: remount SkiaCamera on facing change and as watchdog recovery
Flip only reconfigured the CameraSession in place, leaving the Skia
offscreen-surface cache from the old lens wedged on this Xiaomi: after a
flip the preview banded then went black while the frame pipeline kept
delivering (heartbeat kept rising) and only a full app restart revived it.

- key <SkiaCamera> by facing + mount nonce so a flip fully unmounts and
  remounts the camera (lib clears its surface cache on unmount), i.e. a
  clean restart on every lens switch instead of an in-place reconfigure.
- watchdog now recovers by the same full remount instead of an isActive
  toggle (which never revives a wedged preview), budgeted to 3 restarts.
- bump a re-render on every onStarted: after a remount cameraActive never
  drops (no onStopped on unmount) so the reopen did not re-render and the
  new controller was never re-read, leaving the 1x-10x zoom chips hidden
  until the next touch.

Verified on device: 15+ flips with no wedge, chips visible immediately
after each flip, preview stays live (was ~5 min to black before).
2026-09-09 20:49:24 +07:00
admin 999f49b87f Center shutter between balanced side clusters; tighten flip/preview gap; enlarge active tool tab label 2026-09-09 18:48:04 +07:00
admin a0ca68f176 Keep screen awake in camera mode; nudge flip button toward shutter 2026-09-09 18:40:58 +07:00
admin 55f4184e74 Native zoom presets 1x-10x; back/front camera flip 2026-09-09 18:21:08 +07:00
admin ca3532023a Startup mode default/last-session; GPS watermark default OFF; shutter OFF truly silent
- Settings STARTUP MODE chips (AsyncStorage startup_mode): default = clean camera
  + PROVIA neutral 4:3, last = restore full session look.
- initSession: resolve sim-* recipe ids via FILM_SIMS (fix clobber of session
  ratio/adjustments to 4:3 canonical); restore via setSelectedRecipe not
  applyRecipe so user tweaks (frame/geotag/ratio) survive restore.
- applyDefaultStartup turns GPS watermark OFF (user-requested default).
- Shutter: OFF must not play click on must-play-policy devices. VisionCamera
  v5.2.3 HybridPhotoOutput.kt plays MediaActionSound itself gated by
  (enableShutterSound ?: true) || mustPlayShutterSound — on Xiaomi mustPlay=true
  so OFF still clicked. Both capture paths now gate on enableShutterSound != false.
  node_modules patch not trackable -> artifacts/patches/vision-camera-shutter-silent.patch.
  Verified on Xiaomi 12S Ultra: log SND user=false -> play=false; cold-start
  DEFAULT shows GPS WATERMARK OFF.
2026-09-09 17:59:15 +07:00
admin 3dcfd19d35 PLAN: P6 parity probe results — pending user QA before native default 2026-09-09 17:07:04 +07:00
admin 1fdf765d54 P6 parity probe: same photo through legacy Skia + native engines, 3-panel composite+diff on Xiaomi 2026-09-09 17:06:54 +07:00
admin a51b28f32a PLAN: P5 done 2026-09-09 16:45:00 +07:00
admin 1cda62e039 P5 native export hooked (camera+library) — real photo to gallery on Xiaomi 2026-09-09 16:44:49 +07:00
admin 25e4f21917 P4 native frames (classic/polaroid/wallframe) + GPS watermark verified on Xiaomi (sample gates pass) 2026-09-09 16:34:24 +07:00
admin 11635a0d33 P3 native cinema/clarity/denoise/grain (exportEngine port) verified on Xiaomi (gates pass) 2026-09-09 15:58:15 +07:00
admin 71b9476901 P2 native tone DR/hl/sh (TONE_SKSL port) parity verified on Xiaomi (gates 140/75/209/104/11 pass) 2026-09-09 15:38:35 +07:00
admin a72804f081 docs(plan): tick P1 matrix done 2026-09-09 15:18:37 +07:00
admin d412f40861 P1 native color matrix + crop (processColorAsync): parity verified on Xiaomi (classic-neg on black -> rgb(13,5,0), translate*255) 2026-09-09 15:18:30 +07:00
admin dfcf0f3a28 P0 native decode/encode benchmark passes on Xiaomi (12MP decode+encode ~210-270ms, off-main-thread) 2026-09-09 15:10:30 +07:00
3dtours a8932f52ed feat(native-export): scaffold local Expo module + native decode/encode bench probe (P0)
Local Kotlin module modules/recipescam-export (autolinked via expo ./modules)
exposes decodeEncodeAsync — BitmapFactory decode -> JPEG q95 encode -> file,
all on Dispatchers.IO off the JS/main threads. EXPO_PUBLIC_BENCH=1 builds run
src/dev/nativeBenchProbe.ts: 3 timed decode+encode passes on the bundled
wallframe artwork plus a JS-timer gap check proving the JS thread never
blocks during native rendering. Legacy Skia engine untouched (reference).
2026-09-09 12:39:35 +07:00
3dtours f588849ac5 perf(capture): render exports off the shutter path via FIFO background queue 2026-09-09 12:22:07 +07:00
3dtours bd7efce600 feat(presets): add LEICA simulation chip; collapse recipe strip on sim select 2026-09-09 11:32:30 +07:00
3dtours 09dbc72cce feat: v1.1 — highlight-weighted metering, settings modal, photo viewer, shutter sound control, cleanup 2026-09-09 09:41:55 +07:00
3dtours e9a9005ef2 fix(ev): preview EV via software matrix, flush AE bias only at capture 2026-09-08 21:28:38 +07:00
3dtours dca4ba0b7c fix(4x3): force frame stream to 4:3 sensor aspect via targetResolution 2026-09-08 21:21:05 +07:00
3dtours 5a94978f33 debug(4x3): use console.error for release-build visibility + build marker 2026-09-08 21:15:25 +07:00
3dtours fcad148798 debug(4x3): log actual frame stream geometry at runtime 2026-09-08 21:01:15 +07:00
3dtours 30a2f768da Restore width-fit ratio bands for native FOV plain preview
Plain frame 'none' with ratio 4:3 shows the whole sensor at screen width (85 deg FOV, like the built-in app at 23mm equiv); 3:2 crops the two vertical sides, same height and zoom, matching the export crop. The fullscreen-cover plain preview from 7985f25 was a wrong interpretation of the built-in view and made every ratio look identical fullscreen.
2026-09-08 20:39:24 +07:00
3dtours 7985f25aa8 Match built-in camera 1x FOV for plain ratio preview
Plain frame 'none' with an aspect ratio no longer bands the feed into a letterboxed strip; it keeps the fullscreen cover view like FULL/1x, so 4:3 preview shows the same angle of view as the built-in camera app at 1x (export crop unchanged). Border/bars frames (classic-white, cinematic) keep the banded contain view.
2026-09-08 20:27:24 +07:00
3dtours 125c1280fa Keep ratio bands at native sensor zoom
The band region was the ratio photo re-scaled to the screen width, so 3:2
(0.667) rendered taller than the 4:3 (== native 3:4) band and zoomed the
scene. Anchor the band on the contain-fitted native sensor box instead and
cut the ratio strip out of it at the same scale: 3:2 is now the identical
vertical view with a narrower horizontal crop, never a taller image.
2026-09-08 19:54:45 +07:00
3dtours 56c86df1b5 Show native sensor in viewfinder composition band
SkiaCamera cover-fits the upright frame snapshot onto the fullscreen view,
so a 4:3 ratio band (aspect == native 3:4 sensor) only revealed a center
slice of the sensor instead of the whole frame. Redraw the feed inside the
frame worklet: when a photo-area rect is active (ratio band / polaroid
window / wall window) black the surface, clip to the rect's raw-frame
inverse under the renderToTexture rotation/mirror CTM, and cover-map the
sensor there. Equal-aspect bands now show the full sensor; narrower ratios
(3:2) crop center like the export. Inactive feed keeps the legacy draw.
2026-09-08 19:34:32 +07:00
3dtours d43405a4de Add aspect-ratio composition band and RAW DNG sidecar capture 2026-09-08 18:43:02 +07:00
3dtours a16c8734a8 Rotate wall frame portrait, letterbox feed to frame window 2026-09-08 18:14:25 +07:00
3dtours a1769e8c84 Add wall frame overlay with transparent PNG window 2026-09-08 17:56:50 +07:00
3dtours 669224fa39 Add D.RANGE toggle chip to LIGHT tab; letterbox camera polaroid to 3:4 card 2026-09-08 17:26:39 +07:00
3dtours b5a257e033 Fix camera polaroid overflow and chrome WB chips; move GPS watermark to top 2026-09-08 17:06:22 +07:00
3dtours bfdc964f65 Polaroid print layout in viewfinder and export; split chip rows; drop cinematic; debounce EV bias 2026-09-08 16:30:29 +07:00
3dtours 7f7ceeddc6 Refresh launcher icon and splash logo from 1024px sources 2026-09-08 15:48:23 +07:00
3dtours b581ecea2f Install icon.png launcher icon and app_logo.png Android splash logo 2026-09-08 15:37:38 +07:00
3dtours d2d62c6612 Sit library watermark at photo corner; white icons distinct from amber text 2026-09-08 12:12:47 +07:00
3dtours 58335d8cd1 Add one-space gap between watermark icons and text (preview + export) 2026-09-08 12:06:07 +07:00
3dtours b64d7245c9 Fix library GPS watermark anchor; use Cousine for Vietnamese glyphs 2026-09-08 12:02:50 +07:00
3dtours 588a36d05a feat: cinematic seasonal atmosphere presets (spring/summer/autumn/winter) - enhances existing light with season tints, sun glow, fog veil 2026-09-08 11:38:27 +07:00
3dtours ea281d7974 feat: monochrome Noto Emoji subset icons (pin, camera) before watermark lines 2026-09-08 11:30:47 +07:00
3dtours 43a73c541b fix: legacy picker for real photo GPS on Android 13+, watermark shows place + capture datetime 2026-09-08 11:28:02 +07:00
3dtours 7d77be5029 fix: keep recipe-name sheet above keyboard (KeyboardAvoidingView), gps diagnostic logs 2026-09-08 11:01:15 +07:00
3dtours 358f9b0f2f fix: fresh export URI per save (stale thumbnail), lock spot-meter + quick EV slider, honor photo GPS over device fallback 2026-09-08 10:42:08 +07:00
3dtours 8eca31ce58 fix(camera): matrix-based zoom for pinch/double-tap, tap-to-focus AE/AF lock, library letterbox smear fix, export raw JPEG bytes q95, resume isActive fixes, dark theme polish 2026-09-08 10:25:34 +07:00
3dtours 515f408c67 fix(camera): drop unused expo-camera; viewfinder crash-safe on Android
- remove expo-camera dep (autolink forced CameraX 1.6.0 over vision-camera
  4.7.3's pinned 1.5.0-alpha03, breaking device enumeration on Xiaomi)
- Android: disable Skia frame processor in viewfinder - devices w/o
  hardware-buffered ImageAnalysis frames (Xiaomi 14 Ultra) crash in
  frame.getNativeBuffer(); live filter stays on iOS, filters still apply
  at capture/export
2026-09-07 19:20:33 +07:00
278 changed files with 61077 additions and 3593 deletions
-14
View File
@@ -1,14 +0,0 @@
> Why do I have a folder named ".expo" in my project?
The ".expo" folder is created when an Expo project is started using "expo start" command.
> What do the files contain?
- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds.
- "settings.json": contains the server configuration that is used to serve the application manifest.
- "dev/logs/": contains structured JSONL event logs from CLI commands (e.g. start.log, export.log). These are truncated on each run.
> Should I commit the ".expo" folder?
No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
Upon project creation, the ".expo" folder is already added to your ".gitignore" file.
File diff suppressed because it is too large Load Diff
-412
View File
@@ -1,412 +0,0 @@
{"_e":"root:init","_t":1784254504055,"format":"v0-jsonl","version":"57.0.8"}
{"_e":"env:mode","_t":1784254504056,"nodeEnv":"development","babelEnv":"development","mode":"development"}
{"_e":"env:load","_t":1784254504058,"mode":"development","files":[],"env":{}}
{"_e":"devserver:start","_t":1784254508949,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
{"_e":"metro:config","_t":1784254509422,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"expo:dev-tools-plugin:load","_t":1784254509956,"plugins":[]}
{"_e":"metro:instantiate","_t":1784254516842,"atlas":false,"workers":3,"host":"::","port":8086}
{"_e":"metro:bundling:started","_t":1784254564715,"id":"1","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784254564715,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784254564729,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784254565214,"id":"1","progress":0.010000000000000002,"total":1,"current":1}
{"_e":"metro:bundling:progress","_t":1784254565318,"id":"1","progress":0.09,"total":9,"current":3}
{"_e":"metro:bundling:progress","_t":1784254565418,"id":"1","progress":0.09,"total":41,"current":7}
{"_e":"metro:bundling:progress","_t":1784254565518,"id":"1","progress":0.09,"total":46,"current":11}
{"_e":"metro:bundling:progress","_t":1784254565577,"id":"1","progress":0.09,"total":46,"current":12}
{"_e":"metro:bundling:progress","_t":1784254565690,"id":"1","progress":0.09,"total":147,"current":14}
{"_e":"metro:bundling:progress","_t":1784254565794,"id":"1","progress":0.09,"total":156,"current":24}
{"_e":"metro:bundling:progress","_t":1784254565894,"id":"1","progress":0.09,"total":157,"current":31}
{"_e":"metro:bundling:progress","_t":1784254565998,"id":"1","progress":0.09,"total":185,"current":45}
{"_e":"metro:bundling:progress","_t":1784254566099,"id":"1","progress":0.09,"total":197,"current":52}
{"_e":"metro:bundling:progress","_t":1784254566211,"id":"1","progress":0.09,"total":210,"current":59}
{"_e":"metro:bundling:progress","_t":1784254566311,"id":"1","progress":0.09,"total":223,"current":66}
{"_e":"metro:bundling:progress","_t":1784254566419,"id":"1","progress":0.09782840730462457,"total":227,"current":71}
{"_e":"metro:bundling:progress","_t":1784254566519,"id":"1","progress":0.11201999167013746,"total":245,"current":82}
{"_e":"metro:bundling:progress","_t":1784254566623,"id":"1","progress":0.15123456790123457,"total":270,"current":105}
{"_e":"metro:bundling:progress","_t":1784254566723,"id":"1","progress":0.18367346938775508,"total":287,"current":123}
{"_e":"metro:bundling:progress","_t":1784254566823,"id":"1","progress":0.2352733413538482,"total":301,"current":146}
{"_e":"metro:bundling:progress","_t":1784254566923,"id":"1","progress":0.259462890625,"total":320,"current":163}
{"_e":"metro:bundling:progress","_t":1784254567024,"id":"1","progress":0.27952464836940155,"total":331,"current":175}
{"_e":"metro:bundling:progress","_t":1784254567124,"id":"1","progress":0.33271161520688924,"total":345,"current":199}
{"_e":"metro:bundling:progress","_t":1784254567225,"id":"1","progress":0.35676406135865596,"total":370,"current":221}
{"_e":"metro:bundling:progress","_t":1784254567326,"id":"1","progress":0.37024645369120834,"total":401,"current":244}
{"_e":"metro:bundling:progress","_t":1784254567428,"id":"1","progress":0.37472705039741466,"total":428,"current":262}
{"_e":"metro:bundling:progress","_t":1784254567529,"id":"1","progress":0.3978698224852071,"total":455,"current":287}
{"_e":"metro:bundling:progress","_t":1784254567628,"id":"1","progress":0.41077662667090886,"total":479,"current":307}
{"_e":"metro:bundling:progress","_t":1784254567730,"id":"1","progress":0.42145226326742974,"total":496,"current":322}
{"_e":"metro:bundling:progress","_t":1784254567834,"id":"1","progress":0.42145226326742974,"total":536,"current":333}
{"_e":"metro:bundling:progress","_t":1784254567938,"id":"1","progress":0.42145226326742974,"total":540,"current":339}
{"_e":"metro:bundling:progress","_t":1784254568057,"id":"1","progress":0.42145226326742974,"total":543,"current":345}
{"_e":"metro:bundling:progress","_t":1784254568158,"id":"1","progress":0.42145226326742974,"total":556,"current":354}
{"_e":"metro:bundling:progress","_t":1784254568257,"id":"1","progress":0.42145226326742974,"total":556,"current":356}
{"_e":"metro:bundling:progress","_t":1784254568357,"id":"1","progress":0.42472014414228576,"total":557,"current":363}
{"_e":"metro:bundling:progress","_t":1784254568458,"id":"1","progress":0.42472014414228576,"total":571,"current":370}
{"_e":"metro:bundling:progress","_t":1784254568559,"id":"1","progress":0.44212055590581395,"total":573,"current":381}
{"_e":"metro:bundling:progress","_t":1784254568663,"id":"1","progress":0.46466158211707237,"total":578,"current":394}
{"_e":"metro:bundling:progress","_t":1784254568764,"id":"1","progress":0.48831470460153875,"total":581,"current":406}
{"_e":"metro:bundling:progress","_t":1784254568909,"id":"1","progress":0.500828402366864,"total":585,"current":414}
{"_e":"metro:bundling:progress","_t":1784254569015,"id":"1","progress":0.500828402366864,"total":2334,"current":415}
{"_e":"metro:bundling:progress","_t":1784254569115,"id":"1","progress":0.500828402366864,"total":2338,"current":435}
{"_e":"metro:bundling:progress","_t":1784254569222,"id":"1","progress":0.500828402366864,"total":2343,"current":440}
{"_e":"metro:bundling:progress","_t":1784254569326,"id":"1","progress":0.500828402366864,"total":2346,"current":459}
{"_e":"metro:bundling:progress","_t":1784254569467,"id":"1","progress":0.500828402366864,"total":2347,"current":467}
{"_e":"metro:bundling:progress","_t":1784254569567,"id":"1","progress":0.500828402366864,"total":2348,"current":472}
{"_e":"metro:bundling:progress","_t":1784254569667,"id":"1","progress":0.500828402366864,"total":2367,"current":491}
{"_e":"metro:bundling:progress","_t":1784254569771,"id":"1","progress":0.500828402366864,"total":2428,"current":523}
{"_e":"metro:bundling:progress","_t":1784254569871,"id":"1","progress":0.500828402366864,"total":2454,"current":542}
{"_e":"metro:bundling:progress","_t":1784254569971,"id":"1","progress":0.500828402366864,"total":2465,"current":563}
{"_e":"metro:bundling:progress","_t":1784254570071,"id":"1","progress":0.500828402366864,"total":2477,"current":583}
{"_e":"metro:bundling:progress","_t":1784254570173,"id":"1","progress":0.500828402366864,"total":2478,"current":625}
{"_e":"metro:bundling:progress","_t":1784254570273,"id":"1","progress":0.500828402366864,"total":2478,"current":677}
{"_e":"metro:bundling:progress","_t":1784254570374,"id":"1","progress":0.500828402366864,"total":2478,"current":727}
{"_e":"metro:bundling:progress","_t":1784254570474,"id":"1","progress":0.500828402366864,"total":2478,"current":772}
{"_e":"metro:bundling:progress","_t":1784254570574,"id":"1","progress":0.500828402366864,"total":2478,"current":818}
{"_e":"metro:bundling:progress","_t":1784254570674,"id":"1","progress":0.500828402366864,"total":2478,"current":863}
{"_e":"metro:bundling:progress","_t":1784254570774,"id":"1","progress":0.500828402366864,"total":2478,"current":912}
{"_e":"metro:bundling:progress","_t":1784254570874,"id":"1","progress":0.500828402366864,"total":2478,"current":965}
{"_e":"metro:bundling:progress","_t":1784254570974,"id":"1","progress":0.500828402366864,"total":2478,"current":1008}
{"_e":"metro:bundling:progress","_t":1784254571076,"id":"1","progress":0.500828402366864,"total":2478,"current":1056}
{"_e":"metro:bundling:progress","_t":1784254571176,"id":"1","progress":0.500828402366864,"total":2478,"current":1105}
{"_e":"metro:bundling:progress","_t":1784254571277,"id":"1","progress":0.500828402366864,"total":2478,"current":1160}
{"_e":"metro:bundling:progress","_t":1784254571377,"id":"1","progress":0.500828402366864,"total":2478,"current":1209}
{"_e":"metro:bundling:progress","_t":1784254571478,"id":"1","progress":0.500828402366864,"total":2478,"current":1264}
{"_e":"metro:bundling:progress","_t":1784254571578,"id":"1","progress":0.500828402366864,"total":2478,"current":1311}
{"_e":"metro:bundling:progress","_t":1784254571678,"id":"1","progress":0.500828402366864,"total":2478,"current":1357}
{"_e":"metro:bundling:progress","_t":1784254571778,"id":"1","progress":0.500828402366864,"total":2478,"current":1410}
{"_e":"metro:bundling:progress","_t":1784254571878,"id":"1","progress":0.500828402366864,"total":2478,"current":1461}
{"_e":"metro:bundling:progress","_t":1784254571978,"id":"1","progress":0.500828402366864,"total":2478,"current":1507}
{"_e":"metro:bundling:progress","_t":1784254572078,"id":"1","progress":0.500828402366864,"total":2478,"current":1560}
{"_e":"metro:bundling:progress","_t":1784254572178,"id":"1","progress":0.500828402366864,"total":2478,"current":1611}
{"_e":"metro:bundling:progress","_t":1784254572279,"id":"1","progress":0.500828402366864,"total":2478,"current":1668}
{"_e":"metro:bundling:progress","_t":1784254572379,"id":"1","progress":0.500828402366864,"total":2478,"current":1721}
{"_e":"metro:bundling:progress","_t":1784254572480,"id":"1","progress":0.5136689550856253,"total":2478,"current":1776}
{"_e":"metro:bundling:progress","_t":1784254572580,"id":"1","progress":0.5394363050209071,"total":2478,"current":1820}
{"_e":"metro:bundling:progress","_t":1784254572681,"id":"1","progress":0.5737542838642687,"total":2478,"current":1877}
{"_e":"metro:bundling:progress","_t":1784254572781,"id":"1","progress":0.6047290409029646,"total":2478,"current":1927}
{"_e":"metro:bundling:progress","_t":1784254572881,"id":"1","progress":0.6397417532559323,"total":2478,"current":1982}
{"_e":"metro:bundling:progress","_t":1784254572981,"id":"1","progress":0.6737507987969679,"total":2478,"current":2034}
{"_e":"metro:bundling:progress","_t":1784254573081,"id":"1","progress":0.7106802981654216,"total":2478,"current":2089}
{"_e":"metro:bundling:progress","_t":1784254573181,"id":"1","progress":0.7506914764373623,"total":2478,"current":2147}
{"_e":"metro:bundling:progress","_t":1784254573281,"id":"1","progress":0.782489458485683,"total":2478,"current":2192}
{"_e":"metro:bundling:progress","_t":1784254573382,"id":"1","progress":0.822981380620811,"total":2478,"current":2248}
{"_e":"metro:bundling:progress","_t":1784254573483,"id":"1","progress":0.8637444540202369,"total":2478,"current":2303}
{"_e":"metro:bundling:progress","_t":1784254573583,"id":"1","progress":0.8846124232278917,"total":2489,"current":2341}
{"_e":"metro:bundling:progress","_t":1784254573683,"id":"1","progress":0.8880743173619988,"total":2499,"current":2355}
{"_e":"metro:bundling:progress","_t":1784254573786,"id":"1","progress":0.8880743173619988,"total":2589,"current":2373}
{"_e":"metro:bundling:progress","_t":1784254573886,"id":"1","progress":0.8880743173619988,"total":2601,"current":2399}
{"_e":"metro:bundling:progress","_t":1784254573987,"id":"1","progress":0.8880743173619988,"total":2602,"current":2410}
{"_e":"metro:bundling:progress","_t":1784254574087,"id":"1","progress":0.8880743173619988,"total":2614,"current":2424}
{"_e":"metro:bundling:progress","_t":1784254574188,"id":"1","progress":0.8880743173619988,"total":2617,"current":2444}
{"_e":"metro:bundling:progress","_t":1784254574288,"id":"1","progress":0.8880743173619988,"total":2619,"current":2451}
{"_e":"metro:bundling:progress","_t":1784254574388,"id":"1","progress":0.8880743173619988,"total":2621,"current":2455}
{"_e":"metro:bundling:progress","_t":1784254574488,"id":"1","progress":0.8880743173619988,"total":2625,"current":2462}
{"_e":"metro:bundling:progress","_t":1784254574588,"id":"1","progress":0.8880743173619988,"total":2631,"current":2469}
{"_e":"metro:bundling:progress","_t":1784254574688,"id":"1","progress":0.8880743173619988,"total":2636,"current":2474}
{"_e":"metro:bundling:progress","_t":1784254574844,"id":"1","progress":0.8880743173619988,"total":2640,"current":2481}
{"_e":"metro:bundling:progress","_t":1784254574944,"id":"1","progress":0.8880743173619988,"total":2649,"current":2493}
{"_e":"metro:bundling:progress","_t":1784254575044,"id":"1","progress":0.8880743173619988,"total":2700,"current":2508}
{"_e":"metro:bundling:progress","_t":1784254575144,"id":"1","progress":0.8880743173619988,"total":2707,"current":2526}
{"_e":"metro:bundling:progress","_t":1784254575245,"id":"1","progress":0.8880743173619988,"total":2713,"current":2534}
{"_e":"metro:bundling:progress","_t":1784254575345,"id":"1","progress":0.8880743173619988,"total":2778,"current":2556}
{"_e":"metro:bundling:progress","_t":1784254575447,"id":"1","progress":0.8880743173619988,"total":2786,"current":2569}
{"_e":"metro:bundling:progress","_t":1784254575547,"id":"1","progress":0.8880743173619988,"total":2787,"current":2572}
{"_e":"metro:bundling:progress","_t":1784254575647,"id":"1","progress":0.8880743173619988,"total":2787,"current":2574}
{"_e":"metro:bundling:progress","_t":1784254575778,"id":"1","progress":0.8880743173619988,"total":2787,"current":2575}
{"_e":"metro:bundling:progress","_t":1784254575878,"id":"1","progress":0.8880743173619988,"total":2789,"current":2582}
{"_e":"metro:bundling:progress","_t":1784254575980,"id":"1","progress":0.8880743173619988,"total":2789,"current":2593}
{"_e":"metro:bundling:progress","_t":1784254576082,"id":"1","progress":0.8880743173619988,"total":2799,"current":2612}
{"_e":"metro:bundling:progress","_t":1784254576182,"id":"1","progress":0.8880743173619988,"total":2813,"current":2637}
{"_e":"metro:bundling:progress","_t":1784254576282,"id":"1","progress":0.8880743173619988,"total":2824,"current":2661}
{"_e":"metro:bundling:progress","_t":1784254576384,"id":"1","progress":0.9156393512217658,"total":2830,"current":2708}
{"_e":"metro:bundling:progress","_t":1784254576484,"id":"1","progress":0.9156393512217658,"total":2848,"current":2724}
{"_e":"metro:bundling:progress","_t":1784254576584,"id":"1","progress":0.9156393512217658,"total":2849,"current":2726}
{"_e":"metro:bundling:progress","_t":1784254576684,"id":"1","progress":0.9156393512217658,"total":2868,"current":2734}
{"_e":"metro:bundling:progress","_t":1784254576827,"id":"1","progress":0.9156393512217658,"total":2868,"current":2735}
{"_e":"metro:bundling:progress","_t":1784254576927,"id":"1","progress":0.9156393512217658,"total":2885,"current":2738}
{"_e":"metro:bundling:progress","_t":1784254577029,"id":"1","progress":0.9156393512217658,"total":2885,"current":2741}
{"_e":"metro:bundling:progress","_t":1784254577129,"id":"1","progress":0.9156393512217658,"total":2900,"current":2760}
{"_e":"metro:bundling:progress","_t":1784254577229,"id":"1","progress":0.9242757760075363,"total":2901,"current":2789}
{"_e":"metro:bundling:progress","_t":1784254577332,"id":"1","progress":0.936287891094404,"total":2903,"current":2809}
{"_e":"metro:bundling:progress","_t":1784254577435,"id":"1","progress":0.936287891094404,"total":2938,"current":2819}
{"_e":"metro:bundling:progress","_t":1784254577540,"id":"1","progress":0.936287891094404,"total":2986,"current":2825}
{"_e":"metro:bundling:progress","_t":1784254577642,"id":"1","progress":0.936287891094404,"total":3015,"current":2842}
{"_e":"metro:bundling:progress","_t":1784254577740,"id":"1","progress":0.936287891094404,"total":3030,"current":2856}
{"_e":"metro:bundling:progress","_t":1784254577840,"id":"1","progress":0.936287891094404,"total":3035,"current":2860}
{"_e":"metro:bundling:progress","_t":1784254577940,"id":"1","progress":0.936287891094404,"total":3044,"current":2871}
{"_e":"metro:bundling:progress","_t":1784254578041,"id":"1","progress":0.936287891094404,"total":3047,"current":2879}
{"_e":"metro:bundling:progress","_t":1784254578141,"id":"1","progress":0.936287891094404,"total":3065,"current":2890}
{"_e":"metro:bundling:progress","_t":1784254578241,"id":"1","progress":0.936287891094404,"total":3080,"current":2900}
{"_e":"metro:bundling:progress","_t":1784254578342,"id":"1","progress":0.936287891094404,"total":3082,"current":2913}
{"_e":"metro:bundling:progress","_t":1784254578442,"id":"1","progress":0.936287891094404,"total":3082,"current":2928}
{"_e":"metro:bundling:progress","_t":1784254578543,"id":"1","progress":0.936287891094404,"total":3089,"current":2949}
{"_e":"metro:bundling:progress","_t":1784254578642,"id":"1","progress":0.936287891094404,"total":3096,"current":2973}
{"_e":"metro:bundling:progress","_t":1784254578742,"id":"1","progress":0.936287891094404,"total":3114,"current":2999}
{"_e":"metro:bundling:progress","_t":1784254578843,"id":"1","progress":0.936287891094404,"total":3114,"current":3002}
{"_e":"metro:bundling:progress","_t":1784254578943,"id":"1","progress":0.936287891094404,"total":3123,"current":3009}
{"_e":"metro:bundling:progress","_t":1784254579045,"id":"1","progress":0.936287891094404,"total":3123,"current":3011}
{"_e":"metro:bundling:progress","_t":1784254579064,"id":"1","progress":0.936287891094404,"total":3123,"current":3012}
{"_e":"metro:bundling:progress","_t":1784254579164,"id":"1","progress":0.936287891094404,"total":3131,"current":3025}
{"_e":"metro:bundling:progress","_t":1784254579265,"id":"1","progress":0.9365842200881371,"total":3134,"current":3033}
{"_e":"metro:bundling:progress","_t":1784254579367,"id":"1","progress":0.9368419128824654,"total":3147,"current":3046}
{"_e":"metro:bundling:progress","_t":1784254579468,"id":"1","progress":0.9387456790123457,"total":3150,"current":3052}
{"_e":"metro:bundling:progress","_t":1784254579568,"id":"1","progress":0.9393609473418997,"total":3150,"current":3053}
{"_e":"metro:bundling:progress","_t":1784254579682,"id":"1","progress":0.9412079617031998,"total":3150,"current":3056}
{"_e":"metro:bundling:progress","_t":1784254579782,"id":"1","progress":0.9430567901234569,"total":3150,"current":3059}
{"_e":"metro:bundling:progress","_t":1784254579883,"id":"1","progress":0.9473777777777779,"total":3150,"current":3066}
{"_e":"metro:bundling:progress","_t":1784254579989,"id":"1","progress":0.9529773417570666,"total":3152,"current":3077}
{"_e":"metro:bundling:progress","_t":1784254580089,"id":"1","progress":0.9549354307189066,"total":3159,"current":3087}
{"_e":"metro:bundling:progress","_t":1784254580189,"id":"1","progress":0.9686047908988945,"total":3160,"current":3110}
{"_e":"metro:bundling:progress","_t":1784254580292,"id":"1","progress":0.9717926782024794,"total":3168,"current":3123}
{"_e":"metro:bundling:progress","_t":1784254580392,"id":"1","progress":0.9767778200247423,"total":3168,"current":3131}
{"_e":"metro:bundling:progress","_t":1784254580502,"id":"1","progress":0.9768938323842832,"total":3184,"current":3147}
{"_e":"metro:bundling:progress","_t":1784254580608,"id":"1","progress":0.9769943965134558,"total":3198,"current":3161}
{"_e":"metro:bundling:progress","_t":1784254580709,"id":"1","progress":0.9769943965134558,"total":3220,"current":3182}
{"_e":"metro:bundling:progress","_t":1784254580809,"id":"1","progress":0.9791352776121244,"total":3242,"current":3208}
{"_e":"metro:bundling:progress","_t":1784254580909,"id":"1","progress":0.9791352776121244,"total":3272,"current":3232}
{"_e":"metro:bundling:progress","_t":1784254581010,"id":"1","progress":0.9848326177487721,"total":3284,"current":3259}
{"_e":"metro:bundling:progress","_t":1784254581111,"id":"1","progress":0.999,"total":3291,"current":3290}
{"_e":"metro:bundling:progress","_t":1784254581211,"id":"1","progress":0.999,"total":3291,"current":3291}
{"_e":"metro:bundling:done","_t":1784254581468,"id":"1","total":3291,"ms":16753.428873}
{"_e":"metro:client_log","_t":1784254596888,"level":"warn","data":["SafeAreaView has been deprecated and will be removed in a future release. Please use 'react-native-safe-area-context' instead. See https://github.com/AppAndFlow/react-native-safe-area-context"]}
{"_e":"metro:bundling:started","_t":1784254612886,"id":"3","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784254612886,"id":"3","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1784254612993,"id":"3","total":1,"ms":107.061183}
{"_e":"metro:client_log","_t":1784254630240,"level":"warn","data":["SafeAreaView has been deprecated and will be removed in a future release. Please use 'react-native-safe-area-context' instead. See https://github.com/AppAndFlow/react-native-safe-area-context"]}
{"_e":"metro:client_log","_t":1784254721520,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
{"_e":"metro:client_log","_t":1784254774528,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
{"_e":"metro:client_log","_t":1784254928412,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
{"_e":"metro:bundling:started","_t":1784256200326,"id":"7","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784256200326,"id":"7","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784256200332,"id":"7","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784256200434,"id":"7","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1784256200538,"id":"7","total":1,"ms":211.987279}
{"_e":"metro:client_log","_t":1784256223781,"level":"warn","data":["SafeAreaView has been deprecated and will be removed in a future release. Please use 'react-native-safe-area-context' instead. See https://github.com/AppAndFlow/react-native-safe-area-context"]}
{"_e":"metro:client_log","_t":1784256310429,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
{"_e":"metro:client_log","_t":1784256369062,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
{"_e":"metro:client_log","_t":1784256387012,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
{"_e":"metro:client_log","_t":1784256420271,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
{"_e":"metro:bundling:started","_t":1784258596497,"id":"9","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784258596520,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258596532,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258596843,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258597094,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258597194,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258597294,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258597394,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258597494,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258597594,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258597694,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258597809,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258597925,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784258598025,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1784258598321,"id":"9","total":1,"ms":1802.095472}
{"_e":"metro:client_log","_t":1784258647493,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
{"_e":"metro:bundling:started","_t":1784261637702,"id":"b","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784261637702,"id":"b","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784261637708,"id":"b","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784261637808,"id":"b","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784261638016,"id":"b","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784261638197,"id":"b","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1784261638447,"id":"b","total":1,"ms":744.986372}
{"_e":"metro:client_log","_t":1784261670156,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
{"_e":"metro:client_log","_t":1784261697357,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
{"_e":"metro:bundling:started","_t":1784263415011,"id":"d","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784263415013,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784263415016,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784263415116,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784263415858,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1784263416167,"id":"d","total":1,"ms":1153.731646}
{"_e":"metro:bundling:started","_t":1784266840468,"id":"f","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784266840469,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266840484,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266840743,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266840992,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266841361,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266841461,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266841561,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266841661,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266841761,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266841861,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266841961,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266842061,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266842173,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266842275,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266842846,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266843171,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266843304,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266843413,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266844439,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266844538,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266844818,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266844918,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266844939,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266845040,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266845141,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266845241,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266845341,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266845443,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266845546,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784266845580,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1784266846113,"id":"f","total":1,"ms":5644.099564}
{"_e":"metro:client_log","_t":1784266851912,"level":"error","data":["[TypeError: undefined is not a function]"]}
{"_e":"root:init","_t":1784293044660,"format":"v0-jsonl","version":"57.0.8"}
{"_e":"env:mode","_t":1784293044688,"nodeEnv":"development","babelEnv":"development","mode":"development"}
{"_e":"env:load","_t":1784293044699,"mode":"development","files":[],"env":{}}
{"_e":"devserver:start","_t":1784293048866,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
{"_e":"metro:config","_t":1784293054547,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"expo:dev-tools-plugin:load","_t":1784293056345,"plugins":[]}
{"_e":"metro:instantiate","_t":1784293066659,"atlas":false,"workers":6,"host":"::","port":8081}
{"_e":"metro:bundling:started","_t":1784293282277,"id":"1","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784293282278,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784293282305,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784293284527,"id":"1","progress":0.010000000000000002,"total":1,"current":1}
{"_e":"metro:bundling:progress","_t":1784293284627,"id":"1","progress":0.09,"total":9,"current":3}
{"_e":"metro:bundling:progress","_t":1784293284739,"id":"1","progress":0.25,"total":12,"current":6}
{"_e":"metro:bundling:progress","_t":1784293284907,"id":"1","progress":0.34027777777777785,"total":12,"current":7}
{"_e":"metro:bundling:progress","_t":1784293285240,"id":"1","progress":0.4444444444444444,"total":12,"current":8}
{"_e":"metro:bundling:progress","_t":1784293285340,"id":"1","progress":0.4444444444444444,"total":60,"current":12}
{"_e":"metro:bundling:progress","_t":1784293285447,"id":"1","progress":0.4444444444444444,"total":60,"current":13}
{"_e":"metro:bundling:progress","_t":1784293285550,"id":"1","progress":0.4444444444444444,"total":65,"current":19}
{"_e":"metro:bundling:progress","_t":1784293285649,"id":"1","progress":0.4444444444444444,"total":77,"current":41}
{"_e":"metro:bundling:progress","_t":1784293285750,"id":"1","progress":0.4444444444444444,"total":94,"current":52}
{"_e":"metro:bundling:progress","_t":1784293285850,"id":"1","progress":0.4444444444444444,"total":123,"current":67}
{"_e":"metro:bundling:progress","_t":1784293285950,"id":"1","progress":0.4444444444444444,"total":133,"current":83}
{"_e":"metro:bundling:progress","_t":1784293286050,"id":"1","progress":0.4444444444444444,"total":155,"current":92}
{"_e":"metro:bundling:progress","_t":1784293286155,"id":"1","progress":0.4444444444444444,"total":186,"current":105}
{"_e":"metro:bundling:progress","_t":1784293286255,"id":"1","progress":0.4444444444444444,"total":186,"current":110}
{"_e":"metro:bundling:progress","_t":1784293286355,"id":"1","progress":0.4444444444444444,"total":193,"current":119}
{"_e":"metro:bundling:progress","_t":1784293286471,"id":"1","progress":0.4444444444444444,"total":200,"current":129}
{"_e":"metro:bundling:progress","_t":1784293286534,"id":"1","progress":0.4444444444444444,"total":200,"current":130}
{"_e":"metro:bundling:progress","_t":1784293286653,"id":"1","progress":0.4444444444444444,"total":232,"current":142}
{"_e":"metro:bundling:progress","_t":1784293286753,"id":"1","progress":0.4444444444444444,"total":328,"current":149}
{"_e":"metro:bundling:progress","_t":1784293286928,"id":"1","progress":0.4444444444444444,"total":328,"current":150}
{"_e":"metro:bundling:progress","_t":1784293287051,"id":"1","progress":0.4444444444444444,"total":2077,"current":151}
{"_e":"metro:bundling:progress","_t":1784293287151,"id":"1","progress":0.4444444444444444,"total":2091,"current":183}
{"_e":"metro:bundling:progress","_t":1784293287251,"id":"1","progress":0.4444444444444444,"total":2105,"current":198}
{"_e":"metro:bundling:progress","_t":1784293287351,"id":"1","progress":0.4444444444444444,"total":2172,"current":215}
{"_e":"metro:bundling:progress","_t":1784293287465,"id":"1","progress":0.4444444444444444,"total":2225,"current":237}
{"_e":"metro:bundling:progress","_t":1784293287568,"id":"1","progress":0.4444444444444444,"total":2248,"current":253}
{"_e":"metro:bundling:progress","_t":1784293287668,"id":"1","progress":0.4444444444444444,"total":2271,"current":275}
{"_e":"metro:bundling:progress","_t":1784293287768,"id":"1","progress":0.4444444444444444,"total":2309,"current":306}
{"_e":"metro:bundling:progress","_t":1784293287868,"id":"1","progress":0.4444444444444444,"total":2322,"current":326}
{"_e":"metro:bundling:progress","_t":1784293287990,"id":"1","progress":0.4444444444444444,"total":2322,"current":327}
{"_e":"metro:bundling:progress","_t":1784293288090,"id":"1","progress":0.4444444444444444,"total":2326,"current":368}
{"_e":"metro:bundling:progress","_t":1784293288198,"id":"1","progress":0.4444444444444444,"total":2326,"current":410}
{"_e":"metro:bundling:progress","_t":1784293288305,"id":"1","progress":0.4444444444444444,"total":2326,"current":438}
{"_e":"metro:bundling:progress","_t":1784293288405,"id":"1","progress":0.4444444444444444,"total":2326,"current":466}
{"_e":"metro:bundling:progress","_t":1784293288506,"id":"1","progress":0.4444444444444444,"total":2326,"current":531}
{"_e":"metro:bundling:progress","_t":1784293288606,"id":"1","progress":0.4444444444444444,"total":2326,"current":588}
{"_e":"metro:bundling:progress","_t":1784293288707,"id":"1","progress":0.4444444444444444,"total":2326,"current":647}
{"_e":"metro:bundling:progress","_t":1784293288807,"id":"1","progress":0.4444444444444444,"total":2326,"current":713}
{"_e":"metro:bundling:progress","_t":1784293288908,"id":"1","progress":0.4444444444444444,"total":2326,"current":780}
{"_e":"metro:bundling:progress","_t":1784293289008,"id":"1","progress":0.4444444444444444,"total":2326,"current":850}
{"_e":"metro:bundling:progress","_t":1784293289108,"id":"1","progress":0.4444444444444444,"total":2326,"current":907}
{"_e":"metro:bundling:progress","_t":1784293289211,"id":"1","progress":0.4444444444444444,"total":2326,"current":969}
{"_e":"metro:bundling:progress","_t":1784293289311,"id":"1","progress":0.4444444444444444,"total":2326,"current":1040}
{"_e":"metro:bundling:progress","_t":1784293289411,"id":"1","progress":0.4444444444444444,"total":2326,"current":1108}
{"_e":"metro:bundling:progress","_t":1784293289511,"id":"1","progress":0.4444444444444444,"total":2326,"current":1179}
{"_e":"metro:bundling:progress","_t":1784293289611,"id":"1","progress":0.4444444444444444,"total":2326,"current":1239}
{"_e":"metro:bundling:progress","_t":1784293289711,"id":"1","progress":0.4444444444444444,"total":2326,"current":1307}
{"_e":"metro:bundling:progress","_t":1784293289811,"id":"1","progress":0.4444444444444444,"total":2326,"current":1375}
{"_e":"metro:bundling:progress","_t":1784293289911,"id":"1","progress":0.4444444444444444,"total":2326,"current":1447}
{"_e":"metro:bundling:progress","_t":1784293290011,"id":"1","progress":0.4444444444444444,"total":2326,"current":1508}
{"_e":"metro:bundling:progress","_t":1784293290111,"id":"1","progress":0.4614182344856344,"total":2326,"current":1580}
{"_e":"metro:bundling:progress","_t":1784293290211,"id":"1","progress":0.49289333852838557,"total":2326,"current":1633}
{"_e":"metro:bundling:progress","_t":1784293290311,"id":"1","progress":0.5347973005443715,"total":2326,"current":1701}
{"_e":"metro:bundling:progress","_t":1784293290412,"id":"1","progress":0.5738420738609268,"total":2326,"current":1762}
{"_e":"metro:bundling:progress","_t":1784293290512,"id":"1","progress":0.6115697239845065,"total":2326,"current":1819}
{"_e":"metro:bundling:progress","_t":1784293290613,"id":"1","progress":0.6665465865327388,"total":2326,"current":1899}
{"_e":"metro:bundling:progress","_t":1784293290713,"id":"1","progress":0.7100561967633445,"total":2326,"current":1960}
{"_e":"metro:bundling:progress","_t":1784293290813,"id":"1","progress":0.7594305724883536,"total":2326,"current":2027}
{"_e":"metro:bundling:progress","_t":1784293290913,"id":"1","progress":0.7974657485960396,"total":2346,"current":2095}
{"_e":"metro:bundling:progress","_t":1784293291013,"id":"1","progress":0.7974657485960396,"total":2415,"current":2124}
{"_e":"metro:bundling:progress","_t":1784293291115,"id":"1","progress":0.7974657485960396,"total":2472,"current":2162}
{"_e":"metro:bundling:progress","_t":1784293291215,"id":"1","progress":0.7974657485960396,"total":2474,"current":2174}
{"_e":"metro:bundling:progress","_t":1784293291315,"id":"1","progress":0.7974657485960396,"total":2519,"current":2215}
{"_e":"metro:bundling:progress","_t":1784293291416,"id":"1","progress":0.7974657485960396,"total":2536,"current":2243}
{"_e":"metro:bundling:progress","_t":1784293291519,"id":"1","progress":0.7974657485960396,"total":2569,"current":2280}
{"_e":"metro:bundling:progress","_t":1784293291628,"id":"1","progress":0.7974657485960396,"total":2606,"current":2318}
{"_e":"metro:bundling:progress","_t":1784293291728,"id":"1","progress":0.7974657485960396,"total":2659,"current":2329}
{"_e":"metro:bundling:progress","_t":1784293291831,"id":"1","progress":0.7974657485960396,"total":2675,"current":2348}
{"_e":"metro:bundling:progress","_t":1784293291931,"id":"1","progress":0.7974657485960396,"total":2687,"current":2378}
{"_e":"metro:bundling:progress","_t":1784293292033,"id":"1","progress":0.7974657485960396,"total":2707,"current":2408}
{"_e":"metro:bundling:progress","_t":1784293292133,"id":"1","progress":0.8159888890401819,"total":2710,"current":2448}
{"_e":"metro:bundling:progress","_t":1784293292234,"id":"1","progress":0.8444485836968604,"total":2714,"current":2494}
{"_e":"metro:bundling:progress","_t":1784293292333,"id":"1","progress":0.864934254482803,"total":2715,"current":2525}
{"_e":"metro:bundling:progress","_t":1784293292433,"id":"1","progress":0.8762830037348633,"total":2723,"current":2549}
{"_e":"metro:bundling:progress","_t":1784293292536,"id":"1","progress":0.8864205287942541,"total":2735,"current":2575}
{"_e":"metro:bundling:progress","_t":1784293292636,"id":"1","progress":0.8979424496100379,"total":2748,"current":2604}
{"_e":"metro:bundling:progress","_t":1784293292736,"id":"1","progress":0.9052557235874816,"total":2760,"current":2626}
{"_e":"metro:bundling:progress","_t":1784293292836,"id":"1","progress":0.9052557235874816,"total":2779,"current":2637}
{"_e":"metro:bundling:progress","_t":1784293292936,"id":"1","progress":0.9052557235874816,"total":2808,"current":2645}
{"_e":"metro:bundling:progress","_t":1784293293039,"id":"1","progress":0.9052557235874816,"total":2817,"current":2655}
{"_e":"metro:bundling:progress","_t":1784293293138,"id":"1","progress":0.9052557235874816,"total":2833,"current":2669}
{"_e":"metro:bundling:progress","_t":1784293293239,"id":"1","progress":0.9052557235874816,"total":2843,"current":2686}
{"_e":"metro:bundling:progress","_t":1784293293339,"id":"1","progress":0.9052557235874816,"total":2851,"current":2705}
{"_e":"metro:bundling:progress","_t":1784293293439,"id":"1","progress":0.9066178148700104,"total":2864,"current":2727}
{"_e":"metro:bundling:progress","_t":1784293293541,"id":"1","progress":0.9148469566887284,"total":2872,"current":2747}
{"_e":"metro:bundling:progress","_t":1784293293641,"id":"1","progress":0.9148469566887284,"total":2901,"current":2764}
{"_e":"metro:bundling:progress","_t":1784293293742,"id":"1","progress":0.9148469566887284,"total":2901,"current":2767}
{"_e":"metro:bundling:progress","_t":1784293293842,"id":"1","progress":0.9148469566887284,"total":2913,"current":2773}
{"_e":"metro:bundling:progress","_t":1784293293942,"id":"1","progress":0.9148469566887284,"total":2914,"current":2775}
{"_e":"metro:bundling:progress","_t":1784293294081,"id":"1","progress":0.9148469566887284,"total":2915,"current":2779}
{"_e":"metro:bundling:progress","_t":1784293294182,"id":"1","progress":0.9148469566887284,"total":2928,"current":2791}
{"_e":"metro:bundling:progress","_t":1784293294282,"id":"1","progress":0.9148469566887284,"total":2949,"current":2805}
{"_e":"metro:bundling:progress","_t":1784293294382,"id":"1","progress":0.9148469566887284,"total":2955,"current":2815}
{"_e":"metro:bundling:progress","_t":1784293294482,"id":"1","progress":0.9148469566887284,"total":2998,"current":2824}
{"_e":"metro:bundling:progress","_t":1784293294582,"id":"1","progress":0.9148469566887284,"total":3048,"current":2844}
{"_e":"metro:bundling:progress","_t":1784293294682,"id":"1","progress":0.9148469566887284,"total":3050,"current":2853}
{"_e":"metro:bundling:progress","_t":1784293294783,"id":"1","progress":0.9148469566887284,"total":3071,"current":2877}
{"_e":"metro:bundling:progress","_t":1784293294884,"id":"1","progress":0.9148469566887284,"total":3091,"current":2891}
{"_e":"metro:bundling:progress","_t":1784293294988,"id":"1","progress":0.9148469566887284,"total":3097,"current":2900}
{"_e":"metro:bundling:progress","_t":1784293295088,"id":"1","progress":0.9148469566887284,"total":3108,"current":2914}
{"_e":"metro:bundling:progress","_t":1784293295188,"id":"1","progress":0.9148469566887284,"total":3121,"current":2926}
{"_e":"metro:bundling:progress","_t":1784293295288,"id":"1","progress":0.9148469566887284,"total":3123,"current":2935}
{"_e":"metro:bundling:progress","_t":1784293295389,"id":"1","progress":0.9148469566887284,"total":3123,"current":2937}
{"_e":"metro:bundling:progress","_t":1784293295489,"id":"1","progress":0.9148469566887284,"total":3123,"current":2943}
{"_e":"metro:bundling:progress","_t":1784293295589,"id":"1","progress":0.9148469566887284,"total":3124,"current":2952}
{"_e":"metro:bundling:progress","_t":1784293295689,"id":"1","progress":0.9148469566887284,"total":3125,"current":2974}
{"_e":"metro:bundling:progress","_t":1784293295789,"id":"1","progress":0.9148469566887284,"total":3125,"current":2987}
{"_e":"metro:bundling:progress","_t":1784293295892,"id":"1","progress":0.9198330727820985,"total":3128,"current":3000}
{"_e":"metro:bundling:progress","_t":1784293295992,"id":"1","progress":0.9299565847185871,"total":3141,"current":3029}
{"_e":"metro:bundling:progress","_t":1784293296094,"id":"1","progress":0.9492962002534049,"total":3154,"current":3073}
{"_e":"metro:bundling:progress","_t":1784293296194,"id":"1","progress":0.9629720079436931,"total":3157,"current":3098}
{"_e":"metro:bundling:progress","_t":1784293296294,"id":"1","progress":0.9629720079436931,"total":3173,"current":3108}
{"_e":"metro:bundling:progress","_t":1784293296394,"id":"1","progress":0.9629720079436931,"total":3185,"current":3124}
{"_e":"metro:bundling:progress","_t":1784293296494,"id":"1","progress":0.9648012926893179,"total":3210,"current":3153}
{"_e":"metro:bundling:progress","_t":1784293296594,"id":"1","progress":0.9747663656275425,"total":3229,"current":3188}
{"_e":"metro:bundling:progress","_t":1784293296694,"id":"1","progress":0.9834384092814489,"total":3247,"current":3220}
{"_e":"metro:bundling:progress","_t":1784293296794,"id":"1","progress":0.9834384092814489,"total":3286,"current":3254}
{"_e":"metro:bundling:progress","_t":1784293296903,"id":"1","progress":0.999,"total":3300,"current":3300}
{"_e":"metro:bundling:done","_t":1784293297284,"id":"1","total":3300,"ms":15006.607121}
{"_e":"metro:client_log","_t":1784293311557,"level":"error","data":["[Error: [Reanimated] Failed to find host instance for a ref.]","\n\n\u001b[1mCall Stack\u001b[22m\n\u001b[2m\u001b[90m SkiaPictureView (\u001b]8;;<anonymous>\u0007<anonymous>\u001b]8;;\u0007)\u001b[39m\u001b[22m\n\u001b[2m\u001b[90m Canvas (\u001b]8;;http:/192.168.1.25:8081/index.ts.bundle\u0007http:/192.168.1.25:8081/index.ts.bundle\u001b]8;;\u0007)\u001b[39m\u001b[22m"]}
{"_e":"metro:bundling:started","_t":1784294061559,"id":"9","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784294061559,"id":"9","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1784294061634,"id":"9","total":1,"ms":75.165257}
{"_e":"metro:bundling:started","_t":1784295273557,"id":"b","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784295273557,"id":"b","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784295273562,"id":"b","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1784295273724,"id":"b","total":1,"ms":167.330607}
{"_e":"metro:bundling:started","_t":1784296589113,"id":"d","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784296589113,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296589124,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296589255,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296589338,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296589438,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296589538,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296589638,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296589738,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296589852,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296589952,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296590056,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296590156,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296590256,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784296590304,"id":"d","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1784296590410,"id":"d","total":1,"ms":1297.260922}
{"_e":"metro:bundling:started","_t":1784296639397,"id":"e","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784296639397,"id":"e","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1784296639495,"id":"e","total":1,"ms":97.436217}
{"_e":"metro:bundling:started","_t":1784299195451,"id":"f","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784299195476,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784299195479,"id":"f","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1784299196005,"id":"f","total":1,"ms":529.887212}
-8
View File
@@ -1,8 +0,0 @@
{
"devices": [
{
"installationId": "3ac39ffb-519e-4fcd-b686-c20d0a63c449",
"lastUsed": 1784299195276
}
]
}
+4
View File
@@ -1 +1,5 @@
node_modules
.expo/
modules/*/android/build/
android/keystore.properties
android/app/release.keystore
+1777 -190
View File
File diff suppressed because it is too large Load Diff
+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.
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Copyright (c) 2026 Lộc Phạm
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+750
View File
@@ -0,0 +1,750 @@
# PLAN — 7 hạng mục UI: PHOTO STYLE · tên sim · WATERMARK→FRAME · TEMP · FAVORITED · RESET* · ROTATE
Nhánh: `feat/vision-camera-v5` (RecipesCam, `C:/Users/locpham/RecipesCam` ↔ `/home/locpham/RecipesCam`)
Ngày: 2026-09-09. Trạng thái: **HOÀN TẤT cả 7 hạng mục** — F2 `6adf0b5`, F1 `d66ae3d`, F4 `7ed4301`, F6 `ba7e579`, F5 `ba971f1`, F3 `4131c9f` + `dcb74a7`, đổi tên chip frame `6788d51`, F7 `9ddaeac` + `0ce561c`. Mỗi commit đều qua typecheck (14), `assembleRelease` và verify trên `emulator-5554`; `git status --short` sạch (chỉ còn file plan này, untracked). **Follow-up 4 lỗi hành vi strip ROTATE đã sửa, verify trên máy và commit `da48cec` — xem mục 12.** **Toàn bộ câu hỏi mục 10 đã chốt** (Q1 `CLASSIC CHRIPES` giữ nguyên, Q4 chip không kèm tên sim, Q6 strip `RESET · AUTO · 0 · 90 · 180 · 270 · STRAIGHTEN (±45°)` — AUTO ngay sau RESET, Q7 ★ chỉ recipe).
Đây là plan riêng, **không** gộp vào `PLAN.md` (đang là kế hoạch native export engine).
Baseline typecheck: `node --stack-size=16000 ./node_modules/typescript/lib/tsc.js --noEmit` → **14 lỗi nền** (không được tăng).
---
## 0. Hiện trạng (mốc trong code)
| Thứ | Nơi |
|---|---|
| Tabs + nhãn | `src/components/ToolRail.tsx:5` (`TabId`), `:15-22` (`TOOLS`), `:26` (FRAME chỉ ở library) |
| Chip theo tab | `src/components/AdjustmentPanel.tsx:492` `case 'recipes'`, `:548` `case 'wb'`, `:587` `case 'frame'`, `:617` `case 'watermark'` |
| Cơ chế strip 1-hàng | `GroupKey` `:22`, `openGroup` `:217`, `groupDefs` `:415`, `groupChip` `:481`, `choiceChips` `:321` |
| `renderChip` (đã có `amberValue`, `trash`, `disabled`) | `:365` |
| Sims | `src/utils/defaultRecipes.ts:39-52` (`FILM_SIMS`), `filmSimToRecipe` `:55` |
| WB presets | `AdjustmentPanel.tsx:118` (`WB_PAIRS`), `wbActive()`/`wbApply` `:~380` |
| Recipes lưu trữ | `src/utils/storageUtils.ts` (`@camrecipe_pro:custom_recipes`) |
| `Recipe` type | `src/types/index.ts:87` |
| Reset | `App.tsx:506` `handleResetAll`, prop `onResetAll` (`App.tsx:1277`) |
| Watermark state | `App.tsx:86-102` (`DEFAULT_CUSTOM_WM`, `wmRotation`, `DEFAULT_GPS_WM`), `wmEditing` `App.tsx:1235` |
| Xoay ảnh (sau F7) | `App.tsx`: `photoRotation: 0\|90\|180\|270` + `photoStraighten` + `autoHorizon`/`horizonRoll`; strip `AdjustmentPanel.tsx` `groupDefs.rotate`; `rotateSkImage90` + `rotateSkImageBy` `src/utils/skiaImage.ts`, dùng ở `exportEngine.ts` + `Viewfinder.tsx` |
| Frame | `src/utils/frameUtils.ts:9` (`FRAMES`), `App.tsx:547` `handleUpdateFrame` |
Quy ước chung: file **CRLF** → sửa bằng python (`io.open(p, encoding='utf-8').read()` … ghi `newline='\r\n'`) hoặc `edit_file` cẩn thận; mọi lệnh shell prefix `export MSYS_NO_PATHCONV=1`; `PYTHONIOENCODING=utf-8` khi có non-ASCII.
---
## 1. F1 — Gom sim vào chip `PHOTO STYLE`
**Việc:** trong tab PRESETS, 8 chip sim (`FILM_SIMS.map`, `AdjustmentPanel.tsx:497`) không còn nằm trực tiếp trên hàng chip mà nằm trong strip của một group chip `PHOTO STYLE`.
**Cách làm**
1. `GroupKey` thêm `'style'`; `groupDefs.style`:
- `label: 'PHOTO STYLE'`, `off: 'sim-provia'`,
- `value`: id sim đang chọn (`currentRecipeId` nếu là sim, ngược lại `''`),
- `options`: `FILM_SIMS.map(s => ({ v: s.id, d: s.name }))`,
- `onPick`: `setShowRecipes(false); onSelectRecipe(filmSimToRecipe(sim))` (giữ nguyên hành vi cũ).
2. `case 'recipes'` (`:492`): thay khối `...FILM_SIMS.map(...)` bằng `renderChip(groupChip('style'))`; giữ `RECIPES` + `CREATE`.
3. Nhãn chip: **chỉ `PHOTO STYLE`**, không kèm tên sim (đã chốt — TopBar góc trái đã hiện `activeRecipe.name`). `groupDefs.style.value` vẫn dùng để chip sáng amber khi sim ≠ mặc định, nhưng không in value ra nhãn.
4. `amberValue` bật khi sim đang chọn ≠ sim khởi động (`sim-provia`).
**Kiểm chứng:** PRESETS chỉ còn `RESET · PHOTO STYLE · RECIPES · CREATE`; tap `PHOTO STYLE` → strip 8 sim; tap một sim → áp đúng look, chip đổi nhãn; luật "1 strip/lần" giữ; `RECIPES`/`CREATE` không đổi.
---
## 2. F2 — Đổi tên sim
`src/utils/defaultRecipes.ts:40-51`:
| id | tên mới |
|---|---|
| `sim-provia` | `PROVIPES` |
| `sim-velvia` | `VELVIPES` |
| `sim-astia` | `ASTIPES` |
| `sim-classic-chrome` | `CLASSIC CHRIPES` |
| `sim-classic-neg` | `CLASSIC NEGIPES` |
| `sim-eterna` | `ETERNIPES` |
| `sim-acros` | `ACRIPES` |
| `sim-leica` | `LEITZ STREETLIFE` |
- **Chỉ đổi `name`**, giữ `id` + `baseFilter` → phiên đã lưu (`selectedRecipeId`) và các `find(f => f.id === 'sim-provia')` (`App.tsx:349,507`) không gãy.
- Tên tự lan ra: chip PHOTO STYLE, TopBar (`activeRecipe.name`), strip recipe, picker simulation trong `RecipeCreateModal.tsx:330`.
- Dọn nhắc tên cũ trong text UI: `SettingsModal.tsx:135` ("camera + PROVIA …"); comment `App.tsx:194,332`, `RecipeCreateModal.tsx:27,152`.
- Recipe **bundled cũng đổi tên theo** (đã chốt): `defaultRecipes.ts` — `PROVIA STD` → `PROVIPES STD` (`:117`), `VELVIA VIVID` → `VELVIPES VIVID` (`:93`), `ACROS MONO` → `ACRIPES MONO` (`:141`), `CLASSIC NEG.` → `CLASSIC NEGIPES.` (`:69`). **Chỉ đổi `name`, giữ `id`** (`provia-default`, `velvia-default`, `acros-default`, `classic-neg-default`) → recipe đã lưu trong AsyncStorage vẫn trỏ đúng object.
- **Không** đổi: `RETRO AMBER 🏮` (`:165`) và `CINEMA SPRING/SUMMER/AUTUMN/WINTER` (`:190-265`) — không gắn sim nào.
**Kiểm chứng:** top bar + chip + modal theo tên mới; look (matrix) không đổi.
---
## 3. F3 — WATERMARK thành chip trong tab FRAME
**Chặn:** FRAME hiện chỉ có ở library (`ToolRail.tsx:26`) → ở camera watermark sẽ mất lối vào. Chọn (Q3):
- **(A, đề xuất)** bỏ filter `:26` → FRAME có ở cả 2 mode; frame picker cũng dùng được ở camera (hiện camera chỉ nhận frame từ recipe).
- (B) giữ FRAME library-only + camera vẫn có tab WATERMARK riêng → không đúng yêu cầu, loại.
**Kiểm chứng code — viewfinder camera có hiển thị đúng phần trong suốt của frame không? CÓ, không cần vẽ thêm gì.**
- `Viewfinder.tsx:1937` `renderCameraOverlays()` được gọi trong nhánh camera (`:2102`), ngay trên `<SkiaCamera>` (`:2025`) — layer này là `StyleSheet.absoluteFill`, tức feed phủ full màn hình và nằm **dưới** overlay.
- `:1974` `renderFrameOverlay()` gọi **chung** cho mọi frame, không phân nhánh theo mode:
- `:1778` polaroid: chỉ vẽ bar đen ngoài card + card trắng quanh cửa sổ; **cửa sổ để trống** → feed lộ qua (comment `:1782-1787` nói đúng điều này).
- `:1817` wallframe: bar đen ngoài frame rect + PNG mat qua `drawMatrix`; **cửa sổ trong suốt của PNG** cho feed lộ qua (cover-crop giữa, WYSIWYG với crop export — comment `:1821-1824`).
- `:1839` classic-white / `:1852` cinematic: viền trắng / bar đen theo `frameRect` = `region` (band tỉ lệ) → đã đúng ở camera.
- `polaroidGeo` `:1124`, `wallGeo` `:1131` **không** phụ thuộc mode (chỉ `selectedFrame` + `vw/vh`) → bản camera dùng đúng rect đó; hai nhánh library riêng (`:2162`, `:2242`) nằm **sau** `if (mode === 'camera') { … return }` (`:1995`) nên không ảnh hưởng.
- `ToolRail.tsx:10-11` đã ghi sẵn lý do: FRAME library-only vì "camera shows every frame WYSIWYG through the viewfinder overlays".
- Kết luận: **F3 thuần là việc UI** (cho tab FRAME hiện ở camera + đổi điều kiện `wmEditing`); export camera cũng đã dùng `frameId: selectedFrame` (`App.tsx:~906`, `~995`) nên không lệch WYSIWYG.
- Hai điểm nhỏ, **không phải bug**, chỉ để biết: (1) band tỉ lệ chỉ áp cho none/classic-white/cinematic (`:255`) — polaroid/wallframe bỏ qua ratio ở camera đúng như thiết kế; (2) wallframe đợi `wallImage` load mới vẽ (`:1819`) → thoáng một nhịp thấy feed trần.
**Kiểm chứng code 2 — anchor/crop live feed vào đúng cửa sổ trong suốt: ĐÃ CÓ SẴN, không phải việc mới.**
- `Viewfinder.tsx:1201` `cameraPhotoRect` = `polaroidGeo.window` → `wallGeo.window` → `region` (band tỉ lệ) → `null`; `:1211` đẩy rect đó (chuẩn hoá theo `vw/vh`) vào `feedRectSync` `:791` `[active, viewAspect, fx, fy, fw, fh]`.
- Worklet `handleFrame` `:903-960`: khi `feed[0] > 0` thì black surface + **cover-map cảm biến vào ĐÚNG rect đó** (nghịch đảo CTM `renderToTexture` để lấy vùng raw frame), thay vì cover-fit fullscreen. Tức là cửa sổ trong suốt của overlay và vùng feed hiển thị dùng **cùng một rect** → khớp pixel, không cần anchor tay.
- Khớp với export: `exportEngine.ts:155` crop tỉ lệ chạy **trước** bước frame, nên ảnh vào bước polaroid đã đúng aspect cửa sổ (`:462-500` `drawImageRect` lấp kín window); wallframe `:541-570` cover-crop giữa vào window 2:3. Camera capture truyền `aspect: aspectRatio === 'full' ? undefined : aspectRatio` (`App.tsx:~917`) → cùng công thức. Kết luận: **WYSIWYG ở cả 4 frame, 0 dòng code thêm.**
- **Pinch/drag cửa sổ frame ở CAMERA — ĐÃ CHỐT thuộc F3** (bước 7). Hiện `zoomWindowRect()` `:1139-1140` trả `null` khi `mode !== 'library'` và `handleCapture` camera không truyền `frameWindowZoom` (`App.tsx:~917`) → không làm bước 7 thì ảnh polaroid/wall chụp từ camera luôn center-crop 1x (vẫn WYSIWYG, chỉ mất tự do bố cục).
**Cách làm (A)**
1. `ToolRail.tsx`: xoá `{ id: 'watermark' }` khỏi `TOOLS` `:21`; xoá `'watermark'` khỏi `TabId` `:5` (grep hết chỗ dùng).
2. `AdjustmentPanel.tsx` `case 'frame'` `:587`: thêm `renderChip(groupChip('wm'))` nhãn `WATERMARK` (+ ` ON` khi gps/custom đang bật), `amberValue` khi có watermark bật.
3. Strip `wm` là **sub-panel**: khi `openGroup === 'wm'`, hàng chip chính được thay bằng hàng watermark (breadcrumb `‹ WATERMARK` + các chip hiện có ở `:617-660`: GPS ON/OFF, gpsColor/gpsName/gpsTime, CUSTOM ON/OFF, ROTATE, ACCEPT/CANCEL, wmColor/wmSize/wmFont). Giữ luật "1 strip/lần", không cần level-3.
4. Ô nhập `TYPE WATERMARK TEXT` (`:764`, điều kiện `activeTab === 'watermark'`) → đổi thành "sub-panel watermark đang mở".
5. `App.tsx`: lift cờ `wmPanelOpen` (state mới) + prop `wmPanelOpen`/`onToggleWmPanel`; `wmEditing={openTab === 'frame' && wmPanelOpen}` (`:1235`) để drag/pinch watermark trên ảnh (`Viewfinder.tsx:1280,1285,1526`) chỉ arm khi sub-panel mở.
6. `useEffect` reset `openGroup` khi đổi tab (`:229`) đã có → giữ.
7. **Pinch/drag nội dung trong cửa sổ frame ở CAMERA** (đã chốt; phần nặng nhất của F3):
- `Viewfinder.tsx` `zoomWindowRect()` `:1138`: bỏ `if (mode !== 'library') return null` → camera cũng trả cửa sổ (`polaroidGeo.window` / `wallGeo.window`).
- `feedRectSync` `:791` payload 6 → 9 số: `[active, viewAspect, fx, fy, fw, fh, s, u, v]` (`u,v` = tâm nguồn 0..1, cùng ngữ nghĩa `frameWindowZoom` của export); map trong worklet `:903-960` nhân scale + offset trước khi cover-map. Hook đẩy rect `:1211` truyền thêm `s,u,v` từ state zoom camera.
- Clamp như `clampZoomToWindow` `:1176` để cửa sổ không lộ nền; đổi frame/ratio → reset `s=1, u=v=0.5`.
- `App.tsx` nhánh camera `:917`: thêm `frameWindowZoom: viewfinderRef.current?.getFrameWindowZoom?.() ?? null` (đã có sẵn cho nhánh library `:~1011`, `Viewfinder.tsx:153/569`).
- Cử chỉ: gesture layer camera `:2041-2048` đang là responder 1 chạm của `onImageDrag*` (AE/AF + kéo tham số) → chỉ nhận 2 ngón khi cửa sổ frame đang bật, và phải nhường ngón 1 cho AE/AF; **xung đột cần xử lý**: pinch cạnh tranh với zoom preset native (`applyZoom`, `:2125`) và kéo EV/AE.
- Rủi ro: worklet + cử chỉ → **TB–cao**; verify riêng trên máy trước khi commit.
**Kiểm chứng:** camera + library đều thấy tab FRAME; chọn polaroid ở camera → vùng feed trong cửa sổ khớp đúng ảnh export (đối chiếu 1 ảnh chụp thật); pinch/drag được nội dung trong cửa sổ ở camera → ảnh export giữ đúng bố cục đã chọn (không lộ nền card/mat); ở camera chọn lần lượt `POLAROID`/`WALLFRAME`/`CLASSIC WHITE`/`CINEMATIC` → cửa sổ frame trong suốt, feed lộ đúng vị trí, không cần sửa lớp vẽ; FRAME → `WATERMARK` → strip watermark; bật CUSTOM → ô nhập hiện, kéo/thả watermark chạy; đóng panel → drag hết arm; RESET vẫn tắt mọi watermark.
**Đã làm (commit 1 `feat(frame): move the watermark controls into a FRAME sub-panel`) — 3 file:**
- `ToolRail.tsx`: `TabId` bỏ `'watermark'`, `TOOLS` bỏ mục WATERMARK; bỏ prop `mode` + filter library-only của FRAME → rail giống nhau ở cả camera lẫn library.
- `AdjustmentPanel.tsx`: `GroupKey` thêm `'wm'` (không phải strip thường, chỉ là cờ mở hàng phụ); `paramDefs` bỏ `watermark`; `case 'frame'`: `openGroup === 'wm'` → hàng **phụ** (`‹ FRAME` + GPS/CUSTOM/ROTATE/ACCEPT/CANCEL/COLOR/SIZE/FONT) **thay** hàng frame, ngược lại hàng frame + chip cuối `WATERMARK` (`active` khi sub-panel mở, `amberValue` khi có watermark bật); xoá hẳn `case 'watermark'` và các chip watermark cũ trong đó → chuyển thành `wmChip()`/`wmChips()`; ô nhập `TYPE WATERMARK TEXT` đổi điều kiện thành `activeTab === 'frame' && openGroup === 'wm' && customWm.enabled`; `useEffect(() => onWmPanelChange(openGroup === 'wm'), [openGroup])` để App biết lúc nào arm lớp drag của dấu.
- `App.tsx`: state `wmPanelOpen`; `wmEditing={openTab === 'frame' && wmPanelOpen}` (thay `openTab === 'watermark'`); `onWmEdit` (double-tap trên dấu trong khung hình) đổi từ `setOpenTab('watermark')` → `setOpenTab('frame')`; truyền `onWmPanelChange={setWmPanelOpen}`.
- Không đụng `Viewfinder.tsx`: đường vẽ watermark/WYSIWYG frame ở camera vốn đã đúng, F3 chỉ là chuyện điều hướng UI.
**Đã verify trên emulator-5554 (camera):**
- Rail: `PRESETS · FAVORITED · LIGHT · WB · FX · FRAME` — không còn WATERMARK.
- Hàng FRAME ở camera: `RESET · NO FRAME · CLASSIC BORDER · RETRO INSTANT · WALL FRAME · ROTATE n · WATERMARK` (CROP vắng mặt đúng thiết kế); `WATERMARK` → hàng phụ `RESET · ‹ FRAME · GPS WATERMARK OFF · CUSTOM WATERMARK ON · ROTATE n° · ACCEPT · CANCEL · TEXT COLOR · TEXT SIZE · TEXT FONT`; `‹ FRAME` quay lại đúng hàng frame.
- WYSIWYG frame ở camera (quét pixel cột x=540 và hàng y=1000): RETRO POLAROID mat trắng `(250,249,246)`, cửa sổ x 55..1025, y ~430..1730, feed lọt trong đúng cửa sổ; WALL FRAME bar đen `(0,0,0)` + mat xám `(91,91,91)`; CLASSIC BORDER viền trắng `(255,255,255)`; NO FRAME feed ra tới mép.
- Export giữ đúng bố cục: `camrecipe_pro_export_1789094424013.jpg` 960×1455, mat trắng `(250,249,245)` ngoài + nội dung camera trong; cửa sổ đo được 858×1146 → tỉ lệ **0,749** so với cửa sổ trên màn hình **0,746**.
- Arm đúng cả hai chiều: sub-panel **đóng** + có chữ → kéo trên preview đổi EV `0.0 → +1.3` (diff toàn màn 1.146.032 px); sub-panel **mở** → kéo (540,1100)→(320,600) để EV **nguyên +1.3**, diff chỉ 8.203 px so với nền nhiễu feed tự nhiên 11.591 px.
- **Watermark chữ tự nhập HIỆN đúng trong viewfinder camera**: đổi `TEXT COLOR → BLUE` rồi quét `#3b82f6` (±45/kênh) trong vùng feed → **571 px**, bbox `(543,1191)-(641,1212)`, ô lưới 60px tập trung ở (9,19)-(10,20) — đúng `wmAnchor()` (ax = 540, baseline ay ≈ 1212, glyph nằm trên baseline).
- **Ghi chú phương pháp — kết luận "không thấy watermark" trước đó là do đo sai, không phải bug, cũng không phải regression của F3:** so 2 ảnh chụp (bật/tắt CUSTOM, hoặc đổi nội dung chữ) không kết luận được vì (a) hiện/ẩn ô nhập làm preview đổi chiều cao → diff toàn màn hình, (b) feed camera tự nhiễu ~1,6–2,7 % pixel ở ngưỡng 30–45, (c) chữ amber `#f59e0b` trên feed ấm không tách được khỏi nền. Cách đúng: chọn một màu không có trong cảnh (`BLUE`) và đếm pixel đúng màu trên **một** ảnh. Cũng xác nhận: dấu vẫn được vẽ khi sub-panel đã đóng (`wmEditing = false` chỉ ảnh hưởng việc arm, không ảnh hưởng việc vẽ).
**Đổi tên chip frame (commit riêng `6788d51 feat(frame): rename the RETRO POLAROID chip to RETRO INSTANT`) — 3 file, chỉ 1 dòng code:**
- `src/utils/frameUtils.ts`: `FRAMES` — `{ id: 'polaroid', name: 'RETRO INSTANT' }` (id giữ nguyên `polaroid` → session cũ + `frameId` đã lưu không gãy).
- `src/components/Viewfinder.tsx` + `src/utils/exportEngine.ts`: chỉ sửa **comment** cho khớp tên mới (không đổi hành vi).
**Đã làm (commit 2 `feat(frame): pinch and drag the framed window on the camera`) — 2 file:**
- `Viewfinder.tsx`: `zoomWindowRect()` bỏ `if (mode !== 'library') return null` → camera cũng trả `polaroidGeo.window` / `wallGeo.window`; band tỉ lệ vẫn trả band nhưng export không crop qua cửa sổ nên không đụng. `feedRectSync` payload 6 → 9 số `[active, viewAspect, fx, fy, fw, fh, z, u, v]`; effect đẩy rect đọc thêm `frameZoomRef.current` và phụ thuộc thêm `libZoom`.
- Worklet `handleFrame`: chỉ khi `z > 1.001` mới crop — `k = s·z`, `canvas.translate(u0 − k·zwX, v0 − k·zwY)` + `scale(k)`. Tâm `(u,v)` là **toạ độ view** nên **phải** đi qua `toRaw()` giống rect cửa sổ; đưa thẳng vào mặt phẳng raw thì **đảo trục** khi sensor xoay (đúng lỗi đã gặp). `z = 1` → tâm ép về giữa, trùng nhánh cũ.
- Cử chỉ camera: `onImageDragStart` seed `panRef`/`pinchRef`; `onImageDragMove` nhánh mới `g.kind !== 'wm' && !wmArmed && zoomWindowRect()` — 2 ngón → pinch quanh trung điểm + `clampZoomToWindow`; 1 ngón → chỉ khi `g.kind === 'focus'` **và** đã vượt `TAP_SLOP` mới pan (nhường ngón 1 cho AE/AF + kéo tham số); `onImageDragEnd` dọn cả ba ref.
- `useEffect([mode, selectedFrame])`: ở camera → `setLibZoom({ s: 1, tx: 0, ty: 0 })` (đổi frame hoặc vào lại camera thì bắt đầu từ 1x giữa).
- `App.tsx` nhánh capture camera: `const frameZoom = viewfinderRef.current?.getFrameWindowZoom?.() ?? null` đọc **lúc bấm shutter** (hàng đợi render chạy sau cú chụp kế tiếp nên không đọc lúc render), truyền `frameWindowZoom: frameZoom`.
**Kiểm chứng commit 2:** theo quy trình mục 9 (typecheck 14 → `assembleRelease` → install `emulator-5554`) rồi thử cử chỉ trong cửa sổ POLAROID / WALL FRAME ở camera (pinch 2 ngón đổi tỉ lệ, pan 1 ngón dịch nội dung, đều bị kẹp trong cửa sổ) và đối chiếu ảnh export với preview; 1 ngón ngoài cửa sổ vẫn là AE/AF + kéo EV như cũ.
---
## 4. F4 — Gom WB vào chip `TEMP`
**Việc:** các chip `AUTO…FLUOR` (`:558-570`) **và** chip tham số `COLOR TEMP` (`paramDefs.wb[0]`) rời hàng chip, vào strip của group chip `TEMP`; chip `TEMP` hiển thị giá trị đang chọn: `TEMP AUTO`, `TEMP 5000K`, `TEMP FLUOR`.
**Cách làm**
1. `GroupKey` thêm `'temp'`; `groupDefs.temp`:
- `off: 'auto'`,
- `value`: `wbChoice` ≠ null → preset đó; ngược lại `` `${temperature}K` ``,
- `label: 'TEMP'` + value,
- `options`: 7 preset + mục `COLOR TEMP` đặc biệt,
- `onPick`: preset → `wbApply(v)`; `COLOR TEMP` → `toggleParam('temperature')` để mở hàng slider phía trên.
2. `case 'wb'` `:548`: bỏ `choiceChips('wb', …)` và chip `temperature` khỏi `paramChips(paramDefs.wb)` (`TINT` + `WB SHIFT` vẫn ở hàng chính); thêm `renderChip(groupChip('temp'))`.
3. `amberValue` khi WB ≠ neutral/AUTO.
4. **Edge:** kéo slider COLOR TEMP phải `setWbChoice(null)` để chip rơi về `TEMP xxxxK` (hiện `wbChoice` chỉ set trong `wbApply`).
**Kiểm chứng:** WB chỉ còn `RESET · TEMP … · TINT · WB SHIFT · COLOR CHROME · CHROME BLUE`; `TEMP` → strip 7 preset + COLOR TEMP; chọn `FLUOR` → `TEMP FLUOR`; kéo slider → `TEMP 4000K`; chọn `AUTO` → `TEMP AUTO`, nhiệt 5500K/tint 0.
---
## 5. F5 — Tab `FAVORITED`
**Cách làm**
1. `src/utils/storageUtils.ts`: key `@camrecipe_pro:favorites` — `getFavoriteIds(): Promise<string[]>`, `setFavorite(id, on)`. Dùng **danh sách id** (không dùng cờ trên `Recipe`) vì `DEFAULT_RECIPES` không persist → cờ trên object bundled sẽ mất. Dùng cho sim id, recipe bundled và recipe custom.
2. `App.tsx`: state `favoriteIds: string[]`, hydrate cùng `recipes`; `handleToggleFavorite(id)` (optimistic + ghi AsyncStorage); truyền `favoriteIds` + handler xuống panel.
3. `ToolRail.tsx`: thêm `{ id: 'favorites', label: 'FAVORITED' }` ngay sau `PRESETS` (rail là ScrollView ngang nên đủ chỗ).
4. `AdjustmentPanel.tsx`: `case 'favorites'` — chip cho từng recipe yêu thích (`label` = `name`, `active` = đang chọn, `onPress` = `onSelectRecipe`), rỗng → chip disabled `NO FAVORITES YET - TAP ★ ON A RECIPE`; kèm nút bỏ yêu thích.
5. Đánh dấu ★ **chỉ cho recipe** (đã chốt Q7 — sim không cần, `Recipe` đã lưu sim qua `baseFilter`): `renderChip` có tiền lệ nút phụ (`trash` `:373`) → thêm biến thể `star`/`starred` + `onPressStar`, dùng trên chip trong strip `RECIPES` và trong FAVORITED; **không** thêm ★ vào strip `PHOTO STYLE`.
6. `resetEditsToRecipe` / `handleResetAll` **không** đụng `favoriteIds`.
**Kiểm chứng:** ★ một recipe → hiện trong FAVORITED; tắt app mở lại → còn nguyên; bỏ ★ → biến mất; tap chip → áp đúng look; tab rỗng hiện đúng empty state.
**Đã làm (commit `feat(panel): add a FAVORITED tab with a star on every recipe`)** — 4 file:
- `src/utils/storageUtils.ts`: `FAVORITES_KEY = '@camrecipe_pro:favorites'`, `getFavoriteIds(): Promise<string[]>` (guard `Array.isArray` + lọc string, lỗi → `[]`) và `setFavoriteId(id, on)` (đọc lại rồi ghi, dedupe bằng `Set`).
- `src/components/ToolRail.tsx`: `TabId` + `'favorites'`, chip `FAVORITED` ngay sau `PRESETS`.
- `src/components/AdjustmentPanel.tsx`: `TabId` + `'favorites'`; `ChipDef` thêm `star`/`starred`/`onPressStar`; nhánh cuối của `renderChip` thành `c.trash || c.star` với **hai nút phụ độc lập** (recipe custom giữ cả ★ lẫn 🗑); `paramDefs.favorites = []`; `case 'favorites'` (chip = `recipes.filter(r => favoriteIds.includes(r.id))`, rỗng → chip disabled); ★ trên mọi chip của strip `RECIPES`; prop `favoriteIds` + `onToggleFavorite`.
- `App.tsx`: state `favoriteIds: string[]`, hydrate trong `initSession`, `handleToggleFavorite(id)` (optimistic `setFavoriteIds` + `setFavoriteId(...).catch(warn)`), truyền xuống panel. ★ **không** nằm trong `handleResetAll`/`resetDirty` nên RESET không đụng tới.
Khác plan: hàm tên `setFavoriteId` (không phải `setFavorite`); empty state viết `NO FAVORITES YET - TAP THE STAR ON A RECIPE` thay ký tự `★`.
**Đã verify trên emulator-5554:** rail có `FAVORITED`; ★ `VELVIPES VIVID` trong strip RECIPES → icon xám `(161,161,170)` 191 px chuyển amber `#f59e0b` 396 px, top bar **không** đổi look, `RESET` vẫn xám (không tính vào dirty); tab FAVORITED liệt kê đúng chip + ★ amber; tap chip → top bar đổi thành `VELVIPES VIVID`; bỏ ★ → chip biến mất, hiện empty state; `force-stop` rồi mở lại → vẫn còn (AsyncStorage + hydrate trong `initSession`); strip `PHOTO STYLE` **không** có nút phụ; recipe custom (tạo thử `FAVTEST`) có **2** nút phụ cạnh nhau (w=59 + 59) và 🗑 xoá sạch.
---
## 6. F6 — `RESET *` + amber khi có thay đổi
**Cách làm**
1. `renderChip` đã hỗ trợ `amberValue` (chữ amber, không viền) → `{ label: resetDirty ? 'RESET *' : 'RESET', amberValue: resetDirty }`.
2. `resetDirty` tính ở `App.tsx` (nơi có `DEFAULT_ADJUSTMENTS`, `DEFAULT_CUSTOM_WM`, `DEFAULT_GPS_WM`, `DEFAULT_CROP_RECT`) và truyền xuống prop mới `resetDirty: boolean` — tránh export thêm hằng số.
3. Định nghĩa dirty: `selectedRecipe?.id !== 'sim-provia'` ∨ adjustments khác `DEFAULT_ADJUSTMENTS` ∨ `selectedFrame !== 'none'` ∨ `customWm`/`customWmApplied` khác default ∨ `gpsWm` khác default ∨ `useGeotag` ∨ `wmRotation !== 0` ∨ `photoRotation !== 0` ∨ `wallLandscape` ∨ crop khác default.
4. **Đồng bộ (đã chốt):** mọi thứ tính vào dirty phải bị `handleResetAll` (`App.tsx:506`) xoá. Các mục watermark/rotate/frame đã bổ sung ở commit `7454ea1`; **còn thiếu `cropRatio` → `'none'` và `cropRect` → `DEFAULT_CROP_RECT`** → bổ sung, nếu không `RESET *` không bao giờ tắt. Đã chốt **giữ nguyên** `mode` + `aspectRatio` khi reset (không tính vào dirty).
**Kiểm chứng:** mặc định → `RESET` xám; đổi 1 thông số bất kỳ (kể cả chỉ kéo slider, đổi frame, đổi WB) → `RESET *` amber; tap → mọi thứ về mặc định (`PROVIPES`) và chip về `RESET` xám.
---
## 7. F7 — Chip `ROTATE`: 0/90/180/270 + xoay tay + auto chân trời
**Việc (đã chốt):** chip ROTATE hiện là toggle `ROTATE 90`/`ROTATE 0` (`AdjustmentPanel.tsx:600`, `App.tsx:63`) → group chip mở strip: **`RESET · AUTO · 0 · 90 · 180 · 270 · STRAIGHTEN (±45°)`** — AUTO dời lên **ngay sau RESET** (yêu cầu giữa lúc làm, khác thứ tự `… · AUTO` ghi ban đầu ở Q6).
- `0/90/180/270` = xoay nhanh (quarter turn); `STRAIGHTEN` = slider ±45° chỉnh tay; `AUTO` = cân theo chân trời.
- **Auto và tay kết hợp được**: `AUTO` lấy chân trời làm mốc, sau đó kéo `STRAIGHTEN` để tinh chỉnh trên mốc đó (góc hiệu dụng = auto + offset).
- `RESET` trong strip: `quarter = 0`, `straighten = 0`, `autoHorizon = false` (RESET toàn cục ở `App.tsx:506` cũng vậy).
**Phần không thêm dependency**
1. `App.tsx`: `photoRotation: 0|90` → `photoRotation: 0|90|180|270` + `photoStraighten: number` (−45..45, offset tay) + `autoHorizon: boolean`; map session cũ (`photoRotation 90` → `90`). **Góc nghiêng hiệu dụng = `photoStraighten + (autoHorizon ? roll : 0)`** — dấu là **`+roll`** (plan ban đầu ghi `−roll` là SAI, đã sửa và kiểm chứng: nghiêng máy **+10°** → ảnh xuất quay **10° theo chiều kim đồng hồ**, xem mục verify bên dưới); auto làm mốc, slider là tinh chỉnh cộng thêm, tắt AUTO thì về đúng `photoStraighten`. `horizonRoll` đo từ `Accelerometer` là số nguyên độ (lượng tử 1°).
2. `src/utils/skiaImage.ts`: thêm `rotateSkImageBy(image, deg)` (surface đủ lớn + `canvas.rotate`); giữ `rotateSkImage90` cho quarter-turn.
3. Áp ở `exportEngine.ts:142` + `Viewfinder.tsx:330`: quarter trước, straighten sau, kèm scale bù để không lộ góc trống.
4. Chip `ROTATE` ghép nhãn `parts.filter(Boolean).join(' · ')` từ `['ROTATE '+photoRotation` (bỏ số khi `photoRotation === 0 && autoHorizon`, khi đó chỉ còn `ROTATE`), `autoHorizon ? 'AUTO' : null`, `photoStraighten ? ±deg : null]`. Nhãn thực tế đã verify: `ROTATE 0`, `ROTATE 180` (chỉ số), `ROTATE · AUTO`, `ROTATE 180 · AUTO`, `ROTATE · AUTO · +5.5°`; `amberValue` bật khi `photoRotation || photoStraighten || autoHorizon`.
5. `handleResetAll` → `photoRotation=0`, `photoStraighten=0`, `autoHorizon=false`, `horizonRoll=0`; chip `RESET` trong strip cũng tắt AUTO. `resetDirty` (F6) thêm `autoHorizon` để `RESET *` vẫn tắt được.
**Phần auto chân trời (Q5: đồng ý thêm dep) — đã làm ở commit (2)** — `expo-sensors: ~57.0.2`, cài bằng `MSYS_NO_PATHCONV=1 node node_modules/expo/bin/cli install expo-sensors` (**`npx` hỏng từ bash → gọi thẳng `node_modules/expo/bin/cli`**); autolink qua `expo-modules-autolinking` (log build có `expo-sensors (57.0.2)`), **không cần plugin/quyền nào trong `app.json`**; **bắt buộc build lại native/APK**, JS reload không đủ.
- Đo roll bằng `Accelerometer` (không `DeviceMotion`), mẫu 100 ms, **chỉ subscribe khi `autoHorizon && mode === 'library'`**; bỏ mẫu khi `hypot(x,y) < 0.2` (máy nằm phẳng) hoặc `|góc| > 45` (dựng đứng) để level không đuổi theo nhiễu; `horizonRoll = Math.round(atan2(x, y) * 180/π)` (lượng tử 1°).
- Level line trên preview: `autoRoll = autoHorizon && mode === 'library' ? horizonRoll : null`; vạch rộng 55% (4 px nền `rgba(0,0,0,0.5)` + 2 px nét), `transform: [{ rotate: autoRoll + 'deg' }]`, **amber `#f59e0b` khi `|autoRoll| <= 1`**, trắng khi lệch; vẽ ở **cả 3 nhánh library** (polaroid, wall, mặc định).
- Góc bake vào export đi **cùng một đường** với straighten tay: `effectiveStraighten = photoStraighten + (autoHorizon && mode === 'library' ? horizonRoll : 0)` → `exportOptions.photoStraighten` + prop `photoStraighten` của `Viewfinder`.
- Hệ quả (đã thực hiện): F7 **tách 2 commit** — (1) quarter + straighten tay (không dep), (2) auto chân trời (kèm dep + build native).
**Kiểm chứng:** xuất ảnh ở 90/180/270 → kích thước pixel + hướng đúng; straighten ±5° nghiêng đúng chiều; không lộ góc trong; frame/crop khớp preview; auto chân trời: nghiêng máy ~10° → ảnh xuất ra thẳng; bật AUTO rồi kéo STRAIGHTEN ±2° → tổng góc = auto + offset; chọn `180` khi auto bật → ảnh vẫn thẳng (quarter + auto độc lập).
**Đã làm (commit 1 `9ddaeac feat(frame): give ROTATE quarter turns plus a fine straighten`) — 5 file:**
- `src/utils/skiaImage.ts`: thêm `rotateSkImageBy(image, deg)` (surface đủ lớn + `canvas.rotate`, crop giữa giữ tỉ lệ gốc → không lộ góc trống); giữ `rotateSkImage90` cho quarter-turn.
- `App.tsx`: `photoRotation` mở rộng `0|90|180|270` + thêm `photoStraighten`; `handleResetAll` xoá cả hai; `resetDirty` tính thêm chúng.
- `src/utils/exportEngine.ts` + `Viewfinder.tsx`: quarter trước, straighten sau.
- `AdjustmentPanel.tsx`: `photoRotation` thành `groupDefs.rotate` (strip `RESET · 0 · 90 · 180 · 270 · STRAIGHTEN (±45°)`, lúc này chưa có AUTO) + `openParam === 'straighten'` mở slider ±45°.
**Đã làm (commit 2 `0ce561c feat(frame): level the photo to the horizon with AUTO`) — 5 file:** `App.tsx` (subscribe `Accelerometer` + `horizonRoll` + `effectiveStraighten`), `AdjustmentPanel.tsx` (chip `AUTO` ngay sau `RESET` + nhãn ghép + `amberValue`), `Viewfinder.tsx` (level line), `package.json`/`package-lock.json` (`expo-sensors`). Chi tiết ở các gạch đầu dòng phía trên.
**Verify commit 2 trên `emulator-5554`** (điều khiển cảm biến bằng `adb -s emulator-5554 emu sensor set acceleration x:y:z`, mặc định `0:9.77631:0.812349`):
- Strip đúng thứ tự `ROTATE · RESET · AUTO · 0 · 90 · 180 · 270 · STRAIGHTEN (±45°)`; nhãn chip lần lượt `ROTATE 0` → `ROTATE · AUTO` → `ROTATE · AUTO · +5.5°` → `ROTATE 180 · AUTO`; `RESET *` amber ở mọi trạng thái đã đổi.
- Dấu `+roll` chứng minh bằng ảnh thật: nghiêng `(1.703, 9.661, 0.81)` (= +10°) → khớp nhất `PIL rotate(-10)` (MAD **1.18**, so với **47.9** khi không xoay); nghiêng ngược `(-1.703, …)` → `PIL rotate(+10)` (MAD 1.16) ⇒ đối xứng hai chiều.
- AUTO + STRAIGHTEN +5.5 → tổng **+15.5** (MAD 1.24); quarter `180` + AUTO → `base.rotate(180)` rồi model(−10°) (MAD 1.13) ⇒ quarter và auto độc lập, cộng được.
- Level line: roll 0 → ~594 px amber (55% của 1080) ở hàng y≈1199–1203; roll +10 → vạch dốc **9,93°** trên màn hình (đúng bằng góc nghiêng máy).
- `RESET` toàn cục và `RESET` trong strip đều tắt AUTO (chip về `ROTATE 0`, `RESET` hết amber). Cảm biến emulator đã trả về giá trị mặc định sau khi test.
- Lưu ý khi đo: **cài lại APK sẽ reset look/session của app** → luôn xuất lại ảnh nền (base) mới sau mỗi lần install trước khi so ảnh.
---
## 8. Thứ tự thực hiện & commit
| Phase | Hạng mục | Rủi ro | Ghi chú |
|---|---|---|---|
| A | F2 đổi tên → F1 PHOTO STYLE → F4 TEMP → F6 RESET* | thấp | thuần UI, 1 commit/hạng mục |
| B | F5 FAVORITED | thấp–TB | thêm storage key, không đổi shape dữ liệu cũ |
| C | F3 watermark → FRAME **+ pinch/drag cửa sổ frame ở camera** | TB–cao | đụng `TabId`, `wmEditing`, `feedRectSync`/worklet, cử chỉ 2 ngón; tách 2 commit: UI trước, pinch/drag sau |
| D | F7 rotate | cao | đổi kiểu state + đường export; **auto: `expo-sensors` + build lại native** |
Mỗi hạng mục: sửa → typecheck (14) → build → install → verify trên máy → commit riêng (`feat(...)`: mô tả hành vi). F7 và F3 mỗi hạng mục tách 2 commit (F3: UI trước → pinch/drag camera sau; F7: manual trước → auto sau).
---
## 9. Quy trình kiểm chứng (dùng lại)
```bash
export MSYS_NO_PATHCONV=1
cd /c/Users/locpham/RecipesCam
node --stack-size=16000 ./node_modules/typescript/lib/tsc.js --noEmit 2>&1 | grep -c "error TS" # = 14
cd android && cmd /c gradlew.bat assembleRelease # ~1 phút
adb -s emulator-5554 install -r app/build/outputs/apk/release/app-release.apk
```
Trên máy: `uiautomator dump` lấy toạ độ (helper scratchpad `ui.py`), `screencap` + lấy màu nền chip ở `(x1−4, cy)` để phát hiện chip active (amber ≈ `(63,50,32)`/`(48,35,15)`, neutral ≈ `(31,31,35)`/`(27,27,30)`); tap chỉ ăn khi IME đã đóng (tap lần 2 sau khi gõ); toạ độ tool-rail đổi theo tab → luôn dump lại; `which`/`locale`/`sleep` không có → dùng `powershell.exe -Command "Start-Sleep -Seconds N"`.
---
## 10. Câu hỏi cần chốt trước khi làm
1. ~~`ASTIPCES` hay `ASTIPES`?~~ → **`ASTIPES`**; ~~`CLASSIC CHRIPES` hay `CLASSIC CHROMES`?~~ → **giữ `CLASSIC CHRIPES`**.
2. ~~Recipe bundled có đổi tên theo không?~~ → **Có**: `PROVIPES STD`, `VELVIPES VIVID`, `ACRIPES MONO`, `CLASSIC NEGIPES.` (giữ nguyên id).
3. ~~Cho tab FRAME hiện ở cả camera?~~ → **Có**, và **kèm pinch/drag cửa sổ frame ở camera** (F3 bước 7).
4. ~~Chip `PHOTO STYLE` có hiện tên sim không?~~ → **Không** (TopBar góc trái đã hiện tên).
5. ~~Cho phép thêm `expo-sensors`?~~ → **Có**, chấp nhận build lại native.
6. ~~Xoay tay gồm những gì?~~ → **`RESET · AUTO · 0 · 90 · 180 · 270 · STRAIGHTEN ±45°`** (AUTO dời lên ngay sau RESET khi làm), auto + tay kết hợp được.
7. ~~FAVORITED gồm cả sim không?~~ → **Chỉ recipe** (sim đã nằm trong recipe qua `baseFilter`).
8. ~~RESET có reset crop không?~~ → **Có, reset `cropRatio`/`cropRect`**; mode + aspect **vẫn giữ** như hiện tại.
---
## 11. Đối chiếu 7 hạng mục yêu cầu → commit
| # | Yêu cầu | Commit |
|---|---|---|
| 1 | Gom sim vào chip `PHOTO STYLE` | `d66ae3d` |
| 2 | Đổi tên sim (kèm recipe bundled) | `6adf0b5` |
| 3 | `WATERMARK` → chip trong `FRAME` (+ pinch/drag cửa sổ frame ở camera) | `4131c9f` + `dcb74a7` |
| 4 | WB presets vào chip `TEMP` | `7ed4301` |
| 5 | Tab `FAVORITED` | `ba971f1` |
| 6 | `RESET *` amber khi có thay đổi | `ba7e579` |
| 7 | Chip `ROTATE` (0/90/180/270 · STRAIGHTEN ±45° · AUTO) | `9ddaeac` + `0ce561c` |
Phát sinh trong lúc làm: đổi tên chip frame `RETRO POLAROID` → `RETRO INSTANT` (`6788d51`).
---
## 12. Follow-up — 4 lỗi hành vi của strip `ROTATE` (commit `da48cec`)
Bốn lỗi phát hiện sau khi F7 xong, sửa chung một commit `fix(frame): make the ROTATE strip behave on switching parameters`: `App.tsx` + `src/components/AdjustmentPanel.tsx`.
| # | Lỗi | Nguyên nhân | Cách sửa |
|---|---|---|---|
| 1 | Sau khi dùng AUTO straighten, chuyển sang tham số khác thì hàng slider STRAIGHTEN vẫn còn | chip frame (`choiceChips('fr', …)`) và chip `RESET` chỉ gọi handler của chúng, không đóng `openParam` | chip frame gọi `if (openParam) onOpenParamChange(null)` trước `onUpdateFrame`; chip `RESET` thêm `setOpenGroup(null)` + đóng `openParam` trước `onResetAll` |
| 2 | Mở ảnh khác từ thư viện phải reset straighten + AUTO về mặc định | `handlePickImage` chỉ `setPhotoRotation(0)` | thêm `setPhotoStraighten(0)` + `setAutoHorizon(false)` + `setHorizonRoll(0)` |
| 3 | Trong straighten thủ công, nút `<` không hiện lại strip | `toggleParam` đóng `openGroup`, `<` gọi `toggleParam` nên strip biến mất | thêm map `PARAM_GROUP = { straighten: 'rotate', temperature: 'temp' }`; `<` gọi `toggleParam` rồi `setOpenGroup(PARAM_GROUP[key])` (set sau thắng) |
| 4 | Đang straighten thủ công, nhấn AUTO không có tác dụng | `effectiveStraighten = photoStraighten + roll`; roll≈0 nên bật AUTO không đổi gì | nhánh `auto` trong `groupDefs.rotate.onPick`: khi **bật** (`!autoHorizon`) gọi `onStraighten(0)` để ép về đường chân trời sensor; nhấn lại vẫn tắt AUTO như cũ |
Kiểm chứng trên `emulator-5554` (typecheck vẫn **14**, `assembleRelease` OK, cài lại APK rồi mở một ảnh thư viện):
- Đặt STRAIGHTEN thủ công **+22°** → chip `ROTATE 0 · +22°`; nhấn **AUTO** → chip còn `ROTATE · AUTO`, mở lại slider thấy **0°** (item 4).
- Mở slider rồi nhấn chip frame **CLASSIC BORDER** → hàng slider STRAIGHTEN biến mất (item 1); nhấn `RESET` cũng đóng hàng slider và đưa về `ROTATE 0` (item 1, phần RESET).
- Nhấn `<` trong hàng slider → strip `RESET · AUTO · 0 · 90 · 180 · 270 · STRAIGHTEN (±45°)` hiện lại (item 3).
- Mở ảnh thứ hai từ thư viện khi AUTO đang bật → chip về `ROTATE 0` (straighten 0, AUTO tắt) (item 2).
Ghi chú: AUTO vẫn tắt được bằng cách nhấn lại chính chip AUTO; strip `RESET` xoá cả turn + góc + auto.
---
## 13. Follow-up — vạch amber của bubble level còn đọng trên ảnh (commit `d6b3ebd`)
Yêu cầu: sau khi nhấn `ROTATE` → `AUTO`, chuyển sang chip khác thì vạch amber vẫn nằm trên ảnh.
Đó **không phải** hàng slider STRAIGHTEN mà là vạch bubble level vẽ đè lên ảnh trong `Viewfinder` (`levelOverlay`, hiện khi `autoRoll != null`, amber khi `|roll| <= 1`). Nguyên nhân: `autoRoll = autoHorizon && mode === 'library' ? horizonRoll : null` chỉ phụ thuộc AUTO, không biết strip `ROTATE` đã đóng hay chưa.
| # | Lỗi | Cách sửa |
|---|---|---|
| 1 | Vạch amber đọng lại sau khi rời ngữ cảnh `ROTATE` | `App.tsx`: thêm `rotatePanelOpen`; `autoRoll` chỉ khác `null` khi `autoHorizon && mode === 'library' && openTab === 'frame' && rotatePanelOpen` |
| 2 | App không biết strip `ROTATE` đang mở | `AdjustmentPanel.tsx`: thêm prop `onRotatePanelChange`, effect `onRotatePanelChange(openGroup === 'rotate' \|\| openParam === 'straighten')` deps `[openGroup, openParam]` |
| 3 | Chip frame khác không đóng strip `ROTATE` | nhánh `choiceChips('fr', …)` thêm `setOpenGroup(null)` cạnh `onOpenParamChange(null)` sẵn có |
Kiểm chứng trên `emulator-5554` (typecheck vẫn **14**, `assembleRelease` OK, cài lại APK, mở một ảnh thư viện, script `verify3.py`):
- Đối chứng dương: strip mở + `AUTO` bật (`ROTATE · AUTO`) → dò được vạch amber ở hàng **y 1199–1203**, rộng **594 px**.
- Nhấn chip frame `NO FRAME` khi AUTO đang bật → strip đóng, hàng amber **`[]`**.
- Nhấn tab rail `LIGHT` → hàng amber **`[]`**; quay lại `FRAME` → vẫn **`[]`** (strip đóng nên vạch không tự hiện lại).
Ngoài lề: `AUTO` chỉ là bubble level theo cảm biến gia tốc (`Accelerometer`), không dò đường chân trời trong ảnh, nên không nắn được đường chân trời nghiêng sẵn trong ảnh.
---
## 14. Bỏ hẳn AUTO theo cảm biến, thay bằng auto-straighten dò đường chân trời trong ảnh
Yêu cầu: EXIF ảnh trong app không lưu roll lúc chụp, nên AUTO theo gia tốc chỉ đúng khi đang chụp chứ vô nghĩa với ảnh mở từ thư viện. Bỏ hẳn cảm biến, tự dò độ nghiêng từ chính ảnh.
| # | Việc | Nơi |
|---|---|---|
| 1 | Thêm bộ dò nghiêng | file mới `src/utils/horizon.ts`, `detectTilt(image: SkImage \| null, maxAngle = 45): number \| null` |
| 2 | Bỏ cảm biến, AUTO lấy từ ảnh | `App.tsx`: xoá import `expo-sensors`, state `autoHorizon`/`horizonRoll` + effect đọc `Accelerometer`, `effectiveStraighten`; `handleAutoStraighten` = `const tilt = viewfinderRef.current?.detectHorizon?.() ?? null; if (tilt == null) return; setPhotoStraighten(-tilt);` |
| 3 | Bỏ vạch bubble level | `Viewfinder.tsx`: xoá prop `autoRoll` + overlay; thêm `detectHorizon()` vào `ViewfinderHandle` (chạy `detectTilt(applyPhotoRotation(src.image, src.quarter, 0))` trên `horizonSrcRef`) |
| 4 | Chip | `AdjustmentPanel.tsx`: `autoHorizon`/`onAutoHorizon` → `onAutoStraighten`; nhánh AUTO gọi `onAutoStraighten()`; hàng slider `STRAIGHTEN` hiện `min -45 max 45 step 0.5` và đọc số đo |
Chi tiết đã bỏ: prop `onRotatePanelChange` (mục 13 không còn cần vì không còn vạch đè lên ảnh), cặp `setAutoHorizon(false); setHorizonRoll(0);` trong `handleResetAll`.
**Thuật toán `detectTilt` (shear projection).** Thuật toán ban đầu (histogram hướng gradient) sai với cạnh thật: đường 7° đi từng bậc 8 px nên gradient chỉ rơi vào bin 0 (đoạn phẳng) và 45 (bước nhảy) — mọi góc nhỏ đều đọc ra 0 (kiểm chứng số: app bin 0/ratio 0.207, mô phỏng 2-tap bilinear cho đúng (0, 0.206, 133044)). Cách làm đúng:
1. Hạ mẫu chính xác 2× liên tục đến khi cạnh dài ≤ 256 (`Skia.Surface.Make` + `canvas.drawImageRectOptions(..., FilterMode.Linear, MipmapMode.Linear)` + `makeImageSnapshot`) — hạ một lần >2× sẽ alias.
2. `readPixels()` → mảng xám; liệt kê điểm cạnh `mag = sqrt(dx²+dy²)`, bỏ qua `mag < 20`, toạ độ x trừ tâm, bỏ nếu < 32 điểm.
3. `score(deg)` = cắt xiên theo phương pháp profile: `acc[round(py - px*tan(deg)) + limit] += pw`, trả `ss/(tot*tot)` (độ "nhọn" của profile).
4. Quét thô `-45..45` bước 1°, cổng tin cậy `bestScore < 3 * median → null`, rồi tinh chỉnh bước 0.5° trong `best-1..best+1`; trả về độ lẻ.
Dấu: `rotateSkImageBy` dùng `canvas.rotate(deg)` (dương = cùng chiều kim đồng hồ, y hướng xuống) nên `photoStraighten = -tilt`.
Kiểm chứng trên `emulator-5554` (typecheck vẫn **14**, `assembleRelease` OK, cài lại APK, script `autocheck.py` / `expexport.py`):
- Ảnh nghiêng 7° → STRAIGHTEN **-7°**; ảnh 0° → **0°**; ảnh 12° có cấu trúc dọc → **-12°**; ảnh nhiễu đồng nhất (không có đường) → **0°** (cổng tin cậy chặn đúng).
- Export thật (không chỉ đọc slider): AUTO trên ảnh nghiêng 12°, SAVE, đo lại đường chân trời trong file xuất → **-0.85°**. Đo cùng thước đo trên ảnh gốc cho **-0.0° / 7.0° / 12.0°** (trên 1200 cột) ⇒ thước đo đủ nhạy, -0.85° là ảnh đã được nắn thật.
Ghi chú: `expo-sensors` vẫn còn trong `package.json` (không dùng nữa) — cố ý không sửa deps.
## 15. Điều tra "load camera bị chậm"
Kết luận: **không có nút thắt nào trong code app**. Số đo trên `emulator-5554`, APK release tại `1b632ba`, **không** instrument (đo bằng logcat native + `screencap`):
Cold start (`am force-stop` → `am start`, mốc 0 = dòng `START u0`):
| Mốc | Thời điểm |
|---|---|
| `Displayed com.locphamtran.recipescamera` | 0.5 – 1.0 s |
| `HybridCameraSessionSpec: Reconfiguring CameraSession` | 1.2 – 1.7 s |
| `Camera #0 State changed! Type: OPEN` | 1.7 – 2.6 s |
| Preview sống hiện trên màn hình | **~2.5 s** |
Preview đo bằng cách chụp màn hình tại các mốc cố định rồi tính độ sáng trung bình (54×120, thang xám): 1.5 s → mean 2.9; 2.0 s → 3.5 (đen); 2.5 s → 39.7 (có nội dung). Kết quả lặp lại y hệt sau `adb reboot`.
Resume từ background (HOME → mở lại): `START u0` → `Camera #0 OPEN` = **282 ms**.
**Vì sao các lần đo trước ra 5–7 s:** những lần đó chạy ngay sau `gradlew assembleRelease` (host bận + dexopt nền của APK mới). Chính APK đó, đo lại 12+ lần cold start (kể cả sau `adb reboot` và `install -r`) đều 1.7–2.6 s. Log của các lần chậm có `Davey! duration=2803ms` / `Choreographer Skipped 188 frames` ⇒ main thread bị đói; `session.configure()` của VisionCamera chạy trên `Dispatchers.Main` nên bị xếp hàng sau frame dài, kéo `Camera #0 OPEN` ra ~7 s. Bản thân đường camera rất nhanh: `createCameraSession` ~10 ms, thân `configure` ~25 ms.
⇒ Không sửa code. Instrumentation tạm đã gỡ sạch (`git checkout -- App.tsx index.ts src/components/Viewfinder.tsx`, khôi phục `useCameraSession.ts` / `useCameraController.ts` từ `.bak` trong `node_modules`, xoá `.bak`).
Ghi chú không sửa: APK release **172 MB**, trong đó 4 ABI (`x86` 41 MB, `x86_64` 41 MB, `arm64-v8a` 40 MB, `armeabi-v7a` 28 MB). Chỉ ảnh hưởng thời gian cài/first-run trên emulator, không phải thời gian load camera.
Script đo: `cold.py` (timeline logcat) và `onset.py` (độ sáng màn hình) trong scratchpad.
## 16. Sửa: preview khác ảnh export ở chế độ đo sáng Highlight-weighted
**Triệu chứng (người dùng):** ở chế độ CAMERA với METERING MODE = HIGHLIGHT-WEIGHTED, lúc bấm máy ngắm sáng bừng lên toàn khung, rồi ảnh xuất ra không khớp với những gì đã thấy trên ngắm.
**Nguyên nhân:** chế độ highlight cộng thêm **-0,5 EV** (`METER_HL_EV`). EV này được đẩy xuống **hardware AE bias** ngay trước khi chụp (`flushExposureBias()` trong `handleCapture`), còn export thì bỏ qua EV phần mềm (`evFromCamera: true`) vì tin rằng bias phần cứng đã gánh EV.
- `Viewfinder.flushExposureBias()` gọi `setHwBiasStops(clamped / 8)` **trước khi** `setExposureBias()` thực sự landed. Preview render `evTarget − hwBiasStops`, nên ngay khi bấm máy gain phần mềm tụt từ -0,5 EV về 0 → cả khung sáng lên đúng 0,5 EV (= cái "flash" người dùng thấy).
- Bias phần cứng landed bất đồng bộ (một số máy/emulator không hề tôn trọng nó), nên JPEG có thể được chụp với EV = 0 → ảnh sáng hơn ngắm ~0,5 EV (+41% tuyến tính).
**Cách sửa — EV thuần phần mềm, bỏ hẳn hardware bias:**
| | Trước | Sau |
| --- | --- | --- |
| Preview EV | `clamp(user EV + metering offset) − hwBiasStops` | `clamp(adjustments.exposureCompensation)` |
| Export EV | bỏ qua (`evFromCamera: true`), trông vào AE bias | gain `2^EV` như preview (không còn `evFromCamera`) |
| Hardware AE bias | ghi lúc bấm máy | **không bao giờ ghi** |
| EV dip của highlight | prop `meteringEvOffset` riêng + `METER_HL_EV` | nằm luôn trong `meteringAdj.exposureCompensation` (dùng chung cho preview và export) |
Xoá hẳn: `ViewfinderHandle.flushExposureBias()`, state `hwBiasStops`, `evStopsRef`, `meteringEvOffsetRef`, effect cảnh báo `supportsExposureBias`, prop `meteringEvOffset`, và lời gọi trong `handleCapture`. `evFromCamera` vẫn còn trong `exportEngine`/`nativeExport` nhưng không đường nào của app truyền nữa. Net **−71 dòng** (`App.tsx`, `src/components/Viewfinder.tsx`).
**Đo trên `emulator-5554`, APK release, màn 1080×2400.** Vùng ảnh của preview xác định được bằng profile độ sáng: ảnh hiển thị vừa khung (*contain*) 1080×1440 tại `y 480..1920`, letterbox đen hai đầu (đúng 1280 × 1080/960 = 1440), nên so sánh được cùng trường nhìn: preview `[480:1920, 0:1080]` ↔ export 960×1280.
Độ sáng trung bình vùng ảnh của preview, highlight-weighted, một lần bấm máy:
| Mốc | pre | ngay sau bấm | +0,35 s | +1,35 s | lúc đã settle |
| --- | --- | --- | --- | --- | --- |
| Lần 1 | 46,40 | 46,74 | 46,68 | 46,59 | 46,80 |
| Lần 2 | 60,65 | 60,41 | 60,37 | 60,23 | 60,62 |
Không còn nhảy sáng (dao động ±0,7%, dưới mức nhiễu của phép đo).
Tỉ lệ export/preview (E/P) cùng trường nhìn:
| Chế độ | preview | export | E/P |
| --- | --- | --- | --- |
| EVALUATIVE | 100,02 | 101,62 | **1,0160** |
| HIGHLIGHT-WEIGHTED (lần 1) | 60,38 | 62,34 | **1,0325** |
| HIGHLIGHT-WEIGHTED (lần 2) | 60,65 | 62,23 | **1,0260** |
Hai chế độ lệch nhau 1,6% ≈ 0,023 EV (độ lệch hệ thống nhỏ của JPEG/resample). Nếu export vẫn bỏ EV như code cũ, E/P của highlight phải lớn hơn eval ≈ 1,414 lần (+41%). Sai số từng pixel `mean|diff|` ≈ 8,7–9,3 (thang 0–255).
**Kiểm tra:** `tsc --noEmit` vẫn **14 lỗi** như baseline (không thêm lỗi nào). File test trên máy đã xoá (3 JPEG export + 3 dòng MediaStore 1000000266–268 + `/sdcard/ui.xml`); DCIM còn đúng 5 ảnh `IMG_*` gốc.
Script đo: `measure.py` (chụp màn hình trước/sau khi bấm máy + pull JPEG export), `align.py` (dò căn chỉnh preview↔export) trong scratchpad.
## 17. Sửa 3 lỗi hành vi chip/strip: EV bubble · cuộn chip đã sửa · strip làm mất chip
Ba lỗi người dùng báo, đã sửa, build release, cài lên `emulator-5554` và đo lại.
### 17.1 EV bubble hiện EV đã gộp thay vì EV người dùng nhập
**Triệu chứng:** tab LIGHT để EV = 0 nhưng bubble quick-EV trên màn hình hiện **EV −0,5**.
**Nguyên nhân:** `src/components/Viewfinder.tsx` (~dòng 504) đọc `adjustments.exposureCompensation`. Biến `adjustments` (~dòng 362) là `{...recipe.adjustments, ...meteringAdjustments}`, nên ở chế độ HIGHLIGHT-WEIGHTED nó đã cộng sẵn offset −0,5 EV của metering. Bubble/thumb/điểm neo kéo đều lấy từ đây → hiện `userEV − 0,5`.
**Sửa:** cho phần hiển thị đọc thẳng `recipe.adjustments.exposureCompensation`; gain áp dụng vẫn dùng `adjustments.exposureCompensation` như cũ.
```tsx
const evStops = recipe.adjustments.exposureCompensation ?? 0;
```
| | Trước | Sau |
| --- | --- | --- |
| Bubble / thumb / điểm neo kéo | `adjustments.exposureCompensation` (đã gộp metering) | `recipe.adjustments.exposureCompensation` (EV người dùng) |
| Gain áp dụng vào ảnh | `clampEvApplied(adjustments…)` | không đổi |
**Đo trên máy** (HIGHLIGHT-WEIGHTED bật, đã chứng minh có áp dụng: độ sáng trung bình preview 92,74 → 58,47):
| Thao tác | Bubble | Chip LIGHT |
| --- | --- | --- |
| Không kéo | `EV 0.0` tại `[687,1049][797,1095]` | `EV 0.0 EV` |
| Kéo +130 px | `EV +0.8` | `EV +0.8 EV` |
| Kéo về | `EV 0.0` | `EV 0.0 EV` |
Code cũ khi kéo +130 px chỉ ghi `+0.3` (bị trừ 0,5). Kéo về 0 khôi phục đúng.
### 17.2 Nhấn tab trên rail phải nhảy tới chip đầu tiên đã sửa
**Yêu cầu:** nhấn tab → cuộn tới chip đầu tiên có thông số đã sửa; nếu tab không có chip nào sửa thì về chip đầu tiên.
**Sửa trong `src/components/AdjustmentPanel.tsx`:** thêm hằng số cấp module để nhận diện cả 5 strip con của watermark là "cùng một panel":
```tsx
const WM_SUBGROUPS: GroupKey[] = ['wmColor','wmSize','wmFont','gpsColor','gpsName','gpsTime'];
const isWmPanel = (g) => g === 'wm' || (g !== null && WM_SUBGROUPS.includes(g));
```
Toàn bộ khối `switch (activeTab)` dựng hàng chip (~dòng 719–880) được viết lại quanh một helper duy nhất:
```tsx
const chipsScrollRef = useRef<ScrollView>(null);
const chipXRef = useRef<Record<string, number>>({});
const firstAmberChipRef = useRef<string | null>(null);
useEffect(() => {
const id = setTimeout(() => {
const key = firstAmberChipRef.current;
const x = key ? chipXRef.current[key] ?? 0 : 0;
chipsScrollRef.current?.scrollTo({ x: Math.max(0, x - 8), animated: false });
}, 80);
return () => clearTimeout(id);
}, [activeTab, isWmPanel(openGroup)]);
const chipRow = (chips: ChipDef[], padV = 6) => {
firstAmberChipRef.current = chips.find((c) => c.amberValue)?.key ?? null;
return (<ScrollView ref={chipsScrollRef} horizontal showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: padV }}>
{chips.map(renderChip)}</ScrollView>);
};
```
- Mỗi `case` trả về `chipRow([...định nghĩa], 8|6)` (recipes/favorites/frame giữ padding 8, iq/wb/filters 6 — đúng như cũ). Bỏ `<View>` bọc vô nghĩa quanh hàng WB.
- `renderChip` thêm `onLayout` trên View ngoài: `chipXRef.current[c.key] = e.nativeEvent.layout.x`.
- `paramChips` và `choiceChips` đổi kiểu trả về thành `ChipDef[]` (trước trả ReactElement); call site duy nhất còn lại thành `{choiceChips(openGroup, …).map(renderChip)}`.
- Độ trễ 80 ms vì hàng chip vừa remount khi đổi tab, `scrollTo` ngay sẽ trượt.
**Đo trên máy:**
| Kịch bản | Kết quả |
| --- | --- |
| LIGHT mới, chưa sửa gì | offset 0, `RESET` đầu hàng |
| Đặt COLOR +7, sang WB rồi quay lại LIGHT | `COLOR +7` nằm tại `[246,1921][384,1960]` |
| WB (không có chip nào sửa) | offset 0 |
| FRAME khi watermark đang ON | cuộn tới `WATERMARK ON` tại `[783,1916][1017,1955]` |
### 17.3 Strip của watermark làm cả hàng chip biến mất
**Triệu chứng:** đang ở chip watermark, bấm sang chip kế tiếp thì chỉ thấy strip, không thấy chip nào; phải bấm vào strip thì chip mới hiện.
**Nguyên nhân:** hàng chip con của watermark trong `case 'frame'` bị gác bằng `if (openGroup === 'wm')`, nên mọi strip con (`wmColor`, `wmSize`, `wmFont`, `gpsColor`, `gpsName`, `gpsTime`) đều làm hàng chip gỡ khỏi cây.
**Sửa:**
- Cổng hiển thị `if (openGroup === 'wm')` → `if (isWmPanel(openGroup))` — hàng chip ở lại với mọi strip của watermark.
- `onWmPanelChange(openGroup === 'wm')` → `onWmPanelChange(isWmPanel(openGroup))` — giữ lớp bắt kéo ảnh.
- ScrollView của strip thêm `key={openGroup}` để remount về offset 0 mỗi nhóm.
- Strip `ROTATE`: chuyển `{ v: 'straighten', d: 'STRAIGHTEN (±45°)' }` lên **đầu** (trước bị cắt ở x≈1058).
- Strip `TEMP`: đổi option thành `[{ v: 'color-temp', d: 'COLOR TEMP' }, ...WB_PRESETS]`.
**Đo trên bản build cuối:**
| Kịch bản | Chip còn hiển thị? | Strip |
| --- | --- | --- |
| FRAME › WATERMARK › CUSTOM WATERMARK ON › TEXT COLOR | Có: `RESET / CUSTOM WATERMARK ON / ROTATE / ACCEPT / CANCEL / TEXT COLOR` | AMBER WHITE BLACK RED BLUE |
| TEXT SIZE | Có, nguyên vẹn | cỡ chữ |
| GPS ON › TIME | Có, nguyên vẹn | định dạng giờ |
| ROTATE | Có | `STRAIGHTEN (±45°)` tại `[258,1805][534,1844]`, một tap mở `STRAIGHTEN 0°` |
| TEMP | Có | `COLOR TEMP` đầu tiên tại `[233,1815][418,1854]` |
Vào panel con từ hàng frame đã cuộn giờ nhắm lại offset 0 (`< FRAME` đầu hàng) thay vì thừa hưởng offset của hàng frame.
### 17.4 Kiểm tra
- `tsc --noEmit` (`node --stack-size=16000 ./node_modules/typescript/lib/tsc.js`) vẫn **14 lỗi** như baseline (1 `App.tsx` + 13 `src/components/Viewfinder.tsx`); `AdjustmentPanel.tsx` 0 lỗi.
- Build `gradlew.bat --offline assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success; khởi động lại app OK.
- Trạng thái máy đã reset: RESET chip đã bấm (GPS watermark OFF, custom watermark OFF, frame NO FRAME, COLOR 0, straighten 0). LIGHT: `RESET / EXPOSURE 0 / EV 0.0 EV / CONTRAST 0 / COLOR 0`; WB: `RESET / TEMP AUTO / TINT 0 / COLOR CHROME / CHROME BLUE`. Metering về EVALUATIVE (mặc định lúc khởi động).
- File test trên máy đã xoá (`/sdcard/*.xml` của phiên này).
- Diff chưa commit: `App.tsx`, `src/components/AdjustmentPanel.tsx`, `src/components/Viewfinder.tsx` + file kế hoạch này.
## 18. CROP theo tỉ lệ: nút APPLY, ẩn viền amber, RESET về mặc định
**Yêu cầu:** sau khi CROP với ratio thì cho phép APPLY; sau khi APPLY chỉ hiển thị ratio đó và ẩn viền amber; nhấn RESET thì trả ảnh về ratio mặc định lúc load.
**Hai trạng thái:**
| Trạng thái | Điều kiện | Preview |
| --- | --- | --- |
| Đang frame (band) | `cropRatio` là tỉ lệ, `cropApplied !== cropRatio` | viền amber quanh vùng giữ + mask đen opacity 0.55 bên ngoài |
| Đã áp (applied) | `cropApplied === cropRatio` | chỉ còn vùng tỉ lệ, mask đen opacity 1, không viền amber |
Chọn tỉ lệ khác tự quay về trạng thái band (so sánh `cropApplied === cropRatio`, không cần reset thủ công).
**Sửa:**
- `App.tsx`: thêm state `cropApplied`; truyền `cropApplied` cho `Viewfinder` và `AdjustmentPanel`, thêm `onApplyCrop={() => setCropApplied(cropRatio)}`; `handleResetAll` thêm `setCropApplied(null)` cạnh `setCropRatio('none')`; `handleUpdateFrame` xoá `cropApplied` khi frame được chọn.
- `AdjustmentPanel.tsx`: chip `APPLY` nằm trong hàng chip của `case 'frame'`, chèn ngay sau chip nhóm `CROP`:
`cropEnabled && cropRatio !== 'none' && cropRatio !== 'free' && cropApplied !== cropRatio`.
- `Viewfinder.tsx`: `libCropView` (`k = min(vw/dw, vh/dh)`, rect căn giữa) + `cropScreenPx` (dùng làm vùng mask khi đã áp, vẫn `cropWinPx` cho frame/grain/vignette); `libViewMatrix` ghép ngoài·trong `[k·s, 0, k·tx + s·ox, 0, k·s, k·ty + s·oy, 0, 0, 1]` áp cho hai `<Group>` của ảnh library; `libCropTouchStyle` (translate rồi scale `k`, có hiệu chỉnh tâm) trên lớp bắt touch; mask `opacity={libCropView ? 1 : 0.55}`; Rect stroke `#f59e0b` bọc trong `{!libCropView && (...)}`.
**Đo trên máy** (emulator-5554, 1080×2400, ảnh thư viện dọc):
| Bước | Chip APPLY | Amber trong preview | Vùng nội dung |
| --- | --- | --- | --- |
| FRAME, CROP = NONE | không | 0 | ảnh đầy đủ (baseline) |
| chọn 1:1 | có | 14218 px, band 1080×1083 tại y 343–1426 | ảnh đầy đủ + dim |
| APPLY 1:1 | không | 0 | mask đen y 273–344 và 1425–1769; nội dung y 345–1424 |
| chọn 2:3 | có | band 928×1340 tại x 76–1003 | `k = 1,164` |
| APPLY 2:3 | không | 0 | nội dung phóng kín chiều ngang |
| RESET sau APPLY | không | 0 | trùng baseline: chỉ 30/288000 pixel khác, đều ở đồng hồ (99–118, 56–83) |
| đổi tỉ lệ sau APPLY (3:4) | có lại | band 17836 px | tự về trạng thái band |
Với 1:1, `k = 1` nên pixel trong vùng crop của bản APPLY giống hệt bản band (max diff 0) — chỉ mask đổi 0.55 → 1 và viền amber mất.
**Kiểm tra khác:**
- `RESET *` → `RESET` sau khi reset (hết dấu *).
- Kéo 1 ngón ở preview (150 px và 500 px): ảnh không dịch — kể cả khi không crop, khi band, khi đã APPLY. Pan 1x vốn không chạy trong bản này nên không phải hồi quy của touch layer; `libCropTouchStyle` không gây lệch.
- `tsc --noEmit` vẫn **14 lỗi** đúng baseline (1 `App.tsx` + 13 `src/components/Viewfinder.tsx`).
- Build `gradlew.bat --offline assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success.
## 19. Lưu `cropApplied` vào session (9/9/2026)
Trước: APPLY chỉ là state trong bộ nhớ. Cold start / restore LAST SESSION thì mất, preview trở lại ảnh đầy đủ trong khi chip CROP vẫn giữ ratio cũ.
**Sửa:**
- `App.tsx`: thêm `cropApplied` vào snapshot session (cạnh `cropRect`), thêm `cropApplied` vào dependency array của effect lưu session, và khi restore: `if (session.cropApplied && session.cropApplied === session.cropRatio) setCropApplied(session.cropApplied);` (guard bước 2: chỉ nhận khi ratio khớp, tránh restore lệch cặp).
- Chuẩn hoá 4 dòng LF-only ở `Viewfinder.tsx` 512–515 (comment Task 2) về CRLF.
**Đo trên máy** (emulator-5554, STARTUP MODE = LAST SESSION):
| Bước | Chip APPLY | Amber trong preview | Preview |
| --- | --- | --- | --- |
| load ảnh → CROP 1:1 → APPLY → force-stop → mở lại | không | 0 px | mask đen đặc, nội dung y 398–1477 (viewport cao hơn khi deck đóng) |
Rect nội dung khác lúc strip đang mở (y 345–1424) là do viewport cao hơn, không phải lỗi.
- `tsc --noEmit` vẫn **14 lỗi** đúng baseline.
- Build `assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success.
- Đã trả STARTUP MODE về DEFAULT sau khi test.
## 20. EXIF + tên file `RC_<ts>` khi chụp và khi export (9/9/2026)
Trước: ảnh lưu ra `camrecipe_pro_export_<epoch>.jpg`, không có EXIF nào ngoài thứ Skia còn giữ được sau re-encode (thực tế là mất sạch).
**Sửa:**
- `src/utils/exifWrite.ts` (mới, thuần, không import): dựng lại segment APP1/EXIF từ byte JPEG — đọc TIFF của ảnh nguồn, giữ mọi tag module không sở hữu, ghi đè `Software 0x0131` = `RecipesCam 1.2`, `DateTime 0x0132` / `DateTimeOriginal 0x9003` / `DateTimeDigitized 0x9004`, `Orientation 0x0112` = 1, `ImageWidth/Length 0x0100/0x0101`, `PixelX/YDimension 0xa002/0xa003`, resolution khi có dpi, GPS (0x0001–0x0004). Bỏ IFD1/thumbnail, MakerNote và con trỏ IFD cũ. Độ dài APP1 ghi **big-endian** (khác khối TIFF bên trong — bug đầu tiên của module).
- `src/utils/photoMeta.ts` (mới): `rcPhotoName(exported)` → `RC_<yyyymmdd_hhmmss>[_exported].jpg`; `nextPhotoPath()` cộng 1000 ms khi tên đã tồn tại (tránh trùng tên và trùng cache `<Image>`).
- `src/utils/exportEngine.ts`: thay `nextExportFile()` bằng `nextPhotoPath(options.exported)`, thêm bước 9c gọi `writeJpegExif` với `capturedAt`/GPS/dims/dpi và byte của ảnh nguồn.
- `src/utils/nativeExport.ts`: cùng tên file, `stampExifOnFile()` sau `processPhotoAsync` (không truyền width/height — JS không biết kích thước Kotlin render).
- `App.tsx`: `capturedAt = Date.now()` ngay trước khi chụp; `exported: false` cho camera, `exported: true` cho cả 2 nhánh option của library.
Ảnh camera truyền `dateTime` = thời điểm bấm shutter nên EXIF là lúc chụp; ảnh library truyền `null` nên giữ nguyên ngày gốc của file đã load. Khi `sourceJpeg` không đọc được (uri `content://`, lỗi IO) module lấy chính khối EXIF của file đang ghi làm nền, không xoá tag.
**Đo trên máy** (emulator-5554, 1080×2400):
| Thao tác | File | EXIF đọc lại bằng PIL |
| --- | --- | --- |
| chụp (GPS watermark OFF) | `RC_20260911_175607.jpg` | Software `RecipesCam 1.2`, DateTime/DateTimeOriginal 17:56:06, Orientation 1, 960×1280, giữ Make/Model của camera HAL |
| load `RC_libtest.jpg` → export | `RC_20260911_175905_exported.jpg` | giữ Make `OldCam`, Model `X100`, ISO 200, ExposureTime 1/125, FNumber 2.8, FocalLength 35, DateTime 2019:05:04 03:02:01, GPS 48.8584/2.2945; Software `RecipesCam 1.2`; Orientation 1 |
Emulator không có fix GPS (`getCurrentGPS` → `ERR_CURRENT_LOCATION_IS_UNAVAILABLE` dù `adb emu geo fix` trả OK và `location_mode=3`), nên nhánh ghi GPS từ thiết bị chỉ kiểm được ở mức codec: `exifchk/verify.py` đọc lại đúng lat/lon do `writeJpegExif` ghi (10.776889/106.700806) và PASS.
- `tsc --noEmit` vẫn **14 lỗi** đúng baseline.
- Build `assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success.
## 21. EXIF chuẩn theo ảnh mẫu IMG_20260830_140429.jpg (2026-09-11)
Ảnh mẫu (Xiaomi 14 Ultra, 4096×3072, 7 165 975 B) đọc bằng parser tự viết: byte-order `MM`
(big-endian), APP1/Exif là segment **đầu tiên** ngay sau SOI (không có APP0/JFIF), TIFF gồm 4 IFD:
IFD0 (17 entry) → Exif IFD (43 entry) → GPS IFD (11 entry) → Interop IFD (`0xa005` =
{`0x1` `'R98'`, `0x2` `'0100'`}), rồi IFD1 (thumbnail) nối sau IFD0. Tag nằm rải ở cả IFD0 lẫn
Exif IFD chứ không gom một chỗ, và **không sắp theo thứ tự tăng dần**.
Đối chiếu file app ghi ra: tập tag IFD0/GPS khớp mẫu, nhưng thiếu Interop IFD, thiếu
`OffsetTime`/`OffsetTimeOriginal`/`OffsetTimeDigitized`, và khi HAL nghèo tag thì thiếu
`ExifVersion`/`FlashPixVersion`/`ComponentsConfiguration`/`ColorSpace` — đúng nhóm mà phần mềm
tạo khung dùng để nhận dạng thông số.
`src/utils/exifWrite.ts`:
- Thêm IFD thứ 4 (Interop) vào layout + con trỏ `0xa005` trong Exif IFD; parse đọc Interop của
nguồn qua `0xa005`.
- Điền mặc định khi nguồn thiếu: `ExifVersion 0x9000` = `'0230'`, `FlashPixVersion 0xa000` =
`'0100'`, `ComponentsConfiguration 0x9101` = {1,2,3,0}, `ColorSpace 0xa001` = 65535, Interop
`'R98'`/`'0100'`. Nguồn có sẵn thì giữ nguyên, không ghi đè.
- Ghi `OffsetTime 0x9010` (IFD0) + `OffsetTimeOriginal 0x9011` + `OffsetTimeDigitized 0x9012`
(Exif IFD) bằng offset cục bộ `±HH:MM` khi app sở hữu mốc thời gian (ảnh chụp). Ảnh export giữ
ngày của file nguồn thì không bịa offset.
- Fix bug thật: `swapToLittleEndian` dùng `data.slice()` — với Node `Buffer`, `slice()` trả về
*view* chứ không copy, nên hàm swap ngược byte của chính buffer nguồn (đọc ra ISO 36865 thay vì
400 khi nguồn big-endian). Nay dùng `new Uint8Array(data)`.
- Vẫn chủ động bỏ IFD1/thumbnail và MakerNote (ponytail comment: offset tuyệt đối không sống sót
khi TIFF bị dời; thumbnail mang pixel trước khi export).
**Đo lại** (codec Node + PIL + máy thật):
| Kiểm chứng | Kết quả |
| --- | --- |
| `exifchk/run.js` | SELF-CHECK PASSED, gồm check "source bytes left untouched" |
| `exifchk/verify.py` | PIL VERIFY PASSED (thêm Interop/ExifVersion/OffsetTime/ColorSpace/ComponentsConfiguration) |
| round-trip ảnh mẫu qua writer | mọi tag Xiaomi giữ nguyên: ISO 400, ExposureProgram 2, MeteringMode 2, FocalLengthIn35mmFilm 75, GPS 15°23'54.67"N 108°19'22.47"E; thêm Interop |
| chụp `RC_20260911_182721.jpg` (emulator) | Interop `R98`/`'0100'`; `0x9010`/`0x9011`/`0x9012` = `+07:00`; `ComponentsConfiguration`, `FlashPixVersion`, `ColorSpace` có mặt; Software `RecipesCam 1.2`; Orientation 1; 960×1280; giữ Make/Model HAL |
`ExifVersion` của ảnh chụp trên emulator vẫn là `'0210'` vì HAL đã cung cấp — đúng thiết kế: chỉ
điền khi nguồn thiếu.
**Không thể khắc phục**: nếu phần mềm tạo khung cần tag riêng của nhà sản xuất (MakerNote) hoặc tra
cứu cơ sở dữ liệu thiết bị theo Make/Model, app không giả được — MakerNote của máy khác chứa offset
tuyệt đối không dùng lại được, còn Make/Model là do HAL trả về.
- `tsc --noEmit` vẫn **14 lỗi** đúng baseline.
- Build `assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success.
---
## 22. Khung dấu chìm Xiaomi: vì sao Gallery báo "không thể nhận dạng được thông số" (2026-09-11)
**Triệu chứng.** Ảnh chụp `RC_20260911_183114.jpg` mở trong trình edit của Xiaomi, chọn tạo khung
dấu chìm → "không thể nhận dạng được thông số". Ảnh `IMG_20260830_140429.jpg` (app camera gốc) thì
mở bình thường. Hai ảnh **cùng một máy** (Xiaomi 14 Ultra, codename `aurora`).
**Nguyên nhân.** HyperOS Gallery đọc tag EXIF **`0x889e`** (Exif IFD, ASCII) để dựng khung dấu
chìm — đây là JSON do app camera gốc ghi:
```
0x889e {"buildDevice":"aurora","customize":"","cvLensName":"","filterName":"","livephotoInfo":"",
"version":2,"waterLogo":"XIAOMI","waterName":"14 Ultra"}
```
Ảnh mẫu còn có `0x9999` (`{"mirror":false,"sensorType":"rear","Hdr":"auto",...}`) và MakerNote
`0x927c` (`Xiaomi\x00\x01MM...`). **Ảnh của app không có** `0x889e`, `0x9999`, `0x927c`, cũng không
có GPS IFD.
Lý do không phải bug của module: nguồn chụp (CameraX/HAL) **không bao giờ** cấp các tag vendor này.
HAL chỉ cấp tag chuẩn (ISO 2151, 1/50 s, f/2.0, 8.72 mm, FocalLengthIn35mm 23…). Đã chứng minh
module **giữ nguyên** `0x889e`/`0x9999` khi nguồn có (chỉ cố ý bỏ `0x927c`).
**Diff tag-set** (`exifchk/cmp_exif.py`):
| | RC_183114 | IMG mẫu |
| --- | --- | --- |
| SOF0 (w,h) | (3072, 4096) | (4096, 3072) |
| byte order | II | MM |
| IFD0 / Exif / GPS / Interop | 13 / 36 / 0 / 2 | 17 / 43 / 11 / 2 |
| Exif chỉ mẫu có | — | `0x889e` `0x9999` `0x927c` `0x8895` `0x889a` `0x889d` `0x889f` `0xa661` |
| IFD0 chỉ mẫu có | — | `0x8825` `0x889a` `0x889d` `0x88a3` `0x9999` `0x9a00` |
xRes/yRes 300 vs 72, Orientation 1 vs 0, ColorSpace 1 vs 65535, `0x110` Model `24030PN60G` vs
`Xiaomi 14 Ultra`. Layout TIFF của ảnh app **hợp lệ**, không lỗi cấu trúc — Gallery chỉ thiếu blob.
**Fix.** Đọc lại đúng các prop mà app camera gốc dùng rồi tự tổng hợp blob:
- `modules/recipescam-export/.../RecipescamExportModule.kt`: thêm `Function("deviceInfo")` trả
`device` (`ro.product.device`) · `marketName` (`ro.product.marketname` → `ro.product.odm.marketname`
→ `ro.product.vendor.marketname`) · `manufacturer` · `model`. Đọc prop hệ thống ẩn bằng reflection
(`android.os.SystemProperties.get`), lỗi thì trả chuỗi rỗng.
- `src/utils/exifWrite.ts`: `watermarkBlob(device)` dựng JSON **đúng thứ tự field** của ảnh gốc
(`waterName` = marketName bỏ tiền tố brand: `Xiaomi 14 Ultra` → `14 Ultra`). Trong
`writeJpegExif`, ghi `0x889e` **chỉ khi** nguồn thiếu blob và máy là Xiaomi/Redmi/POCO — **không
bao giờ ghi đè** blob nguồn.
- `src/utils/photoMeta.ts`: `getDeviceIdentity()` cache một lần, trả `null` khi module cũ.
- `src/utils/exportEngine.ts`, `src/utils/photoMeta.ts`: truyền `device` vào stamp.
**Kiểm chứng.**
| Kiểm chứng | Kết quả |
| --- | --- |
| blob sinh ra vs blob gốc (`0x889e`) | **IDENTICAL** từng byte, 146 B + NUL = count 147 |
| `exifchk/run.js` | SELF-CHECK PASSED (11 ok) — có check "blob nguồn không bị ghi đè", "không ghi blob trên máy non-Xiaomi" |
| `exifchk/verify.py` (PIL) | PIL VERIFY PASSED |
| `tsc --noEmit` | 14 lỗi, đúng baseline |
| `gradlew --offline assembleRelease` | BUILD SUCCESSFUL |
| cài + mở app trên emulator | PID sống, 0 FATAL EXCEPTION |
**Giới hạn.** Emulator không chạy HyperOS Gallery nên **không** xác nhận trực tiếp được Gallery đã
hết lỗi; cũng **không** xác nhận được `deviceInfo()` trả gì trên máy thật (chỉ suy ra từ tên prop
chuẩn của Xiaomi). Máy non-Xiaomi sẽ không có blob → vẫn đúng như trước.
+111
View File
@@ -0,0 +1,111 @@
# PLAN — Option 3: thay engine export Skia bằng module native Kotlin
Nhánh: `feat/vision-camera-v5` (RecipesCam, /home/locpham/RecipesCam)
Ngày: 2026-09-06. Trạng thái: bắt đầu.
## Vấn đề
`exportEngine.ts` render ~1s/ảnh, sync JSI (`MakeImageFromEncoded`, `makeImageSnapshot` + `encodeToBytes(JPEG,95)`, sharpen) → block JS thread khi queue render chạy. Shutter đã được tách khỏi render bằng FIFO queue (`exportQueueRef`/`enqueueExport`, commit f588849); phần còn lại = render chạy trên thread nền native, JS/UI không bao giờ nghẽn.
## Quyết định đã chốt (user: "theo khuyến nghị hết")
- **D1** Chỉ Android (app không có iOS env).
- **D2** v1 = xấp xỉ gần, KHÔNG pixel-identical: ColorMatrix + các bước CPU/Canvas. KHÔNG port GPU AGSL (RuntimeShader) ở v1. Skia engine GIỮ làm reference/fallback, không xoá trước parity gate.
- **D3** Vehicle = **local Expo Module** `modules/recipescam-export` (expo-modules-core 57.0.6 có sẵn; autolinking quét `./modules` mặc định — đã xác minh trong expo-modules-autolinking src). Không cần nitrogen/nitro-image cho code mới (nitro-image vẫn nằm trong deps, không dùng).
- **D4** Chạy P0 benchmark decode+encode native trên máy Xiaomi trước khi mở rộng pipeline. Nếu CPU path vỡ budget (<~1s/ảnh toàn pipeline, mục tiêu render nền <500ms) thì quay lại cân nhắc GPU.
## Kiến trúc đích
- App gọi module native qua promise → hàm chạy trên background (`AsyncFunction ... Coroutine` + `withContext(Dispatchers.IO)`), JS không block.
- FIFO: mỗi job 1 lời gọi module; module tự serialize trên executor nếu cần (v1: JS queue hiện có là đủ, mỗi task 1 await module).
- Đầu vào: `sourceUri` file ảnh + tham số recipe (matrix float[], tone params, frameId, geotag text...). Đầu ra: file JPEG (path) hoặc bytes. MediaLibrary save + DPI patch giữ nguyên ở JS.
- Legacy engine giữ nguyên file `src/utils/exportEngine.ts`; native path phát triển song song, bật qua flag sau parity gate.
## Ánh xạ pipeline (thứ tự Skia engine → native)
Đọc từ exportEngine.ts (512 dòng) — thứ tự xử lý bắt buộc giữ nguyên để ảnh gần engine cũ:
| # | Skia (nguồn) | Native thay thế | Parity |
|---|---|---|---|
| 1 | aspect crop center-largest (trừ wallframe) | Bitmap crop `Bitmap.createBitmap(src, x,y,w,h)` | exact |
| 2 | matrix màu: `getSkiaColorMatrix` + `applyExposureGain` → `ColorFilter.MakeMatrix` | port toán colorUtils.ts → Kotlin float[4x5] → `ColorMatrixColorFilter` | exact nếu giữ thứ tự phép nhân & rounding; test diff |
| 3 | tone `TONE_SKSL` DR/highlight/shadow (toneShader.ts) | v1: đường cong LUT 1D/luminance xấp xỉ | approx |
| 4 | cinema `CINEMA_SKSL` seasonal grade | v1: bỏ qua hoặc matrix approx | approx (chú thích) |
| 5 | denoise blur | v1: skip / downscale-upscale blur | approx |
| 6 | clarity 3x3 conv sharpen (âm = mist blur) | v1: conv nguyên thuỷ trên IntArray nếu >0; mist = blur | approx (watch perf) |
| 7 | grain overlay noise shader BlendMode.Overlay | noise bitmap tile + PorterDuff Overlay | approx |
| 8 | frames: polaroid card (`POLAROID_CARD`/`polaroidLayout`), wallframe (wallframe.png, xoay 90, `wallframeLayout`), classic borders (`drawFrameOnCanvas`) | Android Canvas draw; port layout constants từ frameUtils.ts | close |
| 9 | GPS watermark: font Cousine + NotoEmoji-GPS, 📍/📷, amber #f59e0b/trắng, vị trí tuỳ framedWindow | Canvas + `Typeface.createFromFile` | close (metrics emoji khác Skia → QA) |
| 10 | screen sharpen (`screenSharpenImage`) | v1: conv 3x3 native hoặc skip | approx |
| 11 | encode `encodeToBytes(JPEG,95)` | `Bitmap.compress(JPEG, 95)` | exact (size có thể lệch nhẹ, chấp nhận) |
| 12 | DPI patch `patchJpegDpi(bytes,300)` (jpegDpi.ts) | GIỮ ở JS trên bytes/phản hồi | exact |
| 13 | ghi file + `MediaLibrary.createAssetAsync` (App.tsx) | giữ nguyên JS | exact |
Nguồn hằng số: src/utils/colorUtils.ts (matrices provia/velvia/classic-chrome/astia/eterna/classic-neg/leica/monochrome, kelvinToRGB, WB/tint, CC), src/utils/toneShader.ts, src/utils/cinemaShader.ts, src/utils/frameUtils.ts (POLAROID_CARD, polaroidLayout, WALLFRAME_W/H 3117/4000, wallframeLayout), src/utils/exportEngine.ts, src/utils/jpegDpi.ts.
## Phases
### P0 — Spike: module skeleton + benchmark decode/encode native
Việc:
- Tạo local Expo module `modules/recipescam-export` (android only) với 1 hàm `decodeEncodeAsync(srcPath, dstPath, quality)` → Map{decodeMs, encodeWriteMs, totalMs}; decode BitmapFactory, encode Bitmap.compress, ghi file, toàn bộ `withContext(Dispatchers.IO)`.
- Probe tạm `src/dev/nativeBenchProbe.ts` + hook App.tsx gated `process.env.EXPO_PUBLIC_BENCH === '1'` (chạy 3 lần trên wallframe.png + đo gap setInterval để chứng minh JS không block).
- Build release với `EXPO_PUBLIC_BENCH=1 ./gradlew assembleRelease` → APK.
- Chạy trên Xiaomi (USB), đọc logcat ReactNativeJS.
Exit: decode+encode < ~500ms; JS gap nhỏ khi render. Đây là gate D4.
File sinh: modules/recipescam-export/*, src/dev/nativeBenchProbe.ts (xoá khi P7).
### P1 — Port matrix màu
- Kotlin: dịch `getSkiaColorMatrix` + `applyExposureGain` (colorUtils.ts) → FloatArray 4x5. Tham số baseFilter + ColorAdjustments truyền từ JS (hoặc gửi matrix đã tính từ JS — JS tính nhanh, matrix 4x5 ~ 20 số — QUYẾT ĐỊNH: tính matrix ở JS bằng colorUtils.ts hiện có rồi truyền float[] xuống native → KHÔNG nhân đôi logic, parity miễn bàn). Cùng kiểu: kelvinToRGB/WB/CC vẫn nằm trong getSkiaColorMatrix → chỉ cần gửi kết quả.
- Native: `processColorAsync(srcPath, dstPath, matrix: FloatArray, crop: {…}|null)` decode → crop → `ColorMatrixColorFilter` qua Paint/Canvas draw → encode.
Exit: ảnh ra khớp Skia matrix (so sánh bằng mắt + diff).
### P2 — Tone (DR/HL/SH) xấp xỉ
- Port công thức toneShader.ts sang dạng LUT áp per-pixel (hoặc 2 pass nếu cần giữ grain sau tone — thứ tự: tone trước clarity/grain như engine).
Exit: vùng highlight/shadow không cháy như engine cũ, chấp nhận sai số.
### P3 — Clarity/grain/denoise/cinema
- Theo bảng: conv 3x3 IntArray (clarity), blur xấp xỉ (denoise/mist), noise tile Overlay (grain). Cinema: quyết định giữ matrix approx hay bỏ.
Exit: ảnh gần engine cũ, tổng thời gian nền OK (đo trên máy).
### P4 — Frames + watermark (Canvas)
- polaroid card, wallframe (asset wallframe.png — đọc qua đường file/asset), classic borders, GPS text/emoji.
Exit: layout khớp frameUtils (kiểm tra pixel các góc/viền bằng overlay ảnh so sánh).
### P5 — Hook vào luồng export thật + flag
- App.tsx: thêm nhánh `useNativeExport` (flag hằng/env); giữ nguyên enqueueExport FIFO; MediaLibrary/DPI giữ nguyên.
Exit: chụp → gallery có ảnh qua native path.
### P6 — Parity + perf + fallback
- Script chạy cùng 1 ảnh nguồn qua 2 engine; xuất ảnh ghép cạnh nhau + điểm diff (downscale) để QA trên máy.
- Đo: cũ ~1s/ảnh block JS → mới <500ms nền.
- Giữ legacy: `processAndExportPhoto` cũ nguyên vẹn, bật qua cờ.
Exit: user QA đạt → mặc định native; legacy chỉ fallback.
### P7 — Dọn & ship
- Xoá probe/dev code, xoá env gate; typecheck; commit + push (KHÔNG stage `.kilo/`, `.expo/`); `assembleRelease`; báo MD5 APK.
- 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).
## 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`).
- API expo-modules-core 57: `AsyncFunction("x") Coroutine { }` (như MediaLibraryModule), coroutines 1.10.2 qua `api` của expo-modules-core.
- Gradle: thêm module → lần build đầu sẽ reconfigure (chậm hơn), không cần sửa settings.gradle.
- 12MP full-res decode ~48MB RAM — ổn, không inSampleSize.
- Emoji trong Canvas có metrics khác Skia — kiểm tra vị trí watermark.
- Grain procedural (Skia hash noise) ≠ noise tile — chấp nhận sai khác nhỏ ở v1.
- Không đặt tên module trùng class đã có.
## Checklist trạng thái
- [x] Chốt quyết định D1–D4
- [x] P0 scaffold: module `modules/recipescam-export` + probe `src/dev/nativeBenchProbe.ts` + hook App.tsx (env-gated); release build OK (recipescam-export 0.1.0 autolink)
- [x] P0 benchmark trên Xiaomi (12MP wallframe: decode 152-182ms, encode+write 56-77ms, total 215-269ms, off-main-thread — PASS)
- [x] P1 matrix (processColorAsync: decode→crop→ColorMatrixColorFilter→JPEG; parity OK trên Xiaomi — classic-neg/black → rgb(13,5,0), translate×255, crop 2000x2000, off-thread, colorMs≈30ms)
- [x] P2 tone (applyTone CPU pass port TONE_SKSL — immutable-decode fix: copy ARGB_8888 trước setPixels; gates 64x64 trên Xiaomi: white hl=-1→140, gray64 sh=+1→75, white dr=1→209, gray26 sh=+1→104, gray26 hl=+1 sh=-1→11 — ALL PASS; full 12MP toneMs 488-548ms = cần tối ưu khi gộp P3)
- [x] P3 clarity/grain (native denoise/clarity/grain/cinema port: boxBlur padding fix + step gates; cinema gates summer-192→(211,209,201), winter-32→(82,85,88), summer-sat→(217,120,47) khớp node ±0; ramp/step blur gates denoise→130, clarity→131, mist→130/denoise-step→170, mist-step→142 PASS; grain structural OK; full 12MP 4.7-5.1s CPU = vượt D4, quyết GPU ở P6)
- [x] P4 frames/watermark
- [x] P5 hook export + flag
- [ ] P6 parity/perf gate — probe chạy xong trên Xiaomi (1fdf765): classic-neg / retro-amber-frame / cinema-summer trên ảnh real 2304x3072; native off-thread 2.7-2.9s (JS ticks 65-70 vẫn chạy) vs legacy GPU 0.36-0.5s (JS-block); diff meanAbs 7.1/13.1/13.3, gt12% 16.8/42.1/42.7, gt32% 0.14/3.7/4.0 (grain procedural + JPEG encoder + tone approx; retro thấp nhất — mist + frame classic). CHỜ user QA ảnh ghép DCIM/parity_*.jpg → flip default native + quyết GPU
- [ ] P7 dọn + ship
## Ngoài P-series (feature branch)
- [x] Startup mode DEFAULT/LAST SESSION (Settings chip + AsyncStorage `startup_mode`; restore session qua FILM_SIMS; fix clobber ratio khi sim selected — 3 file LF)
- [x] GPS watermark mặc định OFF khi startup DEFAULT (applyDefaultStartup setUseGeotag(false)) — verify UI Xiaomi: cold start DEFAULT → FRAME tab GPS WATERMARK OFF
- [x] Shutter OFF thật silent (patch HybridPhotoOutput.kt, 2 path, artifact patches/) — verify log ON/OFF trên Xiaomi
- [ ] Zoom camera native: probe physical zoom + zoom UI (đang làm)
+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`.
+20
View File
@@ -0,0 +1,20 @@
# Release notes
## 1.2.3 (versionCode 5)
Shutter
- The shutter no longer waits on GPS: the fix is prewarmed at launch and bounded by a 2 s deadline, so a tagged shot reaches the gallery in about 3 s instead of 12–25 s.
- A shot pressed before the camera session binds is queued and replayed, not dropped.
- Fixes "Capture Failed — Could not take the photo.": `capturePhotoToFile` needs both its settings and options arguments.
- Saving to the gallery no longer depends on the media permission — the native save runs first, MediaLibrary only as a fallback.
Memory
- The Skia surface and bitmap are released after every export, dropping one full-resolution copy per rotation; the watermark font is parsed once.
Cold start
- The JS bundle is stored uncompressed in the APK.
- Named Lucide imports are rewritten to per-icon modules, so only the icons the app renders are bundled: 4.66 MB → 3.40 MB (−27%).
- The export pipeline and the recipe/GPS helpers load on demand.
- Hidden modals render nothing.
Measured on a vivo V2430 (Android 16) with a force-stop/start/tap probe: the earliest tap that reliably lands a photo moved from ~1.8 s to ~1.6 s once the bundle was trimmed. The rest is spent before app JS — process init, SoLoader, Hermes eval.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

+262
View File
@@ -0,0 +1,262 @@
# Third-Party Notices
This app (recipescamera) embeds or depends on the following open-source
software. Full license texts are reproduced below, grouped by license.
Generated 2026-09-09 from node_modules audit, rev. 2026-09-14 — 31 shipped components.
## Components
| Component | Version | License | Copyright / Author | Project |
|---|---|---|---|---|
| Skia graphics library (bundled native in react-native-skia) | bundled | BSD-3-Clause | Copyright 2011 Google Inc. All rights reserved. | https://skia.org/ |
| canvaskit-wasm | 0.41.0 | BSD-3-Clause | Copyright (c) 2011 Google Inc. All rights reserved. | https://skia.org/ |
| lucide-react-native | 1.24.0 | ISC | Copyright (c) 2026 Lucide Icons and Contributors | https://lucide.dev |
| @react-native-async-storage/async-storage | 2.2.0 | MIT | Copyright (c) 2015-present, Facebook, Inc. | https://github.com/react-native-async-storage/async-storage#readme |
| @react-native-community/slider | 5.2.0 | MIT | react-native-community | https://github.com/callstack/react-native-slider#readme |
| @shopify/react-native-skia | 2.6.2 | MIT | Copyright 2021-present, Shopify Inc. | git+https://github.com/Shopify/react-native-skia.git |
| expo | 57.0.7 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://github.com/expo/expo/tree/main/packages/expo |
| expo-asset | 57.0.6 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/asset/ |
| expo-dev-client | 57.0.6 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/dev-client/ |
| expo-document-picker | 57.0.2 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/document-picker/ |
| expo-file-system | 57.0.1 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/filesystem/ |
| expo-font | 57.0.1 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/font/ |
| expo-haptics | 57.0.1 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/haptics/ |
| expo-iap | 5.6.0 | MIT | Copyright (c) 2025 hyochan | https://github.com/hyodotdev/openiap/tree/main/libraries/expo-iap#readme |
| expo-keep-awake | 57.0.1 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/keep-awake/ |
| expo-location | 57.0.4 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/location/ |
| expo-media-library | 57.0.2 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/media-library/ |
| expo-sharing | 57.0.18 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/sharing/ |
| expo-status-bar | 57.0.1 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/status-bar/ |
| nativewind | 4.2.6 | MIT | Copyright (c) 2023 Mark Lawlor | https://nativewind.dev |
| react | 19.2.3 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates. | https://react.dev/ |
| react-dom | 19.2.3 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates. | https://react.dev/ |
| react-native | 0.86.0 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates. | https://reactnative.dev/ |
| react-native-nitro-image | 0.15.2 | MIT | Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy) | https://github.com/mrousavy/react-native-nitro-image#readme |
| react-native-nitro-modules | 0.37.1 | MIT | Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy) | https://nitro.margelo.com |
| react-native-reanimated | 4.5.0 | MIT | Copyright (c) 2016 Software Mansion <swmansion.com> | https://docs.swmansion.com/react-native-reanimated |
| react-native-safe-area-context | 5.8.0 | MIT | Copyright (c) 2019 Th3rd Wave | https://github.com/AppAndFlow/react-native-safe-area-context#readme |
| react-native-vision-camera | 5.2.3 | MIT | Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy) | https://visioncamera.margelo.com |
| react-native-vision-camera-skia | 5.2.3 | MIT | Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy) | https://visioncamera.margelo.com/api/react-native-vision-camera-skia |
| react-native-vision-camera-worklets | 5.2.3 | MIT | Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy) | https://visioncamera.margelo.com/api/react-native-vision-camera-worklets |
| react-native-web | 0.21.2 | MIT | Copyright (c) Nicolas Gallagher. | git://github.com/necolas/react-native-web.git |
| Cousine | Google Fonts | OFL-1.1 | Copyright 2026 The Cousine Project Authors (github.com/googlefonts/cousine) | https://fonts.google.com/specimen/Cousine |
| Noto Emoji (GPS subset) | Google Fonts | OFL-1.1 | Copyright 2022 The Noto Project Authors (github.com/notofonts/noto-emoji) | https://fonts.google.com/noto/specimen/Noto+Emoji |
## BSD-3-Clause
Applied to: Skia graphics library (bundled native in react-native-skia) (Copyright 2011 Google Inc. All rights reserved.), canvaskit-wasm (Copyright (c) 2011 Google Inc. All rights reserved.)
```text
// Copyright (c) 2011 Google Inc. All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are
// met:
//
// * Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the following disclaimer
// in the documentation and/or other materials provided with the
// distribution.
// * Neither the name of Google Inc. nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
```
## ISC
Applied to: lucide-react-native (Copyright (c) 2026 Lucide Icons and Contributors)
```text
ISC License
Copyright (c) 2026 Lucide Icons and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
The following Lucide icons are derived from the Feather project:
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
The MIT License (MIT) (for the icons listed above)
Copyright (c) 2013-present Cole Bemis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## MIT
Applied to: @react-native-async-storage/async-storage (Copyright (c) 2015-present, Facebook, Inc.), @react-native-community/slider (react-native-community), @shopify/react-native-skia (Copyright 2021-present, Shopify Inc.), expo (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-asset (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-dev-client (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-document-picker (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-file-system (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-font (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-haptics (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-iap (Copyright (c) 2025 hyochan), expo-keep-awake (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-location (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-media-library (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-sharing (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-status-bar (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), nativewind (Copyright (c) 2023 Mark Lawlor), react (Copyright (c) Meta Platforms, Inc. and affiliates.), react-dom (Copyright (c) Meta Platforms, Inc. and affiliates.), react-native (Copyright (c) Meta Platforms, Inc. and affiliates.), react-native-nitro-image (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-nitro-modules (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-reanimated (Copyright (c) 2016 Software Mansion <swmansion.com>), react-native-safe-area-context (Copyright (c) 2019 Th3rd Wave), react-native-vision-camera (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-vision-camera-skia (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-vision-camera-worklets (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-web (Copyright (c) Nicolas Gallagher.)
```text
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## OFL-1.1
Applied to: Cousine (Copyright 2026 The Cousine Project Authors (github.com/googlefonts/cousine)), Noto Emoji (GPS subset) (Copyright 2022 The Noto Project Authors (github.com/notofonts/noto-emoji))
```text
Copyright (c) <dates>, <Copyright Holder> (<URL|email>),
with Reserved Font Name <Reserved Font Name>.
Copyright (c) <dates>, <additional Copyright Holder> (<URL|email>),
with Reserved Font Name <additional Reserved Font Name>.
Copyright (c) <dates>, <additional Copyright Holder> (<URL|email>).
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
```
+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
+45 -4
View File
@@ -92,12 +92,29 @@ android {
applicationId 'com.locphamtran.recipescamera'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0.0"
versionCode 5
versionName "1.2.3"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
ndk {
abiFilters "armeabi-v7a", "arm64-v8a"
}
}
signingConfigs {
release {
// Credentials live in android/keystore.properties (gitignored), next to
// app/release.keystore. Lose either and no future build can install over
// this one - back both up somewhere safe.
def props = new Properties()
def propsFile = rootProject.file('keystore.properties')
if (!propsFile.exists()) throw new GradleException('android/keystore.properties missing')
propsFile.withInputStream { props.load(it) }
storeFile file('release.keystore')
storePassword props.getProperty('storePassword')
keyAlias props.getProperty('keyAlias')
keyPassword props.getProperty('keyPassword')
}
debug {
storeFile file('debug.keystore')
storePassword 'android'
@@ -105,6 +122,21 @@ android {
keyPassword 'android'
}
}
// Two products, two sales channels: lite = free apk handed out for feedback,
// pro = paid apk. Same JS; the tier reaches JS via src/provariant.ts, which
// tools/set-variant.mjs rewrites before each build.
flavorDimensions "tier"
productFlavors {
lite {
dimension "tier"
}
pro {
dimension "tier"
applicationIdSuffix ".pro"
versionNameSuffix "-pro"
resValue "string", "app_name", "RecipesCam Pro"
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
@@ -112,11 +144,14 @@ android {
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
signingConfig signingConfigs.release
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
shrinkResources enableShrinkResources.toBoolean()
minifyEnabled enableMinifyInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
// ponytail: proguard-android.txt carries -dontoptimize, which is what
// Play flags as unoptimized code. The -optimize file drops it; R8's
// optimizer + keep rules from the RN/Expo AARs carry the rest.
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
crunchPngs enablePngCrunchInRelease.toBoolean()
}
@@ -129,6 +164,12 @@ android {
}
androidResources {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
// assets/index.android.bundle is 4.4 MB of Hermes bytecode. Packed
// deflated (the default) the app pays a full inflate of it on the
// startup path, before the first line of JS runs; stored, it is mapped
// straight out of the APK. Costs ~3 MB of download Play re-compresses
// anyway.
noCompress += ["bundle", "hbc"]
}
}
+5
View File
@@ -11,4 +11,9 @@
-keep class com.swmansion.reanimated.** { *; }
-keep class com.facebook.react.turbomodule.** { *; }
# androidx.camera
-keep class androidx.camera.** { *; }
-keep interface androidx.camera.** { *; }
-dontwarn androidx.camera.**
# Add any project specific keep options here:
+17 -5
View File
@@ -1,15 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.RECORD_AUDIO" tools:node="remove"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
<queries>
@@ -19,7 +17,7 @@
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:requestLegacyExternalStorage="true">
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.ENABLE_BSDIFF_PATCH_SUPPORT" android:value="true"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
@@ -36,5 +34,19 @@
<data android:scheme="exp+recipescamera"/>
</intent-filter>
</activity>
<!-- Share target. Without this filter the app never appears in the
system share sheet at all; image/* only, the editor takes photos.
A separate no-display relay, not MainActivity: a host app that starts
the target without FLAG_ACTIVITY_NEW_TASK would otherwise drop the
editor into its own task and the share would look like part of it.
ShareActivity relaunches MainActivity in our own task instead. -->
<activity android:name=".ShareActivity" android:theme="@android:style/Theme.NoDisplay" android:launchMode="standard" android:excludeFromRecents="true" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<action android:name="android.intent.action.SEND_MULTIPLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="image/*"/>
</intent-filter>
</activity>
</application>
</manifest>
@@ -1,13 +1,17 @@
package com.locphamtran.recipescamera
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import com.locphamtran.recipescamera.volume.VolumeShutterModule
import expo.modules.ReactActivityDelegateWrapper
class MainActivity : ReactActivity() {
@@ -19,6 +23,28 @@ class MainActivity : ReactActivity() {
super.onCreate(null)
}
/**
* A share delivered to the running app lands here, and React Native never hands
* the new intent to the activity (nothing in ReactActivity/ReactDelegate calls
* setIntent) — so getIntent() keeps returning the launch intent and the share
* intake would read a photo the user shared minutes ago, or none at all.
*/
override fun onNewIntent(intent: Intent) {
setIntent(intent)
super.onNewIntent(intent)
}
/**
* Volume keys double as a shutter release while the camera screen is up.
* This is the only place that runs before the window turns them into a
* stream-volume change, so the module gets first refusal and the keys it
* does not claim (everything, outside the camera) fall through to the
* default handling unchanged.
*/
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
return VolumeShutterModule.onKeyEvent(event) || super.dispatchKeyEvent(event)
}
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
@@ -0,0 +1,30 @@
package com.locphamtran.recipescamera
import android.app.Activity
import android.content.Intent
import android.os.Bundle
/**
* Share sheet entry point for SEND / SEND_MULTIPLE.
*
* A host app that starts us without FLAG_ACTIVITY_NEW_TASK drops the started
* activity into its own task, so the editor used to show up as a screen of that
* app (it lived in that app's stack, back returned there). MainActivity cannot
* fix that itself: bailing out in onCreate would either boot React twice or skip
* super.onCreate and crash onDestroy.
*
* So the share filters live here. This activity never draws (NoDisplay theme):
* it hands the same intent to MainActivity with NEW_TASK — singleTask then routes
* it to our own task, or re-delivers it to the running instance — and finishes.
* The editor is therefore always a task of its own.
*/
class ShareActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
startActivity(
Intent(intent)
.setClass(this, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
finish()
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

@@ -0,0 +1,6 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splashscreen_background" />
<item
android:gravity="center"
android:drawable="@drawable/splashscreen_logo_img" />
</layer-list>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

@@ -0,0 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Android 12+ system splash: branded logo (app_logo.png) centered on white. -->
<style name="Theme.App.SplashScreen" parent="AppTheme">
<item name="android:windowBackground">@drawable/splashscreen_logo</item>
<item name="android:windowSplashScreenBackground">@color/splashscreen_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splashscreen_logo_img</item>
<item name="android:windowSplashScreenIconBackgroundColor">@color/splashscreen_background</item>
</style>
</resources>
+1 -1
View File
@@ -1,3 +1,3 @@
<resources>
<string name="app_name">recipescamera</string>
<string name="app_name">RecipesCam</string>
</resources>
+5
View File
@@ -22,3 +22,8 @@ allprojects {
apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject"
// v5: vision-camera module builds own C++ (NativeBufferHelper.cpp) with
// `minSdkVersion getExtOrIntegerDefault("minSdkVersion")` = rootProject.ext.minSdkVersion.
// C++ #if __ANDROID_API__ >= 26 else throws "HardwareBuffers require minSdk 26 or higher!" on every frame.
rootProject.ext.minSdkVersion = 26
+10 -3
View File
@@ -10,7 +10,7 @@
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
@@ -21,6 +21,7 @@ org.gradle.parallel=true
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
android.enableJetifier=true
# Enable AAPT2 PNG crunching
android.enablePngCrunchInReleaseBuilds=true
@@ -28,7 +29,7 @@ android.enablePngCrunchInReleaseBuilds=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a
# Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in
@@ -60,4 +61,10 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=true
# Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false
expo.inlineModules.watchedDirectories=[]
expo.inlineModules.watchedDirectories=[]
# Shrink the shipped APK: R8 strips unused Java/Kotlin, shrinkResources drops
# unreferenced resources, bundle compression gzips the embedded JS bundle.
android.enableMinifyInReleaseBuilds=true
android.enableShrinkResourcesInReleaseBuilds=true
android.enableBundleCompression=true
+8
View File
@@ -37,3 +37,11 @@ expoAutolinking.useExpoVersionCatalog()
include ':app'
includeBuild(expoAutolinking.reactNativeGradlePlugin)
// v5: vision-camera(-worklets) native needs minSdk 26 (AHardwareBuffer). Parallel project
// configuration races root build.gradle's assignment, so pin the ext here before any project config.
gradle.beforeProject { project ->
if (project == project.rootProject) {
project.ext.minSdkVersion = 26
}
}
+56 -68
View File
@@ -1,72 +1,60 @@
{
"expo": {
"name": "recipescamera",
"slug": "recipescamera",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false,
"permissions": [
"android.permission.CAMERA",
"android.permission.ACCESS_COARSE_LOCATION",
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE",
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
"android.permission.READ_MEDIA_IMAGES",
"android.permission.READ_MEDIA_VIDEO"
],
"package": "com.locphamtran.recipescamera"
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-asset",
[
"react-native-vision-camera",
{
"enableFrameProcessors": true,
"cameraPermissionText": "Allow CamRecipe Pro to access your camera."
}
],
[
"expo-location",
{
"locationAlwaysAndWhenInUsePermission": "Allow CamRecipe Pro to geotag your photos with location watermark."
}
],
[
"expo-media-library",
{
"photosPermission": "Allow CamRecipe Pro to save processed photos to your library.",
"isAccessMediaLocationEnabled": true
}
],
[
"expo-image-picker",
{
"photosPermission": "Allow CamRecipe Pro to select photos from your library to apply presets.",
"microphonePermission": false
}
],
"expo-sharing"
],
"extra": {
"eas": {
"projectId": "31b3c7c4-c00c-447d-aa77-a087f78480ef"
}
"expo": {
"name": "RecipesCam",
"slug": "recipescamera",
"version": "1.2.3",
"orientation": "portrait",
"icon": "./RecipesCamIcon.png",
"userInterfaceStyle": "light",
"ios": {
"supportsTablet": true,
"infoPlist": {
"NSCameraUsageDescription": "Allow RecipesCam to access your camera."
}
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./RecipesCamIcon.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false,
"permissions": [
"android.permission.CAMERA",
"android.permission.ACCESS_COARSE_LOCATION",
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE",
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
"android.permission.READ_MEDIA_IMAGES"
],
"package": "com.locphamtran.recipescamera"
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-asset",
[
"expo-location",
{
"locationAlwaysAndWhenInUsePermission": "Allow RecipesCam to geotag your photos with location watermark."
}
],
[
"expo-media-library",
{
"photosPermission": "Allow RecipesCam to save processed photos to your library.",
"isAccessMediaLocationEnabled": true
}
],
"expo-sharing"
],
"extra": {
"eas": {
"projectId": "31b3c7c4-c00c-447d-aa77-a087f78480ef"
}
}
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
+26
View File
@@ -1,3 +1,28 @@
// Lucide ships one module per icon, but the barrel import drags all ~1600 in.
// Rewrite each named import into its own icon module so Metro only walks what we use.
const lucidePerIcon = () => ({
visitor: {
ImportDeclaration(path) {
if (path.node.source.value !== "lucide-react-native") return;
const t = require("@babel/types");
const kebab = (n) =>
n
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/([A-Za-z])(\d)/g, "$1-$2")
.toLowerCase();
path.replaceWithMultiple(
path.node.specifiers.map((s) =>
s.type === "ImportSpecifier"
? t.importDeclaration(
[t.importDefaultSpecifier(s.local)],
t.stringLiteral(`lucide-react-native/icons/${kebab(s.imported.name)}`)
)
: t.importDeclaration([s], t.stringLiteral("lucide-react-native"))
)
);
},
},
});
module.exports = function (api) {
api.cache(true);
return {
@@ -7,6 +32,7 @@ module.exports = function (api) {
],
plugins: [
"react-native-reanimated/plugin",
lucidePerIcon
],
};
};
+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;
}
}

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