Commit Graph

67 Commits

Author SHA1 Message Date
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 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 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
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 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 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 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 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 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 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 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 ba971f136c feat(panel): add a FAVORITED tab with a star on every recipe 2026-09-11 09:32:21 +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 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 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 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 6595e5cbc1 Let the GPS and custom watermarks be rotated 2026-09-10 15:56:06 +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 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 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 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 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 5799210949 Add embedded about & open-source credits screen (license audit) 2026-09-09 21:05:59 +07:00
admin 1cda62e039 P5 native export hooked (camera+library) — real photo to gallery on Xiaomi 2026-09-09 16:44:49 +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