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