`git apply patches/vision-camera-isp-off.patch` turns NOISE_REDUCTION_MODE and
EDGE_MODE off for every still JPEG (HybridPhotoOutput.kt) and for the 0.5x path
(UltraWideModule.kt), which is how the two performance APKs were built. Apply
before assembleProRelease / bundleProRelease, revert after; the measured A/B
lives in isp-nr-off.md.
Measured on the 12S Ultra (2203121C), same scene, 1x, EV 0, 3 shots per arm:
- qualityPrioritization 'quality' -> 'balanced': tap->file 1013 ms vs 736 ms
(-277 ms, -27%), settled 1552 ms vs 1280 ms, in-camera 521 ms vs 286 ms.
Repeat on a second session: -228 ms. Image quality at ISO ~450 is inside the
scatter of one arm (flat sigma +2.4%, edge +0.1%, texture -1.1%), so the MFNR
pass quality would have bought is not measurable here. Also removes the
EXPO_PUBLIC_CAPTURE_MODE seam: Metro inlines EXPO_PUBLIC_* in dev only, the
release HBC build does not, so it could never gate shipped behaviour.
- Dark scenes now go through the app's own burst (ISO >= 100 gate for the probe)
and mergeBurst runs in the export queue instead of on the shutter path, so a
second shot is accepted while the frames are still being averaged: double-tap
at 1.2 s used to drop the second photo, now it lands. Output stays the merged
frame (flat sigma 0.825 vs 0.939 for a single frame).
Burst is skipped when the LED is the light source, when the shot came from the
native 0.5x session, for a held burst, for RAW, and below the devicePerf RAM
floor (deviceFloor.ts) where three raws plus the alignment rasters would be
seconds of shutter lag.
Bundles in the work this sits on: frame averaging (burstAlign/burstMerge),
the per-device floor, and the EXIF/ISO readback the burst gate needs (exifWrite,
photoMeta, exportEngine, RecipescamExportModule).
Self-checks: src/utils/{burstAlign,deviceFloor,exifWrite}.check.ts.
App.tsx passed `sharpen: true` on camera exports, and both engines turned
that into a 0.5 unsharp mask whenever the SHARPENING knob sat at its
default 0. The 3x3 kernel [0,-a,0; -a,1+4a,-a; 0,-a,0] with a=0.5 has a
noise gain of 3.167x at low frequency and 5.0x at Nyquist, so high-ISO
files came back far noisier than the viewfinder ever showed.
The knob is now the only source of output sharpening: dropping the
fallback cuts flat-region noise sigma ~1.6x at ISO ~330 (2.36 vs 4.52)
and ~2.0x at ISO ~1900 (0.23 vs 0.46), and the JPEGs are 26-35% smaller.
Measured on a Xiaomi 12S Ultra, 3000x4000 captures, same scene:
1x, ISO 335: sigma 2.35, dark-quartile 1.56, flat-tile 0.349
10x, ISO 2119: sigma 1.09, dark-quartile 0.54, flat-tile 0.225
The SHARPENING knob still sharpens as before when dialled above 0.
Ported from the web studio's 3d273a3, and folded into this branch's
colour-only rule for sims.
- `mono-high-contrast` joins BaseFilter, with its own matrix (a warm B&W
mix: red 0.38 / green 0.56 / blue 0.06, rows equal and summing to 1.00)
and its own FILM_TONE entry (toe -0.32, shoulder +0.26 — the whites step
of the brief). The sim ships with neutral adjustments like every other.
- The stock's own slope (+4 of Contrast) rides `STOCK_BIAS`, which absorbs
the former `STOCK_EXPOSURE` table: one place for tone a stock carries by
itself, keyed by baseFilter, so the EXPOSURE/CONTRAST sliders keep
reading 0 while the render is already pushed.
- Two B&W stocks now exist, so every "is this a colour stock?" test goes
through the new `isMonochromeBase` instead of comparing against
'monochrome' — a stray Kelvin gain on a grey ramp shows up as a tint.
Bumps the version and the release notes for the sim change in c794283:
lc streetlife vivid keeps its fixed +2 exposure, every other sim now carries
colour and tone only.
RELEASE_NOTES.md gains the 1.2.5 entry (Presets) and the measured numbers:
a mid grey goes 0.5000 -> 0.5435 under LC STREETLIFE VIVID, 1.1008 against
exposure +2's own 1.1000, while the sim's own exposure value stays 0.
PLAY_RELEASE_NOTES.md gains the Play listing block for 1.2.5 (318 chars,
under the 500 limit) and is tracked from here on.
Artifacts: lite AAB app-lite-release.aab (65.46 MB, versionCode 7,
com.locphamtran.recipescamera) for Play, pro APK app-pro-release.apk
(83.35 MB, 1.2.5-pro) for direct distribution.
A sim is a stock, not an edit: its whole look must come out of the baseFilter
colour matrix and the FILM_TONE curve, so every entry in FILM_SIMS now carries
the neutral DEFAULT_ADJUSTMENTS verbatim. Dropped the effects that had been
riding along — grain on ACRIPES, clarity+grain on LC STREETLIFE CLASSIC, and
exposure/saturation/clarity/shadow/highlight on LC STREETLIFE VIVID.
The Vivid brightness is real, though, so it moves into STOCK_EXPOSURE in
colorUtils, keyed by baseFilter: a fixed exposure move of the look (2), the
same maths the EXPOSURE slider runs (1.04x + 0.03). Keyed by stock because that
is what the preview and both exporters know — baseFilter travels with every
recipe, an armed sim id does not. A sim's own adjustments stay neutral, so the
EXPOSURE slider reads 0 on it.
Also records the naming rule in the FILM_SIMS header: COLOUR AND TONE ONLY, and
the real stock each sim stands for (reference only, the UI keeps the sim names).
The rail gains an HSL tab between FX and FRAME, and the tab opens the mixer
ported from the web studio: eight hue bands, each with hue/sat/lum knobs on a
-10..+10 ruler, plus three whole-image moves behind an IMAGE divider. Band
weights are linear tents anchored on HSL_BANDS and summing to 1 at every hue, so
neighbouring bands hand over at exactly half strength and no hue falls in a gap.
Pixels under 8% saturation are gated out of the weights (rgb2hsl calls a neutral
red, so an ungated one would ride the RED band), while the whole-image LUM move
is added after the gate and still lifts a picture that -SAT has pulled to grey.
The mixer runs last in TONE_SKSL, so a band edit is judged on the colour the user
actually sampled.
The eyedropper is library-only: PICK arms a layer over the picture, a tap reads
the rendered pixel off the canvas, points the ruler at the nearest band and
hangs a panel carrying that band's three knobs. A live preview has no pixel to
read, so in camera mode the chip greys out - the mixer itself still runs there,
which is why the camera worklet's tone buffer had to grow from 10 slots to 40: a
missed slot would have pushed NaN into every uniform and taken the whole tone
pass down, not just HSL.
HSL is a LITE feature (the web studio builds it for guests too), and MONOCHROME
switches the mixer off outright - a mono stock has no hue to select. Checked on a
Xiaomi thor against an 8-band chart: +10 whole-image HUE rotates all eight bands
by +30 deg, -10 on one band's SAT flattens that band and leaves the other seven
alone, and a whole-image -SAT drops the camera preview's mean chroma 13.7 -> 0.4
with +LUM still lifting it 116 -> 166. Cut as 1.2.4 / versionCode 6.
Straight off PLAN-WEB-TO-ANDROID.md: the sim keeps Classic Chrome's blue row
verbatim and takes Velvia's red and green rows, so skies stay muted while
everything else reads loud, and it carries Chrome's shadow pull.
MONOCHROME is a switch, not a look: it swaps the base filter for the mono stock
and swaps right back, leaving adjustments untouched so a knob turned while it is
on survives the round trip. It goes through commit(), so UNDO undoes it.
The landing page's QR card now carries a link to `GET
/api/photos/:id/preset.recipe`, so the app can pick a look up off a
screen instead of a file. PRESETS gains a SCAN QR chip next to IMPORT:
the code is decoded, checked against the preset route, fetched, and
stored through the same `storeImportedRecipe` path IMPORT already uses,
so an imported look and a scanned look land in one place.
Scanning runs on `expo-camera`, not the viewfinder's vision-camera:
v5's object output is iOS-only, Android's `createObjectOutput` throws.
That is a second camera library in the app, so the sheet pauses the
viewfinder (`paused` prop) — CameraX will not let two clients hold one
lens. A native rebuild is required for the new module.
The code holds a URL, never the recipe, so anything that is not the
preset route is refused by name rather than silently dropped. On a
release APK the plain-HTTP link is blocked by Android's cleartext
policy; debug builds have it on. Noted in 7_SCAN_QR.md along with the
web side, the regex, and the paused-viewfinder rule.
The web studio grew a CLASSIC VIVIDIPES sim (Classic Chrome's blue row spliced
to Velvia's red and green) and an FX MONOCHROME switch; both are written up
here with the exact matrices, anchors and the checks they have to pass before
they count as ported.
The web form (recipes-web f17cb08) now folds its six categorical groups and
carries EXPOSURE, EV, WHITE and BLACK. WHITE/BLACK do not exist in
ColorAdjustments on either side, so the note spells out the two model fields
and the wh/bl uniforms the tone pass needs — an input row without them would be
dead. A TODO in RecipeCreateModal's header points here.
Spec for porting the web's caption band onto this branch: the amber #TAG
over the photo's top-left plus the dark band below carrying the recipe
name and the ISO / grain / warmth line. Values, geometry, colours and the
three draw paths are pinned to recipes-web @ b4d5d29, with the preview
explicitly left clean and the ISO taken from the source EXIF.
HIGHLIGHT -10 then EXPOSURE +10 brought the bright end straight back to 1.0:
a SkPaint runs its shader before its colourFilter, so the exposure matrix on
the same paint as the tone shader landed after the tone pass. The note pins
the three places that carry the wrong order (exportEngine.ts:328/354,
Viewfinder.tsx:1207/1303, RecipescamExportModule.kt steps 2-4), the numbers
measured on the fixed web engine, and the matrix -> tone -> cinema fix.
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.
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).