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.
- 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
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.
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.
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.
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.
- 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.