Commit Graph

157 Commits

Author SHA1 Message Date
admin d5e5da49b7 chore(tools): keep the ISP NR/EDGE-off A/B as a patch
`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.
2026-09-26 11:37:53 +07:00
admin 588fbf77d9 perf(capture): trade CameraX MFNR for latency, average our own low-light burst off the shutter path
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.
2026-09-26 11:37:48 +07:00
admin 9f7d83276d release: 1.2.7 (versionCode 9) for the high-ISO noise fix 2026-09-24 16:09:38 +07:00
admin fd38269709 fix(export): stop forcing a 0.5 full-res unsharp mask on every capture
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.
2026-09-24 16:04:32 +07:00
admin 4863080dfe release: 1.2.6 (versionCode 8) for the B&W HIGH CONTRAST sim 2026-09-22 20:18:39 +07:00
admin 85b55dcf48 android: add the B&W HIGH CONTRAST film sim next to ACRIPES
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.
2026-09-22 19:35:25 +07:00
admin 4ec4260cbd release: 1.2.5 (versionCode 7) for the colour-only film sims
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.
2026-09-22 09:09:27 +07:00
admin c794283083 android: keep film sims colour-only, and fix the Vivid exposure in config
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).
2026-09-22 08:29:37 +07:00
admin cbd0178cda android: port the web studio's HSL selective colour mixer
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.
2026-09-21 18:28:32 +07:00
admin c8e2b6ea5a android: add the CLASSIC VIVIDIPES sim and the FX MONOCHROME switch
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.
2026-09-21 17:20:42 +07:00
3dtours 1e1ead6f54 android: scan the web recipe card's QR and import the look it points at
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.
2026-09-18 17:01:34 +07:00
3dtours e0526283ca docs: note the two web looks that still have to reach Android
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.
2026-09-18 16:17:07 +07:00
3dtours 99069aa837 docs: note the CREATE form's folds and the white/black point rows
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.
2026-09-18 12:32:13 +07:00
3dtours 82efc1f675 docs: note the strip that gets burned into the export
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.
2026-09-18 10:57:00 +07:00
3dtours 2bfe8f1211 docs: note the highlight/exposure pass order bug
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.
2026-09-18 10:34:27 +07:00
admin 3320513d7a Bundle only the icons the app renders
Lucide ships one module per icon, but the barrel import dragged all ~1600 in.
A local Babel plugin rewrites each named import to its own icon module, so the
bundle drops from 4.66 MB to 3.40 MB and the earliest tap that reliably lands a
photo moves from ~1.8 s to ~1.6 s on the test device.
2026-09-17 10:11:12 +07:00
admin 76cf4c6d7e Cut 1.2.3 with a shutter that never waits on GPS
Bound the geotag fix with a 2 s deadline and prewarm it at launch, queue a
shot pressed before the camera session binds and replay it, pass the options
argument capturePhotoToFile requires, save through the native path so the
media permission is not needed, and release the Skia surface and bitmap after
every export. Store the JS bundle uncompressed and load the export pipeline on
demand. Note in RELEASE_NOTES.md that none of this moved the tap threshold.
2026-09-17 10:02:35 +07:00
admin d07dd69f17 Cut the store build as 1.2.2 (versionCode 4) 2026-09-16 19:03:52 +07:00
admin c469d9be81 Let R8 optimize the release dex, not just shrink it 2026-09-16 18:54:49 +07:00
admin 13ad52a975 Move the flash control into the top bar 2026-09-16 18:21:14 +07:00
admin c40b5565d3 Fire the LED on the 0.5x still
0.5x is a bare Camera2 session, so CameraX never saw the flash setting and the ultra-wide still came off unlit. The native module now reads FLASH_INFO_AVAILABLE off the lens, maps off/auto/on to CONTROL_AE_MODE plus FLASH_MODE_SINGLE, and starts an AE precapture so the HAL can meter the flash before the frame. The viewfinder hands its flashMode down beside the shutter, with a Zap control that only appears where a unit exists.
2026-09-16 18:11:17 +07:00
admin 12d58ff810 Build the bundle Play's billing needs 2026-09-16 17:24:48 +07:00
admin 5a5c4b0d11 Let the store take PRO back, and finish every purchase 2026-09-16 17:24:48 +07:00
admin 7b013fb6f8 Move the CROP band, not the picture
Library CROP now resizes the band in photo fractions, the way FREE always
did, instead of panning a re-scaled photo under a screen-sized rectangle:
what the band encloses is the export, with no zoom transform left to undo
and no aspect that only holds on a square photo. The band is laid out
below the top strip, whose height TopBar now reports, so it can never
peek under the header. The grain hashes a cell of width/1080 instead of
the raw pixel grid, so a 4000px export is no longer ~4x finer than the
preview that tuned it.
2026-09-16 17:17:36 +07:00
admin 8f615f6cd7 Give ACRIPES a straight BT.709 ramp and end-only tone 2026-09-16 17:17:11 +07:00
admin a5e7162561 Relay share intents through a headless activity 2026-09-16 17:17:10 +07:00
admin 78420002d1 Correct what the volume keys do behind a modal 2026-09-16 10:26:38 +07:00
admin e1ce7af76f Shoot from the volume keys, while the camera is up 2026-09-16 10:18:25 +07:00
admin 78630d8a2d Bias the 0.5x lens, since its feed has no frame pipeline
The ultra-wide preview is a bare Camera2 surface: it never goes through
Skia, so the software EV gain that carries exposure on the 1x preview had
nothing to act on and quick EV / the LIGHT-EV slider did nothing at 0.5x.
The lens itself now takes the bias, as CONTROL_AE_EXPOSURE_COMPENSATION on
every preview request (session builder, metering rebuilds, and a setEv call
the viewfinder pushes whenever the value changes).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also fix swapToLittleEndian: Buffer.slice() aliases, so the big-endian swap was
mutating the caller's bytes (a Xiaomi source read back as ISO 36865 instead of
400). Use new Uint8Array(data).
2026-09-11 18:28:02 +07:00
admin 62b9735867 feat(exif): stamp RC_<ts> filenames + EXIF on capture and export 2026-09-11 17:59:44 +07:00