158 Commits

Author SHA1 Message Date
3dtours fe79d75669 docs: hand the Android team the recipe/QR deltas the studio just took
The five changes recipes-web landed on 2026-09-29 (34f8601, c0aaa67, bd57dd7,
3b92e4e) only put two new fields into the shareable payload, but four of them
change the look a given recipe produces — so both halves of the story belong in
one note, next to doc 8 which was written against 6814b05.

What the note says:

- The slider/store split. Web now reads EXPOSURE as stops (-5..+5, two decimals)
  and every develop knob as -100..+100, but paramDefs translates both ways and
  the stored units did not move: exposure is still +-10 units of 0.25 EV, every
  other knob is still +-10. Android can keep its +-10 sliders and still be
  byte-compatible, so the doc says that instead of asking for a UI port.
- The one place a unit really does differ: masks[].exposure is the EV itself
  (MASK_EXPOSURE_MAX = 5) because the shader reads pow(2.0, a.x). Getting that
  wrong is a factor of four, so it gets its own row and its own acceptance test.
- The recipe format did not change. The shareable object is still
  {name, baseFilter, adjustments, frameId, useGeotag}, adjustments still travels
  whole, and Android already keeps unknown fields through import -> Look ->
  export because only hslBands is sanitized. So masks survive web -> Android ->
  web today; that is worth an explicit round-trip test rather than being relied
  on as an accident.
- The two new mask fields: temperature (2500..10000 K, NEUTRAL_K = 5500) and
  tint (+-10), resolved in readMasks so a mask stored before them carries a gain
  of exactly (1,1,1), plus the extra float4 wb[count] uniform and the one line
  c = clamp(c * wb, 0, 1) right after EXPOSURE. Gain is hoisted to JS on purpose:
  the Kelvin fit is a curve and a second copy of it in SkSL is a second answer.
- whiteBalanceGain is the same arithmetic Android already has inline in
  getSkiaColorMatrix, so the port is a lift-and-shift with a bit-identical
  matrix as its acceptance test.

One divergence is called out rather than papered over: gradientMask.ts still
runs the old cg = clamp(lifted / max(l, 0.0004), 0.55, 1.35) for the tone knobs
inside a mask, and its comment still claims that is TONE_SKSL's own gain — which
stopped being true when c0aaa67 gave the frame-wide shader the hue-preserving
knee and the five-knot ramp. A mask's HIGHLIGHT is therefore not the frame-wide
HIGHLIGHT on a smaller area, and the note says which side of that the Android
port should follow until the project owner decides.

Verified: every file:line, commit hash and unit in the note was read back out of
origin/recipes-web at 3b92e4e (toneShader.ts, colorUtils.ts, paramDefs.ts,
gradientMask.ts, types/index.ts, ToolRail.tsx, App.tsx, recipeShare.ts) and out
of this branch (src/utils/colorUtils.ts:384-394, src/types/index.ts:81-105,
src/utils/recipeShare.ts:59-110), including grep runs showing Android has no
masks field at all. The measured WB numbers quoted (1.250 -> 1.724 inside the
mask, 0.994 -> 0.994 outside) are the ones the studio's own mask-wb check
produced against the live build.

ponytail: no code touched, one file. The mask-tone alignment is left as an open
question with both candidate behaviours written down, because choosing for the
owner would bake a look decision into two codebases at once.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 17:42:34 +07:00
3dtours f3d26e3275 android: spec the web editor tools to port (exposure/EV/highlight, AUTO, WB white/black, tone curve, gradient mask, FIX, flips) 2026-09-26 11:51:19 +07:00
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
admin 6faef4aef7 feat(crop): two-step crop with APPLY + hidden amber border, persisted in session
- 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.
2026-09-11 17:29:51 +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 d6b3ebda40 fix(frame): take the bubble level off the image when leaving the strip
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.
2026-09-11 12:21:09 +07:00
admin da48ceccb5 fix(frame): make the ROTATE strip behave on switching parameters
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.
2026-09-11 12:07:18 +07:00
admin 0ce561cb70 feat(frame): level the photo to the horizon with AUTO
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.
2026-09-11 11:35:48 +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 dcb74a7243 feat(frame): pinch and drag the framed window on the camera
Two fingers scale the live feed inside a polaroid card / wall opening and
one finger drags it, both clamped to the window, and the shutter passes the
result to the export as frameWindowZoom so the printed crop matches what the
viewfinder showed. The crop centre is mapped through the same out-space ->
raw transform as the window rect; feeding it straight to the raw plane
swapped the axes on a rotated sensor.
2026-09-11 10:36:50 +07:00
admin 4131c9f401 feat(frame): move the watermark controls into a FRAME sub-panel 2026-09-11 10:00:10 +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 ba7e579986 feat(panel): RESET shows a star while the look differs from a clean start 2026-09-11 09:08:37 +07:00
admin 7ed4301055 feat(panel): gather the white balance presets and COLOR TEMP into a TEMP chip 2026-09-11 08:59:49 +07:00
admin d66ae3dc59 feat(panel): collapse the film simulations into a PHOTO STYLE chip 2026-09-11 08:54:38 +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 7454ea1218 feat(panel): one RESET on every tab, back to the app's defaults
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.
2026-09-10 22:18:43 +07:00
admin 02bc3aa4da feat(panel): editor keeps the values when the sim changes; one-tap RESET back to the look 2026-09-10 22:06:36 +07:00
admin 868ebfe6e2 feat(recipe): edit opens the prefilled create form; fields select their value on tap 2026-09-10 21:34:32 +07:00
admin b154e50191 feat(recipe): edit button on the top bar, with overwrite-or-save-as on save 2026-09-10 21:16:50 +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 f13fd8e0f3 fix(library): drop the leftover zoom in the mark editor, re-anchor the framed crop
Two zoom-interaction bugs in the library viewer:

- Opening the WATERMARK editor now resets a photo zoom left over from the
  panel-closed pass. The zoom had shifted the mark's hit box along with the
  matrix and tripped the `libZoom.s <= 1.01` guards, so after visiting another
  tab and zooming, the mark could neither be dragged nor pinched.
- A polaroid/wall photo window that MOVES (deck grows: a chip slider or the
  watermark text field) now re-anchors the photo transform by the same u/v the
  export uses, so the crop inside the frame no longer slides away from the card
  (at 2.5x the content used to drift 2.5x the card's move).

Verified on device (9a6a7277): the mark drags again after tab-switch + zoom;
opening WATERMARK returns the photo to the pre-zoom framing; the framed crop
tracks the card within ~2 px over a 62 px deck-driven move.
2026-09-10 17:40:50 +07:00
admin f4a5b0c683 fix(wm): let the library editor pinch a watermark mark
onLibTouchStart only recorded the pinch baseline when both fingers arrived in
the same touch-start, so a second finger landing later (the normal case) left
wmPinchRef null and the move handler scaled nothing -- pinch-the-mark worked
on the camera viewfinder but not on a library photo. Seed the baseline in the
move handler, as the camera path already does.
2026-09-10 17:15:36 +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 67a2a52a6f Bump the app to version 1.2
versionName 1.2 / versionCode 2, kept in sync with app.json, package.json and the credits screen.
2026-09-10 16:43:03 +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 47e424789d Revert "Zoom the finished photo with a double tap only"
This reverts commit 29b75ec3d3.
2026-09-10 16:06:23 +07:00
admin 29b75ec3d3 Zoom the finished photo with a double tap only 2026-09-10 15:59:15 +07:00
admin 6595e5cbc1 Let the GPS and custom watermarks be rotated 2026-09-10 15:56:06 +07:00
admin 2911dd57f0 Let the wall-frame photo be dragged inside its opening 2026-09-10 15:36:51 +07:00
admin 2835f46c37 Keep the dialled-in look when swapping film simulations 2026-09-10 15:16:21 +07:00
admin 045f5f2048 Keep a thumbnail of the finished export in library mode 2026-09-10 12:31:54 +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 4ad26b514e Show library photos at their own aspect instead of cropping to the ratio band 2026-09-10 11:28:54 +07:00
admin 21e4932249 Reset the custom watermark when returning to the camera
The mark is placed against the library photo it was dragged on, but it
survived the switch to the live camera: the preview kept drawing it and
the next capture burned it in. Camera mode already drops the frame and
the library GPS for the same reason, so drop the accepted watermark too.

Resetting both the draft and the accepted copy keeps the chip honest and
stops the AsyncStorage session snapshot from restoring a camera mode
that still carries a library watermark.
2026-09-10 11:14:09 +07:00
admin e9de4203e9 Gate pinch zoom behind an existing zoom
A two-finger pinch only changes scale once the photo is already zoomed
in; at 100% the gesture is inert, so the paging strip keeps behaving
like a plain gallery. The responder is now held for the whole pinch so
the paging ScrollView cannot steal it mid-gesture.
2026-09-10 11:02:39 +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 6379c025c8 HDF on the live camera preview
Mirror export pass 5b in the onFrame worklet: extract the highlights with
the same 2.5x-1.5 contrast curve, blur that selection, Screen it back at
0.15..0.5 alpha. The selection must be built as an image filter so the
highlight curve runs before the blur (a paint color filter would run after,
smearing the matrix into the selection).

Kept the tone/cinema path intact: the base draw now records the shader it
used and the glow pass reuses it (drawRect) or falls back to the plain
drawImage, so the glow always sits on the same processed pixels.

Measured on the Xiaomi 2203121C: 5 interleaved 0/10 pairs, the HDF=10 mean
luma is +0.70..0.84 above HDF=0 every time (AE metering does not track the
software pass, so the toggle is the only variable), and frame counts are
unchanged (488/489 vs 492/488 frames per 15s; GPU busy rises ~30%, still
inside the 3 ms median GPU frame). No fps loss, so the pass stays.
2026-09-10 10:41:39 +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 d7a8e0681e Add CANCEL to the watermark editor, scope the mark's touch layer to that editor, and stop a library frame following the user onto the camera
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.
2026-09-10 09:58:50 +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 c0c2f8532f Lift bottom deck above the keyboard so the custom watermark input stays visible
Edge-to-edge (edgeToEdgeEnabled) makes Android ignore the manifest's
adjustResize, so the IME never shrank the window and the text input sat
under the keyboard. Track keyboardDidShow/Hide height and pad the deck.
2026-09-10 07:44:03 +07:00
admin 3eeef6feb9 Persist device/camera settings across restarts independent of startup mode
Restore shutter sound/metering/RAW from a dedicated key under both 'default'
and 'last session' startup (the session file only stores the look and is
written on background events, so it can be stale after a force-kill). Guard
the persist effect until hydration finishes so the mount-time defaults cannot
overwrite the stored values; drop the stale session override of these three.
2026-09-10 07:36:22 +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 2512e05c92 Rename launcher label to RecipesCam 2026-09-09 21:55:34 +07:00
admin 4261806dc4 Replace app icon with recipes_icon.png for legacy and adaptive layers 2026-09-09 21:53:23 +07:00
admin f2de481708 Keep GPS watermark user-owned across presets; add pinch/double-tap zoom to photo preview; collapse chrome strip on WB selection 2026-09-09 21:40:28 +07:00
admin 5799210949 Add embedded about & open-source credits screen (license audit) 2026-09-09 21:05:59 +07:00
admin 568f06306a Fix preview wedge after flip: remount SkiaCamera on facing change and as watchdog recovery
Flip only reconfigured the CameraSession in place, leaving the Skia
offscreen-surface cache from the old lens wedged on this Xiaomi: after a
flip the preview banded then went black while the frame pipeline kept
delivering (heartbeat kept rising) and only a full app restart revived it.

- key <SkiaCamera> by facing + mount nonce so a flip fully unmounts and
  remounts the camera (lib clears its surface cache on unmount), i.e. a
  clean restart on every lens switch instead of an in-place reconfigure.
- watchdog now recovers by the same full remount instead of an isActive
  toggle (which never revives a wedged preview), budgeted to 3 restarts.
- bump a re-render on every onStarted: after a remount cameraActive never
  drops (no onStopped on unmount) so the reopen did not re-render and the
  new controller was never re-read, leaving the 1x-10x zoom chips hidden
  until the next touch.

Verified on device: 15+ flips with no wedge, chips visible immediately
after each flip, preview stays live (was ~5 min to black before).
2026-09-09 20:49:24 +07:00
admin 999f49b87f Center shutter between balanced side clusters; tighten flip/preview gap; enlarge active tool tab label 2026-09-09 18:48:04 +07:00
admin a0ca68f176 Keep screen awake in camera mode; nudge flip button toward shutter 2026-09-09 18:40:58 +07:00
admin 55f4184e74 Native zoom presets 1x-10x; back/front camera flip 2026-09-09 18:21:08 +07:00
admin ca3532023a Startup mode default/last-session; GPS watermark default OFF; shutter OFF truly silent
- Settings STARTUP MODE chips (AsyncStorage startup_mode): default = clean camera
  + PROVIA neutral 4:3, last = restore full session look.
- initSession: resolve sim-* recipe ids via FILM_SIMS (fix clobber of session
  ratio/adjustments to 4:3 canonical); restore via setSelectedRecipe not
  applyRecipe so user tweaks (frame/geotag/ratio) survive restore.
- applyDefaultStartup turns GPS watermark OFF (user-requested default).
- Shutter: OFF must not play click on must-play-policy devices. VisionCamera
  v5.2.3 HybridPhotoOutput.kt plays MediaActionSound itself gated by
  (enableShutterSound ?: true) || mustPlayShutterSound — on Xiaomi mustPlay=true
  so OFF still clicked. Both capture paths now gate on enableShutterSound != false.
  node_modules patch not trackable -> artifacts/patches/vision-camera-shutter-silent.patch.
  Verified on Xiaomi 12S Ultra: log SND user=false -> play=false; cold-start
  DEFAULT shows GPS WATERMARK OFF.
2026-09-09 17:59:15 +07:00
admin 3dcfd19d35 PLAN: P6 parity probe results — pending user QA before native default 2026-09-09 17:07:04 +07:00
admin 1fdf765d54 P6 parity probe: same photo through legacy Skia + native engines, 3-panel composite+diff on Xiaomi 2026-09-09 17:06:54 +07:00
admin a51b28f32a PLAN: P5 done 2026-09-09 16:45:00 +07:00
admin 1cda62e039 P5 native export hooked (camera+library) — real photo to gallery on Xiaomi 2026-09-09 16:44:49 +07:00
admin 25e4f21917 P4 native frames (classic/polaroid/wallframe) + GPS watermark verified on Xiaomi (sample gates pass) 2026-09-09 16:34:24 +07:00
admin 11635a0d33 P3 native cinema/clarity/denoise/grain (exportEngine port) verified on Xiaomi (gates pass) 2026-09-09 15:58:15 +07:00
admin 71b9476901 P2 native tone DR/hl/sh (TONE_SKSL port) parity verified on Xiaomi (gates 140/75/209/104/11 pass) 2026-09-09 15:38:35 +07:00
admin a72804f081 docs(plan): tick P1 matrix done 2026-09-09 15:18:37 +07:00
admin d412f40861 P1 native color matrix + crop (processColorAsync): parity verified on Xiaomi (classic-neg on black -> rgb(13,5,0), translate*255) 2026-09-09 15:18:30 +07:00
admin dfcf0f3a28 P0 native decode/encode benchmark passes on Xiaomi (12MP decode+encode ~210-270ms, off-main-thread) 2026-09-09 15:10:30 +07:00
3dtours a8932f52ed feat(native-export): scaffold local Expo module + native decode/encode bench probe (P0)
Local Kotlin module modules/recipescam-export (autolinked via expo ./modules)
exposes decodeEncodeAsync — BitmapFactory decode -> JPEG q95 encode -> file,
all on Dispatchers.IO off the JS/main threads. EXPO_PUBLIC_BENCH=1 builds run
src/dev/nativeBenchProbe.ts: 3 timed decode+encode passes on the bundled
wallframe artwork plus a JS-timer gap check proving the JS thread never
blocks during native rendering. Legacy Skia engine untouched (reference).
2026-09-09 12:39:35 +07:00
3dtours f588849ac5 perf(capture): render exports off the shutter path via FIFO background queue 2026-09-09 12:22:07 +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
3dtours e9a9005ef2 fix(ev): preview EV via software matrix, flush AE bias only at capture 2026-09-08 21:28:38 +07:00
3dtours dca4ba0b7c fix(4x3): force frame stream to 4:3 sensor aspect via targetResolution 2026-09-08 21:21:05 +07:00
3dtours 5a94978f33 debug(4x3): use console.error for release-build visibility + build marker 2026-09-08 21:15:25 +07:00
3dtours fcad148798 debug(4x3): log actual frame stream geometry at runtime 2026-09-08 21:01:15 +07:00
3dtours 30a2f768da Restore width-fit ratio bands for native FOV plain preview
Plain frame 'none' with ratio 4:3 shows the whole sensor at screen width (85 deg FOV, like the built-in app at 23mm equiv); 3:2 crops the two vertical sides, same height and zoom, matching the export crop. The fullscreen-cover plain preview from 7985f25 was a wrong interpretation of the built-in view and made every ratio look identical fullscreen.
2026-09-08 20:39:24 +07:00
3dtours 7985f25aa8 Match built-in camera 1x FOV for plain ratio preview
Plain frame 'none' with an aspect ratio no longer bands the feed into a letterboxed strip; it keeps the fullscreen cover view like FULL/1x, so 4:3 preview shows the same angle of view as the built-in camera app at 1x (export crop unchanged). Border/bars frames (classic-white, cinematic) keep the banded contain view.
2026-09-08 20:27:24 +07:00
3dtours 125c1280fa Keep ratio bands at native sensor zoom
The band region was the ratio photo re-scaled to the screen width, so 3:2
(0.667) rendered taller than the 4:3 (== native 3:4) band and zoomed the
scene. Anchor the band on the contain-fitted native sensor box instead and
cut the ratio strip out of it at the same scale: 3:2 is now the identical
vertical view with a narrower horizontal crop, never a taller image.
2026-09-08 19:54:45 +07:00
3dtours 56c86df1b5 Show native sensor in viewfinder composition band
SkiaCamera cover-fits the upright frame snapshot onto the fullscreen view,
so a 4:3 ratio band (aspect == native 3:4 sensor) only revealed a center
slice of the sensor instead of the whole frame. Redraw the feed inside the
frame worklet: when a photo-area rect is active (ratio band / polaroid
window / wall window) black the surface, clip to the rect's raw-frame
inverse under the renderToTexture rotation/mirror CTM, and cover-map the
sensor there. Equal-aspect bands now show the full sensor; narrower ratios
(3:2) crop center like the export. Inactive feed keeps the legacy draw.
2026-09-08 19:34:32 +07:00
3dtours d43405a4de Add aspect-ratio composition band and RAW DNG sidecar capture 2026-09-08 18:43:02 +07:00
3dtours a16c8734a8 Rotate wall frame portrait, letterbox feed to frame window 2026-09-08 18:14:25 +07:00
3dtours a1769e8c84 Add wall frame overlay with transparent PNG window 2026-09-08 17:56:50 +07:00
3dtours 669224fa39 Add D.RANGE toggle chip to LIGHT tab; letterbox camera polaroid to 3:4 card 2026-09-08 17:26:39 +07:00
3dtours b5a257e033 Fix camera polaroid overflow and chrome WB chips; move GPS watermark to top 2026-09-08 17:06:22 +07:00
3dtours bfdc964f65 Polaroid print layout in viewfinder and export; split chip rows; drop cinematic; debounce EV bias 2026-09-08 16:30:29 +07:00
3dtours 7f7ceeddc6 Refresh launcher icon and splash logo from 1024px sources 2026-09-08 15:48:23 +07:00
3dtours b581ecea2f Install icon.png launcher icon and app_logo.png Android splash logo 2026-09-08 15:37:38 +07:00
3dtours d2d62c6612 Sit library watermark at photo corner; white icons distinct from amber text 2026-09-08 12:12:47 +07:00
3dtours 58335d8cd1 Add one-space gap between watermark icons and text (preview + export) 2026-09-08 12:06:07 +07:00
3dtours b64d7245c9 Fix library GPS watermark anchor; use Cousine for Vietnamese glyphs 2026-09-08 12:02:50 +07:00
3dtours 588a36d05a feat: cinematic seasonal atmosphere presets (spring/summer/autumn/winter) - enhances existing light with season tints, sun glow, fog veil 2026-09-08 11:38:27 +07:00
3dtours ea281d7974 feat: monochrome Noto Emoji subset icons (pin, camera) before watermark lines 2026-09-08 11:30:47 +07:00
3dtours 43a73c541b fix: legacy picker for real photo GPS on Android 13+, watermark shows place + capture datetime 2026-09-08 11:28:02 +07:00
3dtours 7d77be5029 fix: keep recipe-name sheet above keyboard (KeyboardAvoidingView), gps diagnostic logs 2026-09-08 11:01:15 +07:00
3dtours 358f9b0f2f fix: fresh export URI per save (stale thumbnail), lock spot-meter + quick EV slider, honor photo GPS over device fallback 2026-09-08 10:42:08 +07:00
3dtours 8eca31ce58 fix(camera): matrix-based zoom for pinch/double-tap, tap-to-focus AE/AF lock, library letterbox smear fix, export raw JPEG bytes q95, resume isActive fixes, dark theme polish 2026-09-08 10:25:34 +07:00
3dtours 515f408c67 fix(camera): drop unused expo-camera; viewfinder crash-safe on Android
- remove expo-camera dep (autolink forced CameraX 1.6.0 over vision-camera
  4.7.3's pinned 1.5.0-alpha03, breaking device enumeration on Xiaomi)
- Android: disable Skia frame processor in viewfinder - devices w/o
  hardware-buffered ImageAnalysis frames (Xiaomi 14 Ultra) crash in
  frame.getNativeBuffer(); live filter stays on iOS, filters still apply
  at capture/export
2026-09-07 19:20:33 +07:00
3dtours 674fe9d1e2 fix: refresh camera device list after permission grant
useCameraDevices fetches once on mount. First-run flow grants the
camera permission after the viewfinder mounted, so the device list
stays empty until remount/restart and the UI shows 'Camera not
available on this device'. Poll Camera.getAvailableCameraDevices()
for up to 6s after permission is granted and prefer the found device.
2026-09-07 17:30:41 +07:00
3dtours 200be77903 feat: full-res screen sharpen on camera-captured exports
Lightroom-style 'Screen' output sharpen (unsharp mask, 3x3 convolution
via Skia ImageFilter.MakeMatrixConvolution, amount 0.5, kernel sums to
1) applied to final pixels before JPEG encode. Camera-capture branch
passes { dpi: 300, sharpen: true }; library-mode export untouched.
Export pixels were already full resolution - sharpen is a new final
GPU pass, no pixel-count change.
2026-09-07 16:48:16 +07:00
3dtours aa6a74f229 feat: stamp 300dpi metadata on camera-captured exports
Skia JPEG re-encode drops original header (72dpi camera default). New
src/utils/jpegDpi.ts rewrites JFIF APP0 density + EXIF X/YResolution
(endian-aware) or inserts minimal JFIF when neither exists; applied via
processAndExportPhoto options.dpi in the camera-capture branch only.
Library-mode exports untouched. Pixels never change - metadata only.
2026-09-07 16:31:24 +07:00
3dtours 96f412e269 fix: sửa export/gps/UI theo FIX_PLAN + chụp ảnh camera thật (takePhoto)
- export: encodeToBase64 thay SkData.toString, dùng expo-media-library/legacy
- GPS ảnh gốc: resolver EXIF -> MediaLibrary -> device (ACCESS_MEDIA_LOCATION), tag nguồn watermark
- contrast vào color matrix (có hiệu lực mono), ẩn highlight/shadow, denoise/clarity ghi chú export-only
- init session tuần tự, tách setState lồng nhau, share file bằng expo-sharing, DONE lưu gallery
- Phase 4A: chụp camera thật qua vision-camera takePhoto rồi export filter
2026-09-07 06:54:13 +07:00
3dtours 0d276861e0 fix: camera live view monochrome 20fps 2026-07-17 22:15:57 +07:00
3dtours 4c419f266c fix: lỗi áp dụng recipe lên live view của camera 2026-07-17 15:02:05 +07:00
169 changed files with 21561 additions and 3132 deletions
-14
View File
@@ -1,14 +0,0 @@
> Why do I have a folder named ".expo" in my project?
The ".expo" folder is created when an Expo project is started using "expo start" command.
> What do the files contain?
- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds.
- "settings.json": contains the server configuration that is used to serve the application manifest.
- "dev/logs/": contains structured JSONL event logs from CLI commands (e.g. start.log, export.log). These are truncated on each run.
> Should I commit the ".expo" folder?
No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
Upon project creation, the ".expo" folder is already added to your ".gitignore" file.
File diff suppressed because it is too large Load Diff
-177
View File
@@ -1,177 +0,0 @@
{"_e":"root:init","_t":1784254504055,"format":"v0-jsonl","version":"57.0.8"}
{"_e":"env:mode","_t":1784254504056,"nodeEnv":"development","babelEnv":"development","mode":"development"}
{"_e":"env:load","_t":1784254504058,"mode":"development","files":[],"env":{}}
{"_e":"devserver:start","_t":1784254508949,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
{"_e":"metro:config","_t":1784254509422,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"expo:dev-tools-plugin:load","_t":1784254509956,"plugins":[]}
{"_e":"metro:instantiate","_t":1784254516842,"atlas":false,"workers":3,"host":"::","port":8086}
{"_e":"metro:bundling:started","_t":1784254564715,"id":"1","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784254564715,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784254564729,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1784254565214,"id":"1","progress":0.010000000000000002,"total":1,"current":1}
{"_e":"metro:bundling:progress","_t":1784254565318,"id":"1","progress":0.09,"total":9,"current":3}
{"_e":"metro:bundling:progress","_t":1784254565418,"id":"1","progress":0.09,"total":41,"current":7}
{"_e":"metro:bundling:progress","_t":1784254565518,"id":"1","progress":0.09,"total":46,"current":11}
{"_e":"metro:bundling:progress","_t":1784254565577,"id":"1","progress":0.09,"total":46,"current":12}
{"_e":"metro:bundling:progress","_t":1784254565690,"id":"1","progress":0.09,"total":147,"current":14}
{"_e":"metro:bundling:progress","_t":1784254565794,"id":"1","progress":0.09,"total":156,"current":24}
{"_e":"metro:bundling:progress","_t":1784254565894,"id":"1","progress":0.09,"total":157,"current":31}
{"_e":"metro:bundling:progress","_t":1784254565998,"id":"1","progress":0.09,"total":185,"current":45}
{"_e":"metro:bundling:progress","_t":1784254566099,"id":"1","progress":0.09,"total":197,"current":52}
{"_e":"metro:bundling:progress","_t":1784254566211,"id":"1","progress":0.09,"total":210,"current":59}
{"_e":"metro:bundling:progress","_t":1784254566311,"id":"1","progress":0.09,"total":223,"current":66}
{"_e":"metro:bundling:progress","_t":1784254566419,"id":"1","progress":0.09782840730462457,"total":227,"current":71}
{"_e":"metro:bundling:progress","_t":1784254566519,"id":"1","progress":0.11201999167013746,"total":245,"current":82}
{"_e":"metro:bundling:progress","_t":1784254566623,"id":"1","progress":0.15123456790123457,"total":270,"current":105}
{"_e":"metro:bundling:progress","_t":1784254566723,"id":"1","progress":0.18367346938775508,"total":287,"current":123}
{"_e":"metro:bundling:progress","_t":1784254566823,"id":"1","progress":0.2352733413538482,"total":301,"current":146}
{"_e":"metro:bundling:progress","_t":1784254566923,"id":"1","progress":0.259462890625,"total":320,"current":163}
{"_e":"metro:bundling:progress","_t":1784254567024,"id":"1","progress":0.27952464836940155,"total":331,"current":175}
{"_e":"metro:bundling:progress","_t":1784254567124,"id":"1","progress":0.33271161520688924,"total":345,"current":199}
{"_e":"metro:bundling:progress","_t":1784254567225,"id":"1","progress":0.35676406135865596,"total":370,"current":221}
{"_e":"metro:bundling:progress","_t":1784254567326,"id":"1","progress":0.37024645369120834,"total":401,"current":244}
{"_e":"metro:bundling:progress","_t":1784254567428,"id":"1","progress":0.37472705039741466,"total":428,"current":262}
{"_e":"metro:bundling:progress","_t":1784254567529,"id":"1","progress":0.3978698224852071,"total":455,"current":287}
{"_e":"metro:bundling:progress","_t":1784254567628,"id":"1","progress":0.41077662667090886,"total":479,"current":307}
{"_e":"metro:bundling:progress","_t":1784254567730,"id":"1","progress":0.42145226326742974,"total":496,"current":322}
{"_e":"metro:bundling:progress","_t":1784254567834,"id":"1","progress":0.42145226326742974,"total":536,"current":333}
{"_e":"metro:bundling:progress","_t":1784254567938,"id":"1","progress":0.42145226326742974,"total":540,"current":339}
{"_e":"metro:bundling:progress","_t":1784254568057,"id":"1","progress":0.42145226326742974,"total":543,"current":345}
{"_e":"metro:bundling:progress","_t":1784254568158,"id":"1","progress":0.42145226326742974,"total":556,"current":354}
{"_e":"metro:bundling:progress","_t":1784254568257,"id":"1","progress":0.42145226326742974,"total":556,"current":356}
{"_e":"metro:bundling:progress","_t":1784254568357,"id":"1","progress":0.42472014414228576,"total":557,"current":363}
{"_e":"metro:bundling:progress","_t":1784254568458,"id":"1","progress":0.42472014414228576,"total":571,"current":370}
{"_e":"metro:bundling:progress","_t":1784254568559,"id":"1","progress":0.44212055590581395,"total":573,"current":381}
{"_e":"metro:bundling:progress","_t":1784254568663,"id":"1","progress":0.46466158211707237,"total":578,"current":394}
{"_e":"metro:bundling:progress","_t":1784254568764,"id":"1","progress":0.48831470460153875,"total":581,"current":406}
{"_e":"metro:bundling:progress","_t":1784254568909,"id":"1","progress":0.500828402366864,"total":585,"current":414}
{"_e":"metro:bundling:progress","_t":1784254569015,"id":"1","progress":0.500828402366864,"total":2334,"current":415}
{"_e":"metro:bundling:progress","_t":1784254569115,"id":"1","progress":0.500828402366864,"total":2338,"current":435}
{"_e":"metro:bundling:progress","_t":1784254569222,"id":"1","progress":0.500828402366864,"total":2343,"current":440}
{"_e":"metro:bundling:progress","_t":1784254569326,"id":"1","progress":0.500828402366864,"total":2346,"current":459}
{"_e":"metro:bundling:progress","_t":1784254569467,"id":"1","progress":0.500828402366864,"total":2347,"current":467}
{"_e":"metro:bundling:progress","_t":1784254569567,"id":"1","progress":0.500828402366864,"total":2348,"current":472}
{"_e":"metro:bundling:progress","_t":1784254569667,"id":"1","progress":0.500828402366864,"total":2367,"current":491}
{"_e":"metro:bundling:progress","_t":1784254569771,"id":"1","progress":0.500828402366864,"total":2428,"current":523}
{"_e":"metro:bundling:progress","_t":1784254569871,"id":"1","progress":0.500828402366864,"total":2454,"current":542}
{"_e":"metro:bundling:progress","_t":1784254569971,"id":"1","progress":0.500828402366864,"total":2465,"current":563}
{"_e":"metro:bundling:progress","_t":1784254570071,"id":"1","progress":0.500828402366864,"total":2477,"current":583}
{"_e":"metro:bundling:progress","_t":1784254570173,"id":"1","progress":0.500828402366864,"total":2478,"current":625}
{"_e":"metro:bundling:progress","_t":1784254570273,"id":"1","progress":0.500828402366864,"total":2478,"current":677}
{"_e":"metro:bundling:progress","_t":1784254570374,"id":"1","progress":0.500828402366864,"total":2478,"current":727}
{"_e":"metro:bundling:progress","_t":1784254570474,"id":"1","progress":0.500828402366864,"total":2478,"current":772}
{"_e":"metro:bundling:progress","_t":1784254570574,"id":"1","progress":0.500828402366864,"total":2478,"current":818}
{"_e":"metro:bundling:progress","_t":1784254570674,"id":"1","progress":0.500828402366864,"total":2478,"current":863}
{"_e":"metro:bundling:progress","_t":1784254570774,"id":"1","progress":0.500828402366864,"total":2478,"current":912}
{"_e":"metro:bundling:progress","_t":1784254570874,"id":"1","progress":0.500828402366864,"total":2478,"current":965}
{"_e":"metro:bundling:progress","_t":1784254570974,"id":"1","progress":0.500828402366864,"total":2478,"current":1008}
{"_e":"metro:bundling:progress","_t":1784254571076,"id":"1","progress":0.500828402366864,"total":2478,"current":1056}
{"_e":"metro:bundling:progress","_t":1784254571176,"id":"1","progress":0.500828402366864,"total":2478,"current":1105}
{"_e":"metro:bundling:progress","_t":1784254571277,"id":"1","progress":0.500828402366864,"total":2478,"current":1160}
{"_e":"metro:bundling:progress","_t":1784254571377,"id":"1","progress":0.500828402366864,"total":2478,"current":1209}
{"_e":"metro:bundling:progress","_t":1784254571478,"id":"1","progress":0.500828402366864,"total":2478,"current":1264}
{"_e":"metro:bundling:progress","_t":1784254571578,"id":"1","progress":0.500828402366864,"total":2478,"current":1311}
{"_e":"metro:bundling:progress","_t":1784254571678,"id":"1","progress":0.500828402366864,"total":2478,"current":1357}
{"_e":"metro:bundling:progress","_t":1784254571778,"id":"1","progress":0.500828402366864,"total":2478,"current":1410}
{"_e":"metro:bundling:progress","_t":1784254571878,"id":"1","progress":0.500828402366864,"total":2478,"current":1461}
{"_e":"metro:bundling:progress","_t":1784254571978,"id":"1","progress":0.500828402366864,"total":2478,"current":1507}
{"_e":"metro:bundling:progress","_t":1784254572078,"id":"1","progress":0.500828402366864,"total":2478,"current":1560}
{"_e":"metro:bundling:progress","_t":1784254572178,"id":"1","progress":0.500828402366864,"total":2478,"current":1611}
{"_e":"metro:bundling:progress","_t":1784254572279,"id":"1","progress":0.500828402366864,"total":2478,"current":1668}
{"_e":"metro:bundling:progress","_t":1784254572379,"id":"1","progress":0.500828402366864,"total":2478,"current":1721}
{"_e":"metro:bundling:progress","_t":1784254572480,"id":"1","progress":0.5136689550856253,"total":2478,"current":1776}
{"_e":"metro:bundling:progress","_t":1784254572580,"id":"1","progress":0.5394363050209071,"total":2478,"current":1820}
{"_e":"metro:bundling:progress","_t":1784254572681,"id":"1","progress":0.5737542838642687,"total":2478,"current":1877}
{"_e":"metro:bundling:progress","_t":1784254572781,"id":"1","progress":0.6047290409029646,"total":2478,"current":1927}
{"_e":"metro:bundling:progress","_t":1784254572881,"id":"1","progress":0.6397417532559323,"total":2478,"current":1982}
{"_e":"metro:bundling:progress","_t":1784254572981,"id":"1","progress":0.6737507987969679,"total":2478,"current":2034}
{"_e":"metro:bundling:progress","_t":1784254573081,"id":"1","progress":0.7106802981654216,"total":2478,"current":2089}
{"_e":"metro:bundling:progress","_t":1784254573181,"id":"1","progress":0.7506914764373623,"total":2478,"current":2147}
{"_e":"metro:bundling:progress","_t":1784254573281,"id":"1","progress":0.782489458485683,"total":2478,"current":2192}
{"_e":"metro:bundling:progress","_t":1784254573382,"id":"1","progress":0.822981380620811,"total":2478,"current":2248}
{"_e":"metro:bundling:progress","_t":1784254573483,"id":"1","progress":0.8637444540202369,"total":2478,"current":2303}
{"_e":"metro:bundling:progress","_t":1784254573583,"id":"1","progress":0.8846124232278917,"total":2489,"current":2341}
{"_e":"metro:bundling:progress","_t":1784254573683,"id":"1","progress":0.8880743173619988,"total":2499,"current":2355}
{"_e":"metro:bundling:progress","_t":1784254573786,"id":"1","progress":0.8880743173619988,"total":2589,"current":2373}
{"_e":"metro:bundling:progress","_t":1784254573886,"id":"1","progress":0.8880743173619988,"total":2601,"current":2399}
{"_e":"metro:bundling:progress","_t":1784254573987,"id":"1","progress":0.8880743173619988,"total":2602,"current":2410}
{"_e":"metro:bundling:progress","_t":1784254574087,"id":"1","progress":0.8880743173619988,"total":2614,"current":2424}
{"_e":"metro:bundling:progress","_t":1784254574188,"id":"1","progress":0.8880743173619988,"total":2617,"current":2444}
{"_e":"metro:bundling:progress","_t":1784254574288,"id":"1","progress":0.8880743173619988,"total":2619,"current":2451}
{"_e":"metro:bundling:progress","_t":1784254574388,"id":"1","progress":0.8880743173619988,"total":2621,"current":2455}
{"_e":"metro:bundling:progress","_t":1784254574488,"id":"1","progress":0.8880743173619988,"total":2625,"current":2462}
{"_e":"metro:bundling:progress","_t":1784254574588,"id":"1","progress":0.8880743173619988,"total":2631,"current":2469}
{"_e":"metro:bundling:progress","_t":1784254574688,"id":"1","progress":0.8880743173619988,"total":2636,"current":2474}
{"_e":"metro:bundling:progress","_t":1784254574844,"id":"1","progress":0.8880743173619988,"total":2640,"current":2481}
{"_e":"metro:bundling:progress","_t":1784254574944,"id":"1","progress":0.8880743173619988,"total":2649,"current":2493}
{"_e":"metro:bundling:progress","_t":1784254575044,"id":"1","progress":0.8880743173619988,"total":2700,"current":2508}
{"_e":"metro:bundling:progress","_t":1784254575144,"id":"1","progress":0.8880743173619988,"total":2707,"current":2526}
{"_e":"metro:bundling:progress","_t":1784254575245,"id":"1","progress":0.8880743173619988,"total":2713,"current":2534}
{"_e":"metro:bundling:progress","_t":1784254575345,"id":"1","progress":0.8880743173619988,"total":2778,"current":2556}
{"_e":"metro:bundling:progress","_t":1784254575447,"id":"1","progress":0.8880743173619988,"total":2786,"current":2569}
{"_e":"metro:bundling:progress","_t":1784254575547,"id":"1","progress":0.8880743173619988,"total":2787,"current":2572}
{"_e":"metro:bundling:progress","_t":1784254575647,"id":"1","progress":0.8880743173619988,"total":2787,"current":2574}
{"_e":"metro:bundling:progress","_t":1784254575778,"id":"1","progress":0.8880743173619988,"total":2787,"current":2575}
{"_e":"metro:bundling:progress","_t":1784254575878,"id":"1","progress":0.8880743173619988,"total":2789,"current":2582}
{"_e":"metro:bundling:progress","_t":1784254575980,"id":"1","progress":0.8880743173619988,"total":2789,"current":2593}
{"_e":"metro:bundling:progress","_t":1784254576082,"id":"1","progress":0.8880743173619988,"total":2799,"current":2612}
{"_e":"metro:bundling:progress","_t":1784254576182,"id":"1","progress":0.8880743173619988,"total":2813,"current":2637}
{"_e":"metro:bundling:progress","_t":1784254576282,"id":"1","progress":0.8880743173619988,"total":2824,"current":2661}
{"_e":"metro:bundling:progress","_t":1784254576384,"id":"1","progress":0.9156393512217658,"total":2830,"current":2708}
{"_e":"metro:bundling:progress","_t":1784254576484,"id":"1","progress":0.9156393512217658,"total":2848,"current":2724}
{"_e":"metro:bundling:progress","_t":1784254576584,"id":"1","progress":0.9156393512217658,"total":2849,"current":2726}
{"_e":"metro:bundling:progress","_t":1784254576684,"id":"1","progress":0.9156393512217658,"total":2868,"current":2734}
{"_e":"metro:bundling:progress","_t":1784254576827,"id":"1","progress":0.9156393512217658,"total":2868,"current":2735}
{"_e":"metro:bundling:progress","_t":1784254576927,"id":"1","progress":0.9156393512217658,"total":2885,"current":2738}
{"_e":"metro:bundling:progress","_t":1784254577029,"id":"1","progress":0.9156393512217658,"total":2885,"current":2741}
{"_e":"metro:bundling:progress","_t":1784254577129,"id":"1","progress":0.9156393512217658,"total":2900,"current":2760}
{"_e":"metro:bundling:progress","_t":1784254577229,"id":"1","progress":0.9242757760075363,"total":2901,"current":2789}
{"_e":"metro:bundling:progress","_t":1784254577332,"id":"1","progress":0.936287891094404,"total":2903,"current":2809}
{"_e":"metro:bundling:progress","_t":1784254577435,"id":"1","progress":0.936287891094404,"total":2938,"current":2819}
{"_e":"metro:bundling:progress","_t":1784254577540,"id":"1","progress":0.936287891094404,"total":2986,"current":2825}
{"_e":"metro:bundling:progress","_t":1784254577642,"id":"1","progress":0.936287891094404,"total":3015,"current":2842}
{"_e":"metro:bundling:progress","_t":1784254577740,"id":"1","progress":0.936287891094404,"total":3030,"current":2856}
{"_e":"metro:bundling:progress","_t":1784254577840,"id":"1","progress":0.936287891094404,"total":3035,"current":2860}
{"_e":"metro:bundling:progress","_t":1784254577940,"id":"1","progress":0.936287891094404,"total":3044,"current":2871}
{"_e":"metro:bundling:progress","_t":1784254578041,"id":"1","progress":0.936287891094404,"total":3047,"current":2879}
{"_e":"metro:bundling:progress","_t":1784254578141,"id":"1","progress":0.936287891094404,"total":3065,"current":2890}
{"_e":"metro:bundling:progress","_t":1784254578241,"id":"1","progress":0.936287891094404,"total":3080,"current":2900}
{"_e":"metro:bundling:progress","_t":1784254578342,"id":"1","progress":0.936287891094404,"total":3082,"current":2913}
{"_e":"metro:bundling:progress","_t":1784254578442,"id":"1","progress":0.936287891094404,"total":3082,"current":2928}
{"_e":"metro:bundling:progress","_t":1784254578543,"id":"1","progress":0.936287891094404,"total":3089,"current":2949}
{"_e":"metro:bundling:progress","_t":1784254578642,"id":"1","progress":0.936287891094404,"total":3096,"current":2973}
{"_e":"metro:bundling:progress","_t":1784254578742,"id":"1","progress":0.936287891094404,"total":3114,"current":2999}
{"_e":"metro:bundling:progress","_t":1784254578843,"id":"1","progress":0.936287891094404,"total":3114,"current":3002}
{"_e":"metro:bundling:progress","_t":1784254578943,"id":"1","progress":0.936287891094404,"total":3123,"current":3009}
{"_e":"metro:bundling:progress","_t":1784254579045,"id":"1","progress":0.936287891094404,"total":3123,"current":3011}
{"_e":"metro:bundling:progress","_t":1784254579064,"id":"1","progress":0.936287891094404,"total":3123,"current":3012}
{"_e":"metro:bundling:progress","_t":1784254579164,"id":"1","progress":0.936287891094404,"total":3131,"current":3025}
{"_e":"metro:bundling:progress","_t":1784254579265,"id":"1","progress":0.9365842200881371,"total":3134,"current":3033}
{"_e":"metro:bundling:progress","_t":1784254579367,"id":"1","progress":0.9368419128824654,"total":3147,"current":3046}
{"_e":"metro:bundling:progress","_t":1784254579468,"id":"1","progress":0.9387456790123457,"total":3150,"current":3052}
{"_e":"metro:bundling:progress","_t":1784254579568,"id":"1","progress":0.9393609473418997,"total":3150,"current":3053}
{"_e":"metro:bundling:progress","_t":1784254579682,"id":"1","progress":0.9412079617031998,"total":3150,"current":3056}
{"_e":"metro:bundling:progress","_t":1784254579782,"id":"1","progress":0.9430567901234569,"total":3150,"current":3059}
{"_e":"metro:bundling:progress","_t":1784254579883,"id":"1","progress":0.9473777777777779,"total":3150,"current":3066}
{"_e":"metro:bundling:progress","_t":1784254579989,"id":"1","progress":0.9529773417570666,"total":3152,"current":3077}
{"_e":"metro:bundling:progress","_t":1784254580089,"id":"1","progress":0.9549354307189066,"total":3159,"current":3087}
{"_e":"metro:bundling:progress","_t":1784254580189,"id":"1","progress":0.9686047908988945,"total":3160,"current":3110}
{"_e":"metro:bundling:progress","_t":1784254580292,"id":"1","progress":0.9717926782024794,"total":3168,"current":3123}
{"_e":"metro:bundling:progress","_t":1784254580392,"id":"1","progress":0.9767778200247423,"total":3168,"current":3131}
{"_e":"metro:bundling:progress","_t":1784254580502,"id":"1","progress":0.9768938323842832,"total":3184,"current":3147}
{"_e":"metro:bundling:progress","_t":1784254580608,"id":"1","progress":0.9769943965134558,"total":3198,"current":3161}
{"_e":"metro:bundling:progress","_t":1784254580709,"id":"1","progress":0.9769943965134558,"total":3220,"current":3182}
{"_e":"metro:bundling:progress","_t":1784254580809,"id":"1","progress":0.9791352776121244,"total":3242,"current":3208}
{"_e":"metro:bundling:progress","_t":1784254580909,"id":"1","progress":0.9791352776121244,"total":3272,"current":3232}
{"_e":"metro:bundling:progress","_t":1784254581010,"id":"1","progress":0.9848326177487721,"total":3284,"current":3259}
{"_e":"metro:bundling:progress","_t":1784254581111,"id":"1","progress":0.999,"total":3291,"current":3290}
{"_e":"metro:bundling:progress","_t":1784254581211,"id":"1","progress":0.999,"total":3291,"current":3291}
{"_e":"metro:bundling:done","_t":1784254581468,"id":"1","total":3291,"ms":16753.428873}
{"_e":"metro:client_log","_t":1784254596888,"level":"warn","data":["SafeAreaView has been deprecated and will be removed in a future release. Please use 'react-native-safe-area-context' instead. See https://github.com/AppAndFlow/react-native-safe-area-context"]}
{"_e":"metro:bundling:started","_t":1784254612886,"id":"3","platform":"android","environment":null,"entry":"index.ts"}
{"_e":"metro:bundling:progress","_t":1784254612886,"id":"3","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1784254612993,"id":"3","total":1,"ms":107.061183}
{"_e":"metro:client_log","_t":1784254630240,"level":"warn","data":["SafeAreaView has been deprecated and will be removed in a future release. Please use 'react-native-safe-area-context' instead. See https://github.com/AppAndFlow/react-native-safe-area-context"]}
{"_e":"metro:client_log","_t":1784254721520,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
{"_e":"metro:client_log","_t":1784254774528,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
{"_e":"metro:client_log","_t":1784254928412,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
-8
View File
@@ -1,8 +0,0 @@
{
"devices": [
{
"installationId": "3ac39ffb-519e-4fcd-b686-c20d0a63c449",
"lastUsed": 1784254612714
}
]
}
+4
View File
@@ -1 +1,5 @@
node_modules
.expo/
modules/*/android/build/
android/keystore.properties
android/app/release.keystore
+254
View File
@@ -0,0 +1,254 @@
# Kế Hoạch Khắc Phục Lỗi Giao Diện & Bộ Lọc Đồ Họa CamRecipe Pro
Tài liệu này đề xuất phương án kỹ thuật chi tiết nhằm khắc phục triệt để các lỗi giao diện và bộ lọc màu trên phiên bản di động hiện tại, dựa theo giao diện thực tế trong tệp tin `Screenshot_2026-07-17-09-47-33-861_com.locphamtran.recipescamera.jpg`.
---
## 1. Chuyển đổi chức năng nút biểu tượng Con Mắt (Eye Button) khi ở chế độ Thư viện
### Vấn đề kỹ thuật
Như mô tả trong `Screenshot_2026-07-17-09-47-33-861_com.locphamtran.recipescamera.jpg`, khi ứng dụng đang ở chế độ chỉnh sửa ảnh từ thư viện (Library/Edit Mode - nút lưu màu cam nằm ở trung tâm), nút góc dưới bên phải có biểu tượng con mắt vẫn hiển thị. Theo logic tối ưu hóa trải nghiệm người dùng, nút này cần trở thành phím tắt nhanh để quay ngược lại chế độ chụp ảnh trực tiếp (Camera Mode).
### Giải pháp khắc phục
Chúng ta sẽ thay đổi động thuộc tính `onPress` và biểu tượng (Icon) của nút điều khiển góc dưới bên phải dựa vào trạng thái nguồn ảnh hiện tại `currentInputSource`:
* **Nếu `currentInputSource === 'camera'`:** Nút đóng vai trò là nút bật/tắt lưới Grid hoặc Xem trước nhanh (Preview/Overlay).
* **Nếu `currentInputSource === 'library'`:** Nút chuyển đổi biểu tượng thành Camera (`aperture` hoặc `camera` từ `lucide-react-native`) và khi chạm vào sẽ kích hoạt hàm chuyển chế độ `switchInputSource('camera')`.
```javascript
// Logic render nút chức năng góc dưới bên phải
<TouchableOpacity
onPress={currentInputSource === 'library' ? () => switchInputSource('camera') : handleToggleGrid}
className="w-12 h-12 rounded-full bg-zinc-900/80 items-center justify-center"
>
{currentInputSource === 'library' ? (
// Icon Camera thay thế cho icon con mắt khi đang ở chế độ ảnh thư viện
<CameraIcon stroke="#a1a1aa" size={20} />
) : (
// Giữ nguyên icon con mắt khi đang ở chế độ camera native
<EyeIcon stroke="#a1a1aa" size={20} />
)}
</TouchableOpacity>
```
---
## 2. Khắc phục lỗi Topbar bị chèn dưới thanh trạng thái (Status Bar) hệ thống
### Vấn đề kỹ thuật
Phần đầu của ứng dụng chứa tiêu đề "CAMRECIPE PRO" và các phím chế độ "CAMERA", "LIBRARY" đang bị đẩy lên kịch trần màn hình và bị chèn lấp bởi các biểu tượng thông báo hệ thống (sóng wifi, cột sóng nhà mạng, phần trăm pin) như trong ảnh `Screenshot_2026-07-17-09-47-33-861_com.locphamtran.recipescamera.jpg`.
### Giải pháp khắc phục
Hiện tượng này xảy ra do lớp bọc ngoài cùng (Main Container) chưa được xử lý vùng an toàn chuẩn xác trên cả hai hệ điều hành. Chúng ta sẽ sử dụng thư viện chuẩn `react-native-safe-area-context` để lấy chính xác chiều cao vùng khuyết (Notch/Dynamic Island) của từng thiết bị di động:
* **Cài đặt thư viện an toàn:** Sử dụng gói `react-native-safe-area-context`.
* **Áp dụng hook `useSafeAreaInsets`:** Lấy giá trị viền đệm top thời gian thực của thiết bị.
* **Cấu hình đệm động cho phần Header:**
```typescript
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function MainLayout() {
const insets = useSafeAreaInsets();
return (
<View className="flex-1 bg-black">
{/* Header Container được đệm an toàn tự động tránh Status Bar */}
<View
style={{ paddingTop: Math.max(insets.top, 12) }}
className="px-5 pb-3 border-b border-zinc-900 bg-zinc-950 flex-row justify-between items-center"
>
<View className="flex-row items-center gap-2">
<View className="w-2.5 h-2.5 rounded-full bg-amber-500 animate-pulse" />
<Text className="text-xs font-bold text-zinc-400 tracking-widest uppercase">CAMRECIPE PRO</Text>
</View>
{/* Toggle buttons... */}
</View>
{/* Phần Viewfinder và thân dưới ứng dụng */}
</View>
);
}
```
---
## 3. Khắc phục lỗi Live Viewfinder bị mờ nhòe (Blur Overlay) khi áp dụng Recipe
### Vấn đề kỹ thuật
Khi người dùng kích hoạt live preview của recipe, kính ngắm camera hiển thị một lớp mờ sương, khiến độ sắc nét bị giảm mạnh so với camera native của thiết bị. Lỗi này xuất phát từ việc chuỗi lọc hình ảnh `Skia.ImageFilter` hoặc Gaussian Blur bị cấu hình sai trị số mặc định (luôn áp dụng một mức mờ tối thiểu lớn hơn $0$ dù người dùng không giảm Clarity hay bật Denoise).
### Giải pháp khắc phục
Chúng ta cần cô lập hoàn toàn bộ lọc mờ và chỉ cho phép kích hoạt khi và chỉ khi các giá trị tương ứng (Clarity âm hoặc Denoise) được thiết lập một cách chủ động từ người dùng:
* **Kiểm tra điều kiện biên (Boundary Check):** Trước khi đưa ảnh qua bộ lọc Blur, thực hiện kiểm tra điều kiện. Nếu `clarity >= 0` và `grain_strength >= 0` (không ở trạng thái denoise), chúng ta sẽ bỏ qua hoàn toàn (bypass) bộ lọc nhòe và trả lại luồng ảnh gốc sắc nét nguyên bản từ phần cứng.
* **Đồng bộ hóa luồng lọc trên GPU:**
```typescript
// Logic tối ưu hóa bộ lọc Skia tránh mờ nhòe viewfinder
const getActiveFilters = (clarity: number, grainStrength: number) => {
const filters = [];
// Chỉ áp dụng Blur khi người dùng chủ động giảm Clarity xuống mức âm (để giả lập Black Mist)
if (clarity < 0) {
const blurSigma = Math.abs(clarity) * 0.45; // khống chế độ lệch chuẩn siêu mịn
filters.push(Skia.ImageFilter.MakeBlur(blurSigma, blurSigma, TileMode.Clamp, null));
}
// Chỉ áp dụng Denoise mượt mà khi mật độ hạt (grain_strength) được kéo về phía âm
if (grainStrength < 0) {
const denoiseSigma = Math.abs(grainStrength) * 0.35; // Làm mịn hạt nhưng giữ chi tiết rìa
filters.push(Skia.ImageFilter.MakeBlur(denoiseSigma, denoiseSigma, TileMode.Clamp, null));
}
return filters;
};
```
---
## 4. Khắc phục lỗi chế độ Monochrome (ACROS MONO) vẫn hiển thị màu sắc trên Camera
### Vấn đề kỹ thuật
Mặc dù người dùng đã bấm chọn bộ màu trắng đen ACROS MONO (như đang hiển thị viền vàng hổ phách xung quanh nút chọn ở góc dưới), luồng hình ảnh truyền từ Camera trực tiếp lên kính ngắm vẫn có xu hướng bị rò rỉ màu sắc (không chuyển đổi xám hoàn toàn). Nguyên nhân là do ma trận chuyển đổi màu sắc (`ColorMatrix`) của ACROS sử dụng các hệ số kênh chưa chuẩn, hoặc chưa khóa (clamp) tuyệt đối dải bão hòa màu sắc (Saturation) về $0$.
### Giải pháp khắc phục
Để tạo ra chất ảnh trắng đen ACROS nghệ thuật chuẩn xác nhất, chúng phải triệt tiêu hoàn toàn thông tin màu bằng cách áp dụng chính xác các trọng số độ chói (Luma coefficients) theo tiêu chuẩn truyền hình quốc tế ITU-R BT.601 hoặc BT.709:
$$Y = 0.299 \cdot R + 0.587 \cdot G + 0.114 \cdot B$$
Chúng ta sẽ cấu hình ma trận màu Skia cho bộ lọc ACROS MONO như sau:
```typescript
// Ma trận màu xám đơn sắc tuyệt đối (Monochrome ColorMatrix)
const acrosMatrix = [
0.299, 0.587, 0.114, 0, 0, // Kênh Đỏ chỉ lấy thông tin độ chói luma
0.299, 0.587, 0.114, 0, 0, // Kênh Xanh lá tương tự
0.299, 0.587, 0.114, 0, 0, // Kênh Xanh dương tương tự
0, 0, 0, 1, 0 // Kênh Alpha giữ nguyên
];
// Áp dụng bộ lọc phần cứng ma trận màu sắc triệt tiêu hoàn toàn màu gốc
const colorFilter = Skia.ColorFilter.Matrix(
currentFilmSim === 'acros' ? acrosMatrix : standardMatrix
);
```
Bằng việc ép tất cả các hệ số đóng góp kênh màu về đúng tỷ lệ độ chói hồng ngoại của dải xám, camera native khi chuyển sang chế độ ACROS MONO sẽ không còn bất kỳ một vệt màu hay sắc độ ấm/lạnh nào bị rò rỉ lên Viewfinder.
---
## 5. Khắc phục lỗi GPS Geotag Watermark không đọc vị trí gốc của ảnh thư viện
### Vấn đề kỹ thuật
Khi người dùng tải lên một bức ảnh cũ từ thư viện (ví dụ: ảnh chụp tại TAM KỲ trong tệp `Screenshot_2026-07-17-09-47-33-861_com.locphamtran.recipescamera.jpg`), hệ thống Watermark lại tự động lấy vị trí định vị GPS thời gian thực của thiết bị điện thoại hiện tại. Điều này làm mất đi tính chính xác lịch sử của bức ảnh và phá hỏng cơ chế đóng dấu lưu niệm tự động (EXIF Geotagging).
### Giải pháp khắc phục
Chúng ta sẽ xây dựng một hàm phân tích đa tầng để ưu tiên trích xuất tọa độ GPS gốc lưu trong dữ liệu EXIF của chính tệp tin ảnh, và chỉ sử dụng GPS của điện thoại làm phương án dự phòng cuối cùng (fallback):
#### Tầng 1: Sử dụng API Native truy vấn siêu dữ liệu (Expo Media Library)
Khi lấy ảnh từ Thư viện qua `expo-image-picker` hoặc `expo-media-library`, chúng ta truy vấn thông tin chi tiết bằng mã định danh tài sản `assetId`:
```typescript
import * as MediaLibrary from 'expo-media-library';
const getPhotoMetadata = async (assetId: string) => {
try {
const assetInfo = await MediaLibrary.getAssetInfoAsync(assetId);
if (assetInfo.location) {
// Trích xuất tọa độ địa lý gốc từ EXIF ảnh hệ thống
return {
latitude: assetInfo.location.latitude,
longitude: assetInfo.location.longitude,
source: 'EXIF'
};
}
} catch (err) {
console.warn("Không thể đọc EXIF bằng MediaLibrary API:", err);
}
return null;
};
```
#### Tầng 2: Bộ phân tích nhị phân EXIF dự phòng (Cho ảnh từ Cloud/Nguồn ngoài)
Nếu ảnh được truyền dưới dạng URI tạm thời không có `assetId`, chúng ta sử dụng gói phân tích nhị phân siêu nhẹ `exif-reader` để đọc trực tiếp byte đầu vào:
```typescript
import ExifReader from 'exif-reader';
import * as FileSystem from 'expo-file-stream'; // Hoặc expo-file-system
const parseRawExifGPS = async (fileUri: string) => {
try {
const base64Data = await FileSystem.readAsStringAsync(fileUri, { encoding: 'base64' });
const arrayBuffer = Buffer.from(base64Data, 'base64');
const tags = ExifReader(arrayBuffer);
if (tags.gps && tags.gps.GPSLatitude && tags.gps.GPSLongitude) {
return {
latitude: tags.gps.GPSLatitude,
longitude: tags.gps.GPSLongitude,
source: 'RawEXIF'
};
}
} catch (err) {
console.warn("Không tìm thấy dữ liệu GPS EXIF trong tệp tin:", err);
}
return null;
};
```
#### Tầng 3: Hàm hợp nhất Geotag thông minh
Kết hợp logic đa tầng tuần tự để ra quyết định định vị chính xác:
```typescript
const resolvePhotoLocation = async (imageAsset: any) => {
// Bước 1: Thử đọc GPS gốc từ album hệ thống (EXIF)
let gpsData = await getPhotoMetadata(imageAsset.id);
// Bước 2: Thử phân tích nhị phân nếu bước 1 không ra kết quả
if (!gpsData) {
gpsData = await parseRawExifGPS(imageAsset.uri);
}
// Bước 3: Nếu ảnh không chứa tọa độ gốc, dùng GPS của điện thoại làm dự phòng
if (!gpsData) {
const currentPhoneLocation = await getCurrentPhoneGPS();
gpsData = { ...currentPhoneLocation, source: 'DeviceGPS' };
}
// Bước 4: Gọi Reverse-Geocoding để in dấu địa danh nghệ thuật lên Canvas
const locationName = await reverseGeocode(gpsData.latitude, gpsData.longitude);
updateWatermarkUI(locationName, gpsData.latitude, gpsData.longitude);
};
```
+94
View File
@@ -0,0 +1,94 @@
Kế Hoạch Sửa Lỗi Bộ Lọc Màu & Đồng Bộ Định Vị CamRecipe ProTài liệu này tập trung giải quyết triệt để các lỗi hiển thị hình ảnh trên kính ngắm (Viewfinder) di động thực tế dựa theo các tệp tin hình ảnh báo lỗi đính kèm từ người dùng.1. Khắc Phục Lỗi Nhiệt Độ Màu (White Balance Kelvin) Bị NgượcVấn đề kỹ thuật (Ảnh 3 & Ảnh 4)Hiện trạng thực tế:Như trong Screenshot_2026-07-17-10-25-59-810_com.locphamtran.recipescamera.jpg, khi kéo thanh trượt WB lên kịch khung 10000K, kính ngắm camera lại bị ám xanh dương lạnh ngắt.Ngược lại, trong Screenshot_2026-07-17-10-25-55-474_com.locphamtran.recipescamera.jpg, khi hạ WB xuống kịch khung 2500K, kính ngắm lại bị ám vàng/cam ấm rực rỡ.Nguyên nhân: Logic ánh xạ giữa giá trị Kelvin và ma trận bù trừ màu sắc (Color compensation matrix) bị nghịch đảo chéo góc. Trong nhiếp ảnh, khi cài đặt WB lên mức cao (ví dụ $10000\text{K}$), máy ảnh hiểu nguồn sáng môi trường đang rất lạnh nên phải bù trừ bằng sắc tông ấm (Amber/Orange). Khi cài đặt WB thấp ($2500\text{K}$), máy ảnh hiểu nguồn sáng rất ấm (Tungsten) nên phải bù trừ bằng dải tông lạnh (Blue) để đưa sắc trắng về trung tính.Giải pháp khắc phụcChúng ta sẽ viết lại thuật toán quy đổi Kelvin sang RGB bổ sung hệ số bù trừ ảnh nghịch đảo chính xác như sau:$$\text{Temp} = \frac{\text{Kelvin}}{100}$$Hệ số RGB bù trừ màu sắc thực tế sẽ được ánh xạ chính xác trên GPU:Nếu $\text{Kelvin} < 5500\text{K}$ (compensate for warm light): Giảm mạnh hệ số $R$, tăng hệ số $B$ để tạo tông màu xanh lạnh.Nếu $\text{Kelvin} \ge 5500\text{K}$ (compensate for cold light): Tăng mạnh hệ số $R$, giảm hệ số $B$ để tạo tông màu vàng ấm.// Sửa đổi hàm ánh xạ Kelvin trong file src/utils/kelvinToRGB.ts
export function kelvinToRGBCompensation(kelvin: number) {
const k = Math.min(10000, Math.max(2500, kelvin));
let r = 1.0, g = 1.0, b = 1.0;
if (k < 5500) {
// 2500K -> 5500K: Cần ám xanh dương để bù trừ ánh sáng ấm
const factor = (5500 - k) / 3000; // dải 0 -> 1
r = 1.0 - factor * 0.45;
g = 1.0 - factor * 0.15;
b = 1.0 + factor * 0.50; // Tăng cường sắc xanh dương
} else {
// 5500K -> 10000K: Cần ám vàng/đỏ để bù trừ ánh sáng lạnh
const factor = (k - 5500) / 4500; // dải 0 -> 1
r = 1.0 + factor * 0.55; // Tăng cường sắc đỏ ấm
g = 1.0 + factor * 0.20;
b = 1.0 - factor * 0.40;
}
return { r, g, b };
}
2. Khắc Phục Lỗi Rò Rỉ Màu Trên Chế Độ Monochrome (ACROS MONO)Vấn đề kỹ thuật (Ảnh 1)Hiện trạng thực tế: Như hiển thị trong Screenshot_2026-07-17-10-23-53-813_com.locphamtran.recipescamera.jpg, khi nhấn chọn bộ lọc đơn sắc ACROS MONO, dải đèn LED RGB trên bàn phím máy tính vẫn rực rỡ sắc đỏ, xanh, hồng tím thay vì hiển thị dải xám đen mộc mạc.Nguyên nhân: Logic áp màu phim bỏ qua bộ lọc trắng đen của Skia do điều kiện rẽ nhánh ma trận màu bị lỗi hoặc hệ số ma trận màu trắng đen chưa triệt tiêu hoàn toàn dải bão hòa màu sắc (Saturation) trên luồng camera thời gian thực.Giải pháp khắc phụcÉp các hệ số ma trận màu sắc của ACROS MONO về đúng tiêu chuẩn độ chói quốc tế ITU-R BT.709 nhằm triệt tiêu hoàn toàn thông tin màu sắc:$$Y = 0.2126 \cdot R + 0.7152 \cdot G + 0.0722 \cdot B$$// Khóa chặt ma trận lọc màu trắng đen tuyệt đối của Skia
const acrosMatrix = [
0.2126, 0.7152, 0.0722, 0, 0, // Kênh Đỏ chỉ lấy thông tin độ chói xám
0.2126, 0.7152, 0.0722, 0, 0, // Kênh Xanh lá tương tự
0.2126, 0.7152, 0.0722, 0, 0, // Kênh Xanh dương tương tự
0, 0, 0, 1, 0 // Giữ nguyên kênh Alpha
];
// Áp dụng bộ lọc ma trận sắc độ xám đơn sắc chuẩn nhiếp ảnh
const activeColorFilter = currentRecipe.film_sim === 'acros'
? Skia.ColorFilter.Matrix(acrosMatrix)
: Skia.ColorFilter.Matrix(standardColorMatrix);
3. Loại Bỏ Lớp Mờ Sương (Fog Overlay) Trên Viewfinder Khi Thay Đổi WBVấn đề kỹ thuật (Ảnh 2)Hiện trạng thực tế: Như trong Screenshot_2026-07-17-10-25-47-625_com.locphamtran.recipescamera.jpg, khi chỉnh nhiệt độ màu về đúng trị số 6600K, lớp mờ sương (blur) bỗng nhiên biến mất kỳ lạ giúp ảnh sắc nét hẳn lên.Nguyên nhân: Lỗi phân chia dải biến số (division/overflow error) trong hàm shader tự động làm mờ ảnh khi xử lý nhiệt độ màu. Bộ lọc Gaussian Blur (feGaussianBlur) bị liên kết sai lệch với biến nhiệt độ màu wb, khiến cho ở các dải nhiệt độ khác ngoài 6600K, bán kính mờ (stdDeviation) bị tính toán sai vượt ngưỡng $0$ mặc dù độ sắc nét hoặc độ mịn màng không thay đổi.Giải pháp khắc phụcCô lập hoàn toàn giá trị mờ sương khỏi bộ điều phối WB. Chỉ kích hoạt hiệu ứng mờ sương khi và chỉ khi người dùng chủ động tinh chỉnh dải thanh trượt Clarity < 0 hoặc Denoise < 0:// Tách biệt hoàn toàn tính toán bán kính mờ sương khỏi White Balance
const getBlurSigma = (clarity: number, grainStrength: number) => {
'worklet';
let blurSigma = 0;
// Chỉ áp dụng hiệu ứng Bloom/Mist mềm mại khi Clarity ở mức âm
if (clarity < 0) {
blurSigma = Math.abs(clarity) * 0.45; // Khống chế bán kính mờ mịn màng
}
// Chỉ áp dụng khử nhiễu (Denoise) làm mượt hạt khi Grain ở mức âm
if (grainStrength < 0) {
const denoiseSigma = Math.abs(grainStrength) * 0.35;
blurSigma = Math.max(blurSigma, denoiseSigma);
}
return blurSigma; // Trả về 0 nếu các thông số này không ở mức âm
};
4. Đồng Bộ Hóa Recipes Thời Gian Thực Trên Native Camera PreviewVấn đề kỹ thuậtHiện trạng thực tế: Các thông số phơi sáng trong tab EXPOSURE hay dải lọc màu trong tab WB & CHROME thay đổi trên thanh trượt nhưng kính ngắm camera trực tiếp vẫn trơ trơ không thay đổi màu sắc.Nguyên nhân: Biến trạng thái tùy chỉnh của React Native (adjustments) khi thay đổi không kích hoạt việc vẽ lại (re-render) trên luồng xử lý frame của camera (Vision Camera Frame Processor hoặc Skia Canvas), do thiếu các biến liên kết phụ thuộc (dependency array) hoặc chưa đưa giá trị thông số màu về dạng Shared Values của thư viện react-native-reanimated.Giải pháp khắc phụcSử dụng hook useSharedValue để truyền giá trị tức thời của thanh sliders sang luồng xử lý đồ họa GPU của Skia nhằm cập nhật kính ngắm ở tốc độ 60 FPS:import { useSharedValue, useDerivedValue } from 'react-native-reanimated';
export function ViewfinderCamera() {
// Đồng bộ hóa các dải thông số màu dưới dạng Shared Values của GPU
const exposureVal = useSharedValue(0.7);
const kelvinVal = useSharedValue(5800);
const colorChromeVal = useSharedValue('strong');
// Đăng ký lắng nghe sự thay đổi của sliders để cập nhật tức thì ma trận màu Skia
const skiaColorFilter = useDerivedValue(() => {
const matrix = calculateDynamicMatrix(exposureVal.value, kelvinVal.value, colorChromeVal.value);
return Skia.ColorFilter.Matrix(matrix);
}, [exposureVal, kelvinVal, colorChromeVal]);
return (
<Canvas style={{ flex: 1 }}>
<CameraPreviewTexture />
<ColorMatrix matrix={skiaColorFilter} />
</Canvas>
);
}
5. Khắc Phục Lỗi Đọc GPS Định Vị Gốc Của Ảnh Thư ViệnVấn đề kỹ thuậtHiện trạng thực tế: Khi tải ảnh lên từ thư viện, Geotag watermark ở góc dưới bên trái vẫn hiển thị tọa độ GPS của vị trí điện thoại hiện tại, bỏ qua lịch sử vị trí chụp gốc lưu trong EXIF của tệp tin.Nguyên nhân: API chọn ảnh (expo-image-picker) theo mặc định chỉ trả về một đường dẫn URI tạm thời bị lược bỏ hoàn toàn các siêu dữ liệu EXIF nhạy cảm vì lý do bảo mật quyền riêng tư của hệ điều hành.Giải pháp khắc phụcSử dụng gói truy vấn tài sản chuyên sâu expo-media-library để yêu cầu hệ thống cung cấp siêu dữ liệu chi tiết của bức ảnh gốc thông qua mã định danh tài sản (assetId):import * as MediaLibrary from 'expo-media-library';
export const getOriginalPhotoLocation = async (assetId: string) => {
try {
// Yêu cầu quyền truy cập thư viện đầy đủ từ hệ điều hành
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status === 'granted') {
// Truy vấn thông tin chi tiết của ảnh gốc bao gồm tọa độ GPS chụp lịch sử
const assetInfo = await MediaLibrary.getAssetInfoAsync(assetId);
if (assetInfo.location) {
return {
latitude: assetInfo.location.latitude,
longitude: assetInfo.location.longitude,
source: 'EXIF_ORIGINAL'
};
}
}
} catch (error) {
console.warn("Không thể trích xuất định vị EXIF gốc từ ảnh thư viện:", error);
}
return null; // Trả về null để chuyển sang định vị hiện tại làm dự phòng (fallback)
};
+234
View File
@@ -0,0 +1,234 @@
# 4 — FIX: HIGHLIGHT bị EXPOSURE kéo ngược (thứ tự pass màu sai)
Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06.
Nguồn đối chiếu: nhánh `recipes-web`, commit **`8e6c149`** — bản web đã sửa và đo được.
---
## 1. Hiện tượng
Kéo **HIGHLIGHT −10** rồi **EXPOSURE +10**: vùng sáng đã được ghì xuống lại bị đẩy
trở lại mức cháy (1.0). Nhìn như thanh HIGHLIGHT "chết" — càng tăng EXPOSURE càng
mất khả năng giữ highlight.
---
## 2. Nguyên nhân gốc
Skia áp **shader TRƯỚC colorFilter** trong cùng một `SkPaint`. Ma trận màu
(chứa `adjustments.exposure` + gain EV) và tone shader đang nằm trên **cùng một
paint**, nên gain exposure chạy **sau** pass tone:
1. Pass tone: pixel sáng 1.0 → HIGHLIGHT −10 ghì về **0.78**
2. Ma trận exposure: **×1.2 + 0.15** → 1.086 → **kẹp về 1.0**
Thanh HIGHLIGHT vẫn chạy đúng — nó chỉ bị pass sau xoá sạch kết quả.
### 2.1 `src/utils/exportEngine.ts` (engine JS/Skia — đường export)
```ts
// dòng 328
const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix));
paint.setColorFilter(colorFilter);
// dòng 330 — comment mô tả SAI thứ tự thực tế:
// "3b. Tone shader — DR / Highlight / Shadow curve on top of the matrix."
// dòng 354
paint.setShader(toneShader);
```
Comment nói tone nằm "on top of the matrix", nhưng Skia làm ngược lại.
### 2.2 `src/components/Viewfinder.tsx` (preview camera trực tiếp)
Cùng lỗi, cùng một paint:
```ts
// dòng 1207
paint.setColorFilter(baseColorFilter);
// dòng 1303
paint.setShader(fullShader);
```
Khối HDF (dòng ~1339) còn **cố tình nhân ma trận màu lần thứ hai**:
```ts
Skia.ImageFilter.MakeCompose(
glowBlur,
keep(Skia.ImageFilter.MakeColorFilter(baseColorFilter, null)) // "bloom lands on GRADED pixels"
)
```
→ vệt bloom cũng bị đẩy sáng sai. Sau khi sửa phải bỏ phần nhân lại này.
### 2.3 `modules/recipescam-export/.../RecipescamExportModule.kt` (module native Kotlin)
Thứ tự pass hiện tại là **tone → cinema → matrix**, tức matrix chạy **cuối** — đúng
cùng một lỗi:
| Dòng | Pass |
|---|---|
| 423–429 | `// 2. Tone pass` → `applyTone(bmp, dr, hl, sh)` |
| 434–438 | `// 3. Cinema seasonal grade` → `applyCinema(bmp, cinemaFlat)` |
| 443–452 | `// 4. Color matrix via paint pass` → `ColorMatrixColorFilter` |
| 423–426 | comment: "Engine order: the tone shader samples the base image, cinema samples the tone output, **then the color matrix filter applies on top**" |
---
## 3. Bằng chứng đo được (bản web, trước và sau khi sửa)
Nguồn: ảnh gradient dọc 0.98 → 0.30 (320×240 JPEG), đo p95 (vùng sáng) và trung vị
(midtone) của ảnh render. Engine thật, không phải mô phỏng.
| Trường hợp | top (p95) | mid |
|---|---|---|
| nguồn | 1.000 | 0.502 |
| HIGHLIGHT −10 | 0.780 | 0.518 |
| EXPOSURE +10 | 1.000 | 0.769 |
| HIGHLIGHT −10 + EXPOSURE +10 — **một paint (lỗi)** | **1.000** | 0.722 |
| HIGHLIGHT −10 + EXPOSURE +10 — **hai pass (đã sửa)** | **0.780** | 0.722 |
Sau khi sửa: midtone vẫn sáng lên (0.502 → 0.722) nhưng vùng sáng giữ nguyên 0.780.
---
## 4. Cách sửa
**Nguyên tắc:** ma trận màu phải tạo ra **ảnh đã grade** trước, rồi tone/cinema shader
mới lấy ảnh đó làm con. Đúng một câu: *matrix → tone → cinema*.
### 4.1 `src/utils/exportEngine.ts`
Thêm helper (đặt cạnh `screenSharpenImage`), rồi đổi bước 3:
```ts
// Ma trận màu, áp riêng vào một ảnh mới. SkPaint chạy shader TRƯỚC colorFilter,
// nên pixel đã grade phải tồn tại như một ảnh trước khi tone/cinema đọc nó.
function gradeThrough(image: any, w: number, h: number, colorFilter: any): any {
const surf = createSurface(w, h);
if (!surf) return null;
try {
const p = Skia.Paint();
p.setColorFilter(colorFilter);
surf.getCanvas().drawImage(image, 0, 0, p);
p.dispose();
return surf.makeImageSnapshot() ?? null;
} finally {
surf.dispose();
}
}
```
Trong `renderPhoto`:
```ts
const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix));
const matrixIsIdentity = evMatrix.every((v, i) => v === (i % 6 === 0 ? 1 : 0));
const tone = getToneUniforms(adjustments, recipe.baseFilter);
const cinemaOn = /* cinema && cinemaIsActive(cinema.flat) */;
let toneShader: any = null;
let paintShader: any = null;
// Ma trận phải thành ảnh riêng khi có pass phía sau đọc lại pixel.
let gradeImage = skImage;
const graded = (toneIsActive(tone) || cinemaOn) && !matrixIsIdentity;
if (graded) {
const snapshot = gradeThrough(skImage, width, height, colorFilter);
if (snapshot) { own(snapshot); gradeImage = snapshot; }
}
const imageShaderOf = () =>
own(gradeImage.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None));
if (toneIsActive(tone)) {
toneShader = own(toneEffect.makeShaderWithChildren(toneUniformArray(tone), [imageShaderOf()]));
}
if (cinemaOn) {
const cs = own(cinemaEffect.makeShaderWithChildren(cinema.flat, [toneShader ?? imageShaderOf()]));
if (cs) paintShader = cs;
}
if (!paintShader && toneShader) paintShader = toneShader;
// Chỉ MỘT trong hai được tô màu pixel: ảnh đã grade đã mang ma trận rồi,
// chỉ nhánh chưa grade mới cần colorFilter.
if (paintShader) paint.setShader(paintShader);
else if (!graded) paint.setColorFilter(colorFilter);
```
Và trong bước HDF (5b): bloom đọc pixel đã grade, nên **bỏ lần áp ma trận thứ hai**:
```ts
const srcShader = paintShader ?? imageShaderOf();
const bloomGrade = (inner?: any) =>
graded ? undefined : own(Skia.ImageFilter.MakeColorFilter(inner ?? colorFilter, null));
// ...
glowPaint.setImageFilter(grade ? own(Skia.ImageFilter.MakeCompose(blur, grade)) : blur);
```
Hai điều kiện đệm: khi **không có tone/cinema** hoặc **ma trận là identity** thì
`graded === false` → không tạo surface thừa, giữ nguyên đường vẽ cũ.
### 4.2 `src/components/Viewfinder.tsx` (preview mỗi frame)
Cùng nguyên tắc, nhưng đây là đường chạy **mỗi frame** nên phải cân nhắc chi phí:
ma trận phải được vẽ vào một surface phụ (hoặc texture trung gian) **trước**, rồi
tone/cinema shader lấy ảnh đó làm `imageShader` con.
- Đổi `paint.setColorFilter(baseColorFilter)` + `paint.setShader(fullShader)` trên
cùng một paint thành: grade trước vào texture `gradedTex`, `fullShader` dựng từ
`gradedTex.makeShaderOptions(...)`.
- `hasTone === false && hasCinema === false` (hoặc ma trận identity): giữ đường vẽ
một-pass như hiện tại, đừng thêm surface.
- Bỏ `MakeColorFilter(baseColorFilter, null)` khỏi chuỗi imageFilter của glow —
bloom giờ đọc thẳng pixel đã grade.
- **Đo lại fps sau khi sửa**: thêm 1 pass fullscreen/frame. Nếu tụt quá ngưỡng
chấp nhận, phương án rẻ hơn là nhân hệ số gain exposure **bên trong SKSL của tone
shader** (trước khi áp curve) — nhưng chỉ khi vẫn giữ được thứ tự *film matrix →
curve*; đừng quay lại nhét exposure vào sau curve.
### 4.3 `RecipescamExportModule.kt` (native)
Đổi thứ tự các bước cho khớp: **matrix → tone → cinema**. Matrix (bước "4." hiện
tại) phải chạy trước bước tone "2.":
```kotlin
// 2. Color matrix (grade phim + exposure) — PHẢI chạy trước tone.
val androidMatrix = FloatArray(20) { i -> if (i % 5 == 4) matrix[i] * 255f else matrix[i] }
val out = Bitmap.createBitmap(bmp.width, bmp.height, Bitmap.Config.ARGB_8888)
Canvas(out).drawBitmap(bmp, 0f, 0f, Paint().apply { colorFilter = ColorMatrixColorFilter(androidMatrix) })
bmp.recycle()
bmp = out
// 3. Tone (DR / highlight / shadow) trên pixel đã grade.
if (toneActive) applyTone(bmp, toneDr.toFloat(), toneHl.toFloat(), toneSh.toFloat())
// 4. Cinema seasonal grade.
if (cinemaActive) applyCinema(bmp, cinemaFlat)
```
`ensureMutable(bmp)` giữ nguyên vị trí (trước tone), và sửa lại comment dòng 423–426
— bỏ câu "then the color matrix filter applies on top".
---
## 5. Kiểm thử (bắt buộc trước khi coi là xong)
1. Nguồn: gradient dọc 0.98 → 0.30 (320×240 JPEG) — có headroom để thấy highlight.
2. Export với `highlight = -10`, `exposure = +10`.
3. Kỳ vọng: **p95 ≈ 0.78** (KHÔNG được 1.00), midtone tăng rõ so với ảnh gốc.
4. Chạy đủ **cả ba đường** (export engine JS, preview camera, module native Kotlin)
và so ảnh với nhau — parity gate của `PLAN.md`.
5. Đối chiếu với bản web: `recipes-web` @ `8e6c149`, file
`docker/frontend/src/engine/exportEngine.ts` (helper `gradeThrough`).
---
## 6. Ghi chú phụ
- Lỗi này **không** nằm ở công thức tone (`toneShader.ts`) hay ma trận màu
(`colorUtils.ts`) — cả hai đều đúng. Chỉ sai **thứ tự thực thi**.
- `HIGHLIGHT +10` không lộ lỗi vì kết quả dù sao cũng kẹp về 1.0 — chỉ nhánh âm mới
nhìn thấy. Đừng "kiểm thử" bằng HIGHLIGHT dương.
- Bất kỳ pass nào khác đọc lại pixel sau tone/cinema (bloom HDF, grain, vignette)
đều phải đọc **ảnh đã grade**, và ma trận không được áp lần hai.
+264
View File
@@ -0,0 +1,264 @@
# 5 — Đốt dải thông tin ("strip") vào ảnh xuất
Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06.
Nguồn đối chiếu: nhánh `recipes-web`, commit **`b4d5d29`** — bản web đã làm và đo được.
---
## 1. Mục tiêu
Ảnh **xuất ra** phải mang đúng dải thông tin mà app đang hiển thị trên thẻ recipe:
một **tagline amber** `#TAG` nằm góc trên-trái **trên chính bức ảnh**, và một **dải
caption nền tối** treo **bên dưới** ảnh, gồm **tên recipe** (đậm, sáng) và **dòng
kỹ thuật** `ISO · GRAIN · WARMTH/MONO` (mono, xám).
Đây là bản sao của khối `.lp-shot` + `.lp-frame-meta` trên landing:
```html
<div class="lp-shot">
<img … />
<span class="lp-tagline">#ACROS_100</span> <!-- amber, góc trên-trái -->
</div>
<div class="lp-frame-meta">
<b>ACROS 100</b> <!-- tên recipe -->
<span>ISO 400 · GRAIN 4 · WARMTH +2</span> <!-- dòng kỹ thuật, mono -->
</div>
```
Thuộc tính web (để đối chiếu màu/kích thước): `docker/frontend/src/styles/landing.css`
dòng 288–291 — tagline `color: var(--lp-amber)`, meta `font-family: var(--lp-mono)`.
**Ranh giới quan trọng:** dải này CHỈ có trong **file xuất**. Preview / viewfinder /
màn hình chỉnh sửa **không** được vẽ nó — nếu không, người dùng thấy hai lần.
---
## 2. Giá trị lấy từ đâu
Bản web tính trong một `useMemo` duy nhất (`recipes-web` @ `b4d5d29`,
`docker/frontend/src/App.tsx`, dòng ~658–671):
```ts
const name = (recipe.name || 'RECIPE').trim();
const slug = name.replace(/[^\p{L}\p{N}]+/gu, '_').replace(/^_+|_+$/g, '').toUpperCase();
const grain = Math.max(0, Math.round(recipe.adjustments.grain ?? 0));
const warmth = Math.round((recipe.adjustments.temperature - 5500) / 250);
const tail = recipe.baseFilter === 'monochrome'
? 'MONO'
: `WARMTH ${warmth >= 0 ? '+' : ''}${warmth}`;
const strip = {
tag: `#${slug || 'RECIPE'}`, // UPPERCASE, khoảng trắng -> '_'
title: name, // giữ nguyên hoa/thường
meta: `ISO ${iso ?? 'AUTO'} · GRAIN ${grain} · ${tail}`,
};
```
- `·` là **U+00B7 MIDDLE DOT**, có dấu cách hai bên.
- `iso` là ISO đọc từ **EXIF của ảnh nguồn**; không có thì in `AUTO` (xem §5).
- `grain` lấy thẳng từ knob `adjustments.grain` (0..10), **không** cộng phần re-grain
của NOISE REDUCTION âm — dòng kỹ thuật mô tả *công thức*, không mô tả pass xuất.
- `warmth` là bước nhảy 250K quanh mốc 5500K, luôn kèm dấu (`+2` / `-3`).
- `baseFilter === 'monochrome'` thì thay hẳn `WARMTH …` bằng `MONO`.
---
## 3. Hình học & màu (chính xác từng pixel)
Đặt `W`, `H` = chiều rộng/cao của ảnh **sau khi đã ghép frame** (ảnh export cuối cùng,
trước encode). Mọi kích thước suy từ `W`:
| Đại lượng | Công thức |
|---|---|
| `band` | `round(W * 0.155)` |
| `pad` | `round(W * 0.03)` |
| `tagSize` | `round(W * 0.026)` |
| `titleSize` | `round(W * 0.042)` |
| `metaSize` | `round(W * 0.026)` |
Các bước vẽ, **theo đúng thứ tự**:
1. Tạo surface mới `(W, H + band)` — **ảnh cao thêm đúng `band`**. Vẽ ảnh cũ vào `(0, 0)`.
2. **Tagline** trên ảnh, góc trên-trái, font mono:
- bản `#000000` tại `(pad + 1, pad + tagSize + 1)` (drop shadow, để tag đọc được
trên trời sáng),
- bản `#f59e0b` (amber) tại `(pad, pad + tagSize)`.
3. **Dải nền** dưới ảnh: hình chữ nhật `(0, H, W, band)` tô `#0b0b0b`.
4. **Tên recipe** `#f2f2f2`, baseline `(pad, H + pad + titleSize)`.
5. **Dòng kỹ thuật** `#9a9a9a`, baseline
`(pad, H + pad + titleSize + round(metaSize * 1.7))` — tức cách baseline tên
`round(metaSize * 1.7)`.
Font: **Cousine Regular** — cùng typeface app đã bundle
(`assets/Cousine-Regular.ttf`; engine đã nạp sẵn trong `watermarkFonts.typeface`).
Ví dụ đo được (web): nguồn 1200×900 → file xuất **1200×1086** (`band = 186`).
---
## 4. Ba đường phải sửa (parity gate)
Bản web chỉ có một engine; bản app có **ba** đường vẽ, cả ba phải ra **cùng một file**:
### 4.1 `src/utils/exportEngine.ts` — engine Skia (bước 9b)
Chèn ngay **trước** dòng encode (`let bytes = resultImage.encodeToBytes(...)`, ~dòng 917),
sau bước snapshot + sharpen (bước 9 hiện tại, ~dòng 896):
```ts
const caption = options?.caption ?? null; // thêm vào ExportOptions
if (caption) {
const W = resultImage.width();
const band = Math.round(W * 0.155);
const capSurface = createSurface(W, resultImage.height() + band);
const loaded = watermarkFonts; // cùng typeface Cousine đã nạp
if (capSurface && loaded) {
own(capSurface);
const cc = capSurface.getCanvas();
cc.drawImage(resultImage, 0, 0, own(Skia.Paint()));
const pad = Math.round(W * 0.03);
const tagSize = Math.round(W * 0.026);
const tagFont = own(Skia.Font(loaded.typeface, tagSize));
const shadow = own(Skia.Paint()); shadow.setColor(Skia.Color('#000000'));
const amber = own(Skia.Paint()); amber.setColor(Skia.Color('#f59e0b'));
cc.drawText(caption.tag, pad + 1, pad + tagSize + 1, shadow, tagFont);
cc.drawText(caption.tag, pad, pad + tagSize, amber, tagFont);
const bandTop = resultImage.height();
const bandPaint = own(Skia.Paint()); bandPaint.setColor(Skia.Color('#0b0b0b'));
cc.drawRect(Skia.XYWHRect(0, bandTop, W, band), bandPaint);
const titleSize = Math.round(W * 0.042);
const metaSize = Math.round(W * 0.026);
const titleFont = own(Skia.Font(loaded.typeface, titleSize));
const metaFont = own(Skia.Font(loaded.typeface, metaSize));
const titlePaint = own(Skia.Paint()); titlePaint.setColor(Skia.Color('#f2f2f2'));
const metaPaint = own(Skia.Paint()); metaPaint.setColor(Skia.Color('#9a9a9a'));
cc.drawText(caption.title, pad, bandTop + pad + titleSize, titlePaint, titleFont);
cc.drawText(caption.meta, pad, bandTop + pad + titleSize + Math.round(metaSize * 1.7),
metaPaint, metaFont);
const composed = own(capSurface.makeImageSnapshot());
release(owned, resultImage); // đăng ký `own`/`release` như mọi buffer khác
resultImage = composed;
} else if (capSurface) {
capSurface.dispose();
}
}
```
`ExportOptions` thêm:
```ts
// Dải file mang theo: tagline amber trên ảnh + caption band bên dưới.
// Để trống cho preview/thumbnail — chỉ đường xuất truyền vào.
caption?: { tag: string; title: string; meta: string } | null;
```
Và tại call-site xuất (App.tsx) truyền `caption: strip` (đúng object §2) — **không**
truyền vào đường preview.
### 4.2 `src/utils/nativeExport.ts` + `RecipescamExportModule.kt` — đường native
Đường native không đi qua Skia, nên phải **vẽ band trong Kotlin**. Thêm `caption`
vào `NativeExportOptions` và đẩy xuống map `adjust` (cùng chỗ với `geo`, `frameId`):
```ts
caption: options?.caption ?? null, // tag/title/meta — null khi không xuất file
```
Trong `RecipescamExportModule.kt`, chèn **giữa bước 7 (sharpen) và bước 8 (encode)**
(~dòng 568, sau khối `if (sharpen > 0) { … }`), vẽ lên chính `render`:
```kotlin
// 7b. Strip (parity với exportEngine #9b): band mới cao hơn ảnh.
val cap = adjust["caption"] as? Map<*, *>
if (cap != null) {
val tag = cap["tag"] as? String
val title = cap["title"] as? String
val meta = cap["meta"] as? String
if (tag != null && title != null && meta != null) {
val W = render.width; val H = render.height
val band = Math.round(W * 0.155f)
val taller = Bitmap.createBitmap(W, H + band, Bitmap.Config.ARGB_8888)
val cc = Canvas(taller)
cc.drawBitmap(render, 0f, 0f, null)
val pad = Math.round(W * 0.03f)
val tagSize = Math.round(W * 0.026f)
val titleSize = Math.round(W * 0.042f)
val metaSize = Math.round(W * 0.026f)
// Cùng typeface Cousine mà stampGeo đang dùng (raw assets_cousineregular).
val face = geoTypefaceRes(ctx, "assets_cousineregular")
// tagline: shadow đen lệch 1px rồi amber
val shadow = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.BLACK; typeface = face; textSize = tagSize.toFloat() }
val amber = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#f59e0b"); typeface = face; textSize = tagSize.toFloat() }
cc.drawText(tag, (pad + 1).toFloat(), (pad + tagSize + 1).toFloat(), shadow)
cc.drawText(tag, pad.toFloat(), (pad + tagSize).toFloat(), amber)
// dải nền tối
cc.drawRect(0f, H.toFloat(), W.toFloat(), (H + band).toFloat(),
Paint().apply { color = Color.parseColor("#0b0b0b") })
val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#f2f2f2"); typeface = face; textSize = titleSize.toFloat() }
val metaPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#9a9a9a"); typeface = face; textSize = metaSize.toFloat() }
cc.drawText(title, pad.toFloat(), (H + pad + titleSize).toFloat(), titlePaint)
cc.drawText(meta, pad.toFloat(), (H + pad + titleSize + Math.round(metaSize * 1.7)).toFloat(), metaPaint)
render.recycle()
render = taller
}
}
```
Lưu ý: band nằm **dưới frame** — RETRO POLAROID / WALL FRAME đã ghép xong trước đó
(bước 6b), nên `H` lúc này là chiều cao tấm card/khung, không phải ảnh gốc. Web làm
y hệt: bước 9b chạy sau khi frame đã ghép.
### 4.3 `src/components/Viewfinder.tsx` — preview
**Không** vẽ gì. Đây là điểm dễ sai nhất: dải thuộc về *file*, không thuộc về màn hình.
---
## 5. ISO từ EXIF
Bản web: `exifr.parse(bytes, { pick: ['ISO', 'ISOSpeedRatings'] })` →
`number | null`; `null` in `AUTO` (`recipes-web` @ `b4d5d29`,
`docker/frontend/src/engine/imageOps.ts`).
App hiện **chưa đọc tag ISO** (chỉ có GPS trong `src/utils/exifGps.ts` và ghi EXIF
trong `exifWrite.ts`). Thêm một helper nhỏ cạnh `exifGps.ts` đọc APP1/TIFF tag
`0x8827` (ISOSpeedRatings) từ bytes ảnh nguồn, hoặc lấy từ EXIF mà asset của ảnh đã
nạp sẵn có. Chưa có thì trả `null` → dòng kỹ thuật in `ISO AUTO`.
Đừng bịa ISO từ knob: build này `ISO_SUPPORTED = false`
(`src/components/RecipeCreateModal.tsx` dòng 74) — ISO trong dòng kỹ thuật là ISO
**của máy ảnh lúc chụp**, không phải một lựa chọn trong app.
---
## 6. Kiểm thử (bắt buộc trước khi coi là xong)
1. Xuất ảnh có recipe đặt tên, grain > 0, temperature ≠ 5500.
2. Kỳ vọng hình học: file cao hơn ảnh gốc đúng `round(W * 0.155)`; ví dụ nguồn
1200×900 → **1200×1086**.
3. Đo pixel: hàng trong dải nền tối (độ sáng < 0.2); **tagline amber** có pixel
`#f59e0b` (đếm > 20 pixel) ở góc trên-trái *trên ảnh*; chữ `#f2f2f2` /
`#9a9a9a` nằm trong dải dưới.
4. Chạy **cả ba đường** — engine Skia, native Kotlin, và (đối chiếu) web @ `b4d5d29`.
Band phải trùng khít giữa ba đường: cùng chiều cao, cùng màu, cùng baseline.
5. Preview / viewfinder: khẳng định **không** xuất hiện dải (không có pixel amber
`#f59e0b` ngoài watermark vốn có).
6. Đối chiếu web: `docker/frontend/src/engine/exportEngine.ts` bước **9b**
(`recipes-web` @ `b4d5d29`).
---
## 7. Ghi chú phụ
- Band đặt **sau** frame và **trước** encode — nếu vẽ trước frame, khung sẽ đè mất
chữ; nếu vẽ sau encode, file không còn pixel để vẽ.
- `band` tính từ **chiều rộng ảnh cuối**, không phải chiều rộng màn hình: ảnh 12MP
và ảnh 1200px ra tỉ lệ chữ khác nhau, đúng như mong đợi.
- Khi ảnh đã có watermark người dùng / GPS stamp, dải vẫn treo ngoài ảnh — không
chồng lên các lớp đó.
- Nếu sau này app có "lưu vào bộ sưu tập" tách khỏi "tải xuống": bản web cố ý lưu
bản **sạch** (không đốt dải) cho lưới cộng đồng và chỉ đốt dải vào file tải xuống,
để khung của lưới tự vẽ tagline từ nhãn đã lưu — tránh tag hai lần. Nếu app không
có lưới cộng đồng thì cứ đốt thẳng vào file xuất.
+191
View File
@@ -0,0 +1,191 @@
# 6 — CREATE RECIPES: gấp nhóm + thêm EXPOSURE / EV / WHITE / BLACK
Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06.
Nguồn đối chiếu: nhánh `recipes-web`, commit **`f17cb08`** ("web: fold the create
form's groups and add the white/black point rows") — bản web đã làm, đã đo và
đang chạy ở `http://localhost:8090/app`. Commit `cf4b01d` là việc đổi màu tên
recipe đang chọn trong FAVORITED; bản app **đã có sẵn** (`AdjustmentPanel.tsx`,
tab `favorites`: `active: r.id === currentRecipeId`) nên không cần làm lại.
Đây là ghi chú yêu cầu, **chưa implement trên app**. Web đã xong và là bản mẫu
để đối chiếu từng dòng.
---
## 1. Mục tiêu
Form CREATE RECIPES đã dài thành một cuộn dọc. Hai việc:
1. **Gấp được** sáu nhóm phân loại: SIMULATION, DYNAMIC RANGE, GRAIN EFFECT,
COLOR CHROME EFFECT, COLOR CHROME EFFECT BLUE, WHITE BALANCE.
2. **Thêm bốn ô số**: EXPOSURE, EV, WHITE, BLACK.
Thứ tự hàng giữ nguyên như điện thoại đang có, chỉ chèn thêm — trừ
`EXPOSURE COMP.` đổi tên thành `EV` (cùng một tham số `exposureCompensation`,
chỉ khác nhãn; web đã đổi để hai chữ EXPOSURE/EV không lẫn nhau).
---
## 2. Việc 1 — gấp nhóm (thuần UI, chỉ `RecipeCreateModal.tsx`)
Web dùng `<details>`/`<summary>` gốc của trình duyệt: không state, không
thư viện, trình duyệt tự giữ cờ mở/đóng. App dùng React Native nên phải tự làm:
một `useState<Record<string, boolean>>` cho sáu nhóm, nhãn nhóm bấm được, mũi
tên xoay 90° khi mở, và phần thân chỉ render khi nhóm đang mở.
Ranh giới từng nhóm trong `RecipeCreateModal.tsx` (số dòng theo bản hiện tại):
| Nhóm | Hàng nằm trong |
| --- | --- |
| SIMULATION | dải chip `FILM_SIMS` |
| DYNAMIC RANGE | dải chip `DR_OPTS` |
| GRAIN EFFECT | dải chip `GRAIN_OPTS` |
| COLOR CHROME EFFECT | dải chip `CC_OPTS` |
| COLOR CHROME EFFECT BLUE | dải chip `CC_OPTS` |
| WHITE BALANCE | dải chip `WB_PRESETS` **+ hai ô WB SHIFT RED/BLUE** |
Ngoài nhóm (luôn hiện): NAME, lưới số (`fieldSets`), hàng ISO, SAVE/EXPORT.
Khối WB SHIFT đi cùng WHITE BALANCE — về mặt nhiếp ảnh nó là một phần của WB.
Mặc định **mở cả sáu** để form không đổi hành vi cho tới khi người dùng tự gấp.
CSS web đã dùng (đối chiếu nếu muốn giống hệt):
```css
.create-sec { display: flex; flex-direction: column; gap: 6px; }
.create-sec > summary { display: flex; align-items: center; gap: 6px; cursor: pointer; list-style: none; user-select: none; }
.create-sec > summary::-webkit-details-marker { display: none; }
.create-sec > summary::before { content: '▸'; font-size: 9px; transition: transform 0.15s ease; }
.create-sec[open] > summary::before { transform: rotate(90deg); }
```
---
## 3. Việc 2 — bốn ô số
`fieldSets` hiện tại:
```ts
{ label: 'HIGHLIGHT', value: fHigh, set: setFHigh },
{ label: 'SHADOW', value: fShadow, set: setFShadow },
{ label: 'COLOR', value: fColor, set: setFColor },
{ label: 'NOISE RED.', value: fNr, set: setFNr, ph: '-10..10' },
{ label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '-10..10' },
{ label: 'CLARITY', value: fClarity, set: setFClarity },
{ label: 'EXPOSURE COMP.', value: fEv, set: setFEv, ph: 'EV' },
```
Web sau khi sửa (đúng thứ tự này):
```ts
{ label: 'HIGHLIGHT', value: fHigh, set: setFHigh },
{ label: 'SHADOW', value: fShadow, set: setFShadow },
{ label: 'WHITE', value: fWhites, set: setFWhites },
{ label: 'BLACK', value: fBlacks, set: setFBlacks },
{ label: 'COLOR', value: fColor, set: setFColor },
{ label: 'NOISE RED.', value: fNr, set: setFNr, ph: '-10..10' },
{ label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '-10..10' },
{ label: 'CLARITY', value: fClarity, set: setFClarity },
{ label: 'EXPOSURE', value: fExposure, set: setFExposure },
{ label: 'EV', value: fEv, set: setFEv, ph: '±3 EV' },
```
Thêm ba state (`fWhites`, `fBlacks`, `fExposure`) song song với các `f*` đang
có, seed từ `seedAdj`/`valuesOf`, và **bắt buộc** thêm vào `handleSave` — thiếu
một dòng là ô vừa nhập bị `...seedAdj` ghi đè và recipe lưu ra khác màn hình:
```ts
whites: int(fWhites, simAdj.whites ?? 0, -10, 10),
blacks: int(fBlacks, simAdj.blacks ?? 0, -10, 10),
exposure: int(fExposure, simAdj.exposure, -10, 10),
```
`EXPOSURE` là `adjustments.exposure` (-10..10, độ lợi ma trận) — đã có sẵn trong
model và trong tab LIGHT, chỉ thiếu hàng trong CREATE. `EV` là
`exposureCompensation` (-3..+3 EV) như hàng `EXPOSURE COMP.` cũ.
---
## 4. `WHITE` / `BLACK` là tham số MỚI — phải thêm cả model lẫn shader
Không có `whites`/`blacks` ở đâu trong app (đã grep cả hai nhánh). Nếu chỉ vẽ ô
nhập mà không đụng engine thì đó là **ô chết**: nhập xong không ảnh nào đổi.
### 4.1 `src/types/index.ts` — `ColorAdjustments`
```ts
highlight: number; // -10 to +10 (- pulls highlights down / roll, + brightens)
shadow: number; // -10 to +10 (+ lifts shadows, - deepens)
whites?: number; // -10 to +10 (white point: the very top of the tone curve)
blacks?: number; // -10 to +10 (black point: the very toe of the tone curve)
```
Để **optional** (`?`) — recipe cũ trong máy không có hai khóa này vẫn đọc được.
### 4.2 `src/utils/toneShader.ts` — `TONE_SKSL`
Khai báo uniform ngay sau `sh` (thứ tự này phải khớp `toneUniformArray`):
```glsl
uniform float wh;
uniform float bl;
```
Trong `main`, chèn **sau** hai dòng Dynamic Range và **trước** `o = clamp(o, …)`:
```glsl
// White/black point. The HL/SH knees above already spend the slope budget
// (each just under 1.0), so these two use cubic end-weights instead of
// another smoothstep: only the last/first stretch of the ramp moves, and the
// derivative stays >= 1 - 3*0.18 = 0.46 for every combination, so the curve
// can never invert. + lifts that end (faded blacks / brighter whites),
// - digs it in (true black / rolled white).
o += bl * 0.18 * (1.0 - t) * (1.0 - t) * (1.0 - t);
o += wh * 0.18 * t * t * t;
```
Vì sao **không** dùng thêm một cặp `smoothstep` như HL/SH: hai knee 0.65..1.00
và 0.00..0.55 đã tiêu gần hết "ngân sách độ dốc" (mỗi cái ~0.94/1.0); thêm một
knee nữa là đẩy tổng độ dốc qua 1.0 và đường cong **đảo** (điểm sáng hơn ra tối
hơn). Cubic end-weight chỉ chạm ~đoạn cuối của dải, đạo hàm luôn ≥ 0.46.
### 4.3 Bốn chỗ còn lại trong cùng file
```ts
export interface ToneUniforms {
dr: number; hl: number; sh: number;
wh: number; // -1..1 (adjustments.whites / 10 — white point)
bl: number; // -1..1 (adjustments.blacks / 10 — black point)
vib: number; …
}
```
```ts
const wh = Math.max(-1, Math.min(1, (adj.whites ?? 0) / 10));
const bl = Math.max(-1, Math.min(1, (adj.blacks ?? 0) / 10));
```
`getToneUniforms` trả thêm `wh, bl`; `toneIsActive` thêm `u.wh !== 0 || u.bl !== 0`
(không thêm thì pass tone bị bỏ qua và hai ô không có tác dụng); và
`toneUniformArray` — **thứ tự phần tử phải trùng khai báo GLSL**:
```ts
return [u.dr, u.hl, u.sh, u.wh, u.bl, u.vib,
u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb];
```
`dr`/`hl`/`sh` hai nhánh giống nhau; chỉ 4 khối ở mục 4.2–4.3 là mới.
---
## 5. Kiểm thử (đo được, không đoán)
Web đã đo bằng ảnh thật (`/tmp/w0/p2.jpg`): `WHITE +10` nâng độ chói decile trên
cùng, `BLACK +10` nâng decile dưới cùng và **không** đụng đỉnh, `BLACK -10` hạ
decile dưới. Vòng round-trip `.recipe` (XML xor16-v1) giữ nguyên
`whites = 4`, `blacks = -3`, `exposureCompensation = 2` qua chính importer của
điện thoại.
Bản app cần đo lại trên **cả ba đường vẽ** (engine Skia xuất file, native export,
preview viewfinder) như ghi chú 5 đã nêu: cùng một cặp WHITE/BLACK phải ra cùng
một ảnh.
+158
View File
@@ -0,0 +1,158 @@
# 7 — SCAN QR: quét mã trên trang web để import recipe vào app
Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06.
Nguồn đối chiếu: nhánh `recipes-web`, commit **`8a889db`** ("web: the QR card
hands out the look that made the photo") — bản web đã làm, đã đo, đang chạy ở
`http://localhost:8090`.
Đây là ghi chú cho phần mềm Android **và đã implement trên nhánh này**. Khác
các ghi chú 1–6 (chỉ nêu yêu cầu), ghi chú này ghi lại cả hai nửa: web đã mở ra
cái gì, và app đọc cái đó bằng cách nào.
---
## 1. Web đã mở ra cái gì (phía `recipes-web`)
Trang admin có thêm **checkbox thứ tư `qr`** cạnh `strip / tester / creator`.
Tick `qr` cho một ảnh nghĩa là: ảnh đó xuất hiện ở thẻ QR trên landing page.
Ảnh được tick `qr` phải có **preset** (`photos.recipe`, cột JSON). Khi có,
thẻ QR trên landing trỏ tới một route công khai mới của backend:
```
GET /api/photos/:id/preset.recipe
```
| Trường hợp | Trả về |
| --- | --- |
| `id` không phải số, không có hàng, `recipe IS NULL`, hoặc ảnh không tick `qr` | `404` |
| Hợp lệ | `200`, `content-type: application/xml; charset=utf-8`, `x-content-type-options: nosniff`, `content-disposition: attachment; filename="recipescam-<id>.recipe"` |
Nội dung file là **đúng định dạng `.recipe` mà app đã xuất/nhập**: XML bọc
payload xor16-v1, sinh tại `docker/backend/src/recipeFile.ts` (bản sao của
`shared/utils/recipeShare.ts`). Salt mới mỗi lần tải — hai lần quét cùng một mã
ra hai file khác byte nhưng cùng recipe. `test/security.mjs` khoá hình dạng
envelope này (+12 check, backend 134/134).
Vì vậy **mã QR chỉ chứa URL**, không chứa recipe. Vài chục byte, không phải
vài KB base64 — camera chỉ cần bắt được một liên kết ngắn.
Nhãn i18n liên quan (đã có trên web): `adm.pickQr` = `QR card` / `Thẻ QR`,
`adm.noPreset` = `no look to hand out` / `không có look để chia sẻ`.
---
## 2. Vì sao KHÔNG dùng vision-camera để quét
Đường thẳng nhất là quét QR bằng chính session camera của viewfinder. Không
được: **vision-camera v5 trên Android không có object output.**
```text
modules/.../HybridCameraFactory.kt:112
createObjectOutput → throw "CameraObjectOutput is not available on Android!"
```
`useObjectOutput` là API iOS-only. Barcode/QR trên Android phải đi đường khác,
nên app dùng **`expo-camera`** — nó bọc chính barcode scanner của Android.
```bash
npx expo install expo-camera # ~57.0.5
```
`app.json` thêm plugin trước `expo-sharing`:
```json
["expo-camera", { "cameraPermission": "Allow RecipesCam to scan a recipe QR code." }]
```
**Hệ quả bắt buộc: phải build lại dev client / APK.** `expo-camera` là native
module mới; bundle JS cũ không có nó.
App giờ có **hai thư viện camera**. CameraX không cho hai client cùng giữ một
ống kính, nên khi sheet quét mở thì viewfinder phải nhả camera ra — xem mục 4.
### 2.1 HTTP thuần (cleartext) — chỉ chạy ở debug
Route preset hiện phục vụ qua **HTTP thuần** (`http://<host>:8090/...`). Android
9+ chặn cleartext mặc định, nhưng manifest debug đã bật sẵn:
```xml
<!-- android/app/src/debug/AndroidManifest.xml -->
<application android:usesCleartextTraffic="true" ... />
```
`android/app/src/main/AndroidManifest.xml` **không** có dòng đó. Nghĩa là:
| Bản build | Quét thẻ QR |
| --- | --- |
| dev client / APK debug | chạy — cleartext được bật |
| APK release | `fetch` bị chặn, `Import failed` |
Nếu bản release cũng phải quét được thì **một trong hai**: phục vụ landing qua
HTTPS (tốt hơn — khi đó không phải đụng gì), hoặc bật cleartext cho release bằng
`expo-build-properties` (`android.usesCleartextTraffic: true`) — plugin này chưa
cài, nên chỉ thêm khi thật sự cần.
---
## 3. `src/components/QrScanModal.tsx` (mới)
Sheet quét: `Modal` + `CameraView` (`facing="back"`, `barcodeScannerSettings` chỉ
`['qr']`), xin quyền bằng `useCameraPermissions()` khi mở, và một **chốt
`takenRef`** — scanner báo mã ở mọi frame còn giữ mã, chốt chặn lần đầu để
không import mười lần. Mở lại sheet thì chốt reset.
Props: `{ visible, onCancel, onScanned }`. Khung ngắm màu hổ phách + dòng
`POINT AT A RECIPE QR` + nút X đóng. Khung ngắm `pointerEvents="none"` để cú
chạm rơi xuống camera bên dưới.
---
## 4. Nối vào App
| File | Thay đổi |
| --- | --- |
| `App.tsx` | state `scanVisible`; `storeImportedRecipe(xml)` tách ra dùng chung với IMPORT; `handleScanRecipe(value)`; truyền `onScanRecipe` xuống panel; render `<QrScanModal>`; truyền `paused={scanVisible}` xuống viewfinder |
| `src/components/AdjustmentPanel.tsx` | prop `onScanRecipe: () => void`; chip `SCAN QR` sau chip `IMPORT` trong tab `recipes` |
| `src/components/Viewfinder.tsx` | prop `paused?: boolean` (mặc định `false`); `isActive={appActive && !ultraWideOn && !paused}`, `active={appActive && ultraWideOn && !paused}` |
| `app.json` | plugin `expo-camera` + câu xin quyền |
| `package.json` | `expo-camera ~57.0.5` |
`handleScanRecipe` **đóng sheet trước**, rồi mới xử lý — thất bại là một hộp
thoại, không phải một scanner còn chạy. Cổng `liteSaveBlocked` /
`promptRecipeLimit()` đứng trước, giống đường IMPORT.
Liên kết phải đúng route preset, không thì từ chối kèm lý do:
```ts
const link = /^https?:\/\/\S+\/api\/photos\/\d+\/preset\.recipe$/.exec(value.trim());
if (!link) throw new Error('That code is not a RecipesCam recipe link.');
const res = await fetch(link[0]);
if (!res.ok) throw new Error(`The recipe link answered ${res.status}.`);
await storeImportedRecipe(await res.text());
```
Nhờ vậy mã QR của wifi, link shop, link app store… đều bị chặn bằng câu
`That code is not a RecipesCam recipe link.` chứ không im lặng nuốt.
`storeImportedRecipe` chính là thân cũ của `handleImportRecipe`: dynamic-import
`importRecipeXml` → `saveCustomRecipe` → `getAllRecipes` → `applyRecipe` →
Haptics success → `Alert.alert('Recipe Imported', …)`. Hai đường (chọn file và
quét mã) giờ dùng chung một chỗ lưu, nên recipe quét về nằm cùng danh sách
custom như recipe import bằng file.
---
## 5. Kiểm thử
- `node --stack-size=12000 node_modules/typescript/bin/tsc --noEmit` → đúng 15
lỗi có sẵn (`Viewfinder.tsx`, `skiaImage.ts`), **không lỗi nào ở
`App.tsx` / `QrScanModal.tsx` / `AdjustmentPanel.tsx`.
(`npx tsc --noEmit` trên cây Android crash `RangeError: Maximum call stack size exceeded` — lỗi có sẵn, phải truyền `--stack-size`.)
- Phía web: `preset-roundtrip.cjs` 11/0 — file backend sinh ra import được bằng
chính parser của app (tiếng Việt có dấu, `classic-vivid`, adjustments,
`frameId`, `useGeotag`), và envelope lạ bị từ chối.
- **Còn phải đo trên máy thật** sau khi build lại: quét thẻ QR trên landing →
recipe xuất hiện trong PRESETS và look đổi; quét một mã không phải link
RecipesCam → hộp thoại từ chối; mở sheet trong khi viewfinder đang chạy →
không có lỗi CameraX "camera already in use".
+715
View File
@@ -0,0 +1,715 @@
# 8 — PORT BỘ CÔNG CỤ CHỈNH ẢNH TỪ WEB SANG APP ANDROID
Nhánh đích: `feat/vision-camera-v5` (base khi viết: **`d5e5da4`**). Ngày: 2026-09-26.
Nguồn đối chiếu: nhánh **`recipes-web`**, commit **`6814b05`** (bản web đã chạy và đo được).
Tài liệu này là **spec để code**, không phải báo cáo. Mỗi mục có: nguồn web (file/hàm),
hiện trạng Android, việc phải làm, mã mẫu, và **tiêu chí nghiệm thu**. Toàn bộ số dòng
đã đối chiếu trực tiếp trên hai nhánh tại thời điểm viết.
> **Cách lấy nguồn tham chiếu:** nhánh `feat/vision-camera-v5` **không chứa** thư mục
> `docker/frontend`. Đọc bản web bằng:
>
> ```sh
> git show origin/recipes-web:docker/frontend/shared/utils/toneShader.ts
> git show origin/recipes-web:docker/frontend/src/engine/exportEngine.ts
> ```
>
> Đường dẫn "web" trong tài liệu này luôn tính từ `docker/frontend/`.
---
## 0. Phạm vi và bảng khoảng trống
Bảy hạng mục người dùng yêu cầu, theo đúng thứ tự sẽ làm:
| # | Hạng mục | Web có (nguồn) | Android hiện có | Việc phải làm |
|---|----------|----------------|-----------------|----------------|
| 1 | EXPOSURE / EV / HIGHLIGHT | `shared/utils/toneShader.ts` `EXPOSURE_SKSL` + `TONE_SKSL`; `shared/utils/colorUtils.ts` `EV_PER_UNIT`/`SIM_EXPOSURE_BIAS_EV`/`exposureStops` (`171-193`) | EV nhét trong ma trận màu (`src/utils/colorUtils.ts:425-426`, `applyExposureGain:444-455`); highlight knee `smoothstep(0.65,1.0)` gain `0.22` không có `(1-t)` (`src/utils/toneShader.ts:131,134`) | Tách EV sang pass linear; port đúng knee/`(1-t)` của web; **áp luôn fix thứ tự pass của `4_FIX_HIGHLIGHT_ORDER.md`** |
| 2 | AUTO (auto setting) | `src/ui/Histogram.tsx` `readHistogram` + `autoExposureStops`; `src/App.tsx:2497-2504` | Không có (ROTATE có AUTO riêng cho straighten — khác việc) | Histogram 256 bin từ pixel ảnh gốc + `autoExposureStops`, ghi vào `exposureCompensation` |
| 3 | WHITE / BLACK trong WB | `TONE_SKSL` web dòng `168-169`; uniform `wh`/`bl` (`78-79`, `338-339`, `390-391`, `433-434`, `454`, `466-467`); `paramDefs.ts:137,148` | Không có `whites`/`blacks`. `paramDefs.wb` chỉ có `temperature`,`tint` (`src/utils/paramDefs.ts:109-132`) | Thêm 2 uniform + 2 entry `paramDefs.wb` |
| 4 | TONE CURVE | `shared/utils/toneCurve.ts` (203 dòng) + `src/ui/ToneCurvePanel.tsx` | Không có | Port toán + LUT + panel graph |
| 5 | GRADIENT MASK | `shared/utils/gradientMask.ts` (169 dòng) + `App.tsx` `gradientChips:2750`/`maskRulers:2355` | Không có | Port shader + geometry + UI chip/strip/cột chỉnh |
| 6 | FIX (HEAL, MOSAIC) | `shared/utils/heal.ts` (324), `mosaic.ts` (104), `brush.ts` (26) | Không có | Port shader + pixel sampler + UI |
| 7 | H-FLIP / V-FLIP trong ROTATE | `shared/utils/skiaImage.ts` `flipSkImage:84`, `applyPhotoRotation:114` | `applyPhotoRotation(image, quarter, straighten)` — **không có flip** (`src/utils/skiaImage.ts:69`) | Thêm flip + state + UI strip |
### 0.1 Đã có sẵn trên nhánh này — KHÔNG làm lại
Các commit `c8e2b6e`…`d5e5da4` đã port xong và **không** nằm trong phạm vi tài liệu này:
- **HSL selective colour mixer** (`cbd0178`): đã có `hslBands`/`hslHue`/`hslSat`/`hslLum`
trong `ColorAdjustments` (`src/types/index.ts:77-104`), `HSL_BANDS`/`hslBandGaps`/
`sanitizeHslBands` trong `colorUtils.ts:11-115`, band block trong `TONE_SKSL`
(`toneShader.ts:51-58,76-82,194`) và tab `HSL` trong `ToolRail.tsx:18`.
⇒ **Bỏ** mọi ghi chú cũ kiểu "HSL web-only".
- **Stock `classic-vivid` và `mono-high-contrast`** (`c8e2b6e`, `85b55dc`): đã có trong
`BaseFilter` (`src/types/index.ts:69`) **và** đã có entry `FILM_TONE`
(`src/utils/toneShader.ts:299-322`, gồm `'mono-high-contrast': { sh: -0.32, hl: 0.26 }`).
- **`STOCK_BIAS`** (`colorUtils.ts:161-168`) đã thay vai trò của `SIM_EXPOSURE_BIAS_EV` +
`SIM_CONTRAST_BIAS` bên web, nhưng còn ở **đơn vị cũ** — xem mục 3.3.
Ghi chú phạm vi khác: ba nhánh light/chroma, split-tone, Color Chrome, vibrance trong
`TONE_SKSL` Android **đã khớp** web — không đụng.
---
## 1. Hiện trạng Android
### 1.1 Ba đường render — mọi pass mới phải khớp cả ba
| Đường | File | Đặc điểm |
|-------|------|----------|
| Export JS/Skia (imperative) | `src/utils/exportEngine.ts` → `processAndExportPhoto` | Toàn quyền: surface phụ, snapshot, LUT, cache effect theo count. Đây là đường "chuẩn" để so. |
| Preview library (declarative) | `src/components/Viewfinder.tsx`, `libPhoto()` (`3127-3200`) | Chỉ lồng được `<Shader source={effect}>` quanh một chuỗi shader của ảnh. **Không** chèn được pass giữa tuỳ ý. |
| Camera preview (worklet) | `Viewfinder.tsx` `<SkiaCamera onFrame={handleFrame}>`, `drawFullFrame` | Mỗi frame; tránh thêm pass nặng. |
| Export native Kotlin | `src/utils/nativeExport.ts:47` + `modules/recipescam-export/android/.../RecipescamExportModule.kt` | Bật bằng `EXPO_PUBLIC_NATIVE_EXPORT=1` (`App.tsx:61`). Chỉ dùng khi `pro` (`App.tsx:1376`). |
`Viewfinder.tsx:461` dựng ảnh tĩnh đã quay cho chế độ library bằng memo
`applyPhotoRotation(loadedImage, photoRotation, photoStraighten)`.
### 1.2 Hạ tầng đã có
- `Skia.RuntimeEffect.Make` + `makeShaderWithChildren` + `<Shader source>` — đã dùng
(`exportEngine.ts:371-380`, `Viewfinder.tsx:3141-3146`).
- Mẫu đọc pixel: `src/utils/horizon.ts:18-56` — `Skia.Surface.Make(w,h)` (`32`) →
`drawImageRectOptions` downscale → `makeImageSnapshot()` → `readPixels()` (`50`)
(Uint8Array RGBA hoặc Float32Array). Dùng lại cho histogram/AUTO và `findHealSource`.
- Undo: `App.tsx` `commit(coalesce, mutate)` (`757-775`, `PEEK_GAP_MS = 700` tại `751`),
`applyLook` (`804`), `handleUndo` (`820`), `Look` (`719-720`) — thêm field vào `Look`
là tự động sống qua undo/redo/session (`lookNow` tại `728`).
- Chip UI: `AdjustmentPanel.tsx` `chipRow` (`867`), `paramChips` (`475`),
`groupDefs` (`652`), `groupDefs.rotate` (`713-733`), `rotateChip()` (`791-803`).
- `RecipeCreateModal.handleSave` spread `...seedAdj`, nên knob **không có row** trong form
vẫn được lưu (không bị zero).
### 1.3 Điều KHÔNG có (phải dựng mới)
Không có: brush, heal, mosaic, mask vẽ tay, curve editor, flip. Không SVG, không
`react-native-gesture-handler` — mọi touch đi qua RN responder. Không i18n (chuỗi
hardcode tiếng Anh, giữ nguyên).
`exportEngine.ts` **không** cache `RuntimeEffect` theo count: chỉ có `watermarkFonts`
(`184`) ở module scope, mọi effect khác dựng lại trong hàm. Heal/mosaic/mask bắt buộc
phải cache — xem mục 2.4.
### 1.4 Thứ tự pass hiện tại của `exportEngine.ts`
```
218 1 nạp ảnh → 241 1 rotate → 281 1b crop → 250 1c deviceFloor → 337 2 surface
350 3 color matrix (+ STOCK_BIAS, +2^EV gain) ← CÙNG paint với 3b/3c: SAI (xem doc 4)
359 3b tone shader → 392 3c cinema
414 4 denoise/clarity/soften → 477 5 draw → 489 5b HDF
572 6 grain → 619 6b vignette → 648 7 frame
832 8 watermark → 924 9 snapshot/JPEG/EXIF → 976 10 file → 988 11 gallery
```
`4_FIX_HIGHLIGHT_ORDER.md` đã chỉ ra `SkPaint` chạy **shader TRƯỚC colorFilter**, nên
ma trận (mang exposure) hiện đang áp **sau** tone → HIGHLIGHT bị xoá sạch. Kiểm tra
`grep -rn gradeThrough src/` → **rỗng**, fix này **chưa được áp**. Mục 3 dưới đây áp nó
cùng lúc với việc tách EV.
---
## 2. Quy tắc chung cho mọi pass mới
### 2.1 Thứ tự pass đích
```
rotate(+flip) → crop → matrix → [EXPOSURE linear] → tone(hl/sh/wh/bl) → cinema
→ curve → denoise/clarity/soften → draw → HDF
→ masks → heal → mosaic → grain → vignette → frame → watermark
```
### 2.2 Sai lệch **có chủ ý** so với web
Web đặt `masks → heal → mosaic` **sau** grain và vignette
(`exportEngine.ts:708-800`). Android đặt chúng **trước** grain/vignette. Lý do:
1. Preview library chỉ lồng được shader trong chuỗi shader của ảnh — mask/heal/mosaic
phải nằm trong chuỗi đó, tức trước lớp grain/vignette vốn là node riêng
(`Viewfinder.tsx:3230-3233`/`3288-3291` grain, `renderVignette:2809,3237,3295,3348`).
2. Grain/vignette chạy sau sẽ **phủ đều** lên vết sửa, nên vết heal/mosaic trông tự
nhiên hơn (nhiễu hạt chồng lên đúng như mọi vùng khác).
Điều phải giữ bằng mọi giá: `masks` chạy **trước** `heal` (web có comment giải thích ở
`gradientMask.ts:149-155` và `exportEngine.ts:708-737`) — vết heal phải mượn pixel **đã
mang ánh sáng của mask**.
### 2.3 Điểm chèn
| Đường | Vị trí chèn |
|-------|-------------|
| `exportEngine.ts` | Ngay sau khối vignette, trước `// 7. Frame.` (`648`) |
| `Viewfinder.tsx` | Trong `libPhoto()` (`3127`), sau `<Blur>`/clarity và trước HDF glow |
| `nativeExport.ts` / Kotlin | Theo `4_FIX_HIGHLIGHT_ORDER.md` §4.3 rồi nối tiếp |
### 2.4 Quy ước code
- Shader dựng theo **số lượng** phần tử (`healSkSL(n)`, `mosaicSkSL(n)`,
`gradientMaskSkSL(n)`) và **cache** effect theo count — copy mẫu web
`exportEngine.ts:154-210` (`healEffectFor`/`mosaicEffectFor`/`maskEffectFor`).
- Mọi pass mới phải có nhánh "tắt": uniform toàn 0 / danh sách rỗng ⇒ **không** tạo
surface, **không** đổi đường vẽ cũ. Đây là điều kiện để không regress ảnh hiện tại.
- Không đổi ngữ nghĩa `evFromCamera` (`exportEngine.ts:35,353`): ảnh chụp từ camera đã
nhận AE bias phần cứng, **không** cộng thêm EV của `exposureCompensation`.
---
## 3. EXPOSURE / EV / HIGHLIGHT
### 3.1 Nguồn web
- `shared/utils/toneShader.ts` — `EXPOSURE_SKSL`: linear hoá sRGB (`0.04045/12.92` +
`2.4`), nhân `exp2(ev)`, encode lại. Chạy **giữa** ảnh đã grade và tone shader.
- `shared/utils/colorUtils.ts:171-193`:
- `SIM_EXPOSURE_BIAS_EV = { 'leica-vivid': 0.25 }`, `SIM_CONTRAST_BIAS = { 'mono-high-contrast': 4 }`
- `EV_PER_UNIT = 0.25` → núm EXPOSURE ±10 = ±2.5 EV
- `exposureStops(adj, baseFilter) = (adj.exposure ?? 0) * EV_PER_UNIT + bias`
- Comment khẳng định: EV/biến thiên sim **không** nằm trong ma trận màu.
- `exportEngine.ts:458-476` — bước 3: ma trận riêng một ảnh, rồi `evStops = exposureStops() + userEv`.
### 3.2 Hiện trạng Android (sai)
`src/utils/colorUtils.ts`:
```
186 const exposure = adj.exposure + (STOCK_BIAS[baseFilter]?.exposure ?? 0);
425 const expScale = 1 + (exposure / 10) * 0.2; // -10 → 0.8x, +10 → 1.2x
426 const expOffset = (exposure / 10) * 0.15;
444 export function applyExposureGain(matrix, evStops) // gain 2^ev trong gamma-space
```
`exposureCompensation` ±3 EV (`src/types/index.ts:104`), áp qua `applyExposureGain` tại
`exportEngine.ts:353-357` và `nativeExport.ts:61`. Kết quả: +1 EV đẩy mid-grey 0.5 →
**1.0** (cháy), đúng như web đã sửa.
`STOCK_BIAS['leica-vivid'] = { exposure: 2 }` (`colorUtils.ts:163`) là **đơn vị cũ**: web
đo ra ≈ **0.25 EV** và đã chuyển sang thang stops.
### 3.3 Việc phải làm
**Bước A — tách EV khỏi ma trận.**
1. `colorUtils.ts:186` — bỏ `+ (STOCK_BIAS[baseFilter]?.exposure ?? 0)`; xoá khối
`expScale`/`expOffset` (`425-438`) khỏi `getSkiaColorMatrix`; xoá
`applyExposureGain` (`444-455`) khỏi mọi caller. Giữ `STOCK_BIAS.contrast`
(`mono-high-contrast: 4`) — web cũng giữ `SIM_CONTRAST_BIAS` trong ma trận.
2. Đổi `STOCK_BIAS['leica-vivid']` từ `{ exposure: 2 }` → **bỏ khỏi `STOCK_BIAS`**, và
thêm đúng bản web:
```ts
// src/utils/colorUtils.ts
export const EV_PER_UNIT = 0.25; // núm EXPOSURE ±10 = ±2.5 EV (web: giống hệt)
const SIM_EXPOSURE_BIAS_EV: Partial<Record<BaseFilter, number>> = { 'leica-vivid': 0.25 };
/** EV người dùng (núm), biến thiên sim, và EV thô — cộng ở thang stops. */
export function exposureStops(
adj: ColorAdjustments, baseFilter: BaseFilter | undefined, userEv: number
): number {
return (adj.exposure ?? 0) * EV_PER_UNIT
+ (SIM_EXPOSURE_BIAS_EV[baseFilter ?? 'none'] ?? 0)
+ userEv;
}
```
**Bước B — thêm `EXPOSURE_SKSL`** vào `src/utils/toneShader.ts` (copy nguyên văn web,
giữ comment gốc):
```ts
export const EXPOSURE_SKSL = `
uniform shader src;
uniform float ev;
vec3 toLinear(vec3 c) {
return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c));
}
vec3 toEncoded(vec3 c) {
return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c));
}
vec4 main(vec2 xy) {
vec4 c = src.eval(xy);
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
return vec4(clamp(toEncoded(toLinear(rgb) * exp2(ev)), 0.0, 1.0), c.a);
}
`;
```
**Bước C — áp `4_FIX_HIGHLIGHT_ORDER.md` §4.1/§4.2/§4.3 nguyên trạng.** Ma trận phải
thành ảnh riêng (`gradeThrough`) trước khi tone/cinema đọc. Đồng thời **bỏ lần áp ma
trận thứ hai** trong khối HDF (`exportEngine.ts:529,549`, `Viewfinder.tsx:1369,1387`).
Thứ tự sau khi sửa: `matrix → exposure(linear) → tone → cinema`. Exposure chèn cùng chỗ
với ma trận: `gradeImage` (kết quả `gradeThrough`) → shader exposure → tone shader con.
`ev = exposureStops(adjustments, recipe.baseFilter, evFromCamera ? 0 : exposureCompensation)`.
Với `evFromCamera` thì **vẫn** giữ phần núm EXPOSURE + biến thiên sim (chúng chưa từng
đi qua phần cứng), chỉ bỏ `exposureCompensation`.
### 3.4 Nghiệm thu
1. Parity với web trên **cùng ảnh + cùng recipe** (so pixel, không so cảm giác).
2. Mid-grey +1 EV cho ra **≈ 0.69** encoded, **không** cháy 1.00.
3. Bảng p95 của `4_FIX_HIGHLIGHT_ORDER.md` §5: nguồn gradient dọc 0.98→0.30,
`HIGHLIGHT −10` → p95 **0.780**, `HIGHLIGHT −10 + EXPOSURE +10` → p95 **vẫn 0.780**,
midtone 0.502 → 0.722.
4. `leica-vivid` sau khi sửa sáng thêm **≈ 0.25 EV** so với trước (đo trên cùng ảnh), và
**giống** bản web.
5. Chạy đủ ba đường (export JS, preview library, Kotlin) và so ảnh với nhau.
> **Thay đổi hành vi phải báo trước:** núm EXPOSURE từ "±10 đơn vị ≈ ×0.8…×1.2" thành
> "±2.5 EV". Recipe cũ đặt EXPOSURE ≠ 0 sẽ sáng/tối hơn rõ rệt. Web đã chấp nhận đánh
> đổi này (`colorUtils.ts` comment "every recipe already saved with a non-zero EXPOSURE
> gets brighter with it").
---
## 4. HIGHLIGHT
### 4.1 Nguồn web (`TONE_SKSL`)
```
57 float hlMask = smoothstep(0.50, 1.00, t);
60 float o = t + hl * hlMask * (1.0 - t) + sh * 0.34 * shMask;
63 o -= dr * 0.18 * hlMask * t;
```
Knee **0.50**, biên độ **1.0** (không scale), dạng **`(1-t)`** (càng gần trắng càng ít
đẩy) ⇒ monotonic, giữ được vùng sáng không cháy. `shMask = 1 - smoothstep(0, 0.55, t)`.
### 4.2 Hiện trạng Android (`src/utils/toneShader.ts`)
```
131 float hlMask = smoothstep(0.65, 1.00, t);
134 float o = t + hl * 0.22 * hlMask + sh * 0.34 * shMask;
```
Knee 0.65, gain 0.22, cộng thẳng không có `(1-t)` ⇒ vùng sáng đã cháy vẫn bị đẩy lên và
thanh "chết" đúng như `4_FIX_HIGHLIGHT_ORDER.md` §1 mô tả.
### 4.3 Việc phải làm
Thay hai dòng `131` và `134` bằng bản web. Giữ `sh * 0.34` (hai bên giống nhau). Không
đổi `dr` (dòng `137`, đã khớp). Cập nhật comment đầu file (mô tả knee 0.65) cho khớp.
`FILM_TONE` (`toneShader.ts:299-322`) đã có đủ sáu stock web hỗ trợ, gồm
`classic-vivid` và `mono-high-contrast` — **không đụng**.
### 4.4 Nghiệm thu
- `HIGHLIGHT −10` trên ảnh có vùng trắng đẩy p95 xuống ~0.78 và **không** đổi midtone
(so với ảnh gốc, mid lệch < 0.01).
- `HIGHLIGHT +10` rồi `−10` quay về đúng ảnh gốc (đối xứng, sai số ≤ 1/255).
- `mono-high-contrast` (dùng `hl: 0.26`) giữ nguyên diện mạo sau khi đổi knee — kiểm bằng
ảnh so trước/sau.
---
## 5. WHITE / BLACK trong WB
### 5.1 Nguồn web
`TONE_SKSL` — chèn **sau** nhánh lightness/chroma, trước split-tone:
```
168 vec3 dk = 1.0 - rgb;
169 rgb = clamp(rgb + bl * 0.18 * dk * dk * dk + wh * 0.18 * rgb * rgb * rgb, 0.0, 1.0);
```
Trọng số **cubic** theo khoảng cách của kênh tới đầu mút: trong tối kênh tối nhất dịch
nhiều nhất, trong sáng kênh sáng nhất dịch nhiều nhất ⇒ kéo R/G/B về chung một toe và
shoulder — đây là **động tác white balance**, không phải thêm một thanh tone. Monotonic:
đạo hàm `1 − 3×0.18 = 0.46` tại đầu mút, không thể đảo chiều.
Uniform web: `uniform float wh; uniform float bl;` (`toneShader.ts:78-79`), nhận
`Math.max(-1, Math.min(1, (adj.whites ?? 0) / 10))` (`390-391`), đưa vào mảng đúng thứ tự
khai báo sau `dr, hl, sh` (`454`), và `toneIsActive` thêm `u.wh !== 0 || u.bl !== 0`
(`466-467`).
`shared/utils/paramDefs.ts:137,148` — thứ tự WB: `temperature, tint, whites` (label
`WHITE`), `blacks` (`BLACK`), min/max ±10, step 1, `display: sign`.
### 5.2 Việc phải làm
1. `src/types/index.ts` — thêm `whites?: number; // -10..+10` và `blacks?: number` vào
`ColorAdjustments` (interface ở dòng `77`), đặt ngay sau `tint`/`wbBlue` cho khớp web.
2. `src/utils/toneShader.ts`:
- thêm `uniform float wh; uniform float bl;` **ngay sau** `uniform float sh;` (dòng `66`);
- thêm khối `dk` ở đúng vị trí web (sau nhánh lightness/chroma, trước split-tone ở `147`);
- thêm `wh`/`bl` vào `ToneUniforms` (`275`), `getToneUniforms` (`325`, chia 10 và kẹp ±1),
`toneUniformArray` (`388`, đúng thứ tự khai báo), `toneIsActive` (`396`).
3. `src/utils/paramDefs.ts:109-132` — thêm 2 entry vào `wb` sau `tint`, label
`WHITE`/`BLACK`, ±10 step 1, `display: sign`, `get: (a) => a.whites ?? 0`,
`set: (v) => ({ whites: v })` (tương tự `blacks`). `chipRow` của tab WB
(`AdjustmentPanel.tsx:957`) tự hiện chip — không phải sửa UI.
4. `RecipeCreateModal` — **không cần** thêm row: `handleSave` đã spread `seedAdj`.
### 5.3 Nghiệm thu
- `WHITE +10` chỉ nâng vùng sáng, vùng tối và mid lệch < 1/255.
- `BLACK +10` chỉ nâng vùng tối (đen thành xám nhẹ), vùng sáng lệch < 1/255.
- Trên ảnh xám trung tính, `WHITE −10` **không** sinh cast màu (ba kênh dịch như nhau).
- Recipe cũ (không có `whites`/`blacks`) render **y hệt** trước khi sửa.
---
## 6. AUTO (auto setting)
### 6.1 Nguồn web
`src/ui/Histogram.tsx`:
```ts
const BINS = 256; // 0..255
const SAMPLE = 320; // downscale cạnh dài trước khi bin
export const AUTO_EV_MAX = 2.5;
export function autoExposureStops(lum: number[]): number {
let sum = 0, weighted = 0;
for (let i = 0; i < lum.length; i++) { sum += lum[i]; weighted += (i / (lum.length - 1)) * lum[i]; }
const avg = Math.max(0.001, sum > 0 ? weighted / sum : 0);
const stops = Math.log2(0.48 / avg);
return Math.max(-AUTO_EV_MAX, Math.min(AUTO_EV_MAX, stops));
}
```
Luma weights `0.2126/0.7152/0.0722` (đúng trọng số shader dùng). Target **0.48**, guard
`≥ 0.001`, kẹp ±2.5 EV.
`src/App.tsx:2497-2504` `autoExposure()`: đọc histogram của **ảnh gốc đã load**, rồi
`setAdjustment({ exposureCompensation: Math.round(stops * 10) / 10 })`. Chip `AUTO` đứng
**đầu** row tab LIGHT (`App.tsx:2586-2587`).
### 6.2 Việc phải làm (Android)
1. Thêm `src/utils/histogram.ts`:
```ts
export const AUTO_EV_MAX = 2.5;
// Pixel đã downscale (cạnh dài 320) từ readPixels — cùng mẫu horizon.ts:32-56.
export function lumaBins(rgba: Uint8Array | Float32Array, isFloat: boolean): number[] { /* 256 bin */ }
export function autoExposureStops(lum: number[]): number { /* y như web */ }
```
Nguồn pixel: `Skia.Surface.Make` + `drawImageRectOptions` downscale về cạnh dài **320**
trên **ảnh gốc đã load** (rotation không đổi histogram, không cần `applyPhotoRotation`),
theo đúng mẫu `src/utils/horizon.ts:32-56`. Nhớ `dispose()` surface/snapshot — Hermes
không tự thu hồi buffer native (`exportEngine.ts:113-121`).
2. Chip `AUTO` **đứng đầu** row LIGHT: `AdjustmentPanel.tsx:951` hiện là
`chipRow([...paramChips(paramDefs.iq), groupChip('dr')])` → đổi thành
`chipRow([autoChip(), ...paramChips(paramDefs.iq), groupChip('dr')])`, nhãn `AUTO`.
3. `onPress`: chạy `autoExposureStops` → `handleUpdateAdjustments({ exposureCompensation: ev })`
(`App.tsx:869`). Đi qua `handleUpdateAdjustments` để có `commit(true, …)` ⇒ **một bước undo**.
### 6.3 Nghiệm thu
- **Idempotent:** bấm AUTO hai lần liên tiếp ra cùng một giá trị EV (vì đọc ảnh gốc,
không đọc ảnh đã áp EV).
- Ảnh phơi sáng đúng (avg ≈ 0.48) ⇒ EV ≈ 0 (|EV| ≤ 0.1).
- Ảnh tối đen ⇒ EV = +2.5 (không `Infinity`, không `NaN`).
- Ảnh cháy trắng ⇒ EV = −2.5.
- Undo một lần quay về EV trước khi bấm.
Ngoài phạm vi: web **chỉ** auto EV. Không auto contrast/white balance/tone.
---
## 7. TONE CURVE
### 7.1 Nguồn web — `shared/utils/toneCurve.ts` (203 dòng)
| Hàm/hằng | Vai trò |
|----------|---------|
| `CURVE_CHANNELS = ['rgb','r','g','b']` | master + 3 kênh |
| `CURVE_LUT_SIZE = 256`, `IDENTITY_CURVE = [[0,0],[1,1]]` | LUT 256 mức |
| `CURVE_MIN_GAP = 0.02`, `FLAT = 0.002` | khoảng cách điểm tối thiểu; ngưỡng "phẳng" |
| `curvePoints(curve, ch)` | clamp01 + sort + ghim hai đầu về (0,0) và (1,1) |
| `curveIsActive(curve)` | có kênh nào lệch `FLAT` khỏi đường chéo |
| `tangents(pts)` | tiếp tuyến **Fritsch–Carlson** (monotone cubic) |
| `sampleCurve(pts, x)`, `curveLut(curve)` | master ∘ kênh → `Uint8Array` RGBA, A=255 |
| `CURVE_SKSL` | 2 child (`src`, `lut`), sample `v * 255 + 0.5` |
| `addCurvePoint` | thêm điểm **trên** đường (click không nhảy giá trị) |
| `moveCurvePoint` | hai đầu bị ghim theo trục x |
| `removeCurvePoint`, `isFlatCurve` | xoá điểm, kiểm tra phẳng |
`curveLut` áp theo thứ tự **master ∘ channel**.
### 7.2 UI web — `src/ui/ToneCurvePanel.tsx` (357), `src/App.tsx`
- Graph `SIZE = 224`, `STEPS = 64` vẽ đường, `HIT = 11px` bán kính bắt điểm.
- Tabs `RGB / R / G / B` + `RESET` (`{ }` = xoá toàn bộ curve).
- Histogram vẽ **sau** lưu đồ (nền).
- Kéo card bằng head; pointer capture khi kéo điểm.
- `put()` xoá channel khi phẳng (không lưu curve rỗng).
- Chip `TONE CURVE` nằm sau group D.RANGE trong row LIGHT; amber khi `curveIsActive`.
### 7.3 Việc phải làm (Android)
1. `src/utils/toneCurve.ts` — port nguyên toán + hằng số + `CURVE_SKSL`. `curveLut` trả
`Uint8Array(256 * 4)`.
2. Dựng LUT thành ảnh Skia cho shader: `Skia.Image.MakeImage({ width: 256, height: 1,
colorType: RGBA_8888, alphaType: Premul }, data, 256 * 4)`; shader con thứ hai là
`lutImage.makeShaderOptions(TileMode.Clamp, TileMode.Clamp, FilterMode.Linear,
MipmapMode.None)` — như web (`exportEngine.ts:533-560`, khối 3e).
3. `ColorAdjustments.toneCurve?: ToneCurve`.
4. Chip `TONE CURVE` trong row LIGHT **sau** `groupChip('dr')` (`AdjustmentPanel.tsx:951`);
mở panel graph khi `openParam === 'curve'`; amber khi `curveIsActive`.
5. Panel: graph 224px dựng bằng Skia (`<Canvas>` + `<Path>` + `<Circle>`), tabs
RGB/R/G/B, RESET, histogram nền (dùng lại hàm của mục 6), kéo điểm bằng RN responder
trên `<View>` phủ (không có pointer capture sẵn — dùng `PanResponder`), tap-trên-đường
để thêm điểm, double-tap để xoá (`CURVE_MIN_GAP 0.02`, HIT 11px).
6. Một cử chỉ kéo = **một** bước undo: copy cách `handleUpdateAdjustments` gộp
(`App.tsx:869`, `PEEK_GAP_MS = 700` tại `751`).
### 7.4 Nghiệm thu
- Curve identity ⇒ ảnh **không đổi** (diff = 0) và pass bị bỏ (không tạo LUT/surface).
- Master kéo 1/4 → 0 làm nửa dưới tối đúng theo đường monotone; `R` kéo xuống ⇒ ảnh ngả
cyan, và **chỉ** kênh đỏ đổi.
- `Fritsch–Carlson` không overshoot: LUT monotone khi các điểm vào monotone.
- Export == preview (cùng LUT).
- **Rủi ro cao cần kiểm trên device:** nhánh declarative `<Shader source={curveEffect}>
{graded}</Shader>` cần **2 child shader** (chuỗi đã grade + `ImageShader` của LUT với
`fit="none"` và `rect={rect(0, 0, 256, 1)}`). Nếu `@shopify/react-native-skia` không
nhận `rect` như mong đợi, fallback: render curve trong đường **imperative** cho preview
tĩnh (dùng đúng `gradeThrough` của mục 3), và giữ declarative chỉ khi tone/cinema.
Đường export imperative thì không có rủi ro này.
---
## 8. GRADIENT MASK
### 8.1 Nguồn web — `shared/utils/gradientMask.ts` (169 dòng)
Hằng số: `MASK_KIND = { linear: 0, radial: 1 }`, `MASK_EXPOSURE_MAX = 5`,
`MASK_DEFAULT_FEATHER = 0.5`, `MASK_MIN = 0.01`.
`maskUniforms` — layout `(3n + 1) × vec4`:
| offset | nội dung |
|--------|----------|
| `i * 4` | `[x, y, ex, ey]` — gốc (pin) và đầu kéo (linear) |
| `(n + i) * 4` | `[rx, ry, angle, feather]` — ellipse (radial) |
| `(2n + i) * 4` | `[exposure, contrast / 10, saturation / 10, kind]` |
| `3n * 4` | `[width, height, 0, 0]` — khung mà các phân số là của nó |
Alpha (web, `maskBlock`): linear `a = smoothstep(0, 1, clamp(dot(pos − pin·size, d) / |d|²))`;
radial `st = (pos − center·size) / size.x`, xoay `−angle`, `d = length(r / max(rx, ry, 1e-5))`,
`a = 1 − smoothstep(max(0, 1 − feather), 1, d)`. `maskAdjust` = exposure (luỹ thừa 2)
→ contrast quanh 0.5 → saturation như mix khỏi luma REC-709, rồi clamp.
Chạy **trước** HEAL (`gradientMask.ts:149-155`, `exportEngine.ts:708-737`).
`readMasks` lọc: kind hợp lệ, kẹp trong khung, và loại mask suy biến — linear có
`hypot(ex−x, ey−y) > MASK_MIN`, radial có `rx > 0 && ry > 0`.
### 8.2 UI web (`App.tsx`)
- Chip `GRADIENT MASK` trong row FX, amber khi `masks.length > 0` (`2636`).
- `gradientChips` (`2750-2765`): `LINEAR` / `RADIAL` / `CLEAR`.
- Cột chỉnh mask (`3136-3155`) + `maskRulers` (`2355-2406`): EXPOSURE ±5 EV step 0.1,
CONTRAST ±10, SATURATION ±10, FEATHER 0-100% (chỉ radial), DELETE.
- Geometry kéo: `maskPin` / `maskFromDrag` / `maskDragged` (`121-230`).
- `setMaskKnob` (`1123-1145`).
### 8.3 Việc phải làm (Android)
1. `src/utils/gradientMask.ts` — port hằng số + `readMasks` + `maskUniforms` +
`maskAdjust` + `gradientMaskSkSL(count)`.
2. **Thêm uniform `origin`.** Web vẽ lên canvas chính là ảnh, nên `pos` đã là toạ độ ảnh.
Preview Android có `dispRect` (`Viewfinder.tsx:1705,3331`) nên shader phải dùng
`pos − origin` trước khi chia `size`. Gói `origin` vào `vec4 size` đang có
(`[width, height, originX, originY]`) để **không** đổi layout 3n+1.
3. `GradientMask` interface (kind, x, y, ex, ey, rx, ry, angle, feather, exposure,
contrast, saturation) + `ColorAdjustments.masks?: GradientMask[]`.
4. Pass: cache `maskEffectFor(n)` (mẫu web `exportEngine.ts:204-210`), snapshot → clear →
vẽ lại như web (`exportEngine.ts:708-737`).
5. UI: chip `GRADIENT MASK` trong row FX + strip `LINEAR`/`RADIAL`/`CLEAR`
+ cột chỉnh (EXPOSURE ±5 EV 0.1 / CONTRAST ±10 / SATURATION ±10 / FEATHER 0-100%
chỉ radial / DELETE). Touch: RN responder trên `<View>` phủ — kéo pin/kéo đầu/đổi
bán kính ellipse; vẽ outline + handle + đường chéo bằng Skia.
6. Drag < `MASK_MIN` khi thả ⇒ huỷ, không tạo mask (khớp `readMasks`).
### 8.4 Nghiệm thu
- `+2 EV` trong mask làm vùng trong mask sáng **≈ 2 stop**, vùng ngoài lệch < 1/255.
- Hai mask chồng nhau: mask thứ hai đọc kết quả mask thứ nhất (đúng "stack").
- Kéo thả < 0.01 không sinh mask rác trong state.
- MASK chạy trước HEAL: heal đặt trong mask mượn pixel đã mang ánh sáng mask (ảnh kiểm:
vết heal trên vùng mask tối không còn sáng hơn nền).
- FEATHER = 0 (radial) ⇒ rìa cứng, `a` nhảy 1 → 0 tại `d = 1`.
- Toạ độ đúng khi zoom/pan: mask dán vào **ảnh**, không dán vào khung nhìn
(kiểm bằng cách zoom rồi so vị trí outline với pixel).
---
## 9. FIX — HEAL và MOSAIC
### 9.1 Nguồn web
`shared/utils/heal.ts` (324 dòng) — mọi hằng số phải giữ nguyên:
```
HEAL_FEATHER 0.85 HEAL_DEFAULT_R 0.012
SEARCH_DISTANCES [2.6, 4.2, 6.5] SEARCH_DIRS 8 RING_R 1.15 RING_PAD 1
RING_TAPS: 12 điểm vành, lấy MEDIAN INSIDE_TAPS: 16
LIGHT_GATE 20 LIGHT_WEIGHT 3 BLEND_TAPS 16
```
- `healUniforms` — `(2n + 1)` vec4 (mỗi spot 2 vec4: vị trí/bán kính + nguồn `sx,sy`).
- `healSkSL(count)` — `correction` theo góc, sample `pos + ps − pd`.
- `findHealSource(sample, x, y, r)` — 8 hướng × 3 khoảng × bản mirror, tính điểm lệch
sáng có trọng số (`LIGHT_WEIGHT 3`, `LIGHT_GATE 20`), từ chối (`null`) nếu không có
nguồn hợp lệ.
`shared/utils/mosaic.ts` (104) — `MOSAIC_CELL 0.02`, `MOSAIC_DEFAULT_R 0.05`;
`mosaicUniforms` `(n + 1)` vec4, vec4 cuối `[width, height, cell, 0]`,
`cell = max(1, MOSAIC_CELL * width)`; `mosaicSkSL(count)` lấy ô
`img.eval((floor(pos / cell) + 0.5) * cell)`, **rim cứng**.
`shared/utils/brush.ts` (26) — `BRUSH_MIN_R 0.003`, `BRUSH_MAX_R 0.25`,
`BRUSH_SPACING 0.6`, `wheelBrushR(r, deltaY)`.
Types web: `HealSpot { x, y, r, sx, sy }`, `MosaicSpot { x, y, r }`.
### 9.2 UI web (`App.tsx`)
- Chip `FIX` amber khi heal hoặc mosaic có spot; `fixChips` (`2715-2745`): `HEAL` /
`MOSAIC` kèm readout `%`, `CLEAR`, `brushTool`.
- Chọn spot HEAL để kéo và có nút `×` xoá; MOSAIC **không** kéo được.
- `spotUnder` bắt theo bán kính `max(4, r * box.width)`.
- Một cử chỉ vẽ = một bước undo.
### 9.3 Việc phải làm (Android)
1. `src/utils/brush.ts`, `heal.ts`, `mosaic.ts` — port **nguyên hằng số và công thức**,
gồm `findHealSource`.
2. Pixel sampler cho `findHealSource`: dùng đúng mẫu `horizon.ts:32-56` — render ảnh
(đã grade, **chưa** mask/heal) xuống surface cạnh dài ~512, `readPixels()` →
`Uint8Array` RGBA. Đây là "sample" đầu vào. **Không** dùng ảnh preview đã áp mask.
3. UI: chip `FIX` trong row FX + strip `HEAL` / `CLEAR` / `MOSAIC` / `CLEAR`.
Android **không có con lăn** ⇒ thêm một row slider `SIZE` (thay `wheelBrushR`), map
tuyến tính vào `BRUSH_MIN_R..BRUSH_MAX_R`.
4. HEAL: chạm/kéo tạo spot, chạm vào spot để chọn, kéo để dời, nút `×` để xoá.
MOSAIC: chạm/kéo tạo spot, không dời. Một cử chỉ = `commit(true, …)` một bước undo.
5. Shader theo count + cache (`healEffectFor(n)`, `mosaicEffectFor(n)`), snapshot → clear
→ vẽ lại. Thứ tự: **masks → heal → mosaic** (web `exportEngine.ts:708-737` (masks), `740-775` (heal), `776-800` (mosaic)).
6. Nếu `findHealSource` trả `null`: **giữ nguyên** pixel gốc (không bịa nguồn).
### 9.4 Nghiệm thu
- Xoá hạt bụi trên nền phẳng (trời, tường): vết sửa không lộ, seam viền ≤ ~2/255 so với
nền (đo dọc vành spot).
- Không có nguồn hợp lệ (hạt nằm trên biên tương phản cao) ⇒ ảnh **không đổi**.
- Mosaic rim **cứng** — kiểm pixel tại rìa: nhảy bậc, không gradient.
- 30 spot vẫn render (đo thời gian; cache effect hoạt động, không dựng lại shader mỗi frame).
- Export == preview.
---
## 10. H-FLIP / V-FLIP trong ROTATE
### 10.1 Nguồn web — `shared/utils/skiaImage.ts`
```
84 export function flipSkImage(image, horizontal, vertical)
canvas.scale(h ? -1 : 1, v ? -1 : 1);
canvas.translate(h ? -w : 0, v ? -h : 0);
canvas.drawImage(image, 0, 0);
114 export function applyPhotoRotation(image, quarter = 0, straighten = 0, flipH = false, flipV = false)
turn (rotateSkImage90 × turns) → straighten (rotateSkImageBy) → flip
```
Comment web: *"a flip is what the user sees, so it mirrors the photo as it stands,
whatever turn and angle are already on it"* ⇒ flip áp **sau** turn + straighten.
`rotateSkImage90` đổi chiều rộng/cao; flip **không** đổi dims.
### 10.2 Hiện trạng Android
`src/utils/skiaImage.ts:69-73` — `applyPhotoRotation(image, quarter = 0, straighten = 0)`,
không flip. Callers phải sửa:
- `src/utils/exportEngine.ts:241`
- `src/components/Viewfinder.tsx:461` (memo) và `:803` (`detectTilt(applyPhotoRotation(src.image, src.quarter, 0))`)
### 10.3 Việc phải làm
1. `src/utils/skiaImage.ts` — thêm `flipSkImage` + hai tham số `flipH`/`flipV` cho
`applyPhotoRotation`, copy nguyên web (gồm `dispose()` các bản trung gian).
2. State: `photoFlipH`/`photoFlipV` (boolean) cạnh `photoRotation`/`photoStraighten`
(`App.tsx:104-105`); thêm vào `Look` (`719-720`), `lookNow()` (`728`), `applyLook`
(`804-812`), "look đã đổi" (`699-700`), và `ExportOptions` (`exportEngine.ts:91-92`).
3. Truyền xuống: options export (`App.tsx:1753-1754`), `Viewfinder` (`App.tsx:1983-1984`
và `2100-2101`), `nativeExport`.
4. RESET của strip ROTATE phải reset **cả hai** flip (như đang reset turn + angle —
`AdjustmentPanel.tsx:727-729`).
5. UI: `groupDefs.rotate.options` (`AdjustmentPanel.tsx:718-726`) thêm
`{ v: 'fliph', d: 'H-FLIP' }` và `{ v: 'flipv', d: 'V-FLIP' }`; `onPick`
(`730-745`) toggle cờ. `rotateChip()` (`791-803`) thêm phần `H-FLIP`/`V-FLIP` vào
`parts` và vào `amberValue`.
**Lưu ý:** strip ROTATE hiện là radio (một `value`), `value` đang là
`openParam === 'straighten' ? 'straighten' : String(photoRotation)` (`716`). Thêm hai
toggle thì `value` phải ưu tiên `fliph`/`flipv` khi cờ bật — cùng cách `straighten`
đang chiếm `value`.
### 10.4 Nghiệm thu
- `H-FLIP` ↔ `V-FLIP`: mirror đúng trục (kiểm 4 pixel góc đối xứng).
- `H-FLIP + V-FLIP` **pixel-identical** với `180°` (diff = 0 trên ảnh không đối xứng).
- Dims **không** đổi sau flip (kể cả khi đang có turn 90 và straighten ≠ 0).
- Crop vẫn hợp lệ: `cropRect` áp sau rotate+flip, không tràn khung.
- Export == preview.
- Sống qua reload/session và qua undo (một lần bấm = một bước undo).
---
## 11. Kiểm thử chung và parity gate
1. **Parity gate ba đường** (theo `PLAN.md`): cùng ảnh + cùng recipe → export JS,
preview library (ảnh tĩnh), và Kotlin (nếu `EXPO_PUBLIC_NATIVE_EXPORT=1`) phải khớp
(đo diff pixel, ngưỡng đã dùng trước đây trong repo).
2. **Không regress:** một recipe cũ (chỉ matrix + tone + grain, không mask/heal/curve/flip)
phải render **y hệt** trước và sau khi port. Đây là test hồi quy bắt buộc chạy đầu tiên.
3. **Bảng số của doc 4** phải tái lập được (mục 3.4).
4. **Idempotence:** AUTO bấm hai lần ra cùng EV; ROTATE `AUTO` (straighten) bấm hai lần
ra cùng góc (đã có).
5. **Undo:** mỗi knob mới (whites/blacks/curve/mask/heal/mosaic/flip) đều phải undo được
một bước và redo khớp.
6. **Session:** mọi field mới sống qua reload (đi qua `Look`/session serialise).
7. **Perf:** đo fps preview library sau khi thêm pass; mask + heal + mosaic + curve cùng
lúc là trường hợp nặng nhất. Nếu tụt quá ngưỡng, dùng phương án rẻ của doc 4 §4.2
(nhân gain bên trong SKSL) — nhưng **không** phá thứ tự `matrix → exposure → tone`.
---
## 12. Checklist thứ tự làm và rủi ro
Thứ tự cố định — mỗi bước phải xanh test trước khi sang bước sau:
| Bước | Việc | Phụ thuộc | Rủi ro |
|------|------|-----------|--------|
| A | §3 tách EV + `EXPOSURE_SKSL` + **áp doc 4** (`gradeThrough`, bỏ ma trận lần hai ở HDF) | — | Vuốt: đây là thay đổi chạm mọi ảnh. Làm riêng, có test hồi quy (11.2). |
| B | §4 knee HIGHLIGHT (2 dòng) | A | Thấp; nhưng phải làm sau A mới thấy đúng. |
| C | §5 WHITE/BLACK (uniform + paramDefs) | B | Thấp. Chỉ thêm uniform, không đổi layout cũ. |
| D | §10 H-FLIP/V-FLIP | — | Thấp; chạm state/session nên phải kiểm reload + undo. |
| E | §6 AUTO | A | Trung bình; `readPixels` tốn — đo thời gian, `dispose()` đủ. |
| F | §7 TONE CURVE | A | Cao ở nhánh declarative (2 child shader). Fallback imperative. |
| G | §8 GRADIENT MASK | A | Trung bình; uniform `origin` là điểm dễ sai toạ độ. |
| H | §9 FIX (HEAL/MOSAIC) | G | Cao nhất: pixel sampler + `findHealSource` + brush UI mới. |
Rủi ro tổng:
- **Hermes không thu hồi buffer native** — mọi surface/snapshot/image mới đều phải
`dispose()` tường minh (xem comment đầu `exportEngine.ts:113-121`).
- **Declarative preview không chèn pass tuỳ ý** — mask/heal/mosaic/curve đều phải nằm
trong chuỗi shader của ảnh; nếu một pass không lồng được, chuyển nhánh preview tĩnh
sang imperative.
- **`RuntimeEffect` dựng lại mỗi frame** là bug hiệu năng kinh điển — cache theo count.
---
## 13. Những gì KHÔNG port
- **Per-mask invert / highlight-shadow weighting** — web hiện không có; không thêm.
- **`grainSize`** — web-only ở thời điểm này.
- **Chuột lăn đổi cỡ brush** (`wheelBrushR`) — Android dùng row slider SIZE.
- **Halation** — không có trong phạm vi 7 hạng mục.
- **`hslOn`/band HSL, `classic-vivid`, `mono-high-contrast`, `STOCK_BIAS.contrast`** —
**đã** port xong trên nhánh này (mục 0.1). Không làm lại, chỉ điều chỉnh
`STOCK_BIAS.exposure` của `leica-vivid` theo mục 3.3.
+264
View File
@@ -0,0 +1,264 @@
# 9 — RECIPE / QR: NHỮNG GÌ WEB VỪA SỬA VÀ APP ANDROID PHẢI KHỚP
Nhánh đích: `feat/vision-camera-v5` (base khi viết: **`f3d26e3`**). Ngày: 2026-09-29.
Nguồn đối chiếu: nhánh **`recipes-web`**, commit **`3b92e4e`** — bản web đã chạy ở
`http://127.0.0.1:8090`, bundle live `index-J8ISFv_z.js`.
Bốn commit web trong đợt này, tất cả ngày 2026-09-29:
```
34f8601 studio: the develop column becomes five panels, and the four tone knobs move knots instead of channels
c0aaa67 Studio: fold WB and FX into LIGHT, and put every develop slider on -100..+100
bd57dd7 EXPOSURE reads in stops: -5..+5, two decimals, on an unchanged store
3b92e4e A gradient mask can carry the LIGHT column's white balance now: COLOR TEMP and TINT
```
Tài liệu 8 (`8_EDITOR_TOOLS_PORT.md`) đối chiếu web ở `6814b05`. Tài liệu này ghi **phần
lệch phát sinh sau đó** và, quan trọng hơn cho đội Android, **cái gì trong file `.recipe`
(QR) đã đổi / chưa đổi**. Lệnh đọc nguồn web vẫn như doc 8:
```sh
git show origin/recipes-web:docker/frontend/shared/utils/gradientMask.ts
```
---
## 0. Bảng khoảng trống
| # | Web vừa đổi gì | Nguồn web | Ảnh hưởng `.recipe` | Android phải làm |
|---|----------------|-----------|---------------------|------------------|
| 1 | EXPOSURE đọc theo **stop** (−5..+5, 2 số lẻ) | `shared/utils/paramDefs.ts:53-66`, `shared/utils/colorUtils.ts:201` | **Store không đổi** (±10 unit, 1 unit = 0.25 EV) | Chỉ UI/đơn vị hiển thị. Recipe cũ đọc y nguyên |
| 2 | Mọi slider "develop" thành **−100..+100** | `shared/utils/paramDefs.ts:40-43,160,184` | **Store không đổi** (±10) — ×10 là của slider | Không bắt buộc. Không port thì recipe vẫn khớp |
| 3 | Rail **10 tab → 8 tab** (WB, FX gộp vào LIGHT) | `src/ui/ToolRail.tsx:15-27` | Không | Không (layout, không vào recipe) |
| 4 | Bốn knob tone thành **knot của một ramp** + knee giữ hue | `shared/utils/toneShader.ts:109,196-221,242-247` | Không có field mới, nhưng **look đổi** | Port theo doc 8 §4/§5 (xem §3.3) |
| 5 | Mask gradient mang **COLOR TEMP + TINT** | `shared/utils/gradientMask.ts:63-65,116-117,166-167,201,320`, `shared/types/index.ts:139-175,228` | **Có 2 field mới trong `adjustments.masks[]`** | Port mask (doc 8 §8) + 2 field + 1 array uniform `wb` |
Điểm đáng chú ý cho đội Android: **bốn trong năm thay đổi không đụng tới payload**. Chỉ mục 5
thêm field. Nhưng mục 1 + 4 làm **cùng một recipe cho ra look khác** giữa hai bên — xem §3.3.
---
## 1. Đơn vị: slider web vs store (và vs Android)
Web tách hẳn **đơn vị slider** khỏi **đơn vị store**: `paramDefs.ts` có `get`/`set` dịch hai
chiều, nên một recipe viết trước đợt này vẫn đọc ra đúng look.
```ts
// shared/utils/paramDefs.ts:40-43
const HUNDRED = new Set(['contrast', 'color', 'vibrance', 'highlight', 'shadow', 'whites',
'blacks', 'tint', 'denoise', 'clarity', 'dehaze', 'sharpening']);
const deepen = (def) => HUNDRED.has(def.key)
? { ...def, min: -100, max: 100, get: (a) => def.get(a) * 10, set: (v) => def.set(v / 10) }
: def;
```
```ts
// shared/utils/paramDefs.ts:53-66
{ key: 'exposure', min: -5, max: 5, step: 0.01, display: twoStops, // '+0.50' / '-0.25'
get: (a) => (a.exposure ?? 0) * EV_PER_UNIT,
set: (v) => ({ exposure: Math.round((v / EV_PER_UNIT) * 1e4) / 1e4 }) }
```
| Trường | Đơn vị store (web **và** Android) | Slider web (`3b92e4e`) | Slider Android (`src/utils/paramDefs.ts`) |
|--------|-----------------------------------|------------------------|-------------------------------------------|
| `adjustments.exposure` | ±10 unit, 1 unit = **0.25 EV** (`EV_PER_UNIT`, `colorUtils.ts:201`) | −5..+5 **stop**, 2 số lẻ | −10..+10, step 1, `display: sign` (`paramDefs.ts:32-41`) |
| `contrast` `highlight` `shadow` `whites` `blacks` `tint` `denoise` `clarity` `dehaze` `sharpening` `vibrance` `color` | ±10 | −100..+100 (`HUNDRED` + `deepen`) | ±10, step 1 |
| `temperature` | 2500..10000 K | 2500..10000, step 100, `'${v}K'` (`paramDefs.ts:163-172`) | 2500..10000 (đã khớp) |
| `masks[].exposure` | **chính là EV**, ±5 (`MASK_EXPOSURE_MAX`, `gradientMask.ts:45`) | −5..+5, step 0.1 | — (chưa có mask) |
| `masks[].temperature` | 2500..10000 K, **default 5500** (`NEUTRAL_K`, `gradientMask.ts:63`) | 2500..10000, step 100 | — |
| `masks[].tint` | ±10 (default 0) | −100..+100 (maskKnobRow ×10, `App.tsx:2474-2492`) | — |
**Hệ quả cho Android:** ba dòng đầu **không cần làm gì để recipe khớp** — store giữ nguyên
thang cũ. Chỉ nên đổi UI nếu muốn hai bên trông giống nhau (Android `EXPOSURE` ±10 unit =
±2.5 EV, web đọc thành −5..+5 stop; cùng một con số lưu, khác nhãn). Chú ý `EV_PER_UNIT`
**đã export** ở cả hai phía — dùng nó, đừng viết lại `0.25`.
Chú ý riêng: `masks[].exposure` **không** theo thang ±10 của `adjustments.exposure`. Nó là
EV trực tiếp vì shader đọc `pow(2.0, a.x)`. Đây là chỗ dễ sai nhất khi port.
---
## 2. Cái gì thực sự đi qua file `.recipe` / QR
Không đổi gì so với `7_SCAN_QR.md`: **QR chỉ chứa URL** `GET /api/photos/:id/preset.recipe`,
file trả về là XML bọc payload xor16-v1 (`docker/backend/src/recipeFile.ts`, bản sao của
`shared/utils/recipeShare.ts`; nguồn sự thật là bản web).
Hình dạng shareable — **web và Android giống hệt nhau, từng chữ**:
```ts
// recipeShare.ts:59-77 (web) == src/utils/recipeShare.ts:59-77 (Android)
const shareable = { name, baseFilter, adjustments, frameId, useGeotag };
```
`adjustments` được gửi **nguyên khối**. Nghĩa là:
1. **Field mới tự chảy qua.** Web ghi `adjustments.masks[].temperature/tint`; không cần bump
`version="1"`, không cần đổi `ALGORITHM`, không cần salt mới.
2. **Android hiện giữ được field lạ.** `importRecipeXml` làm
`{ ...DEFAULT_ADJUSTMENTS, ...raw.adjustments }` rồi **chỉ** sanitize `hslBands`
(`recipeShare.ts:97-102`). `masks` vào bộ nhớ, sống qua `Look`/session, và **xuất lại
nguyên vẹn** vì `exportRecipeXml` gửi lại chính `recipe.adjustments`.
⇒ Web → Android → Web **không mất mask**. Đây là hành vi phải **khoá bằng test**, không
phải tính năng tình cờ (xem §4.2).
3. **Nhưng Android không render mask** (`grep -n masks src/types/index.ts` → rỗng;
`ColorAdjustments` `src/types/index.ts:81-105` không có `masks`). Ảnh xuất từ Android vì
thế **thiếu toàn bộ mask** dù recipe có. Đó là việc của doc 8 §8, không phải lỗi format.
4. `RecipeCreateModal.handleSave` spread `...seedAdj`, nên mở một recipe import được rồi bấm
LƯU **không** làm rơi `masks`. Phải giữ đúng như vậy sau khi thêm field (đừng liệt kê tay
từng knob).
5. Web clamp khi **đọc** (`readMasks`, `gradientMask.ts:87-121`), không clamp khi ghi. Android
cũng nên clamp ở `readMasks` tương đương, không nhét clamp vào `importRecipeXml`.
---
## 3. Việc Android phải làm
### 3.1 Tách `whiteBalanceGain` (thuần refactor, kết quả không đổi)
Android **đã có** công thức WB frame-wide, viết inline trong `getSkiaColorMatrix`:
```
src/utils/colorUtils.ts:384-394
const rgbTemp = kelvinToRGB(temperature);
const tintMagenta = (tint / 10) * 0.08;
const wbGain = normalizeGainLuma([
rgbTemp.r * (1 + tintMagenta),
rgbTemp.g * (1 - tintMagenta),
rgbTemp.b * (1 + tintMagenta),
]);
```
Web đã rút đúng khối này ra thành một hàm export, **vì nó được hỏi hai lần** (frame-wide và
từng mask). Việc cần làm: chép y nguyên chữ ký web, cho `getSkiaColorMatrix` gọi lại nó.
```ts
// shared/utils/colorUtils.ts:130-137 (web) — chép sang src/utils/colorUtils.ts
export function whiteBalanceGain(temperature: number, tint: number): { r: number; g: number; b: number } {
const rgbTemp = kelvinToRGB(temperature); // kelvinToRGB đã có ở Android:117
const tintMagenta = (tint / 10) * 0.08;
return normalizeGainLuma([ // đã có ở Android:147
rgbTemp.r * (1 + tintMagenta),
rgbTemp.g * (1 - tintMagenta),
rgbTemp.b * (1 + tintMagenta),
]);
}
```
Gain tại 5500 K / tint 0 = `(1, 1, 1)` chính xác (`kelvinToRGB(5500) === kelvinToRGB(5500)`,
rồi `normalizeGainLuma`). `KELVIN_TAME = 0.5` nằm trong `kelvinToRGB`, không đụng.
**Nghiệm thu bắt buộc:** `getSkiaColorMatrix` trước và sau refactor phải ra **cùng mảng 20
số** với mọi cặp `(baseFilter, temperature, tint)` trong lưới thử (kể cả `wbRed/wbBlue`, kể
cả stock mono — nhánh `isMonochromeBase` phải bỏ qua WB **trước khi** gọi hàm).
### 3.2 Mask gradient (doc 8 §8) + hai field mới
Port theo doc 8 §8, thêm đúng ba điểm so với spec cũ:
1. `GradientMask` thêm `temperature?: number` (2500..10000) và `tint?: number` (±10) —
giống `shared/types/index.ts:174-175` (comment web ở `:170-173` giải thích: cùng đơn vị,
cùng gain, chỉ khác vùng đọc).
2. `readMasks` resolve default: `temperature = clampK(num(m.temperature, 5500))`,
`tint = clampA(num(m.tint, 0))` (`gradientMask.ts:116-117`) — mask lưu trước đó ⇒ gain 1.
3. Uniform: thêm **một mảng `float4` nữa**, `wb[count]`, **sau** `fx[count]`; buffer thành
`6*n + 1 (+1 nếu air)` float4, mảng `wb` ở index `5*n + i`, `size` (width/height) ở `6*n`,
`air` ở `6*n+1` (`gradientMask.ts:132-171, 314-322`).
Shader — một dòng, ngay sau EXPOSURE, trước CONTRAST:
```sksl
half3 maskAdjust(half3 c, half3 wb, float4 a, float4 tone, float4 fx, half dark /*, half3 blur, float3 air*/) {
c = c * half(pow(2.0, a.x)); // EXPOSURE, EV
c = clamp(c * wb, half3(0.0), half3(1.0)); // ← MỚI: WB của mask
c = (c - half(0.5)) * half(1.0 + a.y) + half(0.5);
...
```
`wb` là gain **hoisted lên JS**, không tính trong SkSL: fit Kelvin là một đường cong, viết lại
trong shader là bản sao thứ hai của nó. Call site: `maskAdjust(c.rgb, wb[i].rgb, adj[i], tone[i], fx[i], dark)`.
**Chiều đã đo trên web** (đừng đảo): 2500 K = **lạnh**, 10000 K = **ấm**. Trong mask, đặt
10000 K đo được R/B `1.250 → 1.724`; pixel ngoài mask `0.994 → 0.994` (không đổi). Đó là
bằng chứng mask-ảnh-hưởng-cục-bộ phải tái lập được.
### 3.3 Cảnh báo lệch: tone **trong mask** chưa bằng tone frame-wide
Đây là điểm dễ hiểu sai nhất khi đọc song song doc 8 và bản web mới nhất.
- Frame-wide (`shared/utils/toneShader.ts:242-247`, từ `c0aaa67`): knee giữ hue, không còn
`cg = clamp(o / max(t, 0.0004), 0.55, 1.35)`:
```sksl
float k = 1.0;
float hiC = max(max(rgb.r, rgb.g), rgb.b);
float loC = min(min(rgb.r, rgb.g), rgb.b);
if (hiC > t) k = min(k, (1.0 - o) / (hiC - t));
if (loC < t) k = min(k, o / (t - loC));
rgb = clamp(vec3(o) + (rgb - vec3(t)) * k, 0.0, 1.0);
```
và bốn knob tone là **knot của một ramp** (`TONE_ANCHOR = 0.25`; `blMask/shMask/whMask/hlMask`
tại `:196-199`; `a0..a4` tại `:214-221`).
- Trong mask (`shared/utils/gradientMask.ts:218-220`): **vẫn công thức cũ**
`half cg = clamp(lifted / max(l, 0.0004), 0.55, 1.35); c = clamp(lifted + (c - l) * cg, …)`,
và mask **không** có knot ramp. Comment ngay trên đó (`:207-213`) vẫn nói "TONE_SKSL's own
cg" — **comment đã cũ** sau `c0aaa67`.
⇒ Trên web hiện tại, HIGHLIGHT của một mask **không** phải cùng một phép move như HIGHLIGHT
toàn khung, đúng cái điều comment hứa. Đã hỏi chủ dự án có align không — chưa có quyết định.
Cho tới lúc đó:
- **Android port mask theo bản web đang chạy** (công thức `cg` cũ trong mask), nếu mục tiêu là
parity với web **hôm nay**.
- Nếu chủ dự án chốt align, việc cần làm là thay khối `:218-220` bằng công thức `k` giữ hue —
và khi đó **mask phải chạy trước** phép move tone nào khác của khung (`masks` đứng **sau**
tone frame-wide trong chuỗi pass, xem doc 8 §2.1). Ghi lại ở đây để cả hai bên cùng đổi một
lần, tránh web sửa trước rồi Android port theo bản cũ.
### 3.4 Port tối thiểu nếu chỉ muốn "giữ field, chưa render"
Nếu ưu tiên là **không mất dữ liệu khi chia sẻ QR** trước khi kịp port shader mask, chỉ cần:
```ts
// src/types/index.ts — đủ để TS không nuốt field khi ai đó truy cập
masks?: GradientMask[];
```
Ở runtime Android **đã** giữ field rồi (§2.2), nên thay đổi này là để code đọc/ghi tường minh,
không phải để cứu dữ liệu. **Không** thêm sanitize cho `masks` ở giai đoạn này — sanitize nửa
vời sẽ âm thầm cắt dữ liệu mà web vẫn gửi (ví dụ bỏ mask chưa có `temperature`).
---
## 4. Tiêu chí nghiệm thu
1. **Round-trip QR, không mất field.** Recipe web có mask + WB (ví dụ 8000 K / tint −3):
web xuất `.recipe` → `importRecipeXml` Android → `exportRecipeXml` Android → import lại
web. So `adjustments.masks` sau khi bỏ salt: phải **bằng byte** bản gốc, gồm cả
`temperature`, `tint`, `kind`, `angle`, `feather`, `exposure` (EV), và thứ tự các mask.
2. **Không có test nào phụ thuộc vào việc "field lạ bị bỏ".** Thêm một test khoá hành vi §2.2
(một field lạ bất kỳ trong `adjustments` phải sống qua import → export).
3. **Clamp đúng biên.** Mask gửi `temperature: 99999` ⇒ đọc ra 10000; `2000` ⇒ 2500; `tint: 40`
⇒ 10. Mask **không** có hai field ⇒ gain `(1,1,1)` và ảnh **không đổi một pixel** so với
trước khi port.
4. **Refactor WB trung tính.** `getSkiaColorMatrix` ra cùng 20 số trước/sau §3.1, trên lưới
`(baseFilter × temperature × tint)` gồm cả stock mono.
5. **WB của mask là cục bộ.** Đặt 10000 K: pixel **trong** mask đổi tỉ lệ R/B (ấm lên), pixel
**ngoài** mask **không đổi** (web đo `1.250 → 1.724` trong mask, `0.994 → 0.994` ngoài mask).
6. **Đơn vị EV của mask.** `masks[].exposure = +1` phải sáng đúng **1 stop** (không phải 1/4
stop như nếu lỡ dùng thang ±10 của `adjustments.exposure`).
7. **Parity ba đường** như doc 8 §11.1, và **look cũ không regress** (recipe chỉ có matrix +
tone + grain phải render y hệt trước/sau).
8. **Tone:** nếu chủ dự án chốt align tone trong mask (§3.3), phải có số đo hue trước/sau
tương đương bảng trong commit `c0aaa67` — HIGHLIGHT −100 Δhue `0.00°` trên mọi patch.
---
## 5. Những gì KHÔNG làm
- **Không đổi `version` / `ALGORITHM` / salt** của envelope `.recipe`. `test/security.mjs`
(backend 134/134) khoá hình dạng này.
- **Không nhét mask vào QR**: mã QR vẫn chỉ là URL (§2). Recipe nằm ở nội dung file tải về.
- **Không commit đi theo đợt này:** `preview`/`mask` UI của web chỉ đổi layout, không có gì
để port sang app.
- **Không port thang −100..+100** sang Android chỉ để "giống web" — đó là lựa chọn UI, store
hai bên đã khớp; đổi thì phải đổi cả `paramDefs`, cả chip readout, cả test ảnh.
+2103 -159
View File
File diff suppressed because it is too large Load Diff
+144
View File
@@ -0,0 +1,144 @@
# KẾ HOẠCH SỬA TOÀN BỘ + TÍNH NĂNG GPS GỐC ẢNH CAMERA OS
App: `RecipesCam` (Expo SDK 57 / RN 0.86 / Skia / vision-camera).
Nguyên tắc: sửa nhỏ nhất, từng Phase có tiêu chí verify trên thiết bị thật, không thay đổi stack.
---
## 0. TÓM TẮT
- **Trục trặc chính**: luồng "Save ảnh đã lọc" và "đọc GPS ảnh cũ" đều chết — gồm 1 lỗi API Skia (`toString` không tồn tại) và **1 lỗi tương thích SDK 57 chưa từng được xử lý**: từ `expo-media-library@57`, toàn bộ hàm legacy (`getAssetInfoAsync`, `saveToLibraryAsync`, ...) import từ `expo-media-library` **chỉ là stub ném lỗi** (xem `node_modules/expo-media-library/src/legacyWarnings.ts`); phải import từ `expo-media-library/legacy` hoặc dùng class API mới (`Asset`, `Query`, `Album`).
- **Tính năng mới**: đọc & dùng GPS gốc ghi trong EXIF của ảnh do camera hệ điều hành chụp. Chìa khóa Android: cần quyền `ACCESS_MEDIA_LOCATION` (bật qua plugin option `isAccessMediaLocationEnabled`) — nếu không, Android 10+ **giấu** GPS trong metadata.
---
## 1. PHÁT HIỆN BỔ SUNG (ngoài báo cáo trước)
| Lỗi | Bằng chứng |
|---|---|
| `App.tsx:263 MediaLibrary.getAssetInfoAsync(...)` → **throw ngay** (stub SDK 57) → Tier-2 GPS chưa bao giờ chạy | `legacyWarnings.ts`: `errorOnLegacyMethodUse('getAssetInfoAsync')` |
| `exportEngine.ts:183 MediaLibrary.saveToLibraryAsync(...)` → **throw ngay** → kể cả sửa xong B1 (encodeToBase64), save vẫn chết | cùng nguồn: stub `saveToLibraryAsync` |
| `exportEngine.ts:181 requestPermissionsAsync` vẫn chạy (hàm mới của module mới) nhưng không đủ | `index.ts` chỉ export mới: `requestPermissionsAsync`, `getPermissionsAsync`, `presentPermissionsPicker`, class `Asset/Query/Album` |
| Android 10+: đọc GPS từ MediaStore cần `android.permission.ACCESS_MEDIA_LOCATION` trong manifest, runtime được auto-request khi có trong manifest | plugin `withMediaLibrary.js` option `isAccessMediaLocationEnabled`; `MediaLibraryModule.kt:298-323` |
→ Kết luận: lỗi B1 + SDK57 phải sửa **cùng nhau**, nếu không ảnh không bao giờ lưu được.
---
## 2. PHASE 1 — SỬA LUỒNG XUẤT & LƯU ẢNH (blocker)
File: `src/utils/exportEngine.ts`
1. **B1 — base64 sai** (dòng 173-178):
- Bỏ `Skia.Data.fromBytes(jpegBytes).toString()` (SkData chỉ có `dispose`, `.toString()` rơi về `[object Object]`).
- Dùng API có sẵn: `const base64 = resultImage.encodeToBase64(ImageFormat.JPEG, 95);` (có trong `Image.d.ts:115` + native `JsiSkImage.h`).
- Ghi file: `FileSystem.writeAsStringAsync(tempFileUri, base64, { encoding: EncodingType.Base64 })`.
2. **SDK57 — save**: đổi import `* as MediaLibrary from 'expo-media-library'` → 2 nguồn:
- `import { requestPermissionsAsync } from 'expo-media-library';` (hàm mới, request đủ read + auto `ACCESS_MEDIA_LOCATION` nếu manifest có).
- `import { saveToLibraryAsync } from 'expo-media-library/legacy';` (hoặc migrate sang `Asset.create(uri)` — chọn legacy để diff nhỏ, đánh dấu `ponytail:` chuyển class API khi bỏ legacy).
3. **Giữ nguyên phần còn lại**: pipeline Skia surface → filter → grain → frame → GPS text đúng hướng, không đụng.
Verify: build dev, chọn ảnh → SAVE → mở Gallery thấy file JPEG xem được, watermark đúng. (Không cần thiết bị thật đặc biệt; máy Android bất kỳ.)
---
## 3. PHASE 2 — TÍNH NĂNG: ĐỌC & DÙNG NATIVE GPS ẢNH CHỤP BỞI CAMERA OS
### 3.1 Yêu cầu chức năng
- Chọn ảnh (bất kể nguồn: thư viện/DCIM do Camera OS chụp, ứng dụng file...) trong **library mode** → geotag watermark phải ưu tiên **tọa độ GPS gốc nhúng trong ảnh**, kèm địa danh (reverse geocode), thời gian chụp ảnh — KHÔNG lấy GPS thiết bị hiện tại.
- GPS thiết bị chỉ còn là **phương án cuối** khi: ảnh không có GPS VÀ user đang bật geotag.
- Hiển thị nguồn dữ liệu để QA: watermark line 3 thêm tag `[EXIF]`/`[MEDIA]`/`[DEVICE]` (giữ mặc định tắt nếu muốn sạch UI).
### 3.2 Chuỗi phân giải GPS (thay `resolvePhotoLocation` hiện tại)
```
Tier EXIF (image-picker asset.exif)
→ có GPSLatitude/Longitude? parse chuẩn rational → dùng luôn (source=exif, timestamp=ảnh)
Tier MEDIA (asset.assetId != null)
→ request read permission (main requestPermissionsAsync)
→ MediaLibrary.getAssetInfoAsync(assetId) [import legacy]
→ assetInfo.location → dùng (source=mediaLibrary)
Tier DEVICE (chỉ khi useGeotag=true)
→ getCurrentGPS() → dùng (source=device)
Mọi tier: reverse geocode ra locality (uppercase) nếu chưa có; formatCoordinate như cũ
```
Tách hẳn sang util mới `src/utils/photoGps.ts` (không nhét tiếp vào App.tsx), giữ `App.tsx` chỉ gọi.
### 3.3 Thay đổi cụ thể
1. `app.json` plugin `expo-media-library` thêm: `"isAccessMediaLocationEnabled": true` → sinh `android.permission.ACCESS_MEDIA_LOCATION`.
2. `App.tsx:236-280`: xóa logic cũ → `const gps = await resolvePhotoGPS(asset, useGeotag)`.
3. Hardening `parseGPS` (bug B7): nhận dạng 3 dạng EXIF:
- số `21.5`,
- array số `[21, 30, 0]`,
- array rational string `["21/1","30/1","0/1"]` → bóc `num/den`.
- Trả `null` thay vì `NaN`; chỉ chấp nhận khi `isFinite`; bỏ qua `0,0` (thiếu dữ liệu).
4. GPSInfo mở rộng (`src/types/index.ts`): `source?: 'exif' | 'mediaLibrary' | 'device'; photoTakenAt?: number;` — timestamp mặc định = thời điểm chụp ảnh nếu đọc được EXIF.
5. Watermark (`Viewfinder.tsx:152-170` + `exportEngine.ts:149-159`): hiển thị giờ từ `gpsInfo.timestamp`; thêm tag nguồn (tham số debug).
6. Permission UX: nếu đọc ảnh mà user chưa grant read/location → Alert giải thích + `requestPermissionsAsync()` đúng thời điểm (không hỏi lúc khởi động).
### 3.4 Ma trận kiểm thử trên máy thật
| Nền tảng | Ảnh camera OS có GPS | Kỳ vọng |
|---|---|---|
| Android 10+ | bật Location khi chụp | cho phép `ACCESS_MEDIA_LOCATION` → watermark = đúng nơi chụp `[MEDIA/EXIF]` |
| Android 10+ | không bật Location | không watermark hoặc `[DEVICE]` nếu fallback bật |
| Android (Photo Picker mới) | có GPS | Picker thường giữ EXIF khi app có location-access; nếu `assetId=null` → nhảy Tier EXIF; thiếu cả 2 → ghi log + fallback |
| iOS full access | có GPS | `assetId` có → Tier MEDIA chạy |
| iOS limited access | — | `assetId=null`, exif không kèm GPS → fallback device; ghi chú UX "cấp full quyền để đọc vị trí ảnh" |
| File/cloud (không qua thư viện OS) | có GPS trong file | tùy chọn Tier EXIF binary (xem 3.5) |
### 3.5 (Tùy chọn, sau khi Phase 2 core chạy) EXIF binary fallback
Ảnh không qua MediaStore (URI tạm/cloud) mà picker không trả exif: parse GPS IFD trực tiếp từ file bằng parser nhỏ (`exif-reader` — đã ghi trong PROJECT_OVERVIEW, chưa cài) hoặc tự đọc 2 IFD. Chỉ thêm khi có nhu cầu thật (YAGNI).
---
## 4. PHASE 3 — BUGS STATE/UI CÒN LẠI
| # | Sửa | File |
|---|---|---|
| B3 | **Quyết định rõ số phận 3 slider**: (a) map `contrast` vào ma trận (scale offset, nhân sau base) + highlight/shadow giữ nguyên hoặc (b) ẩn slider khỏi UI để không bán tính năng ảo. Đề xuất (a) contrast; highlight/shadow triển khai bằng `ComponentTransfer` hoặc ẩn tạm — chốt với người dùng | `src/utils/colorUtils.ts`, `src/components/AdjustmentPanel.tsx` |
| B3b | Monochrome: đưa bước Exposure **ra trước** early-return mono (WB/tint/chrome vẫn skip), để Exposure slider có tác dụng trên ACROS | `colorUtils.ts:113-115` |
| B4 | (Quyết định) Denoise/Clarity hiện chỉ áp lúc export. Tối thiểu: đổi nhãn slider thành "chỉ áp khi SAVE"; tối đa: preview song song bằng ImageFilter Skia. Đề xuất Phase 3 làm nhãn + ghi chú, preview để Phase 4 | `AdjustmentPanel.tsx` |
| B5 | Tuần tự hoá init: `restoreSession()` chạy trước, chỉ `applyRecipe(list[0])` khi **không** có session hợp lệ; bỏ `loadRecipes` tự apply mù | `App.tsx:60-122` |
| B8 | Tách side-effect khỏi updater: tính `next` trước, rồi `setAdjustments(next)` + `setSelectedRecipe({...})` độc lập | `App.tsx:138-149` |
| B6/B9 | (đã gộp vào Phase 1/2) | |
| B10 | Nút DONE giữ ý nghĩa đóng; nếu media permission bị từ chối → hiện trạng thái "chưa lưu vào thư viện" + nút LƯU LẠI | `PreviewModal.tsx` |
| B11 | Xoá file tạm cũ trước khi ghi mới (cache dir 1 file `camrecipe_pro_export_*.jpg` thôi) | `exportEngine.ts` |
| B12 | Restore URI không tồn tại → tự chuyển về `mode='camera'` + reset `libraryImageUri` | `App.tsx:89-114` |
| B13 | Dọn quyền thừa: `expo-image-picker` plugin thêm `microphonePermission: false`; bỏ `RECORD_AUDIO/READ_MEDIA_AUDIO` khỏi app.json | `app.json` |
| B14 | Kelvin 10000K: chốt có giữ `r=1.55` (kẹt kênh) hay giảm hệ số — quyết định thẩm mỹ, mặc định giữ nguyên | `colorUtils.ts` |
| Share | Chuyển `Share.share` file → `expo-sharing` (`shareAsync(uri, {mimeType})`) | `PreviewModal.tsx` |
| Typecheck | TS 6.0.3 trong package-lock làm `tsc` crash (`RangeError`); hạ `typescript` về `~5.9` để `npx tsc --noEmit` dùng được | `package.json` |
---
## 5. PHASE 4 — CAMERA MODE (cần chốt sản phẩm)
Hiện shutter camera mode chỉ Alert. Hai hướng:
- **(A) Implement chụp thật** bằng vision-camera: `device.takePhoto()` (frame processor đang chạy → photo raw chưa filter) → chạy cùng `processAndExportPhoto()` trên file ảnh vừa chụp → lưu gallery. GPS khi chụp: dùng `getCurrentGPS()` (vision-camera không ghi EXIF GPS) — hoặc chấp nhận ảnh tự chụp không có GPS gốc.
- **(B) Khóa UX hiện tại**: ẩn/đổi nút shutter thành hướng dẫn sang LIBRARY.
Khuyến nghị (A) nhưng xếp sau Phase 1-3 vì cần thiết bị thật để verify ổn định.
---
## 6. QA TỔNG THỂ (sau mỗi Phase)
```bash
cd /home/locpham/RecipesCam
npx tsc --noEmit # sau khi hạ TS ~5.9 (Phase 3)
npx expo run:android # máy thật USB (không có emulator image trong máy này)
```
Checklist thủ công:
1. Library → chọn ảnh bất kỳ → kéo Exposure/Sat/WB → SAVE → Gallery mở file đẹp, đúng filter + frame + watermark (Phase 1).
2. Dùng **Camera OS chụp ảnh mới với Location ON** → chọn trong app → watermark hiện đúng nơi chụp + `[EXIF/MEDIA]` (Phase 2).
3. Lặp lại khi ảnh chụp Location OFF → không watermark / `[DEVICE]` đúng chính sách.
4. Background app giữa lúc chỉnh → mở lại → recipe + slider khôi phục đúng (B5).
5. Chọn ACROS MONO → kéo Exposure → ảnh sáng/tối đổi (B3b).
6. Toggle geotag tắt → không watermark; bật lại khi chưa grant location → Alert + fallback đúng.
7. Chụp 2 ảnh liên tiếp → không đè file, không treo (B1/B11).
8. SHARE ảnh → mở được qua app khác (expo-sharing).
Rủi ro: (i) GPS Photo Picker Android/iOS limited-access hành vi khác nhau → giữ log + QA ma trận 3.4; (ii) vision-camera trên máy thiếu GPU frame processor cũ vẫn preview được nhưng `takePhoto` cần test riêng; (iii) `expo-media-library/legacy` sẽ bị gỡ ở SDK tương lai → đã đánh dấu `ponytail:`.
## 7. THỨ TỰ THỰC HIỆN
Phase 1 → verify → Phase 2 (+app.json/plugin) → verify → Phase 3 → verify → Phase 4 (cần chốt A/B).
Mỗi Phase là 1 commit riêng, giữ diff nhỏ.
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Copyright (c) 2026 Lộc Phạm
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+750
View File
@@ -0,0 +1,750 @@
# PLAN — 7 hạng mục UI: PHOTO STYLE · tên sim · WATERMARK→FRAME · TEMP · FAVORITED · RESET* · ROTATE
Nhánh: `feat/vision-camera-v5` (RecipesCam, `C:/Users/locpham/RecipesCam` ↔ `/home/locpham/RecipesCam`)
Ngày: 2026-09-09. Trạng thái: **HOÀN TẤT cả 7 hạng mục** — F2 `6adf0b5`, F1 `d66ae3d`, F4 `7ed4301`, F6 `ba7e579`, F5 `ba971f1`, F3 `4131c9f` + `dcb74a7`, đổi tên chip frame `6788d51`, F7 `9ddaeac` + `0ce561c`. Mỗi commit đều qua typecheck (14), `assembleRelease` và verify trên `emulator-5554`; `git status --short` sạch (chỉ còn file plan này, untracked). **Follow-up 4 lỗi hành vi strip ROTATE đã sửa, verify trên máy và commit `da48cec` — xem mục 12.** **Toàn bộ câu hỏi mục 10 đã chốt** (Q1 `CLASSIC CHRIPES` giữ nguyên, Q4 chip không kèm tên sim, Q6 strip `RESET · AUTO · 0 · 90 · 180 · 270 · STRAIGHTEN (±45°)` — AUTO ngay sau RESET, Q7 ★ chỉ recipe).
Đây là plan riêng, **không** gộp vào `PLAN.md` (đang là kế hoạch native export engine).
Baseline typecheck: `node --stack-size=16000 ./node_modules/typescript/lib/tsc.js --noEmit` → **14 lỗi nền** (không được tăng).
---
## 0. Hiện trạng (mốc trong code)
| Thứ | Nơi |
|---|---|
| Tabs + nhãn | `src/components/ToolRail.tsx:5` (`TabId`), `:15-22` (`TOOLS`), `:26` (FRAME chỉ ở library) |
| Chip theo tab | `src/components/AdjustmentPanel.tsx:492` `case 'recipes'`, `:548` `case 'wb'`, `:587` `case 'frame'`, `:617` `case 'watermark'` |
| Cơ chế strip 1-hàng | `GroupKey` `:22`, `openGroup` `:217`, `groupDefs` `:415`, `groupChip` `:481`, `choiceChips` `:321` |
| `renderChip` (đã có `amberValue`, `trash`, `disabled`) | `:365` |
| Sims | `src/utils/defaultRecipes.ts:39-52` (`FILM_SIMS`), `filmSimToRecipe` `:55` |
| WB presets | `AdjustmentPanel.tsx:118` (`WB_PAIRS`), `wbActive()`/`wbApply` `:~380` |
| Recipes lưu trữ | `src/utils/storageUtils.ts` (`@camrecipe_pro:custom_recipes`) |
| `Recipe` type | `src/types/index.ts:87` |
| Reset | `App.tsx:506` `handleResetAll`, prop `onResetAll` (`App.tsx:1277`) |
| Watermark state | `App.tsx:86-102` (`DEFAULT_CUSTOM_WM`, `wmRotation`, `DEFAULT_GPS_WM`), `wmEditing` `App.tsx:1235` |
| Xoay ảnh (sau F7) | `App.tsx`: `photoRotation: 0\|90\|180\|270` + `photoStraighten` + `autoHorizon`/`horizonRoll`; strip `AdjustmentPanel.tsx` `groupDefs.rotate`; `rotateSkImage90` + `rotateSkImageBy` `src/utils/skiaImage.ts`, dùng ở `exportEngine.ts` + `Viewfinder.tsx` |
| Frame | `src/utils/frameUtils.ts:9` (`FRAMES`), `App.tsx:547` `handleUpdateFrame` |
Quy ước chung: file **CRLF** → sửa bằng python (`io.open(p, encoding='utf-8').read()` … ghi `newline='\r\n'`) hoặc `edit_file` cẩn thận; mọi lệnh shell prefix `export MSYS_NO_PATHCONV=1`; `PYTHONIOENCODING=utf-8` khi có non-ASCII.
---
## 1. F1 — Gom sim vào chip `PHOTO STYLE`
**Việc:** trong tab PRESETS, 8 chip sim (`FILM_SIMS.map`, `AdjustmentPanel.tsx:497`) không còn nằm trực tiếp trên hàng chip mà nằm trong strip của một group chip `PHOTO STYLE`.
**Cách làm**
1. `GroupKey` thêm `'style'`; `groupDefs.style`:
- `label: 'PHOTO STYLE'`, `off: 'sim-provia'`,
- `value`: id sim đang chọn (`currentRecipeId` nếu là sim, ngược lại `''`),
- `options`: `FILM_SIMS.map(s => ({ v: s.id, d: s.name }))`,
- `onPick`: `setShowRecipes(false); onSelectRecipe(filmSimToRecipe(sim))` (giữ nguyên hành vi cũ).
2. `case 'recipes'` (`:492`): thay khối `...FILM_SIMS.map(...)` bằng `renderChip(groupChip('style'))`; giữ `RECIPES` + `CREATE`.
3. Nhãn chip: **chỉ `PHOTO STYLE`**, không kèm tên sim (đã chốt — TopBar góc trái đã hiện `activeRecipe.name`). `groupDefs.style.value` vẫn dùng để chip sáng amber khi sim ≠ mặc định, nhưng không in value ra nhãn.
4. `amberValue` bật khi sim đang chọn ≠ sim khởi động (`sim-provia`).
**Kiểm chứng:** PRESETS chỉ còn `RESET · PHOTO STYLE · RECIPES · CREATE`; tap `PHOTO STYLE` → strip 8 sim; tap một sim → áp đúng look, chip đổi nhãn; luật "1 strip/lần" giữ; `RECIPES`/`CREATE` không đổi.
---
## 2. F2 — Đổi tên sim
`src/utils/defaultRecipes.ts:40-51`:
| id | tên mới |
|---|---|
| `sim-provia` | `PROVIPES` |
| `sim-velvia` | `VELVIPES` |
| `sim-astia` | `ASTIPES` |
| `sim-classic-chrome` | `CLASSIC CHRIPES` |
| `sim-classic-neg` | `CLASSIC NEGIPES` |
| `sim-eterna` | `ETERNIPES` |
| `sim-acros` | `ACRIPES` |
| `sim-leica` | `LEITZ STREETLIFE` |
- **Chỉ đổi `name`**, giữ `id` + `baseFilter` → phiên đã lưu (`selectedRecipeId`) và các `find(f => f.id === 'sim-provia')` (`App.tsx:349,507`) không gãy.
- Tên tự lan ra: chip PHOTO STYLE, TopBar (`activeRecipe.name`), strip recipe, picker simulation trong `RecipeCreateModal.tsx:330`.
- Dọn nhắc tên cũ trong text UI: `SettingsModal.tsx:135` ("camera + PROVIA …"); comment `App.tsx:194,332`, `RecipeCreateModal.tsx:27,152`.
- Recipe **bundled cũng đổi tên theo** (đã chốt): `defaultRecipes.ts` — `PROVIA STD` → `PROVIPES STD` (`:117`), `VELVIA VIVID` → `VELVIPES VIVID` (`:93`), `ACROS MONO` → `ACRIPES MONO` (`:141`), `CLASSIC NEG.` → `CLASSIC NEGIPES.` (`:69`). **Chỉ đổi `name`, giữ `id`** (`provia-default`, `velvia-default`, `acros-default`, `classic-neg-default`) → recipe đã lưu trong AsyncStorage vẫn trỏ đúng object.
- **Không** đổi: `RETRO AMBER 🏮` (`:165`) và `CINEMA SPRING/SUMMER/AUTUMN/WINTER` (`:190-265`) — không gắn sim nào.
**Kiểm chứng:** top bar + chip + modal theo tên mới; look (matrix) không đổi.
---
## 3. F3 — WATERMARK thành chip trong tab FRAME
**Chặn:** FRAME hiện chỉ có ở library (`ToolRail.tsx:26`) → ở camera watermark sẽ mất lối vào. Chọn (Q3):
- **(A, đề xuất)** bỏ filter `:26` → FRAME có ở cả 2 mode; frame picker cũng dùng được ở camera (hiện camera chỉ nhận frame từ recipe).
- (B) giữ FRAME library-only + camera vẫn có tab WATERMARK riêng → không đúng yêu cầu, loại.
**Kiểm chứng code — viewfinder camera có hiển thị đúng phần trong suốt của frame không? CÓ, không cần vẽ thêm gì.**
- `Viewfinder.tsx:1937` `renderCameraOverlays()` được gọi trong nhánh camera (`:2102`), ngay trên `<SkiaCamera>` (`:2025`) — layer này là `StyleSheet.absoluteFill`, tức feed phủ full màn hình và nằm **dưới** overlay.
- `:1974` `renderFrameOverlay()` gọi **chung** cho mọi frame, không phân nhánh theo mode:
- `:1778` polaroid: chỉ vẽ bar đen ngoài card + card trắng quanh cửa sổ; **cửa sổ để trống** → feed lộ qua (comment `:1782-1787` nói đúng điều này).
- `:1817` wallframe: bar đen ngoài frame rect + PNG mat qua `drawMatrix`; **cửa sổ trong suốt của PNG** cho feed lộ qua (cover-crop giữa, WYSIWYG với crop export — comment `:1821-1824`).
- `:1839` classic-white / `:1852` cinematic: viền trắng / bar đen theo `frameRect` = `region` (band tỉ lệ) → đã đúng ở camera.
- `polaroidGeo` `:1124`, `wallGeo` `:1131` **không** phụ thuộc mode (chỉ `selectedFrame` + `vw/vh`) → bản camera dùng đúng rect đó; hai nhánh library riêng (`:2162`, `:2242`) nằm **sau** `if (mode === 'camera') { … return }` (`:1995`) nên không ảnh hưởng.
- `ToolRail.tsx:10-11` đã ghi sẵn lý do: FRAME library-only vì "camera shows every frame WYSIWYG through the viewfinder overlays".
- Kết luận: **F3 thuần là việc UI** (cho tab FRAME hiện ở camera + đổi điều kiện `wmEditing`); export camera cũng đã dùng `frameId: selectedFrame` (`App.tsx:~906`, `~995`) nên không lệch WYSIWYG.
- Hai điểm nhỏ, **không phải bug**, chỉ để biết: (1) band tỉ lệ chỉ áp cho none/classic-white/cinematic (`:255`) — polaroid/wallframe bỏ qua ratio ở camera đúng như thiết kế; (2) wallframe đợi `wallImage` load mới vẽ (`:1819`) → thoáng một nhịp thấy feed trần.
**Kiểm chứng code 2 — anchor/crop live feed vào đúng cửa sổ trong suốt: ĐÃ CÓ SẴN, không phải việc mới.**
- `Viewfinder.tsx:1201` `cameraPhotoRect` = `polaroidGeo.window` → `wallGeo.window` → `region` (band tỉ lệ) → `null`; `:1211` đẩy rect đó (chuẩn hoá theo `vw/vh`) vào `feedRectSync` `:791` `[active, viewAspect, fx, fy, fw, fh]`.
- Worklet `handleFrame` `:903-960`: khi `feed[0] > 0` thì black surface + **cover-map cảm biến vào ĐÚNG rect đó** (nghịch đảo CTM `renderToTexture` để lấy vùng raw frame), thay vì cover-fit fullscreen. Tức là cửa sổ trong suốt của overlay và vùng feed hiển thị dùng **cùng một rect** → khớp pixel, không cần anchor tay.
- Khớp với export: `exportEngine.ts:155` crop tỉ lệ chạy **trước** bước frame, nên ảnh vào bước polaroid đã đúng aspect cửa sổ (`:462-500` `drawImageRect` lấp kín window); wallframe `:541-570` cover-crop giữa vào window 2:3. Camera capture truyền `aspect: aspectRatio === 'full' ? undefined : aspectRatio` (`App.tsx:~917`) → cùng công thức. Kết luận: **WYSIWYG ở cả 4 frame, 0 dòng code thêm.**
- **Pinch/drag cửa sổ frame ở CAMERA — ĐÃ CHỐT thuộc F3** (bước 7). Hiện `zoomWindowRect()` `:1139-1140` trả `null` khi `mode !== 'library'` và `handleCapture` camera không truyền `frameWindowZoom` (`App.tsx:~917`) → không làm bước 7 thì ảnh polaroid/wall chụp từ camera luôn center-crop 1x (vẫn WYSIWYG, chỉ mất tự do bố cục).
**Cách làm (A)**
1. `ToolRail.tsx`: xoá `{ id: 'watermark' }` khỏi `TOOLS` `:21`; xoá `'watermark'` khỏi `TabId` `:5` (grep hết chỗ dùng).
2. `AdjustmentPanel.tsx` `case 'frame'` `:587`: thêm `renderChip(groupChip('wm'))` nhãn `WATERMARK` (+ ` ON` khi gps/custom đang bật), `amberValue` khi có watermark bật.
3. Strip `wm` là **sub-panel**: khi `openGroup === 'wm'`, hàng chip chính được thay bằng hàng watermark (breadcrumb `‹ WATERMARK` + các chip hiện có ở `:617-660`: GPS ON/OFF, gpsColor/gpsName/gpsTime, CUSTOM ON/OFF, ROTATE, ACCEPT/CANCEL, wmColor/wmSize/wmFont). Giữ luật "1 strip/lần", không cần level-3.
4. Ô nhập `TYPE WATERMARK TEXT` (`:764`, điều kiện `activeTab === 'watermark'`) → đổi thành "sub-panel watermark đang mở".
5. `App.tsx`: lift cờ `wmPanelOpen` (state mới) + prop `wmPanelOpen`/`onToggleWmPanel`; `wmEditing={openTab === 'frame' && wmPanelOpen}` (`:1235`) để drag/pinch watermark trên ảnh (`Viewfinder.tsx:1280,1285,1526`) chỉ arm khi sub-panel mở.
6. `useEffect` reset `openGroup` khi đổi tab (`:229`) đã có → giữ.
7. **Pinch/drag nội dung trong cửa sổ frame ở CAMERA** (đã chốt; phần nặng nhất của F3):
- `Viewfinder.tsx` `zoomWindowRect()` `:1138`: bỏ `if (mode !== 'library') return null` → camera cũng trả cửa sổ (`polaroidGeo.window` / `wallGeo.window`).
- `feedRectSync` `:791` payload 6 → 9 số: `[active, viewAspect, fx, fy, fw, fh, s, u, v]` (`u,v` = tâm nguồn 0..1, cùng ngữ nghĩa `frameWindowZoom` của export); map trong worklet `:903-960` nhân scale + offset trước khi cover-map. Hook đẩy rect `:1211` truyền thêm `s,u,v` từ state zoom camera.
- Clamp như `clampZoomToWindow` `:1176` để cửa sổ không lộ nền; đổi frame/ratio → reset `s=1, u=v=0.5`.
- `App.tsx` nhánh camera `:917`: thêm `frameWindowZoom: viewfinderRef.current?.getFrameWindowZoom?.() ?? null` (đã có sẵn cho nhánh library `:~1011`, `Viewfinder.tsx:153/569`).
- Cử chỉ: gesture layer camera `:2041-2048` đang là responder 1 chạm của `onImageDrag*` (AE/AF + kéo tham số) → chỉ nhận 2 ngón khi cửa sổ frame đang bật, và phải nhường ngón 1 cho AE/AF; **xung đột cần xử lý**: pinch cạnh tranh với zoom preset native (`applyZoom`, `:2125`) và kéo EV/AE.
- Rủi ro: worklet + cử chỉ → **TB–cao**; verify riêng trên máy trước khi commit.
**Kiểm chứng:** camera + library đều thấy tab FRAME; chọn polaroid ở camera → vùng feed trong cửa sổ khớp đúng ảnh export (đối chiếu 1 ảnh chụp thật); pinch/drag được nội dung trong cửa sổ ở camera → ảnh export giữ đúng bố cục đã chọn (không lộ nền card/mat); ở camera chọn lần lượt `POLAROID`/`WALLFRAME`/`CLASSIC WHITE`/`CINEMATIC` → cửa sổ frame trong suốt, feed lộ đúng vị trí, không cần sửa lớp vẽ; FRAME → `WATERMARK` → strip watermark; bật CUSTOM → ô nhập hiện, kéo/thả watermark chạy; đóng panel → drag hết arm; RESET vẫn tắt mọi watermark.
**Đã làm (commit 1 `feat(frame): move the watermark controls into a FRAME sub-panel`) — 3 file:**
- `ToolRail.tsx`: `TabId` bỏ `'watermark'`, `TOOLS` bỏ mục WATERMARK; bỏ prop `mode` + filter library-only của FRAME → rail giống nhau ở cả camera lẫn library.
- `AdjustmentPanel.tsx`: `GroupKey` thêm `'wm'` (không phải strip thường, chỉ là cờ mở hàng phụ); `paramDefs` bỏ `watermark`; `case 'frame'`: `openGroup === 'wm'` → hàng **phụ** (`‹ FRAME` + GPS/CUSTOM/ROTATE/ACCEPT/CANCEL/COLOR/SIZE/FONT) **thay** hàng frame, ngược lại hàng frame + chip cuối `WATERMARK` (`active` khi sub-panel mở, `amberValue` khi có watermark bật); xoá hẳn `case 'watermark'` và các chip watermark cũ trong đó → chuyển thành `wmChip()`/`wmChips()`; ô nhập `TYPE WATERMARK TEXT` đổi điều kiện thành `activeTab === 'frame' && openGroup === 'wm' && customWm.enabled`; `useEffect(() => onWmPanelChange(openGroup === 'wm'), [openGroup])` để App biết lúc nào arm lớp drag của dấu.
- `App.tsx`: state `wmPanelOpen`; `wmEditing={openTab === 'frame' && wmPanelOpen}` (thay `openTab === 'watermark'`); `onWmEdit` (double-tap trên dấu trong khung hình) đổi từ `setOpenTab('watermark')` → `setOpenTab('frame')`; truyền `onWmPanelChange={setWmPanelOpen}`.
- Không đụng `Viewfinder.tsx`: đường vẽ watermark/WYSIWYG frame ở camera vốn đã đúng, F3 chỉ là chuyện điều hướng UI.
**Đã verify trên emulator-5554 (camera):**
- Rail: `PRESETS · FAVORITED · LIGHT · WB · FX · FRAME` — không còn WATERMARK.
- Hàng FRAME ở camera: `RESET · NO FRAME · CLASSIC BORDER · RETRO INSTANT · WALL FRAME · ROTATE n · WATERMARK` (CROP vắng mặt đúng thiết kế); `WATERMARK` → hàng phụ `RESET · ‹ FRAME · GPS WATERMARK OFF · CUSTOM WATERMARK ON · ROTATE n° · ACCEPT · CANCEL · TEXT COLOR · TEXT SIZE · TEXT FONT`; `‹ FRAME` quay lại đúng hàng frame.
- WYSIWYG frame ở camera (quét pixel cột x=540 và hàng y=1000): RETRO POLAROID mat trắng `(250,249,246)`, cửa sổ x 55..1025, y ~430..1730, feed lọt trong đúng cửa sổ; WALL FRAME bar đen `(0,0,0)` + mat xám `(91,91,91)`; CLASSIC BORDER viền trắng `(255,255,255)`; NO FRAME feed ra tới mép.
- Export giữ đúng bố cục: `camrecipe_pro_export_1789094424013.jpg` 960×1455, mat trắng `(250,249,245)` ngoài + nội dung camera trong; cửa sổ đo được 858×1146 → tỉ lệ **0,749** so với cửa sổ trên màn hình **0,746**.
- Arm đúng cả hai chiều: sub-panel **đóng** + có chữ → kéo trên preview đổi EV `0.0 → +1.3` (diff toàn màn 1.146.032 px); sub-panel **mở** → kéo (540,1100)→(320,600) để EV **nguyên +1.3**, diff chỉ 8.203 px so với nền nhiễu feed tự nhiên 11.591 px.
- **Watermark chữ tự nhập HIỆN đúng trong viewfinder camera**: đổi `TEXT COLOR → BLUE` rồi quét `#3b82f6` (±45/kênh) trong vùng feed → **571 px**, bbox `(543,1191)-(641,1212)`, ô lưới 60px tập trung ở (9,19)-(10,20) — đúng `wmAnchor()` (ax = 540, baseline ay ≈ 1212, glyph nằm trên baseline).
- **Ghi chú phương pháp — kết luận "không thấy watermark" trước đó là do đo sai, không phải bug, cũng không phải regression của F3:** so 2 ảnh chụp (bật/tắt CUSTOM, hoặc đổi nội dung chữ) không kết luận được vì (a) hiện/ẩn ô nhập làm preview đổi chiều cao → diff toàn màn hình, (b) feed camera tự nhiễu ~1,6–2,7 % pixel ở ngưỡng 30–45, (c) chữ amber `#f59e0b` trên feed ấm không tách được khỏi nền. Cách đúng: chọn một màu không có trong cảnh (`BLUE`) và đếm pixel đúng màu trên **một** ảnh. Cũng xác nhận: dấu vẫn được vẽ khi sub-panel đã đóng (`wmEditing = false` chỉ ảnh hưởng việc arm, không ảnh hưởng việc vẽ).
**Đổi tên chip frame (commit riêng `6788d51 feat(frame): rename the RETRO POLAROID chip to RETRO INSTANT`) — 3 file, chỉ 1 dòng code:**
- `src/utils/frameUtils.ts`: `FRAMES` — `{ id: 'polaroid', name: 'RETRO INSTANT' }` (id giữ nguyên `polaroid` → session cũ + `frameId` đã lưu không gãy).
- `src/components/Viewfinder.tsx` + `src/utils/exportEngine.ts`: chỉ sửa **comment** cho khớp tên mới (không đổi hành vi).
**Đã làm (commit 2 `feat(frame): pinch and drag the framed window on the camera`) — 2 file:**
- `Viewfinder.tsx`: `zoomWindowRect()` bỏ `if (mode !== 'library') return null` → camera cũng trả `polaroidGeo.window` / `wallGeo.window`; band tỉ lệ vẫn trả band nhưng export không crop qua cửa sổ nên không đụng. `feedRectSync` payload 6 → 9 số `[active, viewAspect, fx, fy, fw, fh, z, u, v]`; effect đẩy rect đọc thêm `frameZoomRef.current` và phụ thuộc thêm `libZoom`.
- Worklet `handleFrame`: chỉ khi `z > 1.001` mới crop — `k = s·z`, `canvas.translate(u0 − k·zwX, v0 − k·zwY)` + `scale(k)`. Tâm `(u,v)` là **toạ độ view** nên **phải** đi qua `toRaw()` giống rect cửa sổ; đưa thẳng vào mặt phẳng raw thì **đảo trục** khi sensor xoay (đúng lỗi đã gặp). `z = 1` → tâm ép về giữa, trùng nhánh cũ.
- Cử chỉ camera: `onImageDragStart` seed `panRef`/`pinchRef`; `onImageDragMove` nhánh mới `g.kind !== 'wm' && !wmArmed && zoomWindowRect()` — 2 ngón → pinch quanh trung điểm + `clampZoomToWindow`; 1 ngón → chỉ khi `g.kind === 'focus'` **và** đã vượt `TAP_SLOP` mới pan (nhường ngón 1 cho AE/AF + kéo tham số); `onImageDragEnd` dọn cả ba ref.
- `useEffect([mode, selectedFrame])`: ở camera → `setLibZoom({ s: 1, tx: 0, ty: 0 })` (đổi frame hoặc vào lại camera thì bắt đầu từ 1x giữa).
- `App.tsx` nhánh capture camera: `const frameZoom = viewfinderRef.current?.getFrameWindowZoom?.() ?? null` đọc **lúc bấm shutter** (hàng đợi render chạy sau cú chụp kế tiếp nên không đọc lúc render), truyền `frameWindowZoom: frameZoom`.
**Kiểm chứng commit 2:** theo quy trình mục 9 (typecheck 14 → `assembleRelease` → install `emulator-5554`) rồi thử cử chỉ trong cửa sổ POLAROID / WALL FRAME ở camera (pinch 2 ngón đổi tỉ lệ, pan 1 ngón dịch nội dung, đều bị kẹp trong cửa sổ) và đối chiếu ảnh export với preview; 1 ngón ngoài cửa sổ vẫn là AE/AF + kéo EV như cũ.
---
## 4. F4 — Gom WB vào chip `TEMP`
**Việc:** các chip `AUTO…FLUOR` (`:558-570`) **và** chip tham số `COLOR TEMP` (`paramDefs.wb[0]`) rời hàng chip, vào strip của group chip `TEMP`; chip `TEMP` hiển thị giá trị đang chọn: `TEMP AUTO`, `TEMP 5000K`, `TEMP FLUOR`.
**Cách làm**
1. `GroupKey` thêm `'temp'`; `groupDefs.temp`:
- `off: 'auto'`,
- `value`: `wbChoice` ≠ null → preset đó; ngược lại `` `${temperature}K` ``,
- `label: 'TEMP'` + value,
- `options`: 7 preset + mục `COLOR TEMP` đặc biệt,
- `onPick`: preset → `wbApply(v)`; `COLOR TEMP` → `toggleParam('temperature')` để mở hàng slider phía trên.
2. `case 'wb'` `:548`: bỏ `choiceChips('wb', …)` và chip `temperature` khỏi `paramChips(paramDefs.wb)` (`TINT` + `WB SHIFT` vẫn ở hàng chính); thêm `renderChip(groupChip('temp'))`.
3. `amberValue` khi WB ≠ neutral/AUTO.
4. **Edge:** kéo slider COLOR TEMP phải `setWbChoice(null)` để chip rơi về `TEMP xxxxK` (hiện `wbChoice` chỉ set trong `wbApply`).
**Kiểm chứng:** WB chỉ còn `RESET · TEMP … · TINT · WB SHIFT · COLOR CHROME · CHROME BLUE`; `TEMP` → strip 7 preset + COLOR TEMP; chọn `FLUOR` → `TEMP FLUOR`; kéo slider → `TEMP 4000K`; chọn `AUTO` → `TEMP AUTO`, nhiệt 5500K/tint 0.
---
## 5. F5 — Tab `FAVORITED`
**Cách làm**
1. `src/utils/storageUtils.ts`: key `@camrecipe_pro:favorites` — `getFavoriteIds(): Promise<string[]>`, `setFavorite(id, on)`. Dùng **danh sách id** (không dùng cờ trên `Recipe`) vì `DEFAULT_RECIPES` không persist → cờ trên object bundled sẽ mất. Dùng cho sim id, recipe bundled và recipe custom.
2. `App.tsx`: state `favoriteIds: string[]`, hydrate cùng `recipes`; `handleToggleFavorite(id)` (optimistic + ghi AsyncStorage); truyền `favoriteIds` + handler xuống panel.
3. `ToolRail.tsx`: thêm `{ id: 'favorites', label: 'FAVORITED' }` ngay sau `PRESETS` (rail là ScrollView ngang nên đủ chỗ).
4. `AdjustmentPanel.tsx`: `case 'favorites'` — chip cho từng recipe yêu thích (`label` = `name`, `active` = đang chọn, `onPress` = `onSelectRecipe`), rỗng → chip disabled `NO FAVORITES YET - TAP ★ ON A RECIPE`; kèm nút bỏ yêu thích.
5. Đánh dấu ★ **chỉ cho recipe** (đã chốt Q7 — sim không cần, `Recipe` đã lưu sim qua `baseFilter`): `renderChip` có tiền lệ nút phụ (`trash` `:373`) → thêm biến thể `star`/`starred` + `onPressStar`, dùng trên chip trong strip `RECIPES` và trong FAVORITED; **không** thêm ★ vào strip `PHOTO STYLE`.
6. `resetEditsToRecipe` / `handleResetAll` **không** đụng `favoriteIds`.
**Kiểm chứng:** ★ một recipe → hiện trong FAVORITED; tắt app mở lại → còn nguyên; bỏ ★ → biến mất; tap chip → áp đúng look; tab rỗng hiện đúng empty state.
**Đã làm (commit `feat(panel): add a FAVORITED tab with a star on every recipe`)** — 4 file:
- `src/utils/storageUtils.ts`: `FAVORITES_KEY = '@camrecipe_pro:favorites'`, `getFavoriteIds(): Promise<string[]>` (guard `Array.isArray` + lọc string, lỗi → `[]`) và `setFavoriteId(id, on)` (đọc lại rồi ghi, dedupe bằng `Set`).
- `src/components/ToolRail.tsx`: `TabId` + `'favorites'`, chip `FAVORITED` ngay sau `PRESETS`.
- `src/components/AdjustmentPanel.tsx`: `TabId` + `'favorites'`; `ChipDef` thêm `star`/`starred`/`onPressStar`; nhánh cuối của `renderChip` thành `c.trash || c.star` với **hai nút phụ độc lập** (recipe custom giữ cả ★ lẫn 🗑); `paramDefs.favorites = []`; `case 'favorites'` (chip = `recipes.filter(r => favoriteIds.includes(r.id))`, rỗng → chip disabled); ★ trên mọi chip của strip `RECIPES`; prop `favoriteIds` + `onToggleFavorite`.
- `App.tsx`: state `favoriteIds: string[]`, hydrate trong `initSession`, `handleToggleFavorite(id)` (optimistic `setFavoriteIds` + `setFavoriteId(...).catch(warn)`), truyền xuống panel. ★ **không** nằm trong `handleResetAll`/`resetDirty` nên RESET không đụng tới.
Khác plan: hàm tên `setFavoriteId` (không phải `setFavorite`); empty state viết `NO FAVORITES YET - TAP THE STAR ON A RECIPE` thay ký tự `★`.
**Đã verify trên emulator-5554:** rail có `FAVORITED`; ★ `VELVIPES VIVID` trong strip RECIPES → icon xám `(161,161,170)` 191 px chuyển amber `#f59e0b` 396 px, top bar **không** đổi look, `RESET` vẫn xám (không tính vào dirty); tab FAVORITED liệt kê đúng chip + ★ amber; tap chip → top bar đổi thành `VELVIPES VIVID`; bỏ ★ → chip biến mất, hiện empty state; `force-stop` rồi mở lại → vẫn còn (AsyncStorage + hydrate trong `initSession`); strip `PHOTO STYLE` **không** có nút phụ; recipe custom (tạo thử `FAVTEST`) có **2** nút phụ cạnh nhau (w=59 + 59) và 🗑 xoá sạch.
---
## 6. F6 — `RESET *` + amber khi có thay đổi
**Cách làm**
1. `renderChip` đã hỗ trợ `amberValue` (chữ amber, không viền) → `{ label: resetDirty ? 'RESET *' : 'RESET', amberValue: resetDirty }`.
2. `resetDirty` tính ở `App.tsx` (nơi có `DEFAULT_ADJUSTMENTS`, `DEFAULT_CUSTOM_WM`, `DEFAULT_GPS_WM`, `DEFAULT_CROP_RECT`) và truyền xuống prop mới `resetDirty: boolean` — tránh export thêm hằng số.
3. Định nghĩa dirty: `selectedRecipe?.id !== 'sim-provia'` ∨ adjustments khác `DEFAULT_ADJUSTMENTS` ∨ `selectedFrame !== 'none'` ∨ `customWm`/`customWmApplied` khác default ∨ `gpsWm` khác default ∨ `useGeotag` ∨ `wmRotation !== 0` ∨ `photoRotation !== 0` ∨ `wallLandscape` ∨ crop khác default.
4. **Đồng bộ (đã chốt):** mọi thứ tính vào dirty phải bị `handleResetAll` (`App.tsx:506`) xoá. Các mục watermark/rotate/frame đã bổ sung ở commit `7454ea1`; **còn thiếu `cropRatio` → `'none'` và `cropRect` → `DEFAULT_CROP_RECT`** → bổ sung, nếu không `RESET *` không bao giờ tắt. Đã chốt **giữ nguyên** `mode` + `aspectRatio` khi reset (không tính vào dirty).
**Kiểm chứng:** mặc định → `RESET` xám; đổi 1 thông số bất kỳ (kể cả chỉ kéo slider, đổi frame, đổi WB) → `RESET *` amber; tap → mọi thứ về mặc định (`PROVIPES`) và chip về `RESET` xám.
---
## 7. F7 — Chip `ROTATE`: 0/90/180/270 + xoay tay + auto chân trời
**Việc (đã chốt):** chip ROTATE hiện là toggle `ROTATE 90`/`ROTATE 0` (`AdjustmentPanel.tsx:600`, `App.tsx:63`) → group chip mở strip: **`RESET · AUTO · 0 · 90 · 180 · 270 · STRAIGHTEN (±45°)`** — AUTO dời lên **ngay sau RESET** (yêu cầu giữa lúc làm, khác thứ tự `… · AUTO` ghi ban đầu ở Q6).
- `0/90/180/270` = xoay nhanh (quarter turn); `STRAIGHTEN` = slider ±45° chỉnh tay; `AUTO` = cân theo chân trời.
- **Auto và tay kết hợp được**: `AUTO` lấy chân trời làm mốc, sau đó kéo `STRAIGHTEN` để tinh chỉnh trên mốc đó (góc hiệu dụng = auto + offset).
- `RESET` trong strip: `quarter = 0`, `straighten = 0`, `autoHorizon = false` (RESET toàn cục ở `App.tsx:506` cũng vậy).
**Phần không thêm dependency**
1. `App.tsx`: `photoRotation: 0|90` → `photoRotation: 0|90|180|270` + `photoStraighten: number` (−45..45, offset tay) + `autoHorizon: boolean`; map session cũ (`photoRotation 90` → `90`). **Góc nghiêng hiệu dụng = `photoStraighten + (autoHorizon ? roll : 0)`** — dấu là **`+roll`** (plan ban đầu ghi `−roll` là SAI, đã sửa và kiểm chứng: nghiêng máy **+10°** → ảnh xuất quay **10° theo chiều kim đồng hồ**, xem mục verify bên dưới); auto làm mốc, slider là tinh chỉnh cộng thêm, tắt AUTO thì về đúng `photoStraighten`. `horizonRoll` đo từ `Accelerometer` là số nguyên độ (lượng tử 1°).
2. `src/utils/skiaImage.ts`: thêm `rotateSkImageBy(image, deg)` (surface đủ lớn + `canvas.rotate`); giữ `rotateSkImage90` cho quarter-turn.
3. Áp ở `exportEngine.ts:142` + `Viewfinder.tsx:330`: quarter trước, straighten sau, kèm scale bù để không lộ góc trống.
4. Chip `ROTATE` ghép nhãn `parts.filter(Boolean).join(' · ')` từ `['ROTATE '+photoRotation` (bỏ số khi `photoRotation === 0 && autoHorizon`, khi đó chỉ còn `ROTATE`), `autoHorizon ? 'AUTO' : null`, `photoStraighten ? ±deg : null]`. Nhãn thực tế đã verify: `ROTATE 0`, `ROTATE 180` (chỉ số), `ROTATE · AUTO`, `ROTATE 180 · AUTO`, `ROTATE · AUTO · +5.5°`; `amberValue` bật khi `photoRotation || photoStraighten || autoHorizon`.
5. `handleResetAll` → `photoRotation=0`, `photoStraighten=0`, `autoHorizon=false`, `horizonRoll=0`; chip `RESET` trong strip cũng tắt AUTO. `resetDirty` (F6) thêm `autoHorizon` để `RESET *` vẫn tắt được.
**Phần auto chân trời (Q5: đồng ý thêm dep) — đã làm ở commit (2)** — `expo-sensors: ~57.0.2`, cài bằng `MSYS_NO_PATHCONV=1 node node_modules/expo/bin/cli install expo-sensors` (**`npx` hỏng từ bash → gọi thẳng `node_modules/expo/bin/cli`**); autolink qua `expo-modules-autolinking` (log build có `expo-sensors (57.0.2)`), **không cần plugin/quyền nào trong `app.json`**; **bắt buộc build lại native/APK**, JS reload không đủ.
- Đo roll bằng `Accelerometer` (không `DeviceMotion`), mẫu 100 ms, **chỉ subscribe khi `autoHorizon && mode === 'library'`**; bỏ mẫu khi `hypot(x,y) < 0.2` (máy nằm phẳng) hoặc `|góc| > 45` (dựng đứng) để level không đuổi theo nhiễu; `horizonRoll = Math.round(atan2(x, y) * 180/π)` (lượng tử 1°).
- Level line trên preview: `autoRoll = autoHorizon && mode === 'library' ? horizonRoll : null`; vạch rộng 55% (4 px nền `rgba(0,0,0,0.5)` + 2 px nét), `transform: [{ rotate: autoRoll + 'deg' }]`, **amber `#f59e0b` khi `|autoRoll| <= 1`**, trắng khi lệch; vẽ ở **cả 3 nhánh library** (polaroid, wall, mặc định).
- Góc bake vào export đi **cùng một đường** với straighten tay: `effectiveStraighten = photoStraighten + (autoHorizon && mode === 'library' ? horizonRoll : 0)` → `exportOptions.photoStraighten` + prop `photoStraighten` của `Viewfinder`.
- Hệ quả (đã thực hiện): F7 **tách 2 commit** — (1) quarter + straighten tay (không dep), (2) auto chân trời (kèm dep + build native).
**Kiểm chứng:** xuất ảnh ở 90/180/270 → kích thước pixel + hướng đúng; straighten ±5° nghiêng đúng chiều; không lộ góc trong; frame/crop khớp preview; auto chân trời: nghiêng máy ~10° → ảnh xuất ra thẳng; bật AUTO rồi kéo STRAIGHTEN ±2° → tổng góc = auto + offset; chọn `180` khi auto bật → ảnh vẫn thẳng (quarter + auto độc lập).
**Đã làm (commit 1 `9ddaeac feat(frame): give ROTATE quarter turns plus a fine straighten`) — 5 file:**
- `src/utils/skiaImage.ts`: thêm `rotateSkImageBy(image, deg)` (surface đủ lớn + `canvas.rotate`, crop giữa giữ tỉ lệ gốc → không lộ góc trống); giữ `rotateSkImage90` cho quarter-turn.
- `App.tsx`: `photoRotation` mở rộng `0|90|180|270` + thêm `photoStraighten`; `handleResetAll` xoá cả hai; `resetDirty` tính thêm chúng.
- `src/utils/exportEngine.ts` + `Viewfinder.tsx`: quarter trước, straighten sau.
- `AdjustmentPanel.tsx`: `photoRotation` thành `groupDefs.rotate` (strip `RESET · 0 · 90 · 180 · 270 · STRAIGHTEN (±45°)`, lúc này chưa có AUTO) + `openParam === 'straighten'` mở slider ±45°.
**Đã làm (commit 2 `0ce561c feat(frame): level the photo to the horizon with AUTO`) — 5 file:** `App.tsx` (subscribe `Accelerometer` + `horizonRoll` + `effectiveStraighten`), `AdjustmentPanel.tsx` (chip `AUTO` ngay sau `RESET` + nhãn ghép + `amberValue`), `Viewfinder.tsx` (level line), `package.json`/`package-lock.json` (`expo-sensors`). Chi tiết ở các gạch đầu dòng phía trên.
**Verify commit 2 trên `emulator-5554`** (điều khiển cảm biến bằng `adb -s emulator-5554 emu sensor set acceleration x:y:z`, mặc định `0:9.77631:0.812349`):
- Strip đúng thứ tự `ROTATE · RESET · AUTO · 0 · 90 · 180 · 270 · STRAIGHTEN (±45°)`; nhãn chip lần lượt `ROTATE 0` → `ROTATE · AUTO` → `ROTATE · AUTO · +5.5°` → `ROTATE 180 · AUTO`; `RESET *` amber ở mọi trạng thái đã đổi.
- Dấu `+roll` chứng minh bằng ảnh thật: nghiêng `(1.703, 9.661, 0.81)` (= +10°) → khớp nhất `PIL rotate(-10)` (MAD **1.18**, so với **47.9** khi không xoay); nghiêng ngược `(-1.703, …)` → `PIL rotate(+10)` (MAD 1.16) ⇒ đối xứng hai chiều.
- AUTO + STRAIGHTEN +5.5 → tổng **+15.5** (MAD 1.24); quarter `180` + AUTO → `base.rotate(180)` rồi model(−10°) (MAD 1.13) ⇒ quarter và auto độc lập, cộng được.
- Level line: roll 0 → ~594 px amber (55% của 1080) ở hàng y≈1199–1203; roll +10 → vạch dốc **9,93°** trên màn hình (đúng bằng góc nghiêng máy).
- `RESET` toàn cục và `RESET` trong strip đều tắt AUTO (chip về `ROTATE 0`, `RESET` hết amber). Cảm biến emulator đã trả về giá trị mặc định sau khi test.
- Lưu ý khi đo: **cài lại APK sẽ reset look/session của app** → luôn xuất lại ảnh nền (base) mới sau mỗi lần install trước khi so ảnh.
---
## 8. Thứ tự thực hiện & commit
| Phase | Hạng mục | Rủi ro | Ghi chú |
|---|---|---|---|
| A | F2 đổi tên → F1 PHOTO STYLE → F4 TEMP → F6 RESET* | thấp | thuần UI, 1 commit/hạng mục |
| B | F5 FAVORITED | thấp–TB | thêm storage key, không đổi shape dữ liệu cũ |
| C | F3 watermark → FRAME **+ pinch/drag cửa sổ frame ở camera** | TB–cao | đụng `TabId`, `wmEditing`, `feedRectSync`/worklet, cử chỉ 2 ngón; tách 2 commit: UI trước, pinch/drag sau |
| D | F7 rotate | cao | đổi kiểu state + đường export; **auto: `expo-sensors` + build lại native** |
Mỗi hạng mục: sửa → typecheck (14) → build → install → verify trên máy → commit riêng (`feat(...)`: mô tả hành vi). F7 và F3 mỗi hạng mục tách 2 commit (F3: UI trước → pinch/drag camera sau; F7: manual trước → auto sau).
---
## 9. Quy trình kiểm chứng (dùng lại)
```bash
export MSYS_NO_PATHCONV=1
cd /c/Users/locpham/RecipesCam
node --stack-size=16000 ./node_modules/typescript/lib/tsc.js --noEmit 2>&1 | grep -c "error TS" # = 14
cd android && cmd /c gradlew.bat assembleRelease # ~1 phút
adb -s emulator-5554 install -r app/build/outputs/apk/release/app-release.apk
```
Trên máy: `uiautomator dump` lấy toạ độ (helper scratchpad `ui.py`), `screencap` + lấy màu nền chip ở `(x1−4, cy)` để phát hiện chip active (amber ≈ `(63,50,32)`/`(48,35,15)`, neutral ≈ `(31,31,35)`/`(27,27,30)`); tap chỉ ăn khi IME đã đóng (tap lần 2 sau khi gõ); toạ độ tool-rail đổi theo tab → luôn dump lại; `which`/`locale`/`sleep` không có → dùng `powershell.exe -Command "Start-Sleep -Seconds N"`.
---
## 10. Câu hỏi cần chốt trước khi làm
1. ~~`ASTIPCES` hay `ASTIPES`?~~ → **`ASTIPES`**; ~~`CLASSIC CHRIPES` hay `CLASSIC CHROMES`?~~ → **giữ `CLASSIC CHRIPES`**.
2. ~~Recipe bundled có đổi tên theo không?~~ → **Có**: `PROVIPES STD`, `VELVIPES VIVID`, `ACRIPES MONO`, `CLASSIC NEGIPES.` (giữ nguyên id).
3. ~~Cho tab FRAME hiện ở cả camera?~~ → **Có**, và **kèm pinch/drag cửa sổ frame ở camera** (F3 bước 7).
4. ~~Chip `PHOTO STYLE` có hiện tên sim không?~~ → **Không** (TopBar góc trái đã hiện tên).
5. ~~Cho phép thêm `expo-sensors`?~~ → **Có**, chấp nhận build lại native.
6. ~~Xoay tay gồm những gì?~~ → **`RESET · AUTO · 0 · 90 · 180 · 270 · STRAIGHTEN ±45°`** (AUTO dời lên ngay sau RESET khi làm), auto + tay kết hợp được.
7. ~~FAVORITED gồm cả sim không?~~ → **Chỉ recipe** (sim đã nằm trong recipe qua `baseFilter`).
8. ~~RESET có reset crop không?~~ → **Có, reset `cropRatio`/`cropRect`**; mode + aspect **vẫn giữ** như hiện tại.
---
## 11. Đối chiếu 7 hạng mục yêu cầu → commit
| # | Yêu cầu | Commit |
|---|---|---|
| 1 | Gom sim vào chip `PHOTO STYLE` | `d66ae3d` |
| 2 | Đổi tên sim (kèm recipe bundled) | `6adf0b5` |
| 3 | `WATERMARK` → chip trong `FRAME` (+ pinch/drag cửa sổ frame ở camera) | `4131c9f` + `dcb74a7` |
| 4 | WB presets vào chip `TEMP` | `7ed4301` |
| 5 | Tab `FAVORITED` | `ba971f1` |
| 6 | `RESET *` amber khi có thay đổi | `ba7e579` |
| 7 | Chip `ROTATE` (0/90/180/270 · STRAIGHTEN ±45° · AUTO) | `9ddaeac` + `0ce561c` |
Phát sinh trong lúc làm: đổi tên chip frame `RETRO POLAROID` → `RETRO INSTANT` (`6788d51`).
---
## 12. Follow-up — 4 lỗi hành vi của strip `ROTATE` (commit `da48cec`)
Bốn lỗi phát hiện sau khi F7 xong, sửa chung một commit `fix(frame): make the ROTATE strip behave on switching parameters`: `App.tsx` + `src/components/AdjustmentPanel.tsx`.
| # | Lỗi | Nguyên nhân | Cách sửa |
|---|---|---|---|
| 1 | Sau khi dùng AUTO straighten, chuyển sang tham số khác thì hàng slider STRAIGHTEN vẫn còn | chip frame (`choiceChips('fr', …)`) và chip `RESET` chỉ gọi handler của chúng, không đóng `openParam` | chip frame gọi `if (openParam) onOpenParamChange(null)` trước `onUpdateFrame`; chip `RESET` thêm `setOpenGroup(null)` + đóng `openParam` trước `onResetAll` |
| 2 | Mở ảnh khác từ thư viện phải reset straighten + AUTO về mặc định | `handlePickImage` chỉ `setPhotoRotation(0)` | thêm `setPhotoStraighten(0)` + `setAutoHorizon(false)` + `setHorizonRoll(0)` |
| 3 | Trong straighten thủ công, nút `<` không hiện lại strip | `toggleParam` đóng `openGroup`, `<` gọi `toggleParam` nên strip biến mất | thêm map `PARAM_GROUP = { straighten: 'rotate', temperature: 'temp' }`; `<` gọi `toggleParam` rồi `setOpenGroup(PARAM_GROUP[key])` (set sau thắng) |
| 4 | Đang straighten thủ công, nhấn AUTO không có tác dụng | `effectiveStraighten = photoStraighten + roll`; roll≈0 nên bật AUTO không đổi gì | nhánh `auto` trong `groupDefs.rotate.onPick`: khi **bật** (`!autoHorizon`) gọi `onStraighten(0)` để ép về đường chân trời sensor; nhấn lại vẫn tắt AUTO như cũ |
Kiểm chứng trên `emulator-5554` (typecheck vẫn **14**, `assembleRelease` OK, cài lại APK rồi mở một ảnh thư viện):
- Đặt STRAIGHTEN thủ công **+22°** → chip `ROTATE 0 · +22°`; nhấn **AUTO** → chip còn `ROTATE · AUTO`, mở lại slider thấy **0°** (item 4).
- Mở slider rồi nhấn chip frame **CLASSIC BORDER** → hàng slider STRAIGHTEN biến mất (item 1); nhấn `RESET` cũng đóng hàng slider và đưa về `ROTATE 0` (item 1, phần RESET).
- Nhấn `<` trong hàng slider → strip `RESET · AUTO · 0 · 90 · 180 · 270 · STRAIGHTEN (±45°)` hiện lại (item 3).
- Mở ảnh thứ hai từ thư viện khi AUTO đang bật → chip về `ROTATE 0` (straighten 0, AUTO tắt) (item 2).
Ghi chú: AUTO vẫn tắt được bằng cách nhấn lại chính chip AUTO; strip `RESET` xoá cả turn + góc + auto.
---
## 13. Follow-up — vạch amber của bubble level còn đọng trên ảnh (commit `d6b3ebd`)
Yêu cầu: sau khi nhấn `ROTATE` → `AUTO`, chuyển sang chip khác thì vạch amber vẫn nằm trên ảnh.
Đó **không phải** hàng slider STRAIGHTEN mà là vạch bubble level vẽ đè lên ảnh trong `Viewfinder` (`levelOverlay`, hiện khi `autoRoll != null`, amber khi `|roll| <= 1`). Nguyên nhân: `autoRoll = autoHorizon && mode === 'library' ? horizonRoll : null` chỉ phụ thuộc AUTO, không biết strip `ROTATE` đã đóng hay chưa.
| # | Lỗi | Cách sửa |
|---|---|---|
| 1 | Vạch amber đọng lại sau khi rời ngữ cảnh `ROTATE` | `App.tsx`: thêm `rotatePanelOpen`; `autoRoll` chỉ khác `null` khi `autoHorizon && mode === 'library' && openTab === 'frame' && rotatePanelOpen` |
| 2 | App không biết strip `ROTATE` đang mở | `AdjustmentPanel.tsx`: thêm prop `onRotatePanelChange`, effect `onRotatePanelChange(openGroup === 'rotate' \|\| openParam === 'straighten')` deps `[openGroup, openParam]` |
| 3 | Chip frame khác không đóng strip `ROTATE` | nhánh `choiceChips('fr', …)` thêm `setOpenGroup(null)` cạnh `onOpenParamChange(null)` sẵn có |
Kiểm chứng trên `emulator-5554` (typecheck vẫn **14**, `assembleRelease` OK, cài lại APK, mở một ảnh thư viện, script `verify3.py`):
- Đối chứng dương: strip mở + `AUTO` bật (`ROTATE · AUTO`) → dò được vạch amber ở hàng **y 1199–1203**, rộng **594 px**.
- Nhấn chip frame `NO FRAME` khi AUTO đang bật → strip đóng, hàng amber **`[]`**.
- Nhấn tab rail `LIGHT` → hàng amber **`[]`**; quay lại `FRAME` → vẫn **`[]`** (strip đóng nên vạch không tự hiện lại).
Ngoài lề: `AUTO` chỉ là bubble level theo cảm biến gia tốc (`Accelerometer`), không dò đường chân trời trong ảnh, nên không nắn được đường chân trời nghiêng sẵn trong ảnh.
---
## 14. Bỏ hẳn AUTO theo cảm biến, thay bằng auto-straighten dò đường chân trời trong ảnh
Yêu cầu: EXIF ảnh trong app không lưu roll lúc chụp, nên AUTO theo gia tốc chỉ đúng khi đang chụp chứ vô nghĩa với ảnh mở từ thư viện. Bỏ hẳn cảm biến, tự dò độ nghiêng từ chính ảnh.
| # | Việc | Nơi |
|---|---|---|
| 1 | Thêm bộ dò nghiêng | file mới `src/utils/horizon.ts`, `detectTilt(image: SkImage \| null, maxAngle = 45): number \| null` |
| 2 | Bỏ cảm biến, AUTO lấy từ ảnh | `App.tsx`: xoá import `expo-sensors`, state `autoHorizon`/`horizonRoll` + effect đọc `Accelerometer`, `effectiveStraighten`; `handleAutoStraighten` = `const tilt = viewfinderRef.current?.detectHorizon?.() ?? null; if (tilt == null) return; setPhotoStraighten(-tilt);` |
| 3 | Bỏ vạch bubble level | `Viewfinder.tsx`: xoá prop `autoRoll` + overlay; thêm `detectHorizon()` vào `ViewfinderHandle` (chạy `detectTilt(applyPhotoRotation(src.image, src.quarter, 0))` trên `horizonSrcRef`) |
| 4 | Chip | `AdjustmentPanel.tsx`: `autoHorizon`/`onAutoHorizon` → `onAutoStraighten`; nhánh AUTO gọi `onAutoStraighten()`; hàng slider `STRAIGHTEN` hiện `min -45 max 45 step 0.5` và đọc số đo |
Chi tiết đã bỏ: prop `onRotatePanelChange` (mục 13 không còn cần vì không còn vạch đè lên ảnh), cặp `setAutoHorizon(false); setHorizonRoll(0);` trong `handleResetAll`.
**Thuật toán `detectTilt` (shear projection).** Thuật toán ban đầu (histogram hướng gradient) sai với cạnh thật: đường 7° đi từng bậc 8 px nên gradient chỉ rơi vào bin 0 (đoạn phẳng) và 45 (bước nhảy) — mọi góc nhỏ đều đọc ra 0 (kiểm chứng số: app bin 0/ratio 0.207, mô phỏng 2-tap bilinear cho đúng (0, 0.206, 133044)). Cách làm đúng:
1. Hạ mẫu chính xác 2× liên tục đến khi cạnh dài ≤ 256 (`Skia.Surface.Make` + `canvas.drawImageRectOptions(..., FilterMode.Linear, MipmapMode.Linear)` + `makeImageSnapshot`) — hạ một lần >2× sẽ alias.
2. `readPixels()` → mảng xám; liệt kê điểm cạnh `mag = sqrt(dx²+dy²)`, bỏ qua `mag < 20`, toạ độ x trừ tâm, bỏ nếu < 32 điểm.
3. `score(deg)` = cắt xiên theo phương pháp profile: `acc[round(py - px*tan(deg)) + limit] += pw`, trả `ss/(tot*tot)` (độ "nhọn" của profile).
4. Quét thô `-45..45` bước 1°, cổng tin cậy `bestScore < 3 * median → null`, rồi tinh chỉnh bước 0.5° trong `best-1..best+1`; trả về độ lẻ.
Dấu: `rotateSkImageBy` dùng `canvas.rotate(deg)` (dương = cùng chiều kim đồng hồ, y hướng xuống) nên `photoStraighten = -tilt`.
Kiểm chứng trên `emulator-5554` (typecheck vẫn **14**, `assembleRelease` OK, cài lại APK, script `autocheck.py` / `expexport.py`):
- Ảnh nghiêng 7° → STRAIGHTEN **-7°**; ảnh 0° → **0°**; ảnh 12° có cấu trúc dọc → **-12°**; ảnh nhiễu đồng nhất (không có đường) → **0°** (cổng tin cậy chặn đúng).
- Export thật (không chỉ đọc slider): AUTO trên ảnh nghiêng 12°, SAVE, đo lại đường chân trời trong file xuất → **-0.85°**. Đo cùng thước đo trên ảnh gốc cho **-0.0° / 7.0° / 12.0°** (trên 1200 cột) ⇒ thước đo đủ nhạy, -0.85° là ảnh đã được nắn thật.
Ghi chú: `expo-sensors` vẫn còn trong `package.json` (không dùng nữa) — cố ý không sửa deps.
## 15. Điều tra "load camera bị chậm"
Kết luận: **không có nút thắt nào trong code app**. Số đo trên `emulator-5554`, APK release tại `1b632ba`, **không** instrument (đo bằng logcat native + `screencap`):
Cold start (`am force-stop` → `am start`, mốc 0 = dòng `START u0`):
| Mốc | Thời điểm |
|---|---|
| `Displayed com.locphamtran.recipescamera` | 0.5 – 1.0 s |
| `HybridCameraSessionSpec: Reconfiguring CameraSession` | 1.2 – 1.7 s |
| `Camera #0 State changed! Type: OPEN` | 1.7 – 2.6 s |
| Preview sống hiện trên màn hình | **~2.5 s** |
Preview đo bằng cách chụp màn hình tại các mốc cố định rồi tính độ sáng trung bình (54×120, thang xám): 1.5 s → mean 2.9; 2.0 s → 3.5 (đen); 2.5 s → 39.7 (có nội dung). Kết quả lặp lại y hệt sau `adb reboot`.
Resume từ background (HOME → mở lại): `START u0` → `Camera #0 OPEN` = **282 ms**.
**Vì sao các lần đo trước ra 5–7 s:** những lần đó chạy ngay sau `gradlew assembleRelease` (host bận + dexopt nền của APK mới). Chính APK đó, đo lại 12+ lần cold start (kể cả sau `adb reboot` và `install -r`) đều 1.7–2.6 s. Log của các lần chậm có `Davey! duration=2803ms` / `Choreographer Skipped 188 frames` ⇒ main thread bị đói; `session.configure()` của VisionCamera chạy trên `Dispatchers.Main` nên bị xếp hàng sau frame dài, kéo `Camera #0 OPEN` ra ~7 s. Bản thân đường camera rất nhanh: `createCameraSession` ~10 ms, thân `configure` ~25 ms.
⇒ Không sửa code. Instrumentation tạm đã gỡ sạch (`git checkout -- App.tsx index.ts src/components/Viewfinder.tsx`, khôi phục `useCameraSession.ts` / `useCameraController.ts` từ `.bak` trong `node_modules`, xoá `.bak`).
Ghi chú không sửa: APK release **172 MB**, trong đó 4 ABI (`x86` 41 MB, `x86_64` 41 MB, `arm64-v8a` 40 MB, `armeabi-v7a` 28 MB). Chỉ ảnh hưởng thời gian cài/first-run trên emulator, không phải thời gian load camera.
Script đo: `cold.py` (timeline logcat) và `onset.py` (độ sáng màn hình) trong scratchpad.
## 16. Sửa: preview khác ảnh export ở chế độ đo sáng Highlight-weighted
**Triệu chứng (người dùng):** ở chế độ CAMERA với METERING MODE = HIGHLIGHT-WEIGHTED, lúc bấm máy ngắm sáng bừng lên toàn khung, rồi ảnh xuất ra không khớp với những gì đã thấy trên ngắm.
**Nguyên nhân:** chế độ highlight cộng thêm **-0,5 EV** (`METER_HL_EV`). EV này được đẩy xuống **hardware AE bias** ngay trước khi chụp (`flushExposureBias()` trong `handleCapture`), còn export thì bỏ qua EV phần mềm (`evFromCamera: true`) vì tin rằng bias phần cứng đã gánh EV.
- `Viewfinder.flushExposureBias()` gọi `setHwBiasStops(clamped / 8)` **trước khi** `setExposureBias()` thực sự landed. Preview render `evTarget − hwBiasStops`, nên ngay khi bấm máy gain phần mềm tụt từ -0,5 EV về 0 → cả khung sáng lên đúng 0,5 EV (= cái "flash" người dùng thấy).
- Bias phần cứng landed bất đồng bộ (một số máy/emulator không hề tôn trọng nó), nên JPEG có thể được chụp với EV = 0 → ảnh sáng hơn ngắm ~0,5 EV (+41% tuyến tính).
**Cách sửa — EV thuần phần mềm, bỏ hẳn hardware bias:**
| | Trước | Sau |
| --- | --- | --- |
| Preview EV | `clamp(user EV + metering offset) − hwBiasStops` | `clamp(adjustments.exposureCompensation)` |
| Export EV | bỏ qua (`evFromCamera: true`), trông vào AE bias | gain `2^EV` như preview (không còn `evFromCamera`) |
| Hardware AE bias | ghi lúc bấm máy | **không bao giờ ghi** |
| EV dip của highlight | prop `meteringEvOffset` riêng + `METER_HL_EV` | nằm luôn trong `meteringAdj.exposureCompensation` (dùng chung cho preview và export) |
Xoá hẳn: `ViewfinderHandle.flushExposureBias()`, state `hwBiasStops`, `evStopsRef`, `meteringEvOffsetRef`, effect cảnh báo `supportsExposureBias`, prop `meteringEvOffset`, và lời gọi trong `handleCapture`. `evFromCamera` vẫn còn trong `exportEngine`/`nativeExport` nhưng không đường nào của app truyền nữa. Net **−71 dòng** (`App.tsx`, `src/components/Viewfinder.tsx`).
**Đo trên `emulator-5554`, APK release, màn 1080×2400.** Vùng ảnh của preview xác định được bằng profile độ sáng: ảnh hiển thị vừa khung (*contain*) 1080×1440 tại `y 480..1920`, letterbox đen hai đầu (đúng 1280 × 1080/960 = 1440), nên so sánh được cùng trường nhìn: preview `[480:1920, 0:1080]` ↔ export 960×1280.
Độ sáng trung bình vùng ảnh của preview, highlight-weighted, một lần bấm máy:
| Mốc | pre | ngay sau bấm | +0,35 s | +1,35 s | lúc đã settle |
| --- | --- | --- | --- | --- | --- |
| Lần 1 | 46,40 | 46,74 | 46,68 | 46,59 | 46,80 |
| Lần 2 | 60,65 | 60,41 | 60,37 | 60,23 | 60,62 |
Không còn nhảy sáng (dao động ±0,7%, dưới mức nhiễu của phép đo).
Tỉ lệ export/preview (E/P) cùng trường nhìn:
| Chế độ | preview | export | E/P |
| --- | --- | --- | --- |
| EVALUATIVE | 100,02 | 101,62 | **1,0160** |
| HIGHLIGHT-WEIGHTED (lần 1) | 60,38 | 62,34 | **1,0325** |
| HIGHLIGHT-WEIGHTED (lần 2) | 60,65 | 62,23 | **1,0260** |
Hai chế độ lệch nhau 1,6% ≈ 0,023 EV (độ lệch hệ thống nhỏ của JPEG/resample). Nếu export vẫn bỏ EV như code cũ, E/P của highlight phải lớn hơn eval ≈ 1,414 lần (+41%). Sai số từng pixel `mean|diff|` ≈ 8,7–9,3 (thang 0–255).
**Kiểm tra:** `tsc --noEmit` vẫn **14 lỗi** như baseline (không thêm lỗi nào). File test trên máy đã xoá (3 JPEG export + 3 dòng MediaStore 1000000266–268 + `/sdcard/ui.xml`); DCIM còn đúng 5 ảnh `IMG_*` gốc.
Script đo: `measure.py` (chụp màn hình trước/sau khi bấm máy + pull JPEG export), `align.py` (dò căn chỉnh preview↔export) trong scratchpad.
## 17. Sửa 3 lỗi hành vi chip/strip: EV bubble · cuộn chip đã sửa · strip làm mất chip
Ba lỗi người dùng báo, đã sửa, build release, cài lên `emulator-5554` và đo lại.
### 17.1 EV bubble hiện EV đã gộp thay vì EV người dùng nhập
**Triệu chứng:** tab LIGHT để EV = 0 nhưng bubble quick-EV trên màn hình hiện **EV −0,5**.
**Nguyên nhân:** `src/components/Viewfinder.tsx` (~dòng 504) đọc `adjustments.exposureCompensation`. Biến `adjustments` (~dòng 362) là `{...recipe.adjustments, ...meteringAdjustments}`, nên ở chế độ HIGHLIGHT-WEIGHTED nó đã cộng sẵn offset −0,5 EV của metering. Bubble/thumb/điểm neo kéo đều lấy từ đây → hiện `userEV − 0,5`.
**Sửa:** cho phần hiển thị đọc thẳng `recipe.adjustments.exposureCompensation`; gain áp dụng vẫn dùng `adjustments.exposureCompensation` như cũ.
```tsx
const evStops = recipe.adjustments.exposureCompensation ?? 0;
```
| | Trước | Sau |
| --- | --- | --- |
| Bubble / thumb / điểm neo kéo | `adjustments.exposureCompensation` (đã gộp metering) | `recipe.adjustments.exposureCompensation` (EV người dùng) |
| Gain áp dụng vào ảnh | `clampEvApplied(adjustments…)` | không đổi |
**Đo trên máy** (HIGHLIGHT-WEIGHTED bật, đã chứng minh có áp dụng: độ sáng trung bình preview 92,74 → 58,47):
| Thao tác | Bubble | Chip LIGHT |
| --- | --- | --- |
| Không kéo | `EV 0.0` tại `[687,1049][797,1095]` | `EV 0.0 EV` |
| Kéo +130 px | `EV +0.8` | `EV +0.8 EV` |
| Kéo về | `EV 0.0` | `EV 0.0 EV` |
Code cũ khi kéo +130 px chỉ ghi `+0.3` (bị trừ 0,5). Kéo về 0 khôi phục đúng.
### 17.2 Nhấn tab trên rail phải nhảy tới chip đầu tiên đã sửa
**Yêu cầu:** nhấn tab → cuộn tới chip đầu tiên có thông số đã sửa; nếu tab không có chip nào sửa thì về chip đầu tiên.
**Sửa trong `src/components/AdjustmentPanel.tsx`:** thêm hằng số cấp module để nhận diện cả 5 strip con của watermark là "cùng một panel":
```tsx
const WM_SUBGROUPS: GroupKey[] = ['wmColor','wmSize','wmFont','gpsColor','gpsName','gpsTime'];
const isWmPanel = (g) => g === 'wm' || (g !== null && WM_SUBGROUPS.includes(g));
```
Toàn bộ khối `switch (activeTab)` dựng hàng chip (~dòng 719–880) được viết lại quanh một helper duy nhất:
```tsx
const chipsScrollRef = useRef<ScrollView>(null);
const chipXRef = useRef<Record<string, number>>({});
const firstAmberChipRef = useRef<string | null>(null);
useEffect(() => {
const id = setTimeout(() => {
const key = firstAmberChipRef.current;
const x = key ? chipXRef.current[key] ?? 0 : 0;
chipsScrollRef.current?.scrollTo({ x: Math.max(0, x - 8), animated: false });
}, 80);
return () => clearTimeout(id);
}, [activeTab, isWmPanel(openGroup)]);
const chipRow = (chips: ChipDef[], padV = 6) => {
firstAmberChipRef.current = chips.find((c) => c.amberValue)?.key ?? null;
return (<ScrollView ref={chipsScrollRef} horizontal showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: padV }}>
{chips.map(renderChip)}</ScrollView>);
};
```
- Mỗi `case` trả về `chipRow([...định nghĩa], 8|6)` (recipes/favorites/frame giữ padding 8, iq/wb/filters 6 — đúng như cũ). Bỏ `<View>` bọc vô nghĩa quanh hàng WB.
- `renderChip` thêm `onLayout` trên View ngoài: `chipXRef.current[c.key] = e.nativeEvent.layout.x`.
- `paramChips` và `choiceChips` đổi kiểu trả về thành `ChipDef[]` (trước trả ReactElement); call site duy nhất còn lại thành `{choiceChips(openGroup, …).map(renderChip)}`.
- Độ trễ 80 ms vì hàng chip vừa remount khi đổi tab, `scrollTo` ngay sẽ trượt.
**Đo trên máy:**
| Kịch bản | Kết quả |
| --- | --- |
| LIGHT mới, chưa sửa gì | offset 0, `RESET` đầu hàng |
| Đặt COLOR +7, sang WB rồi quay lại LIGHT | `COLOR +7` nằm tại `[246,1921][384,1960]` |
| WB (không có chip nào sửa) | offset 0 |
| FRAME khi watermark đang ON | cuộn tới `WATERMARK ON` tại `[783,1916][1017,1955]` |
### 17.3 Strip của watermark làm cả hàng chip biến mất
**Triệu chứng:** đang ở chip watermark, bấm sang chip kế tiếp thì chỉ thấy strip, không thấy chip nào; phải bấm vào strip thì chip mới hiện.
**Nguyên nhân:** hàng chip con của watermark trong `case 'frame'` bị gác bằng `if (openGroup === 'wm')`, nên mọi strip con (`wmColor`, `wmSize`, `wmFont`, `gpsColor`, `gpsName`, `gpsTime`) đều làm hàng chip gỡ khỏi cây.
**Sửa:**
- Cổng hiển thị `if (openGroup === 'wm')` → `if (isWmPanel(openGroup))` — hàng chip ở lại với mọi strip của watermark.
- `onWmPanelChange(openGroup === 'wm')` → `onWmPanelChange(isWmPanel(openGroup))` — giữ lớp bắt kéo ảnh.
- ScrollView của strip thêm `key={openGroup}` để remount về offset 0 mỗi nhóm.
- Strip `ROTATE`: chuyển `{ v: 'straighten', d: 'STRAIGHTEN (±45°)' }` lên **đầu** (trước bị cắt ở x≈1058).
- Strip `TEMP`: đổi option thành `[{ v: 'color-temp', d: 'COLOR TEMP' }, ...WB_PRESETS]`.
**Đo trên bản build cuối:**
| Kịch bản | Chip còn hiển thị? | Strip |
| --- | --- | --- |
| FRAME › WATERMARK › CUSTOM WATERMARK ON › TEXT COLOR | Có: `RESET / CUSTOM WATERMARK ON / ROTATE / ACCEPT / CANCEL / TEXT COLOR` | AMBER WHITE BLACK RED BLUE |
| TEXT SIZE | Có, nguyên vẹn | cỡ chữ |
| GPS ON › TIME | Có, nguyên vẹn | định dạng giờ |
| ROTATE | Có | `STRAIGHTEN (±45°)` tại `[258,1805][534,1844]`, một tap mở `STRAIGHTEN 0°` |
| TEMP | Có | `COLOR TEMP` đầu tiên tại `[233,1815][418,1854]` |
Vào panel con từ hàng frame đã cuộn giờ nhắm lại offset 0 (`< FRAME` đầu hàng) thay vì thừa hưởng offset của hàng frame.
### 17.4 Kiểm tra
- `tsc --noEmit` (`node --stack-size=16000 ./node_modules/typescript/lib/tsc.js`) vẫn **14 lỗi** như baseline (1 `App.tsx` + 13 `src/components/Viewfinder.tsx`); `AdjustmentPanel.tsx` 0 lỗi.
- Build `gradlew.bat --offline assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success; khởi động lại app OK.
- Trạng thái máy đã reset: RESET chip đã bấm (GPS watermark OFF, custom watermark OFF, frame NO FRAME, COLOR 0, straighten 0). LIGHT: `RESET / EXPOSURE 0 / EV 0.0 EV / CONTRAST 0 / COLOR 0`; WB: `RESET / TEMP AUTO / TINT 0 / COLOR CHROME / CHROME BLUE`. Metering về EVALUATIVE (mặc định lúc khởi động).
- File test trên máy đã xoá (`/sdcard/*.xml` của phiên này).
- Diff chưa commit: `App.tsx`, `src/components/AdjustmentPanel.tsx`, `src/components/Viewfinder.tsx` + file kế hoạch này.
## 18. CROP theo tỉ lệ: nút APPLY, ẩn viền amber, RESET về mặc định
**Yêu cầu:** sau khi CROP với ratio thì cho phép APPLY; sau khi APPLY chỉ hiển thị ratio đó và ẩn viền amber; nhấn RESET thì trả ảnh về ratio mặc định lúc load.
**Hai trạng thái:**
| Trạng thái | Điều kiện | Preview |
| --- | --- | --- |
| Đang frame (band) | `cropRatio` là tỉ lệ, `cropApplied !== cropRatio` | viền amber quanh vùng giữ + mask đen opacity 0.55 bên ngoài |
| Đã áp (applied) | `cropApplied === cropRatio` | chỉ còn vùng tỉ lệ, mask đen opacity 1, không viền amber |
Chọn tỉ lệ khác tự quay về trạng thái band (so sánh `cropApplied === cropRatio`, không cần reset thủ công).
**Sửa:**
- `App.tsx`: thêm state `cropApplied`; truyền `cropApplied` cho `Viewfinder` và `AdjustmentPanel`, thêm `onApplyCrop={() => setCropApplied(cropRatio)}`; `handleResetAll` thêm `setCropApplied(null)` cạnh `setCropRatio('none')`; `handleUpdateFrame` xoá `cropApplied` khi frame được chọn.
- `AdjustmentPanel.tsx`: chip `APPLY` nằm trong hàng chip của `case 'frame'`, chèn ngay sau chip nhóm `CROP`:
`cropEnabled && cropRatio !== 'none' && cropRatio !== 'free' && cropApplied !== cropRatio`.
- `Viewfinder.tsx`: `libCropView` (`k = min(vw/dw, vh/dh)`, rect căn giữa) + `cropScreenPx` (dùng làm vùng mask khi đã áp, vẫn `cropWinPx` cho frame/grain/vignette); `libViewMatrix` ghép ngoài·trong `[k·s, 0, k·tx + s·ox, 0, k·s, k·ty + s·oy, 0, 0, 1]` áp cho hai `<Group>` của ảnh library; `libCropTouchStyle` (translate rồi scale `k`, có hiệu chỉnh tâm) trên lớp bắt touch; mask `opacity={libCropView ? 1 : 0.55}`; Rect stroke `#f59e0b` bọc trong `{!libCropView && (...)}`.
**Đo trên máy** (emulator-5554, 1080×2400, ảnh thư viện dọc):
| Bước | Chip APPLY | Amber trong preview | Vùng nội dung |
| --- | --- | --- | --- |
| FRAME, CROP = NONE | không | 0 | ảnh đầy đủ (baseline) |
| chọn 1:1 | có | 14218 px, band 1080×1083 tại y 343–1426 | ảnh đầy đủ + dim |
| APPLY 1:1 | không | 0 | mask đen y 273–344 và 1425–1769; nội dung y 345–1424 |
| chọn 2:3 | có | band 928×1340 tại x 76–1003 | `k = 1,164` |
| APPLY 2:3 | không | 0 | nội dung phóng kín chiều ngang |
| RESET sau APPLY | không | 0 | trùng baseline: chỉ 30/288000 pixel khác, đều ở đồng hồ (99–118, 56–83) |
| đổi tỉ lệ sau APPLY (3:4) | có lại | band 17836 px | tự về trạng thái band |
Với 1:1, `k = 1` nên pixel trong vùng crop của bản APPLY giống hệt bản band (max diff 0) — chỉ mask đổi 0.55 → 1 và viền amber mất.
**Kiểm tra khác:**
- `RESET *` → `RESET` sau khi reset (hết dấu *).
- Kéo 1 ngón ở preview (150 px và 500 px): ảnh không dịch — kể cả khi không crop, khi band, khi đã APPLY. Pan 1x vốn không chạy trong bản này nên không phải hồi quy của touch layer; `libCropTouchStyle` không gây lệch.
- `tsc --noEmit` vẫn **14 lỗi** đúng baseline (1 `App.tsx` + 13 `src/components/Viewfinder.tsx`).
- Build `gradlew.bat --offline assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success.
## 19. Lưu `cropApplied` vào session (9/9/2026)
Trước: APPLY chỉ là state trong bộ nhớ. Cold start / restore LAST SESSION thì mất, preview trở lại ảnh đầy đủ trong khi chip CROP vẫn giữ ratio cũ.
**Sửa:**
- `App.tsx`: thêm `cropApplied` vào snapshot session (cạnh `cropRect`), thêm `cropApplied` vào dependency array của effect lưu session, và khi restore: `if (session.cropApplied && session.cropApplied === session.cropRatio) setCropApplied(session.cropApplied);` (guard bước 2: chỉ nhận khi ratio khớp, tránh restore lệch cặp).
- Chuẩn hoá 4 dòng LF-only ở `Viewfinder.tsx` 512–515 (comment Task 2) về CRLF.
**Đo trên máy** (emulator-5554, STARTUP MODE = LAST SESSION):
| Bước | Chip APPLY | Amber trong preview | Preview |
| --- | --- | --- | --- |
| load ảnh → CROP 1:1 → APPLY → force-stop → mở lại | không | 0 px | mask đen đặc, nội dung y 398–1477 (viewport cao hơn khi deck đóng) |
Rect nội dung khác lúc strip đang mở (y 345–1424) là do viewport cao hơn, không phải lỗi.
- `tsc --noEmit` vẫn **14 lỗi** đúng baseline.
- Build `assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success.
- Đã trả STARTUP MODE về DEFAULT sau khi test.
## 20. EXIF + tên file `RC_<ts>` khi chụp và khi export (9/9/2026)
Trước: ảnh lưu ra `camrecipe_pro_export_<epoch>.jpg`, không có EXIF nào ngoài thứ Skia còn giữ được sau re-encode (thực tế là mất sạch).
**Sửa:**
- `src/utils/exifWrite.ts` (mới, thuần, không import): dựng lại segment APP1/EXIF từ byte JPEG — đọc TIFF của ảnh nguồn, giữ mọi tag module không sở hữu, ghi đè `Software 0x0131` = `RecipesCam 1.2`, `DateTime 0x0132` / `DateTimeOriginal 0x9003` / `DateTimeDigitized 0x9004`, `Orientation 0x0112` = 1, `ImageWidth/Length 0x0100/0x0101`, `PixelX/YDimension 0xa002/0xa003`, resolution khi có dpi, GPS (0x0001–0x0004). Bỏ IFD1/thumbnail, MakerNote và con trỏ IFD cũ. Độ dài APP1 ghi **big-endian** (khác khối TIFF bên trong — bug đầu tiên của module).
- `src/utils/photoMeta.ts` (mới): `rcPhotoName(exported)` → `RC_<yyyymmdd_hhmmss>[_exported].jpg`; `nextPhotoPath()` cộng 1000 ms khi tên đã tồn tại (tránh trùng tên và trùng cache `<Image>`).
- `src/utils/exportEngine.ts`: thay `nextExportFile()` bằng `nextPhotoPath(options.exported)`, thêm bước 9c gọi `writeJpegExif` với `capturedAt`/GPS/dims/dpi và byte của ảnh nguồn.
- `src/utils/nativeExport.ts`: cùng tên file, `stampExifOnFile()` sau `processPhotoAsync` (không truyền width/height — JS không biết kích thước Kotlin render).
- `App.tsx`: `capturedAt = Date.now()` ngay trước khi chụp; `exported: false` cho camera, `exported: true` cho cả 2 nhánh option của library.
Ảnh camera truyền `dateTime` = thời điểm bấm shutter nên EXIF là lúc chụp; ảnh library truyền `null` nên giữ nguyên ngày gốc của file đã load. Khi `sourceJpeg` không đọc được (uri `content://`, lỗi IO) module lấy chính khối EXIF của file đang ghi làm nền, không xoá tag.
**Đo trên máy** (emulator-5554, 1080×2400):
| Thao tác | File | EXIF đọc lại bằng PIL |
| --- | --- | --- |
| chụp (GPS watermark OFF) | `RC_20260911_175607.jpg` | Software `RecipesCam 1.2`, DateTime/DateTimeOriginal 17:56:06, Orientation 1, 960×1280, giữ Make/Model của camera HAL |
| load `RC_libtest.jpg` → export | `RC_20260911_175905_exported.jpg` | giữ Make `OldCam`, Model `X100`, ISO 200, ExposureTime 1/125, FNumber 2.8, FocalLength 35, DateTime 2019:05:04 03:02:01, GPS 48.8584/2.2945; Software `RecipesCam 1.2`; Orientation 1 |
Emulator không có fix GPS (`getCurrentGPS` → `ERR_CURRENT_LOCATION_IS_UNAVAILABLE` dù `adb emu geo fix` trả OK và `location_mode=3`), nên nhánh ghi GPS từ thiết bị chỉ kiểm được ở mức codec: `exifchk/verify.py` đọc lại đúng lat/lon do `writeJpegExif` ghi (10.776889/106.700806) và PASS.
- `tsc --noEmit` vẫn **14 lỗi** đúng baseline.
- Build `assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success.
## 21. EXIF chuẩn theo ảnh mẫu IMG_20260830_140429.jpg (2026-09-11)
Ảnh mẫu (Xiaomi 14 Ultra, 4096×3072, 7 165 975 B) đọc bằng parser tự viết: byte-order `MM`
(big-endian), APP1/Exif là segment **đầu tiên** ngay sau SOI (không có APP0/JFIF), TIFF gồm 4 IFD:
IFD0 (17 entry) → Exif IFD (43 entry) → GPS IFD (11 entry) → Interop IFD (`0xa005` =
{`0x1` `'R98'`, `0x2` `'0100'`}), rồi IFD1 (thumbnail) nối sau IFD0. Tag nằm rải ở cả IFD0 lẫn
Exif IFD chứ không gom một chỗ, và **không sắp theo thứ tự tăng dần**.
Đối chiếu file app ghi ra: tập tag IFD0/GPS khớp mẫu, nhưng thiếu Interop IFD, thiếu
`OffsetTime`/`OffsetTimeOriginal`/`OffsetTimeDigitized`, và khi HAL nghèo tag thì thiếu
`ExifVersion`/`FlashPixVersion`/`ComponentsConfiguration`/`ColorSpace` — đúng nhóm mà phần mềm
tạo khung dùng để nhận dạng thông số.
`src/utils/exifWrite.ts`:
- Thêm IFD thứ 4 (Interop) vào layout + con trỏ `0xa005` trong Exif IFD; parse đọc Interop của
nguồn qua `0xa005`.
- Điền mặc định khi nguồn thiếu: `ExifVersion 0x9000` = `'0230'`, `FlashPixVersion 0xa000` =
`'0100'`, `ComponentsConfiguration 0x9101` = {1,2,3,0}, `ColorSpace 0xa001` = 65535, Interop
`'R98'`/`'0100'`. Nguồn có sẵn thì giữ nguyên, không ghi đè.
- Ghi `OffsetTime 0x9010` (IFD0) + `OffsetTimeOriginal 0x9011` + `OffsetTimeDigitized 0x9012`
(Exif IFD) bằng offset cục bộ `±HH:MM` khi app sở hữu mốc thời gian (ảnh chụp). Ảnh export giữ
ngày của file nguồn thì không bịa offset.
- Fix bug thật: `swapToLittleEndian` dùng `data.slice()` — với Node `Buffer`, `slice()` trả về
*view* chứ không copy, nên hàm swap ngược byte của chính buffer nguồn (đọc ra ISO 36865 thay vì
400 khi nguồn big-endian). Nay dùng `new Uint8Array(data)`.
- Vẫn chủ động bỏ IFD1/thumbnail và MakerNote (ponytail comment: offset tuyệt đối không sống sót
khi TIFF bị dời; thumbnail mang pixel trước khi export).
**Đo lại** (codec Node + PIL + máy thật):
| Kiểm chứng | Kết quả |
| --- | --- |
| `exifchk/run.js` | SELF-CHECK PASSED, gồm check "source bytes left untouched" |
| `exifchk/verify.py` | PIL VERIFY PASSED (thêm Interop/ExifVersion/OffsetTime/ColorSpace/ComponentsConfiguration) |
| round-trip ảnh mẫu qua writer | mọi tag Xiaomi giữ nguyên: ISO 400, ExposureProgram 2, MeteringMode 2, FocalLengthIn35mmFilm 75, GPS 15°23'54.67"N 108°19'22.47"E; thêm Interop |
| chụp `RC_20260911_182721.jpg` (emulator) | Interop `R98`/`'0100'`; `0x9010`/`0x9011`/`0x9012` = `+07:00`; `ComponentsConfiguration`, `FlashPixVersion`, `ColorSpace` có mặt; Software `RecipesCam 1.2`; Orientation 1; 960×1280; giữ Make/Model HAL |
`ExifVersion` của ảnh chụp trên emulator vẫn là `'0210'` vì HAL đã cung cấp — đúng thiết kế: chỉ
điền khi nguồn thiếu.
**Không thể khắc phục**: nếu phần mềm tạo khung cần tag riêng của nhà sản xuất (MakerNote) hoặc tra
cứu cơ sở dữ liệu thiết bị theo Make/Model, app không giả được — MakerNote của máy khác chứa offset
tuyệt đối không dùng lại được, còn Make/Model là do HAL trả về.
- `tsc --noEmit` vẫn **14 lỗi** đúng baseline.
- Build `assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success.
---
## 22. Khung dấu chìm Xiaomi: vì sao Gallery báo "không thể nhận dạng được thông số" (2026-09-11)
**Triệu chứng.** Ảnh chụp `RC_20260911_183114.jpg` mở trong trình edit của Xiaomi, chọn tạo khung
dấu chìm → "không thể nhận dạng được thông số". Ảnh `IMG_20260830_140429.jpg` (app camera gốc) thì
mở bình thường. Hai ảnh **cùng một máy** (Xiaomi 14 Ultra, codename `aurora`).
**Nguyên nhân.** HyperOS Gallery đọc tag EXIF **`0x889e`** (Exif IFD, ASCII) để dựng khung dấu
chìm — đây là JSON do app camera gốc ghi:
```
0x889e {"buildDevice":"aurora","customize":"","cvLensName":"","filterName":"","livephotoInfo":"",
"version":2,"waterLogo":"XIAOMI","waterName":"14 Ultra"}
```
Ảnh mẫu còn có `0x9999` (`{"mirror":false,"sensorType":"rear","Hdr":"auto",...}`) và MakerNote
`0x927c` (`Xiaomi\x00\x01MM...`). **Ảnh của app không có** `0x889e`, `0x9999`, `0x927c`, cũng không
có GPS IFD.
Lý do không phải bug của module: nguồn chụp (CameraX/HAL) **không bao giờ** cấp các tag vendor này.
HAL chỉ cấp tag chuẩn (ISO 2151, 1/50 s, f/2.0, 8.72 mm, FocalLengthIn35mm 23…). Đã chứng minh
module **giữ nguyên** `0x889e`/`0x9999` khi nguồn có (chỉ cố ý bỏ `0x927c`).
**Diff tag-set** (`exifchk/cmp_exif.py`):
| | RC_183114 | IMG mẫu |
| --- | --- | --- |
| SOF0 (w,h) | (3072, 4096) | (4096, 3072) |
| byte order | II | MM |
| IFD0 / Exif / GPS / Interop | 13 / 36 / 0 / 2 | 17 / 43 / 11 / 2 |
| Exif chỉ mẫu có | — | `0x889e` `0x9999` `0x927c` `0x8895` `0x889a` `0x889d` `0x889f` `0xa661` |
| IFD0 chỉ mẫu có | — | `0x8825` `0x889a` `0x889d` `0x88a3` `0x9999` `0x9a00` |
xRes/yRes 300 vs 72, Orientation 1 vs 0, ColorSpace 1 vs 65535, `0x110` Model `24030PN60G` vs
`Xiaomi 14 Ultra`. Layout TIFF của ảnh app **hợp lệ**, không lỗi cấu trúc — Gallery chỉ thiếu blob.
**Fix.** Đọc lại đúng các prop mà app camera gốc dùng rồi tự tổng hợp blob:
- `modules/recipescam-export/.../RecipescamExportModule.kt`: thêm `Function("deviceInfo")` trả
`device` (`ro.product.device`) · `marketName` (`ro.product.marketname` → `ro.product.odm.marketname`
→ `ro.product.vendor.marketname`) · `manufacturer` · `model`. Đọc prop hệ thống ẩn bằng reflection
(`android.os.SystemProperties.get`), lỗi thì trả chuỗi rỗng.
- `src/utils/exifWrite.ts`: `watermarkBlob(device)` dựng JSON **đúng thứ tự field** của ảnh gốc
(`waterName` = marketName bỏ tiền tố brand: `Xiaomi 14 Ultra` → `14 Ultra`). Trong
`writeJpegExif`, ghi `0x889e` **chỉ khi** nguồn thiếu blob và máy là Xiaomi/Redmi/POCO — **không
bao giờ ghi đè** blob nguồn.
- `src/utils/photoMeta.ts`: `getDeviceIdentity()` cache một lần, trả `null` khi module cũ.
- `src/utils/exportEngine.ts`, `src/utils/photoMeta.ts`: truyền `device` vào stamp.
**Kiểm chứng.**
| Kiểm chứng | Kết quả |
| --- | --- |
| blob sinh ra vs blob gốc (`0x889e`) | **IDENTICAL** từng byte, 146 B + NUL = count 147 |
| `exifchk/run.js` | SELF-CHECK PASSED (11 ok) — có check "blob nguồn không bị ghi đè", "không ghi blob trên máy non-Xiaomi" |
| `exifchk/verify.py` (PIL) | PIL VERIFY PASSED |
| `tsc --noEmit` | 14 lỗi, đúng baseline |
| `gradlew --offline assembleRelease` | BUILD SUCCESSFUL |
| cài + mở app trên emulator | PID sống, 0 FATAL EXCEPTION |
**Giới hạn.** Emulator không chạy HyperOS Gallery nên **không** xác nhận trực tiếp được Gallery đã
hết lỗi; cũng **không** xác nhận được `deviceInfo()` trả gì trên máy thật (chỉ suy ra từ tên prop
chuẩn của Xiaomi). Máy non-Xiaomi sẽ không có blob → vẫn đúng như trước.
+123
View File
@@ -0,0 +1,123 @@
# Ghi chú port web → Android: sim `CLASSIC VIVIDIPES` + chip FX `MONOCHROME`
Nguồn: web studio `docker/frontend` (nhánh `recipes-web`), commit `d7d7be2` (sim mới) và
`4d0d441` (chip FX). Việc dưới đây là **port sang app Android** (`src/`, nhánh này).
Đối chiếu: `src/` giữa `recipes-web` và `feat/vision-camera-v5` **chỉ khác**
`src/components/RecipeCreateModal.tsx` (+9 dòng) — nên port gần như là chép y nguyên.
Số dòng dưới đây tính theo nhánh này.
---
## 1. Sim mới `CLASSIC VIVIDIPES`
Ghép tại dòng blue: **giữ nguyên dòng blue của `classic-chrome`**, lấy **dòng R/G của `velvia`**.
Sửa 4 file:
1. `src/types/index.ts:65` — `BaseFilter`: thêm `'classic-vivid'`.
2. `src/utils/colorUtils.ts` — thêm nhánh **ngay sau** nhánh `classic-chrome` (dòng 111):
```ts
} else if (baseFilter === 'classic-vivid') {
// Classic Vivid: the Classic Chrome blue row kept verbatim — skies stay the
// same muted teal/cyan — with the red and green rows taken from Velvia, so
// everything that is not blue reads loud.
matrix = [
1.3895, -0.2540, -0.0256, 0, -0.0550, // R — velvia
-0.0772, 1.2355, -0.0261, 0, -0.0661, // G — velvia
0.0652, 0.3973, 0.5575, 0, -0.0100, // B — classic-chrome (nguyên bản)
0, 0, 0, 1, 0,
];
```
3. `src/utils/defaultRecipes.ts:43` — `FILM_SIMS`, thêm ngay sau `sim-classic-chrome`:
```ts
{ id: 'sim-classic-vivid', name: 'CLASSIC VIVIDIPES', baseFilter: 'classic-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS } },
```
4. `src/utils/toneShader.ts:190` — `FILM_TONE`, cạnh `'classic-chrome'`:
```ts
'classic-vivid': { sh: -0.28 },
```
(Dìm shadow thuộc về stock chứ không thuộc dòng ma trận → đi kèm. Bỏ dòng này nếu muốn
VIVIDIPES không dìm shadow.)
**Không phải sửa gì thêm**: chip `PHOTO STYLE` (`AdjustmentPanel.tsx` `groupDefs.style`), TopBar
(`activeRecipe.name`) và picker trong `RecipeCreateModal.tsx` đều map từ `FILM_SIMS`.
**Kiểm chứng**: strip PHOTO STYLE có **10** sim; chọn `CLASSIC VIVIDIPES` → look khác **cả**
`CLASSIC CHRIPES` lẫn `VELVIPES`; `CLASSIC CHRIPES` vẫn nguyên vẹn cạnh nó.
---
## 2. Chip `MONOCHROME` ở tab FX (`filters`)
Đây là **công tắc**, không phải một look: bật thì đổi `baseFilter` sang `monochrome`, tắt thì
trả lại đúng cái ảnh đang mặc (kể cả tên sim). **Không được đụng `adjustments`** — knob chỉnh
trong lúc đang bật phải sống sót.
Web: `docker/frontend/src/App.tsx` — `monoPrev` ref + `toggleMono`, chip `mono` đứng đầu
`case 'fx'`, `active: monoOn` với `monoOn = recipe.baseFilter === 'monochrome'`.
Android cần 2 chỗ:
**a. `src/components/AdjustmentPanel.tsx`** — `case 'filters'` (dòng 888):
```ts
case 'filters':
return chipRow([
{ key: 'mono', label: 'MONOCHROME', active: monoOn, amberValue: monoOn, onPress: onToggleMono },
...paramChips(paramDefs.filters),
]);
```
+ thêm 2 prop vào `interface AdjustmentPanelProps`: `monoOn: boolean;` và `onToggleMono: () => void;`
+ truyền từ `App.tsx` chỗ render `<AdjustmentPanel … />` (`App.tsx:1929` có `currentBaseFilter`,
`onSelectRecipe` — đặt cạnh đó).
**b. `App.tsx`** — Android giữ `adjustments` là state **riêng** (không nằm trong `selectedRecipe`),
nên công tắc chỉ chạm `selectedRecipe`:
```ts
// FX's MONOCHROME switch: not a look of its own — it swaps the base filter for
// the mono stock and swaps right back. Adjustments are never touched.
const monoOn = selectedRecipe?.baseFilter === 'monochrome';
const monoPrevRef = useRef<Recipe | null>(null);
const handleToggleMono = () => {
commit(true, () => {
if (selectedRecipe?.baseFilter === 'monochrome') {
const back = monoPrevRef.current ?? filmSimToRecipe(FILM_SIMS.find((f) => f.id === 'sim-provia')!);
setSelectedRecipe(back);
} else {
monoPrevRef.current = selectedRecipe;
setSelectedRecipe({
...(selectedRecipe ?? filmSimToRecipe(FILM_SIMS[0])),
id: 'mono',
name: 'MONOCHROME',
baseFilter: 'monochrome',
});
}
});
};
```
Lưu ý:
- `commit(true, …)` (dòng 739) để công tắc vào **undo stack** như mọi edit khác — `handleResetAll`
dùng đúng cách này.
- `Look` đã có `recipe` → UNDO tự trả lại đúng `baseFilter` cũ, không cần state phụ nào khác.
- `id: 'mono'` không trùng sim nào nên khi bật, chip PHOTO STYLE **không** còn sáng tên sim cũ
(`groupDefs.style.value` tính từ `currentRecipeId` so với `FILM_SIMS`).
- Tắt khi `monoPrevRef` rỗng (ví dụ vào monochrome bằng cách chọn sim ACRIPES, không qua công tắc)
→ rơi về PROVIA, tức là "tắt là có màu", không phải về đen trắng.
**Kiểm chứng**: bật → ảnh xám (mọi pixel `R=G=B`; chỉ lệch khi WB khác 5500K hoặc tint khác 0);
tắt → ảnh trở lại **y hệt** trước khi bật (so hash pixel); đổi `CONTRAST` trong lúc bật/tắt →
giá trị vẫn giữ; UNDO một nhịp → về màu, chip trở lại trạng thái tắt.
---
## 3. Test web đã chạy (tham chiếu — nằm trong scratchpad của agent, không nằm trong repo)
- `sim-vivid-test.cjs` — **9/0**: chip có mặt, strip đủ 10 sim, chọn được, render khác cả
CHRIPES lẫn VELVIPES.
- `fx-mono-test.cjs` — **15/0**: đo trực tiếp kênh màu trên pixel preview (spread `>25` khi màu,
`<=6` khi mono), hash quay lại y hệt khi tắt, CONTRAST sống sót, UNDO gỡ mono.
- Regression web quanh 2 thay đổi: `panel`, `layout`, `fonts 27/0`, `create 52/0`, `share 22/0`,
`photo-save 19/0`, `redo 30/0`, `accent-brand` — tất cả xanh.
+113
View File
@@ -0,0 +1,113 @@
# PLAN — Option 3: thay engine export Skia bằng module native Kotlin
Nhánh: `feat/vision-camera-v5` (RecipesCam, /home/locpham/RecipesCam)
Ngày: 2026-09-06. Trạng thái: bắt đầu.
## Vấn đề
`exportEngine.ts` render ~1s/ảnh, sync JSI (`MakeImageFromEncoded`, `makeImageSnapshot` + `encodeToBytes(JPEG,95)`, sharpen) → block JS thread khi queue render chạy. Shutter đã được tách khỏi render bằng FIFO queue (`exportQueueRef`/`enqueueExport`, commit f588849); phần còn lại = render chạy trên thread nền native, JS/UI không bao giờ nghẽn.
## Quyết định đã chốt (user: "theo khuyến nghị hết")
- **D1** Chỉ Android (app không có iOS env).
- **D2** v1 = xấp xỉ gần, KHÔNG pixel-identical: ColorMatrix + các bước CPU/Canvas. KHÔNG port GPU AGSL (RuntimeShader) ở v1. Skia engine GIỮ làm reference/fallback, không xoá trước parity gate.
- **D3** Vehicle = **local Expo Module** `modules/recipescam-export` (expo-modules-core 57.0.6 có sẵn; autolinking quét `./modules` mặc định — đã xác minh trong expo-modules-autolinking src). Không cần nitrogen/nitro-image cho code mới (nitro-image vẫn nằm trong deps, không dùng).
- **D4** Chạy P0 benchmark decode+encode native trên máy Xiaomi trước khi mở rộng pipeline. Nếu CPU path vỡ budget (<~1s/ảnh toàn pipeline, mục tiêu render nền <500ms) thì quay lại cân nhắc GPU.
## Kiến trúc đích
- App gọi module native qua promise → hàm chạy trên background (`AsyncFunction ... Coroutine` + `withContext(Dispatchers.IO)`), JS không block.
- FIFO: mỗi job 1 lời gọi module; module tự serialize trên executor nếu cần (v1: JS queue hiện có là đủ, mỗi task 1 await module).
- Đầu vào: `sourceUri` file ảnh + tham số recipe (matrix float[], tone params, frameId, geotag text...). Đầu ra: file JPEG (path) hoặc bytes. MediaLibrary save + DPI patch giữ nguyên ở JS.
- Legacy engine giữ nguyên file `src/utils/exportEngine.ts`; native path phát triển song song, bật qua flag sau parity gate.
## Ánh xạ pipeline (thứ tự Skia engine → native)
Đọc từ exportEngine.ts (512 dòng) — thứ tự xử lý bắt buộc giữ nguyên để ảnh gần engine cũ:
| # | Skia (nguồn) | Native thay thế | Parity |
|---|---|---|---|
| 1 | aspect crop center-largest (trừ wallframe) | Bitmap crop `Bitmap.createBitmap(src, x,y,w,h)` | exact |
| 2 | matrix màu: `getSkiaColorMatrix` + `applyExposureGain` → `ColorFilter.MakeMatrix` | port toán colorUtils.ts → Kotlin float[4x5] → `ColorMatrixColorFilter` | exact nếu giữ thứ tự phép nhân & rounding; test diff |
| 3 | tone `TONE_SKSL` DR/highlight/shadow (toneShader.ts) | v1: đường cong LUT 1D/luminance xấp xỉ | approx |
| 4 | cinema `CINEMA_SKSL` seasonal grade | v1: bỏ qua hoặc matrix approx | approx (chú thích) |
| 5 | denoise blur | v1: skip / downscale-upscale blur | approx |
| 6 | clarity 3x3 conv sharpen (âm = mist blur) | v1: conv nguyên thuỷ trên IntArray nếu >0; mist = blur | approx (watch perf) |
| 7 | grain overlay noise shader BlendMode.Overlay | noise bitmap tile + PorterDuff Overlay | approx |
| 8 | frames: polaroid card (`POLAROID_CARD`/`polaroidLayout`), wallframe (wallframe.png, xoay 90, `wallframeLayout`), classic borders (`drawFrameOnCanvas`) | Android Canvas draw; port layout constants từ frameUtils.ts | close |
| 9 | GPS watermark: font Cousine + NotoEmoji-GPS, 📍/📷, amber #f59e0b/trắng, vị trí tuỳ framedWindow | Canvas + `Typeface.createFromFile` | close (metrics emoji khác Skia → QA) |
| 10 | screen sharpen (`screenSharpenImage`) | v1: conv 3x3 native hoặc skip | approx |
| 11 | encode `encodeToBytes(JPEG,95)` | `Bitmap.compress(JPEG, 95)` | exact (size có thể lệch nhẹ, chấp nhận) |
| 12 | DPI patch `patchJpegDpi(bytes,300)` (jpegDpi.ts) | GIỮ ở JS trên bytes/phản hồi | exact |
| 13 | ghi file + `MediaLibrary.createAssetAsync` (App.tsx) | giữ nguyên JS | exact |
Nguồn hằng số: src/utils/colorUtils.ts (matrices provia/velvia/classic-chrome/astia/eterna/classic-neg/leica/monochrome, kelvinToRGB, WB/tint, CC), src/utils/toneShader.ts, src/utils/cinemaShader.ts, src/utils/frameUtils.ts (POLAROID_CARD, polaroidLayout, WALLFRAME_W/H 3117/4000, wallframeLayout), src/utils/exportEngine.ts, src/utils/jpegDpi.ts.
## Phases
### P0 — Spike: module skeleton + benchmark decode/encode native
Việc:
- Tạo local Expo module `modules/recipescam-export` (android only) với 1 hàm `decodeEncodeAsync(srcPath, dstPath, quality)` → Map{decodeMs, encodeWriteMs, totalMs}; decode BitmapFactory, encode Bitmap.compress, ghi file, toàn bộ `withContext(Dispatchers.IO)`.
- Probe tạm `src/dev/nativeBenchProbe.ts` + hook App.tsx gated `process.env.EXPO_PUBLIC_BENCH === '1'` (chạy 3 lần trên wallframe.png + đo gap setInterval để chứng minh JS không block).
- Build release với `EXPO_PUBLIC_BENCH=1 ./gradlew assembleRelease` → APK.
- Chạy trên Xiaomi (USB), đọc logcat ReactNativeJS.
Exit: decode+encode < ~500ms; JS gap nhỏ khi render. Đây là gate D4.
File sinh: modules/recipescam-export/*, src/dev/nativeBenchProbe.ts (xoá khi P7).
### P1 — Port matrix màu
- Kotlin: dịch `getSkiaColorMatrix` + `applyExposureGain` (colorUtils.ts) → FloatArray 4x5. Tham số baseFilter + ColorAdjustments truyền từ JS (hoặc gửi matrix đã tính từ JS — JS tính nhanh, matrix 4x5 ~ 20 số — QUYẾT ĐỊNH: tính matrix ở JS bằng colorUtils.ts hiện có rồi truyền float[] xuống native → KHÔNG nhân đôi logic, parity miễn bàn). Cùng kiểu: kelvinToRGB/WB/CC vẫn nằm trong getSkiaColorMatrix → chỉ cần gửi kết quả.
- Native: `processColorAsync(srcPath, dstPath, matrix: FloatArray, crop: {…}|null)` decode → crop → `ColorMatrixColorFilter` qua Paint/Canvas draw → encode.
Exit: ảnh ra khớp Skia matrix (so sánh bằng mắt + diff).
### P2 — Tone (DR/HL/SH) xấp xỉ
- Port công thức toneShader.ts sang dạng LUT áp per-pixel (hoặc 2 pass nếu cần giữ grain sau tone — thứ tự: tone trước clarity/grain như engine).
Exit: vùng highlight/shadow không cháy như engine cũ, chấp nhận sai số.
### P3 — Clarity/grain/denoise/cinema
- Theo bảng: conv 3x3 IntArray (clarity), blur xấp xỉ (denoise/mist), noise tile Overlay (grain). Cinema: quyết định giữ matrix approx hay bỏ.
Exit: ảnh gần engine cũ, tổng thời gian nền OK (đo trên máy).
### P4 — Frames + watermark (Canvas)
- polaroid card, wallframe (asset wallframe.png — đọc qua đường file/asset), classic borders, GPS text/emoji.
Exit: layout khớp frameUtils (kiểm tra pixel các góc/viền bằng overlay ảnh so sánh).
### P5 — Hook vào luồng export thật + flag
- App.tsx: thêm nhánh `useNativeExport` (flag hằng/env); giữ nguyên enqueueExport FIFO; MediaLibrary/DPI giữ nguyên.
Exit: chụp → gallery có ảnh qua native path.
### P6 — Parity + perf + fallback
- Script chạy cùng 1 ảnh nguồn qua 2 engine; xuất ảnh ghép cạnh nhau + điểm diff (downscale) để QA trên máy.
- Đo: cũ ~1s/ảnh block JS → mới <500ms nền.
- Giữ legacy: `processAndExportPhoto` cũ nguyên vẹn, bật qua cờ.
Exit: user QA đạt → mặc định native; legacy chỉ fallback.
### P7 — Dọn & ship
- Xoá probe/dev code, xoá env gate; typecheck; commit + push (KHÔNG stage `.kilo/`, `.expo/`); `assembleRelease`; báo MD5 APK.
- Nhắc: nếu npm install chạy lại → node_modules vision-camera về pristine, áp lại patch:
`cd RecipesCam && git apply patches/vision-camera-shutter-silent.patch`
(artifact = diff `HybridPhotoOutput.kt`: shutter OFF thật silent — bỏ OR mustPlayShutterSound).
`cd RecipesCam && git apply patches/vision-camera-isp-off.patch`
(artifact = 3 hunk, 2 file: `HybridPhotoOutput.kt` cho mọi still JPEG, `modules/recipescam-ultrawide/.../UltraWideModule.kt` cho đường 0.5x — cùng cặp cờ NOISE_REDUCTION_MODE + EDGE_MODE của ISP = OFF).
## Rủi ro / ghi chú
- Local module phải bắt chước đúng cấu trúc expo package (đã đọc expo-media-library: `plugins { id 'com.android.library'; id 'expo-module-gradle-plugin' }`, expo-module.config.json khai báo class Kotlin; autolinking quét `./modules`).
- API expo-modules-core 57: `AsyncFunction("x") Coroutine { }` (như MediaLibraryModule), coroutines 1.10.2 qua `api` của expo-modules-core.
- Gradle: thêm module → lần build đầu sẽ reconfigure (chậm hơn), không cần sửa settings.gradle.
- 12MP full-res decode ~48MB RAM — ổn, không inSampleSize.
- Emoji trong Canvas có metrics khác Skia — kiểm tra vị trí watermark.
- Grain procedural (Skia hash noise) ≠ noise tile — chấp nhận sai khác nhỏ ở v1.
- Không đặt tên module trùng class đã có.
## Checklist trạng thái
- [x] Chốt quyết định D1–D4
- [x] P0 scaffold: module `modules/recipescam-export` + probe `src/dev/nativeBenchProbe.ts` + hook App.tsx (env-gated); release build OK (recipescam-export 0.1.0 autolink)
- [x] P0 benchmark trên Xiaomi (12MP wallframe: decode 152-182ms, encode+write 56-77ms, total 215-269ms, off-main-thread — PASS)
- [x] P1 matrix (processColorAsync: decode→crop→ColorMatrixColorFilter→JPEG; parity OK trên Xiaomi — classic-neg/black → rgb(13,5,0), translate×255, crop 2000x2000, off-thread, colorMs≈30ms)
- [x] P2 tone (applyTone CPU pass port TONE_SKSL — immutable-decode fix: copy ARGB_8888 trước setPixels; gates 64x64 trên Xiaomi: white hl=-1→140, gray64 sh=+1→75, white dr=1→209, gray26 sh=+1→104, gray26 hl=+1 sh=-1→11 — ALL PASS; full 12MP toneMs 488-548ms = cần tối ưu khi gộp P3)
- [x] P3 clarity/grain (native denoise/clarity/grain/cinema port: boxBlur padding fix + step gates; cinema gates summer-192→(211,209,201), winter-32→(82,85,88), summer-sat→(217,120,47) khớp node ±0; ramp/step blur gates denoise→130, clarity→131, mist→130/denoise-step→170, mist-step→142 PASS; grain structural OK; full 12MP 4.7-5.1s CPU = vượt D4, quyết GPU ở P6)
- [x] P4 frames/watermark
- [x] P5 hook export + flag
- [ ] P6 parity/perf gate — probe chạy xong trên Xiaomi (1fdf765): classic-neg / retro-amber-frame / cinema-summer trên ảnh real 2304x3072; native off-thread 2.7-2.9s (JS ticks 65-70 vẫn chạy) vs legacy GPU 0.36-0.5s (JS-block); diff meanAbs 7.1/13.1/13.3, gt12% 16.8/42.1/42.7, gt32% 0.14/3.7/4.0 (grain procedural + JPEG encoder + tone approx; retro thấp nhất — mist + frame classic). CHỜ user QA ảnh ghép DCIM/parity_*.jpg → flip default native + quyết GPU
- [ ] P7 dọn + ship
## Ngoài P-series (feature branch)
- [x] Startup mode DEFAULT/LAST SESSION (Settings chip + AsyncStorage `startup_mode`; restore session qua FILM_SIMS; fix clobber ratio khi sim selected — 3 file LF)
- [x] GPS watermark mặc định OFF khi startup DEFAULT (applyDefaultStartup setUseGeotag(false)) — verify UI Xiaomi: cold start DEFAULT → FRAME tab GPS WATERMARK OFF
- [x] Shutter OFF thật silent (patch HybridPhotoOutput.kt, 2 path, artifact patches/) — verify log ON/OFF trên Xiaomi
- [ ] Zoom camera native: probe physical zoom + zoom UI (đang làm)
+64
View File
@@ -0,0 +1,64 @@
# Play release notes
Text for Google Play Console → Release → "What's new". One entry per version, newest
first. Plain text only (the Console renders no markdown), 500 characters max per
language — each block below is measured and fits. Paragraphs are one line each so a
copy-paste lands in the Console unwrapped.
Release name field: `1.2.7 (9)`
## 1.2.7 (versionCode 9)
```
Photos taken in dim light and at high ISO came out much noisier than the preview showed. Every shot was being sharpened in the background by a fixed amount the viewfinder never applied, and it amplified sensor noise about three times over.
That hidden sharpening is gone, so high-ISO shots now come back as clean as the preview promised. The SHARPENING slider still sharpens exactly as before when you move it above 0.
```
419 characters (1.2.7).
Artifact: `android/app/build/outputs/bundle/liteRelease/app-lite-release.aab`
(appId `com.locphamtran.recipescamera`, versionCode 9, versionName 1.2.7).
The pro APK built from the same commit is `app-pro-release.apk`, versionName
`1.2.7-pro` - it is the paid build handed out directly, not uploaded here.
## 1.2.6 (versionCode 8)
```
New film simulation B&W HIGH CONTRAST, next to ACRIPES: a warm-filter black-and-white mix, with deeper blacks and lifted whites, so the greys take a harder push.
Its contrast comes from the stock itself, so the EXPOSURE and CONTRAST sliders stay at 0 while the shot renders pushed.
```
281 characters (1.2.6).
Artifact: `android/app/build/outputs/bundle/liteRelease/app-lite-release.aab`
(appId `com.locphamtran.recipescamera`, versionCode 8, versionName 1.2.6).
The pro APK built from the same commit is `app-pro-release.apk`, versionName
`1.2.6-pro` - it is the paid build handed out directly, not uploaded here.
## 1.2.5 (versionCode 7)
```
Film simulations are now pure colour and tone: each one is the stock's own colour matrix and curve, with no grain, clarity, exposure or saturation baked in - those are your edits to make.
LC STREETLIFE VIVID keeps its brightness from the stock itself, so the EXPOSURE slider stays at 0 while the shot comes out lifted.
```
Artifact: `android/app/build/outputs/bundle/liteRelease/app-lite-release.aab`
(appId `com.locphamtran.recipescamera`, versionCode 7, versionName 1.2.5).
The pro APK built from the same commit is `app-pro-release.apk`, versionName
`1.2.5-pro` - it is the paid build handed out directly, not uploaded here.
## 1.2.4 (versionCode 6)
```
HSL selective colour is here: eight hue bands (red, orange, yellow, green, aqua, blue, purple, magenta), each with its own HUE, SAT and LUM, plus HUE/SAT/LUM IMAGE knobs that move every colour at once.
In the library, tap PICK and touch any colour on the photo: a panel opens on that spot with the three knobs of the band it belongs to.
The tool rail is reordered: presets, favorites, white balance, light, effects, HSL, frame.
```
427 characters (1.2.4).
Artifact: `android/app/build/outputs/bundle/liteRelease/app-lite-release.aab`
(appId `com.locphamtran.recipescamera`, versionCode 6, versionName 1.2.4).
The pro APK built from the same commit is `app-pro-release.apk`, versionName
`1.2.4-pro` — it is the paid build handed out directly, not uploaded here.
+67
View File
@@ -0,0 +1,67 @@
# Release notes
## 1.2.7 (versionCode 9)
Export
- Every camera capture came out of the exporter through a full-resolution unsharp mask the viewfinder never showed. App.tsx passed `sharpen: true`, and with the SHARPENING knob at its default 0 both engines read that flag as an *amount* of 0.5 - a value that existed only as the boolean's fallback. The 3x3 kernel `[0,-a,0; -a,1+4a,-a; 0,-a,0]` at a=0.5 has a noise gain of 3.167x at low frequency and 5.0x at Nyquist, so a dim, high-ISO frame was pushed back several times noisier than the preview promised.
- The knob is now the only source of output sharpening. The boolean is gone from ExportOptions and NativeExportOptions, the 0.5 fallback is gone with it, and screenSharpenImage no longer carries `amount = 0.5` as a default argument - a second door onto the same value. A knob above 0 sharpens exactly as before, the negative knob still means none, and the Kotlin-side `adjust["sharpen"]` amount is untouched.
- Measured on a Xiaomi thor, 3000x4000 captures in one unchanged scene. At 1x / ISO ~330 the noise sigma drops 4.52 -> 2.35, the dark-quartile sigma 3.21 -> 1.56 and a flat 64px tile 0.599 -> 0.349; at 10x / ISO ~1900 the same three fall 1.22 -> 1.09, 0.75 -> 0.54 and 0.464 -> 0.225. With no edit applied, the JPEGs shrink 26% and 35% - the mask had also been spending a third of every file on amplified noise.
- Stills now match the preview, which always rendered without the mask.
Notes
- qualityPrioritization was re-tested as the other suspect and cleared: MAXIMIZE_QUALITY ('quality') moved neither noise figure at either ISO, so the shipped 'balanced' capture mode stays.
- Zoom to 10x in the same room is the high-ISO bench for this device: AE picks ISO ~1900-2100 there against ~330 at 1x.
## 1.2.6 (versionCode 8)
Presets
- New film sim B&W HIGH CONTRAST, next to ACRIPES. Acros' grey ramp taken through a warm filter mix - red 0.38 / green 0.56 / blue 0.06, the three rows equal and summing to 1.00, so a neutral stays the neutral the sensor recorded and nothing clips at either end - and then pushed at both ends: the toe goes to -0.32 against Acros' -0.12, and the shoulder LIFTS to +0.26 instead of rolling off. Midtones see neither mask, so the long smooth stretch between the two ends survives, which is what keeps that hard a push off the posterised look.
- The stock's own slope (+4 of Contrast) is not in the sim's adjustments: the panel still reads EXPOSURE 0 / CONTRAST 0, and FX still reads GRAIN 0 / CLARITY 0, while the render comes out pushed. It rides STOCK_BIAS in colorUtils.ts, keyed by baseFilter - the old STOCK_EXPOSURE table folded in, one place for the tone a stock carries by itself (LC STREETLIFE VIVID exposure +2, B&W HIGH CONTRAST contrast +4).
- Two black-and-white stocks exist now, so every "is this a colour stock?" test goes through the new isMonochromeBase(): saturation, white balance and the R/B fine-tune in the matrix, Color Chrome in the tone shader. A second B&W stock can no longer end up half excluded - a stray Kelvin gain on a grey ramp shows up as a tint.
- Measured against the compiled tables: a 0.25 grey goes 0.2500 -> 0.2250 (the +4 slope pivoting on mid-grey 0.5), a full red goes 0.213 -> 0.368 while blue drops to 0.016, and colour-stage knobs moved hard (saturation +8, white balance 9000K, wbR +5 / wbB -5) leave both B&W matrices byte-identical.
- On a Xiaomi thor: the chip sits right after ACRIPES, its preview reads mean chroma 0.00 against 17.48 for a colour sim, and the frame's luma spread widens as designed (sd 92.3 / p95 245 against ACRIPES' 88.9 / 238).
## 1.2.5 (versionCode 7)
Presets
- A film sim is a stock, not an edit: every entry in FILM_SIMS now carries the neutral defaults verbatim, and its whole look comes from the stock's own colour matrix plus its FILM_TONE curve. The effects that had been riding along are gone - grain on ACRIPES, clarity+grain on LC STREETLIFE CLASSIC, and exposure/saturation/clarity/shadow/highlight on LC STREETLIFE VIVID.
- LC STREETLIFE VIVID is brighter because the stock is brighter, not because a slider was moved: the +2 exposure lives in STOCK_EXPOSURE (colorUtils.ts), keyed by baseFilter, where the preview and both exporters already read it. It never reaches the panel, so EXPOSURE still reads 0 on that sim.
- The naming rule is recorded in the FILM_SIMS header: colour and tone only, plus a reference table of the real stock each sim stands for. Reference only - the UI keeps the sim names.
- Measured against the compiled tables: a mid grey goes 0.5000 -> 0.5435 under LC STREETLIFE VIVID, a ratio of 1.1008 against exposure +2's own 1.1000, while the sim's own exposure value stays 0.
## 1.2.4 (versionCode 6)
HSL
- New HSL tab in the tool rail: the web studio's 8-band selective colour mixer. Each band (RED, ORANGE, YELLOW, GREEN, AQUA, BLUE, PURPLE, MAGENTA) carries hue, saturation and lightness knobs, -10..+10; -10 on SAT is grey, full HUE is a 30 deg turn, full LUM is +0.25.
- Band weights are linear tents anchored on the 8 hues 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 them red, and an ungated neutral would ride the RED band), while the whole-image LUM move is added after the gate, so a picture pulled to grey by -SAT still answers +LUM.
- The mixer runs last in TONE_SKSL, after split-tone/kv and vibrance, so a band edit is judged on the colour the user actually sampled.
- Library mode gains an eyedropper: PICK arms a layer over the picture, a tap reads the rendered pixel, points the ruler at the nearest band and hangs a panel with that band's three knobs. In camera mode PICK is greyed out - a live preview has no pixel to read - but the mixer still runs in the preview.
- HUE / SAT / LUM IMAGE move every hue at once, behind an IMAGE divider in the same chip row.
Rail
- Tool rail order: PRESETS, FAVORITED, WB, LIGHT, FX, HSL, FRAME.
Notes
- HSL is a LITE feature: both builds get it, no PRO gate.
- MONOCHROME disables the mixer outright - a mono stock has no hue to select.
- The camera worklet's tone buffer grew from 10 to 40 slots; a missed slot would have pushed NaN into every uniform and broken the whole tone pass, not just HSL.
- Measured on a Xiaomi thor against an 8-band chart: +10 whole-image HUE rotates all eight bands by exactly +30 deg, -10 on a band's SAT flattens that band to grey while the other seven stay put, and a whole-image -SAT drops the camera preview's mean chroma from 13.7 to 0.4 with +LUM still lifting it 116 -> 166.
## 1.2.3 (versionCode 5)
Shutter
- The shutter no longer waits on GPS: the fix is prewarmed at launch and bounded by a 2 s deadline, so a tagged shot reaches the gallery in about 3 s instead of 12–25 s.
- A shot pressed before the camera session binds is queued and replayed, not dropped.
- Fixes "Capture Failed — Could not take the photo.": `capturePhotoToFile` needs both its settings and options arguments.
- Saving to the gallery no longer depends on the media permission — the native save runs first, MediaLibrary only as a fallback.
Memory
- The Skia surface and bitmap are released after every export, dropping one full-resolution copy per rotation; the watermark font is parsed once.
Cold start
- The JS bundle is stored uncompressed in the APK.
- Named Lucide imports are rewritten to per-icon modules, so only the icons the app renders are bundled: 4.66 MB → 3.40 MB (−27%).
- The export pipeline and the recipe/GPS helpers load on demand.
- Hidden modals render nothing.
Measured on a vivo V2430 (Android 16) with a force-stop/start/tap probe: the earliest tap that reliably lands a photo moved from ~1.8 s to ~1.6 s once the bundle was trimmed. The rest is spent before app JS — process init, SoLoader, Hermes eval.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

+262
View File
@@ -0,0 +1,262 @@
# Third-Party Notices
This app (recipescamera) embeds or depends on the following open-source
software. Full license texts are reproduced below, grouped by license.
Generated 2026-09-09 from node_modules audit, rev. 2026-09-14 — 31 shipped components.
## Components
| Component | Version | License | Copyright / Author | Project |
|---|---|---|---|---|
| Skia graphics library (bundled native in react-native-skia) | bundled | BSD-3-Clause | Copyright 2011 Google Inc. All rights reserved. | https://skia.org/ |
| canvaskit-wasm | 0.41.0 | BSD-3-Clause | Copyright (c) 2011 Google Inc. All rights reserved. | https://skia.org/ |
| lucide-react-native | 1.24.0 | ISC | Copyright (c) 2026 Lucide Icons and Contributors | https://lucide.dev |
| @react-native-async-storage/async-storage | 2.2.0 | MIT | Copyright (c) 2015-present, Facebook, Inc. | https://github.com/react-native-async-storage/async-storage#readme |
| @react-native-community/slider | 5.2.0 | MIT | react-native-community | https://github.com/callstack/react-native-slider#readme |
| @shopify/react-native-skia | 2.6.2 | MIT | Copyright 2021-present, Shopify Inc. | git+https://github.com/Shopify/react-native-skia.git |
| expo | 57.0.7 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://github.com/expo/expo/tree/main/packages/expo |
| expo-asset | 57.0.6 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/asset/ |
| expo-dev-client | 57.0.6 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/dev-client/ |
| expo-document-picker | 57.0.2 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/document-picker/ |
| expo-file-system | 57.0.1 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/filesystem/ |
| expo-font | 57.0.1 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/font/ |
| expo-haptics | 57.0.1 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/haptics/ |
| expo-iap | 5.6.0 | MIT | Copyright (c) 2025 hyochan | https://github.com/hyodotdev/openiap/tree/main/libraries/expo-iap#readme |
| expo-keep-awake | 57.0.1 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/keep-awake/ |
| expo-location | 57.0.4 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/location/ |
| expo-media-library | 57.0.2 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/media-library/ |
| expo-sharing | 57.0.18 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/sharing/ |
| expo-status-bar | 57.0.1 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/status-bar/ |
| nativewind | 4.2.6 | MIT | Copyright (c) 2023 Mark Lawlor | https://nativewind.dev |
| react | 19.2.3 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates. | https://react.dev/ |
| react-dom | 19.2.3 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates. | https://react.dev/ |
| react-native | 0.86.0 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates. | https://reactnative.dev/ |
| react-native-nitro-image | 0.15.2 | MIT | Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy) | https://github.com/mrousavy/react-native-nitro-image#readme |
| react-native-nitro-modules | 0.37.1 | MIT | Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy) | https://nitro.margelo.com |
| react-native-reanimated | 4.5.0 | MIT | Copyright (c) 2016 Software Mansion <swmansion.com> | https://docs.swmansion.com/react-native-reanimated |
| react-native-safe-area-context | 5.8.0 | MIT | Copyright (c) 2019 Th3rd Wave | https://github.com/AppAndFlow/react-native-safe-area-context#readme |
| react-native-vision-camera | 5.2.3 | MIT | Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy) | https://visioncamera.margelo.com |
| react-native-vision-camera-skia | 5.2.3 | MIT | Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy) | https://visioncamera.margelo.com/api/react-native-vision-camera-skia |
| react-native-vision-camera-worklets | 5.2.3 | MIT | Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy) | https://visioncamera.margelo.com/api/react-native-vision-camera-worklets |
| react-native-web | 0.21.2 | MIT | Copyright (c) Nicolas Gallagher. | git://github.com/necolas/react-native-web.git |
| Cousine | Google Fonts | OFL-1.1 | Copyright 2026 The Cousine Project Authors (github.com/googlefonts/cousine) | https://fonts.google.com/specimen/Cousine |
| Noto Emoji (GPS subset) | Google Fonts | OFL-1.1 | Copyright 2022 The Noto Project Authors (github.com/notofonts/noto-emoji) | https://fonts.google.com/noto/specimen/Noto+Emoji |
## BSD-3-Clause
Applied to: Skia graphics library (bundled native in react-native-skia) (Copyright 2011 Google Inc. All rights reserved.), canvaskit-wasm (Copyright (c) 2011 Google Inc. All rights reserved.)
```text
// Copyright (c) 2011 Google Inc. All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are
// met:
//
// * Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the following disclaimer
// in the documentation and/or other materials provided with the
// distribution.
// * Neither the name of Google Inc. nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
```
## ISC
Applied to: lucide-react-native (Copyright (c) 2026 Lucide Icons and Contributors)
```text
ISC License
Copyright (c) 2026 Lucide Icons and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
The following Lucide icons are derived from the Feather project:
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
The MIT License (MIT) (for the icons listed above)
Copyright (c) 2013-present Cole Bemis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## MIT
Applied to: @react-native-async-storage/async-storage (Copyright (c) 2015-present, Facebook, Inc.), @react-native-community/slider (react-native-community), @shopify/react-native-skia (Copyright 2021-present, Shopify Inc.), expo (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-asset (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-dev-client (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-document-picker (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-file-system (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-font (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-haptics (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-iap (Copyright (c) 2025 hyochan), expo-keep-awake (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-location (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-media-library (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-sharing (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-status-bar (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), nativewind (Copyright (c) 2023 Mark Lawlor), react (Copyright (c) Meta Platforms, Inc. and affiliates.), react-dom (Copyright (c) Meta Platforms, Inc. and affiliates.), react-native (Copyright (c) Meta Platforms, Inc. and affiliates.), react-native-nitro-image (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-nitro-modules (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-reanimated (Copyright (c) 2016 Software Mansion <swmansion.com>), react-native-safe-area-context (Copyright (c) 2019 Th3rd Wave), react-native-vision-camera (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-vision-camera-skia (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-vision-camera-worklets (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-web (Copyright (c) Nicolas Gallagher.)
```text
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## OFL-1.1
Applied to: Cousine (Copyright 2026 The Cousine Project Authors (github.com/googlefonts/cousine)), Noto Emoji (GPS subset) (Copyright 2022 The Noto Project Authors (github.com/notofonts/noto-emoji))
```text
Copyright (c) <dates>, <Copyright Holder> (<URL|email>),
with Reserved Font Name <Reserved Font Name>.
Copyright (c) <dates>, <additional Copyright Holder> (<URL|email>),
with Reserved Font Name <additional Reserved Font Name>.
Copyright (c) <dates>, <additional Copyright Holder> (<URL|email>).
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
```
+45 -4
View File
@@ -92,12 +92,29 @@ android {
applicationId 'com.locphamtran.recipescamera'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0.0"
versionCode 9
versionName "1.2.7"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
ndk {
abiFilters "armeabi-v7a", "arm64-v8a"
}
}
signingConfigs {
release {
// Credentials live in android/keystore.properties (gitignored), next to
// app/release.keystore. Lose either and no future build can install over
// this one - back both up somewhere safe.
def props = new Properties()
def propsFile = rootProject.file('keystore.properties')
if (!propsFile.exists()) throw new GradleException('android/keystore.properties missing')
propsFile.withInputStream { props.load(it) }
storeFile file('release.keystore')
storePassword props.getProperty('storePassword')
keyAlias props.getProperty('keyAlias')
keyPassword props.getProperty('keyPassword')
}
debug {
storeFile file('debug.keystore')
storePassword 'android'
@@ -105,6 +122,21 @@ android {
keyPassword 'android'
}
}
// Two products, two sales channels: lite = free apk handed out for feedback,
// pro = paid apk. Same JS; the tier reaches JS via src/provariant.ts, which
// tools/set-variant.mjs rewrites before each build.
flavorDimensions "tier"
productFlavors {
lite {
dimension "tier"
}
pro {
dimension "tier"
applicationIdSuffix ".pro"
versionNameSuffix "-pro"
resValue "string", "app_name", "RecipesCam Pro"
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
@@ -112,11 +144,14 @@ android {
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
signingConfig signingConfigs.release
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
shrinkResources enableShrinkResources.toBoolean()
minifyEnabled enableMinifyInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
// ponytail: proguard-android.txt carries -dontoptimize, which is what
// Play flags as unoptimized code. The -optimize file drops it; R8's
// optimizer + keep rules from the RN/Expo AARs carry the rest.
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
crunchPngs enablePngCrunchInRelease.toBoolean()
}
@@ -129,6 +164,12 @@ android {
}
androidResources {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
// assets/index.android.bundle is 4.4 MB of Hermes bytecode. Packed
// deflated (the default) the app pays a full inflate of it on the
// startup path, before the first line of JS runs; stored, it is mapped
// straight out of the APK. Costs ~3 MB of download Play re-compresses
// anyway.
noCompress += ["bundle", "hbc"]
}
}
+5
View File
@@ -11,4 +11,9 @@
-keep class com.swmansion.reanimated.** { *; }
-keep class com.facebook.react.turbomodule.** { *; }
# androidx.camera
-keep class androidx.camera.** { *; }
-keep interface androidx.camera.** { *; }
-dontwarn androidx.camera.**
# Add any project specific keep options here:
+17 -5
View File
@@ -1,15 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.RECORD_AUDIO" tools:node="remove"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
<queries>
@@ -19,7 +17,7 @@
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:requestLegacyExternalStorage="true">
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.ENABLE_BSDIFF_PATCH_SUPPORT" android:value="true"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
@@ -36,5 +34,19 @@
<data android:scheme="exp+recipescamera"/>
</intent-filter>
</activity>
<!-- Share target. Without this filter the app never appears in the
system share sheet at all; image/* only, the editor takes photos.
A separate no-display relay, not MainActivity: a host app that starts
the target without FLAG_ACTIVITY_NEW_TASK would otherwise drop the
editor into its own task and the share would look like part of it.
ShareActivity relaunches MainActivity in our own task instead. -->
<activity android:name=".ShareActivity" android:theme="@android:style/Theme.NoDisplay" android:launchMode="standard" android:excludeFromRecents="true" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<action android:name="android.intent.action.SEND_MULTIPLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="image/*"/>
</intent-filter>
</activity>
</application>
</manifest>
@@ -1,13 +1,17 @@
package com.locphamtran.recipescamera
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import com.locphamtran.recipescamera.volume.VolumeShutterModule
import expo.modules.ReactActivityDelegateWrapper
class MainActivity : ReactActivity() {
@@ -19,6 +23,28 @@ class MainActivity : ReactActivity() {
super.onCreate(null)
}
/**
* A share delivered to the running app lands here, and React Native never hands
* the new intent to the activity (nothing in ReactActivity/ReactDelegate calls
* setIntent) — so getIntent() keeps returning the launch intent and the share
* intake would read a photo the user shared minutes ago, or none at all.
*/
override fun onNewIntent(intent: Intent) {
setIntent(intent)
super.onNewIntent(intent)
}
/**
* Volume keys double as a shutter release while the camera screen is up.
* This is the only place that runs before the window turns them into a
* stream-volume change, so the module gets first refusal and the keys it
* does not claim (everything, outside the camera) fall through to the
* default handling unchanged.
*/
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
return VolumeShutterModule.onKeyEvent(event) || super.dispatchKeyEvent(event)
}
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
@@ -0,0 +1,30 @@
package com.locphamtran.recipescamera
import android.app.Activity
import android.content.Intent
import android.os.Bundle
/**
* Share sheet entry point for SEND / SEND_MULTIPLE.
*
* A host app that starts us without FLAG_ACTIVITY_NEW_TASK drops the started
* activity into its own task, so the editor used to show up as a screen of that
* app (it lived in that app's stack, back returned there). MainActivity cannot
* fix that itself: bailing out in onCreate would either boot React twice or skip
* super.onCreate and crash onDestroy.
*
* So the share filters live here. This activity never draws (NoDisplay theme):
* it hands the same intent to MainActivity with NEW_TASK — singleTask then routes
* it to our own task, or re-delivers it to the running instance — and finishes.
* The editor is therefore always a task of its own.
*/
class ShareActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
startActivity(
Intent(intent)
.setClass(this, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
finish()
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

@@ -0,0 +1,6 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splashscreen_background" />
<item
android:gravity="center"
android:drawable="@drawable/splashscreen_logo_img" />
</layer-list>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

@@ -0,0 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Android 12+ system splash: branded logo (app_logo.png) centered on white. -->
<style name="Theme.App.SplashScreen" parent="AppTheme">
<item name="android:windowBackground">@drawable/splashscreen_logo</item>
<item name="android:windowSplashScreenBackground">@color/splashscreen_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splashscreen_logo_img</item>
<item name="android:windowSplashScreenIconBackgroundColor">@color/splashscreen_background</item>
</style>
</resources>
+1 -1
View File
@@ -1,3 +1,3 @@
<resources>
<string name="app_name">recipescamera</string>
<string name="app_name">RecipesCam</string>
</resources>
+5
View File
@@ -22,3 +22,8 @@ allprojects {
apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject"
// v5: vision-camera module builds own C++ (NativeBufferHelper.cpp) with
// `minSdkVersion getExtOrIntegerDefault("minSdkVersion")` = rootProject.ext.minSdkVersion.
// C++ #if __ANDROID_API__ >= 26 else throws "HardwareBuffers require minSdk 26 or higher!" on every frame.
rootProject.ext.minSdkVersion = 26
+10 -3
View File
@@ -10,7 +10,7 @@
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
@@ -21,6 +21,7 @@ org.gradle.parallel=true
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
android.enableJetifier=true
# Enable AAPT2 PNG crunching
android.enablePngCrunchInReleaseBuilds=true
@@ -28,7 +29,7 @@ android.enablePngCrunchInReleaseBuilds=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a
# Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in
@@ -60,4 +61,10 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=true
# Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false
expo.inlineModules.watchedDirectories=[]
expo.inlineModules.watchedDirectories=[]
# Shrink the shipped APK: R8 strips unused Java/Kotlin, shrinkResources drops
# unreferenced resources, bundle compression gzips the embedded JS bundle.
android.enableMinifyInReleaseBuilds=true
android.enableShrinkResourcesInReleaseBuilds=true
android.enableBundleCompression=true
+8
View File
@@ -37,3 +37,11 @@ expoAutolinking.useExpoVersionCatalog()
include ':app'
includeBuild(expoAutolinking.reactNativeGradlePlugin)
// v5: vision-camera(-worklets) native needs minSdk 26 (AHardwareBuffer). Parallel project
// configuration races root build.gradle's assignment, so pin the ext here before any project config.
gradle.beforeProject { project ->
if (project == project.rootProject) {
project.ext.minSdkVersion = 26
}
}
+62 -66
View File
@@ -1,70 +1,66 @@
{
"expo": {
"name": "recipescamera",
"slug": "recipescamera",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false,
"permissions": [
"android.permission.CAMERA",
"android.permission.RECORD_AUDIO",
"android.permission.ACCESS_COARSE_LOCATION",
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE",
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
"android.permission.READ_MEDIA_IMAGES",
"android.permission.READ_MEDIA_VIDEO",
"android.permission.READ_MEDIA_AUDIO"
],
"package": "com.locphamtran.recipescamera"
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-asset",
[
"expo-camera",
{
"cameraPermission": "Allow CamRecipe Pro to access your camera to take photos."
}
],
[
"expo-location",
{
"locationAlwaysAndWhenInUsePermission": "Allow CamRecipe Pro to geotag your photos with location watermark."
}
],
[
"expo-media-library",
{
"photosPermission": "Allow CamRecipe Pro to save processed photos to your library."
}
],
[
"expo-image-picker",
{
"photosPermission": "Allow CamRecipe Pro to select photos from your library to apply presets."
}
]
],
"extra": {
"eas": {
"projectId": "31b3c7c4-c00c-447d-aa77-a087f78480ef"
}
"expo": {
"name": "RecipesCam",
"slug": "recipescamera",
"version": "1.2.7",
"orientation": "portrait",
"icon": "./RecipesCamIcon.png",
"userInterfaceStyle": "light",
"ios": {
"supportsTablet": true,
"infoPlist": {
"NSCameraUsageDescription": "Allow RecipesCam to access your camera."
}
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./RecipesCamIcon.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false,
"permissions": [
"android.permission.CAMERA",
"android.permission.ACCESS_COARSE_LOCATION",
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE",
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
"android.permission.READ_MEDIA_IMAGES"
],
"package": "com.locphamtran.recipescamera"
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-asset",
[
"expo-location",
{
"locationAlwaysAndWhenInUsePermission": "Allow RecipesCam to geotag your photos with location watermark."
}
],
[
"expo-media-library",
{
"photosPermission": "Allow RecipesCam to save processed photos to your library.",
"isAccessMediaLocationEnabled": true
}
],
[
"expo-camera",
{
"cameraPermission": "Allow RecipesCam to scan a recipe QR code."
}
],
"expo-sharing"
],
"extra": {
"eas": {
"projectId": "31b3c7c4-c00c-447d-aa77-a087f78480ef"
}
}
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
+26
View File
@@ -1,3 +1,28 @@
// Lucide ships one module per icon, but the barrel import drags all ~1600 in.
// Rewrite each named import into its own icon module so Metro only walks what we use.
const lucidePerIcon = () => ({
visitor: {
ImportDeclaration(path) {
if (path.node.source.value !== "lucide-react-native") return;
const t = require("@babel/types");
const kebab = (n) =>
n
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/([A-Za-z])(\d)/g, "$1-$2")
.toLowerCase();
path.replaceWithMultiple(
path.node.specifiers.map((s) =>
s.type === "ImportSpecifier"
? t.importDeclaration(
[t.importDefaultSpecifier(s.local)],
t.stringLiteral(`lucide-react-native/icons/${kebab(s.imported.name)}`)
)
: t.importDeclaration([s], t.stringLiteral("lucide-react-native"))
)
);
},
},
});
module.exports = function (api) {
api.cache(true);
return {
@@ -7,6 +32,7 @@ module.exports = function (api) {
],
plugins: [
"react-native-reanimated/plugin",
lucidePerIcon
],
};
};
+110
View File
@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RecipesCam — Privacy Policy</title>
<style>
:root { color-scheme: light dark; }
body { max-width: 46rem; margin: 0 auto; padding: 2rem 1.25rem 4rem;
font: 16px/1.65 -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; }
h1 { font-size: 1.6rem; margin-bottom: .25rem; }
h2 { font-size: 1.1rem; margin-top: 2rem; }
.meta { color: #666; font-size: .9rem; }
code { background: rgba(127,127,127,.15); padding: .1em .3em; border-radius: 4px; }
</style>
</head>
<body>
<h1>RecipesCam — Privacy Policy</h1>
<p class="meta">Applies to the Android apps <strong>RecipesCam</strong> (com.locphamtran.recipescamera, free/LITE)
and <strong>RecipesCam Pro</strong> (com.locphamtran.recipescamera.pro, paid/PRO).<br>
Effective date: 14 September 2026 · Last updated: 14 September 2026</p>
<p>RecipesCam is a camera app: it applies film-style recipes to photos you take or pick, and saves the
result to your device. It is built and published by an individual developer ("we"). This policy explains
what the app accesses and what happens to that data.</p>
<h2>Summary</h2>
<ul>
<li>The app has <strong>no user accounts</strong> and <strong>no server of ours</strong>.</li>
<li><strong>No photo, location, or usage data is transmitted to us or to any third party.</strong> There is no
analytics, advertising, crash-reporting, or tracking SDK in the app.</li>
<li>Everything the app does — capture, processing, watermarking, saving — happens on your device.</li>
</ul>
<h2>Data the app accesses, and why</h2>
<table>
<tr><th align="left">Access</th><th align="left">Permission</th><th align="left">Purpose</th></tr>
<tr>
<td>Camera</td><td><code>CAMERA</code></td>
<td>To take photos and show the live preview. Photos stay on your device.</td>
</tr>
<tr>
<td>Photos and videos in your library</td>
<td><code>READ_MEDIA_IMAGES</code>, <code>READ_MEDIA_VISUAL_USER_SELECTED</code>,
<code>READ_EXTERNAL_STORAGE</code> (Android 12 and older)</td>
<td>To let you pick an existing photo to edit, and to show your library inside the app's picker.</td>
</tr>
<tr>
<td>Save processed photos</td>
<td><code>WRITE_EXTERNAL_STORAGE</code> (Android 9 and older) and the system media-library APIs</td>
<td>To write the processed photo and its metadata back to your gallery.</td>
</tr>
<tr>
<td>Photo location metadata (EXIF GPS)</td><td><code>ACCESS_MEDIA_LOCATION</code></td>
<td>To let a photo you imported keep its location when you export it again. Read on device only.</td>
</tr>
<tr>
<td>Your location</td>
<td><code>ACCESS_COARSE_LOCATION</code>, <code>ACCESS_FINE_LOCATION</code></td>
<td>Used only when you switch on the optional location watermark, to stamp the place name onto the
photo. Location is never collected in the background, never stored beyond the photo you asked to
geotag, and never sent to us.</td>
</tr>
<tr>
<td>Vibration</td><td><code>VIBRATE</code></td><td>Haptic feedback on shutter and controls.</td>
</tr>
<tr>
<td>Network</td><td><code>INTERNET</code></td>
<td>Present for the app's runtime, which is built on React Native. The app itself makes no network
requests and contacts no server we operate.</td>
</tr>
</table>
<p>The app does not request access to your microphone, contacts, calendar, health data, SMS, call logs,
files outside your photo library, or your physical activity, and it does not use the
"display over other apps" permission.</p>
<h2>Storage on your device</h2>
<p>Your recipes, presets, favourites and app settings are stored locally in the app's private storage on
your device. Removing the app removes them. Photos you take or export live in your own gallery, under
your control; the app keeps no copy of its own.</p>
<h2>Sharing</h2>
<p>When you tap Share, the app hands the chosen photo to the app you pick through Android's standard share
sheet. What that app then does with the photo is governed by its own privacy policy. We receive nothing.</p>
<h2>Third-party software</h2>
<p>The app is built with open-source libraries (React Native, Expo, Skia, VisionCamera, and others). They
run locally inside the app and do not collect data on our behalf. Their licences and copyright notices are
listed in the app under <em>Settings → About → Open-source licences</em> and in the
<code>THIRD_PARTY_NOTICES.md</code> file shipped with the source.</p>
<h2>Children</h2>
<p>The app is not directed at children under 13 and does not knowingly collect personal information from
anyone, including children.</p>
<h2>Your rights and choices</h2>
<p>Because no personal data leaves your device, there is nothing for us to delete or export on your behalf.
You can revoke any permission at any time in Android's <em>Settings → Apps → RecipesCam → Permissions</em>;
the app will keep working minus the feature that needs it. Uninstalling the app deletes all data it stored
locally.</p>
<h2>Changes to this policy</h2>
<p>If this policy changes, the effective date above is updated and the new text is published at the same
URL before it takes effect.</p>
<h2>Contact</h2>
<p>Questions about this policy or the app: <a href="mailto:tranloclqd@gmail.com">tranloclqd@gmail.com</a>.</p>
</body>
</html>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

+15 -2
View File
@@ -1,8 +1,21 @@
import { registerRootComponent } from 'expo';
import React from 'react';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// SafeAreaProvider: root overlays (TopBar / CameraControls) read insets so the
// fullscreen viewfinder can run edge-to-edge under the system bars.
// (index.ts is plain TS — no JSX here.)
function Root() {
return React.createElement(
SafeAreaProvider,
null,
React.createElement(App)
);
}
// registerRootComponent calls AppRegistry.registerComponent('main', () => Root);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);
registerRootComponent(Root);

Some files were not shown because too many files have changed in this diff Show More