4 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
17 changed files with 1844 additions and 15 deletions
+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.
+69 -13
View File
@@ -29,6 +29,10 @@ import type { PickedPhotoAsset } from './src/utils/photoGps';
import { PARAM_DEFS } from './src/utils/paramDefs';
import { LITE_FAVORITE_LIMIT, LITE_MARK, LITE_RECIPE_LIMIT, proLookInUse } from './src/utils/entitlement';
import { initProUnlock, useProUnlock } from './src/utils/proUnlock';
import { readJpegIso } from './src/utils/exifWrite';
import { readPhotoBytes, devicePerf } from './src/utils/photoMeta';
import { BURST_FRAMES, BURST_MIN_ISO, mergeBurst } from './src/utils/burstMerge';
import { canBurst } from './src/utils/deviceFloor';
import UltraWide from './modules/recipescam-ultrawide';
import RecipescamExport from './modules/recipescam-export';
import VolumeShutter from './modules/recipescam-volume';
@@ -56,13 +60,6 @@ const CAMERA_SETTINGS_KEY = 'camera_settings';
// decides which engine ships.
const NATIVE_EXPORT = process.env.EXPO_PUBLIC_NATIVE_EXPORT === '1';
// B1: the shutter's CameraX capture mode. 'balanced' ships as the default —
// measured on the 12S Ultra at the same 3000x4000, a frame takes ~340 ms
// against ~540 ms for 'quality', with no resolution loss, because this device
// has ZSL (supportsSpeedQualityPrioritization) and 'balanced'/speed ride it.
// EXPO_PUBLIC_CAPTURE_MODE still overrides, but ONLY in dev bundles: Metro
// inlines EXPO_PUBLIC_*, the release HBC build does not (B0 finding) — never
// gate shipped behaviour on it.
// How long a shutter may wait for a GPS fix before exporting without it. The
// photo is never held hostage by the receiver: indoors a Balanced fix takes
// 20s+ (measured 25s on the V2430), which is the whole reason a capture used to
@@ -78,10 +75,6 @@ const loadRenderer = (native: boolean) =>
native
? import('./src/utils/nativeExport').then((m) => m.processAndExportPhotoNative)
: import('./src/utils/exportEngine').then((m) => m.processAndExportPhoto);
const CAPTURE_MODE =
process.env.EXPO_PUBLIC_CAPTURE_MODE === 'speed' || process.env.EXPO_PUBLIC_CAPTURE_MODE === 'quality'
? process.env.EXPO_PUBLIC_CAPTURE_MODE
: 'balanced';
export default function App() {
const [mode, setMode] = useState<'camera' | 'library'>('camera');
@@ -342,7 +335,17 @@ export default function App() {
// Full-resolution photo output — attached to the live <Camera> in Viewfinder.
// Photo capture is resolution-independent from the Skia preview output.
const photoOutput = usePhotoOutput({
qualityPrioritization: CAPTURE_MODE,
// LATENCY, measured: 'balanced' is CAPTURE_MODE_MINIMIZE_LATENCY (one
// frame, no CameraX still-image post-processing); 'quality' is
// CAPTURE_MODE_MAXIMIZE_QUALITY, which adds in-camera multi-frame noise
// reduction plus reprocessing. Same scene at ISO ~410 on the 12S Ultra,
// 3 shots per arm: in-camera START->onImageCaptured 291 ms vs 499 ms,
// tap->file on disk 797 ms vs 1025 ms (-22%), and the two JPEG sets were
// indistinguishable (flat-area sigma 1.08 vs 1.09, edge energy -3%).
// Dark scenes are handled by this app's own burst + merge below (ISO >=
// 800), so CameraX MFNR would only ever be a second, redundant pass.
// Revert to 'quality' if a high-ISO A/B ever shows merge losing to MFNR.
qualityPrioritization: 'balanced',
});
// Second photo output for RAW DNG sidecars (RATIO panel → RAW on). Kept as
// a live output only while rawEnabled — CameraX cannot bind two ImageCapture
@@ -1495,6 +1498,56 @@ export default function App() {
)
).filePath;
}
// Low light, hand-held: one frame is mostly sensor noise, and no OEM stack is
// reachable from here (the extension probe found the vendor pipelines closed to a
// third-party client). So take the frames and average them ourselves. Four ways
// out, checked before an extra exposure is spent: the LED is the light source
// (a burst would ride it while it recharges), the ultra-wide still came from the
// native Camera2 session, a held burst is the user asking for ONE frame, and RAW
// needs the second ImageCapture output CameraX will not bind alongside this one.
// canBurst: on a 1 GB floor device (deviceFloor.ts) three raws plus the
// alignment rasters are seconds of shutter lag, so the plain frame stands.
// Set when the low-light burst below starts its merge. The merge is not
// awaited on the shutter path: the export task awaits it instead, so the
// next shot is accepted while the frames are still being averaged.
let burstMerge: Promise<string | null> | null = null;
if (!ultraWideShot && !opts?.burst && !rawEnabled && !(flashAvailable && flashMode !== 'off') && canBurst(devicePerf().ramMb)) {
try {
// ISO is the only signal this app can read for ambient light: the controller
// reports 0.0 for every still, so it comes off the file the shutter just
// wrote. No tag, no burst.
const jpeg = await readPhotoBytes(toUri(filePath));
const iso = jpeg ? readJpegIso(jpeg) : null;
if (iso !== null && iso >= BURST_MIN_ISO) {
const frames = [filePath];
for (let i = 1; i < BURST_FRAMES; i++) {
const extra = await photoOutput.capturePhotoToFile(
// No second shutter sound and no flash: three clicks for one photo
// reads as a malfunction.
{ enableShutterSound: false, flashMode: 'off' },
{},
);
frames.push(extra.filePath);
}
// Averaging three 12 MP frames costs ~1.4 s measured on the 12S Ultra
// (three decodes, two alignment stages, one draw pass, one JPEG
// encode) and all three frames are already in hand, so the merge
// rides the FIFO export queue below instead of the shutter path:
// the next shot is accepted as soon as the exposures land, and only
// the export of this photo waits on the average. A refused merge
// (null: drift too large, a frame unreadable) keeps the shutter frame.
// ponytail: a merge in the queue and a following capture can each
// hold a 12 MP frame (~48 MB); the FIFO serializes the merge against
// the render, not against the camera. Re-serialize if a low-RAM body
// starts swapping.
burstMerge = mergeBurst(frames);
}
} catch (burstError) {
// A failed burst costs two wasted exposures, never the photo.
console.warn('Low-light burst skipped:', burstError);
}
}
const sourceUri = toUri(filePath);
// RAW DNG sidecar: fire the second exposure immediately after the JPEG
@@ -1574,7 +1627,10 @@ export default function App() {
frameWindowZoom: frameZoom,
};
const paint = await loadRenderer(nativeExport);
return paint(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options);
// The burst average lands here, in the queue: the export waits for it,
// the shutter did not. sourceUri is the frame the shutter already wrote.
const source = burstMerge ? toUri((await burstMerge) ?? filePath) : sourceUri;
return paint(source, recipeArgs, selectedFrame, useGeotag, captureGps, options);
};
enqueueExport(async () => {
try {
+2
View File
@@ -82,6 +82,8 @@ Exit: user QA đạt → mặc định native; legacy chỉ fallback.
- 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`).
@@ -1,5 +1,6 @@
package com.locphamtran.recipescamera.export
import android.app.ActivityManager
import android.content.ContentValues
import android.content.Context
import android.content.Intent
@@ -206,6 +207,20 @@ class RecipescamExportModule : Module() {
)
}
// Floor-device gate (src/utils/deviceFloor.ts): total RAM decides whether a
// full-resolution surface chain and the 3-frame burst fit. One cheap query
// and no permission; a platform that will not answer returns 0, which JS
// reads as "unknown" and keeps the full-quality path.
Function("devicePerf") { ->
val am = appContext.reactContext?.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
val mem = ActivityManager.MemoryInfo()
val known = if (am != null) { am.getMemoryInfo(mem); true } else false
mapOf(
"ramMb" to (if (known) (mem.totalMem / (1024L * 1024L)).toInt() else 0),
"cores" to Runtime.getRuntime().availableProcessors(),
)
}
// P0 spike harness: copy a bundled drawable asset (e.g. "wallframe", the
// 3117x4000 artwork — representative 12MP decode) into cacheDir and hand the
// real file path back to JS, which then runs decodeEncodeAsync on it. This
+7
View File
@@ -105,6 +105,13 @@ export interface RecipescamExportModule {
* codename, marketing name, manufacturer, model. Empty strings when absent.
*/
deviceInfo(): { device: string; marketName: string; manufacturer: string; model: string };
/**
* Device facts the capture and render paths budget against (see
* src/utils/deviceFloor.ts): total RAM in MB and CPU core count. `ramMb` is 0
* when the platform refused the read — callers treat 0 as "unknown", never as
* a floor device, so an unmeasurable phone keeps the full-quality path.
*/
devicePerf(): { ramMb: number; cores: number };
/**
* The image a system share handed this launch (AndroidManifest
+65
View File
@@ -0,0 +1,65 @@
diff --git a/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/outputs/HybridPhotoOutput.kt b/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/outputs/HybridPhotoOutput.kt
index 883fa50..e0248e8 100644
--- a/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/outputs/HybridPhotoOutput.kt
+++ b/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/outputs/HybridPhotoOutput.kt
@@ -1,7 +1,10 @@
package com.margelo.nitro.camera.hybrids.outputs
+import android.hardware.camera2.CameraMetadata
+import android.hardware.camera2.CaptureRequest
import android.media.MediaActionSound
import android.util.Log
+import androidx.camera.camera2.interop.Camera2Interop
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import androidx.camera.core.resolutionselector.ResolutionSelector
@@ -108,6 +111,21 @@
// Capture Mode (Quality, Speed, ...)
val captureMode = options.qualityPrioritization.toCaptureMode()
setCaptureMode(captureMode)
+
+ // The ISP's own noise reduction and edge enhancement, off for every
+ // still (patches/vision-camera-isp-off.patch, the shutter-silent
+ // patch's twin). The vendor pair is tuned to hide sensor noise in a
+ // screen-sized JPEG and charges the fine detail for it; this app
+ // sharpens and grains the file itself with Skia, so it would rather
+ // have the texture and the noise than the wax. Camera2Interop because
+ // the CameraX builders have no typed setter for either key. DNG never
+ // passes through the ISP, so the option is a no-op on the RAW output.
+ Camera2Interop.Extender(this)
+ .setCaptureRequestOption(
+ CaptureRequest.NOISE_REDUCTION_MODE,
+ CameraMetadata.NOISE_REDUCTION_MODE_OFF,
+ )
+ .setCaptureRequestOption(CaptureRequest.EDGE_MODE, CameraMetadata.EDGE_MODE_OFF)
if (options.previewImageTargetSize != null) {
setPostviewEnabled(true)
diff --git a/modules/recipescam-ultrawide/android/src/main/java/com/locphamtran/recipescamera/ultrawide/UltraWideModule.kt b/modules/recipescam-ultrawide/android/src/main/java/com/locphamtran/recipescamera/ultrawide/UltraWideModule.kt
index c4bf38d..39308e3 100644
--- a/modules/recipescam-ultrawide/android/src/main/java/com/locphamtran/recipescamera/ultrawide/UltraWideModule.kt
+++ b/modules/recipescam-ultrawide/android/src/main/java/com/locphamtran/recipescamera/ultrawide/UltraWideModule.kt
@@ -8,6 +8,7 @@ import android.hardware.camera2.CameraCaptureSession
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraDevice
import android.hardware.camera2.CameraManager
+import android.hardware.camera2.CameraMetadata
import android.hardware.camera2.CaptureRequest
import android.hardware.camera2.CaptureResult
import android.hardware.camera2.TotalCaptureResult
@@ -554,6 +555,15 @@ class UltraWidePreviewView(context: Context, appContext: AppContext) : ExpoView(
req.set(CaptureRequest.JPEG_ORIENTATION, sensorOrientation)
req.set(CaptureRequest.FLASH_MODE, flashMode(flash))
req.set(CaptureRequest.CONTROL_AE_MODE, flashAeMode(flash))
+ // The ISP's own noise reduction and edge enhancement, off for the
+ // ultra-wide still too (patches/vision-camera-isp-off.patch), so 0.5x
+ // lands in the same file as the other lenses: the app sharpens and
+ // grains with Skia, it does not want the vendor wax.
+ req.set(
+ CaptureRequest.NOISE_REDUCTION_MODE,
+ CameraMetadata.NOISE_REDUCTION_MODE_OFF
+ )
+ req.set(CaptureRequest.EDGE_MODE, CameraMetadata.EDGE_MODE_OFF)
// The live preview carries the EV as a hardware AE bias (see setEv); the
// file must not, or the lens bias and the export's software gain would
// stack into a double EV. Pin the bias off and let the precapture trigger
+107
View File
@@ -0,0 +1,107 @@
// Self-check for the burst alignment maths — the one part of the low-light
// merge that is decided by arithmetic rather than by Skia, and the part a wrong
// answer turns into a visibly doubled photo. Run it after touching
// burstAlign.ts (or the constants that feed it):
// node src/utils/burstAlign.check.ts
// Exits non-zero on the first failed expectation.
import { bestShift, nearest2D, profilesOf, sadAt, type Pixels } from './burstAlign.ts';
function check(name: string, ok: boolean, detail = ''): void {
if (!ok) {
console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`);
process.exit(1);
}
console.log(`ok ${name}`);
}
// Deterministic structure (a hash, not Math.random) so a failure is repeatable.
function texture(w: number, h: number, seed: number): Pixels {
const px = new Uint8Array(w * h * 4);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const n = Math.sin(x * 12.9898 + y * 78.233 + seed) * 43758.5453;
const v = 96 + Math.floor((n - Math.floor(n)) * 128);
const p = (y * w + x) * 4;
px[p] = v;
px[p + 1] = v;
px[p + 2] = v;
px[p + 3] = 255;
}
}
return px;
}
// `a` cut down to what a camera would see of `b` after a shift of (dx, dy).
function shifter(a: Pixels, w: number, h: number, dx: number, dy: number): Pixels {
const px = new Uint8Array(w * h * 4);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const sx = x - dx;
const sy = y - dy;
if (sx < 0 || sy < 0 || sx >= w || sy >= h) continue;
const d = (y * w + x) * 4;
const s = (sy * w + sx) * 4;
px[d] = a[s];
px[d + 1] = a[s + 1];
px[d + 2] = a[s + 2];
px[d + 3] = 255;
}
}
return px;
}
const W = 192;
const H = 128;
const ref = texture(W, H, 1);
const refProfile = profilesOf(ref, W, H);
// 1) A known shift comes back out of the profiles, as the offset that draws the
// frame back onto the reference: content moved right by 7 is pulled left by 7.
{
const cur = shifter(ref, W, H, 7, -4);
const curProfile = profilesOf(cur, W, H);
const dx = bestShift(refProfile.cols, curProfile.cols, 12);
const dy = bestShift(refProfile.rows, curProfile.rows, 12);
check('bestShift undoes a (7, -4) move', dx === -7 && dy === 4, `got (${dx}, ${dy})`);
}
// 2) The 2D confirmation returns the translation, not the seed, when they agree.
{
const cur = shifter(ref, W, H, -5, 3);
const curProfile = profilesOf(cur, W, H);
const seedX = bestShift(refProfile.cols, curProfile.cols, 12);
const seedY = bestShift(refProfile.rows, curProfile.rows, 12);
const best = nearest2D(ref, cur, W, H, seedX, seedY, 2);
check('nearest2D confirms it on the pixels', best.x === 5 && best.y === -3, `got (${best.x}, ${best.y})`);
}
// 3) A featureless pair — an unreadable scene, or a flat wall in the dark —
// merges unshifted instead of being pushed somewhere by noise.
{
const flat: Pixels = new Uint8Array(W * H * 4).fill(200);
for (let i = 3; i < flat.length; i += 4) flat[i] = 255;
const best = nearest2D(flat, flat, W, H, 9, 9, 2);
check('flat pair stays at (0, 0)', best.x === 0 && best.y === 0, `got (${best.x}, ${best.y})`);
}
// 4) A candidate that keeps less than half the frame cannot win on a flat error.
{
const a: Pixels = new Uint8Array(W * H * 4).fill(10);
const b: Pixels = new Uint8Array(W * H * 4).fill(10);
check('disjoint candidate rejected', sadAt(a, b, W, H, W, 0) === Infinity);
check('full-overlap candidate scored', sadAt(a, b, W, H, 0, 0) === 0);
}
// 5) The fine stage searches around the coarse estimate: the true offset is
// found from a seed that is already close, and a seed that is wrong can only
// pull the answer as far as the window allows — never back to the truth.
{
const cur = shifter(ref, W, H, 2, 0);
const curProfile = profilesOf(cur, W, H);
check('expect-centred search finds -2 from a seed of -2', bestShift(refProfile.cols, curProfile.cols, 1, -2) === -2);
const off = bestShift(refProfile.cols, curProfile.cols, 1, 5);
check('a wrong seed stays inside its window', Math.abs(off - 5) <= 1, `got ${off}`);
}
console.log('\nburstAlign: all checks passed');
+135
View File
@@ -0,0 +1,135 @@
// Alignment maths for the low-light hand-held burst. Pure — no Skia, no React
// Native — so the check beside it runs on plain node:
// node src/utils/burstAlign.check.ts
//
// Frames taken within a second of each other differ by translation only (the
// zoom and the lens do not change between them), so the estimate is one shift
// per axis, read off the row and column luminance PROFILES of a downscaled
// frame. Correlating two profiles costs O(w+h) per candidate offset against
// O(w*h) for 2D block matching — the difference between a few hundred ms and
// minutes of JS thread for a 12MP burst. The 2D check then confirms the profile
// estimate against the actual pixels, so a periodic scene (blinds, railings,
// a tiled wall) cannot lock the estimator onto the wrong repetition — and (0,0)
// stays a candidate throughout, so a scene neither stage can read merges
// unshifted rather than wrongly shifted.
export interface Profiles {
/** Mean luminance of each row, top to bottom. */
rows: Float32Array;
/** Mean luminance of each column, left to right. */
cols: Float32Array;
}
/** RGBA pixels as they come out of Skia's readPixels (8-bit or float). */
export type Pixels = Uint8Array | Float32Array;
// Rec.709 luma on the unmanaged 8-bit values. A monotone luminance proxy is all
// a correlation needs — this is not a colour-managed Y.
const LUMA_R = 0.2126;
const LUMA_G = 0.7152;
const LUMA_B = 0.0722;
export function profilesOf(rgba: Pixels, w: number, h: number): Profiles {
const rows = new Float32Array(h);
const cols = new Float32Array(w);
for (let y = 0; y < h; y++) {
const row = y * w * 4;
let sum = 0;
for (let x = 0; x < w; x++) {
const p = row + x * 4;
const l = rgba[p] * LUMA_R + rgba[p + 1] * LUMA_G + rgba[p + 2] * LUMA_B;
sum += l;
cols[x] += l;
}
rows[y] = sum / w;
}
for (let x = 0; x < w; x++) cols[x] /= h;
return { rows, cols };
}
/**
* The shift of `cur` against `ref`, in profile samples, searched over
* `expect ± max` and scored by mean absolute difference over the overlap.
* Mean, not sum, so a candidate that only matches on a handful of pixels is not
* favoured; overlaps shorter than half the signal are rejected outright.
* Convention: `ref[i] ≈ cur[i - shift]`.
*/
export function bestShift(ref: Float32Array, cur: Float32Array, max: number, expect = 0): number {
const center = Math.round(expect);
const lo = Math.max(-(cur.length - 1), center - max);
const hi = Math.min(ref.length - 1, center + max);
let best = center;
let bestErr = Infinity;
for (let s = lo; s <= hi; s++) {
const from = Math.max(0, s);
const to = Math.min(ref.length, cur.length + s);
if (to - from < ref.length / 2) continue;
let err = 0;
for (let i = from; i < to; i++) err += Math.abs(ref[i] - cur[i - s]);
err /= to - from;
if (err < bestErr) {
bestErr = err;
best = s;
}
}
return best;
}
/**
* Mean absolute luminance difference between two downscaled frames at a
* candidate shift, over the overlap — the same convention as `bestShift`
* (`a[y][x] ≈ b[y - dy][x - dx]`). `Infinity` when the candidate keeps less than
* half the frame: a nearly disjoint pair would otherwise score a suspiciously
* low error on whatever flat area it did keep.
*/
export function sadAt(a: Pixels, b: Pixels, w: number, h: number, dx: number, dy: number): number {
const x0 = Math.max(0, dx);
const y0 = Math.max(0, dy);
const x1 = Math.min(w, w + dx);
const y1 = Math.min(h, h + dy);
if (x1 <= x0 || y1 <= y0) return Infinity;
if ((x1 - x0) * (y1 - y0) < (w * h) / 2) return Infinity;
let sum = 0;
for (let y = y0; y < y1; y++) {
const ra = (y * w + x0) * 4;
const rb = ((y - dy) * w + (x0 - dx)) * 4;
for (let x = x0; x < x1; x++) {
const pa = ra + (x - x0) * 4;
const pb = rb + (x - x0) * 4;
const la = a[pa] * LUMA_R + a[pa + 1] * LUMA_G + a[pa + 2] * LUMA_B;
const lb = b[pb] * LUMA_R + b[pb + 1] * LUMA_G + b[pb + 2] * LUMA_B;
sum += Math.abs(la - lb);
}
}
return sum / ((x1 - x0) * (y1 - y0));
}
/**
* Confirm a profile estimate on the pixels: search `±radius` around it, keeping
* (0,0) in the running, and return the best 2D match. This is what makes a
* profile lock onto a real translation rather than onto a repeated pattern —
* and what makes "no shift" win whenever the pixels do not support a shift.
*/
export function nearest2D(
ref: Pixels,
cur: Pixels,
w: number,
h: number,
seedX: number,
seedY: number,
radius: number
): { x: number; y: number } {
let best = { x: 0, y: 0 };
let bestErr = sadAt(ref, cur, w, h, 0, 0);
for (let dy = seedY - radius; dy <= seedY + radius; dy++) {
for (let dx = seedX - radius; dx <= seedX + radius; dx++) {
if (dx === 0 && dy === 0) continue;
const err = sadAt(ref, cur, w, h, dx, dy);
if (err < bestErr) {
bestErr = err;
best = { x: dx, y: dy };
}
}
}
return best;
}
+227
View File
@@ -0,0 +1,227 @@
// Low-light hand-held burst: N exposures of the same scene averaged into one
// JPEG, aligned first. This is the cheap end of what the stock camera does — an
// OEM stacks its own frames inside the HAL (and the extension probe showed no
// third-party client is served on the 12S/14 Ultra), so the only way to a
// multi-frame denoise from an app is to take the frames itself. The exposures
// come from the same photo output the shutter already uses, and the average
// buys about sqrt(N) off the sensor noise: ~1.7x for three frames, which is the
// difference between an ISO 1900 frame and one shot at ISO 700.
//
// Cost, all of it on the shutter path in the dark: N-1 extra exposures (~0.5s
// each in QUALITY mode), two decodes per frame (one for the alignment rasters,
// one for the merge draw — deliberately never more than one 12MP frame in
// memory at a time, see exportEngine's surface note) and one JPEG encode.
import { Skia, AlphaType, BlendMode, ColorType, ImageFormat, type SkImage, type SkSurface } from '@shopify/react-native-skia';
import { File } from 'expo-file-system';
import * as FileSystem from 'expo-file-system/legacy';
import { bestShift, nearest2D, profilesOf, type Profiles, type Pixels } from './burstAlign';
import { stampExifOnFile } from './photoMeta';
// The ISO at which one frame stops being enough. Above it the shutter output is
// dominated by sensor noise — the 12S Ultra metered ISO 1918 on a dim indoor
// shot, where the stock camera stacks and the app could not. Below it there is
// nothing to win, so the plain (and much faster) capture stands.
// ponytail: one threshold, no shutter-speed term — CameraX exposes no exposure
// reading to a third-party app. Make it adaptive if ISO 800 ever misjudges a body.
export const BURST_MIN_ISO = 800;
// Total exposures. Three, not the four or five a HAL would stack: 1/3 is exactly
// representable as an 8-bit paint alpha (85/255), so the average carries no gain
// error, and two extra frames are as much hand-held drift as the merge tolerates.
export const BURST_FRAMES = 3;
// Alignment scales: coarse (1/8) finds the offset over a wide range, fine (1/4)
// trims it to ±2 full-resolution pixels. Both are CPU surfaces — a downscaled
// raster is cheap there, and a GPU readback is not.
const COARSE_DIV = 8;
const FINE_DIV = 4;
const COARSE_RADIUS = 10; // ±80 px of a 3000 px frame
const COARSE_REFINE = 2; // 2D neighbourhood of the profile estimate, ±16 px
const FINE_RADIUS = 5; // ±20 px around the coarse estimate, in 1/4-scale pixels
// Motion beyond this share of the frame is a different picture, not drift: the
// intersection crop would eat the photo, and the average would ghost.
const MAX_SPREAD = 0.02;
// GPU when a context exists (the merge draws three 12MP images), CPU otherwise —
// the same fallback exportEngine makes, for the same cold-start reason.
function surfaceFor(width: number, height: number): SkSurface | null {
return Skia.Surface.MakeOffscreen(width, height) ?? Skia.Surface.Make(width, height);
}
// Capture outputs a bare path; expo-file-system's File wants a scheme.
const uriOf = (path: string): string => (/^[a-z][a-z0-9+.-]*:\/\//i.test(path) ? path : `file://${path}`);
async function loadFrame(path: string): Promise<SkImage | null> {
try {
const bytes = await new File(uriOf(path)).bytes();
const data = Skia.Data.fromBytes(bytes);
const image = Skia.Image.MakeImageFromEncoded(data);
data.dispose();
return image;
} catch (e) {
console.warn('Burst frame decode failed:', e);
return null;
}
}
// One downscaled RGBA raster of a frame, for the alignment stages.
function rasterize(src: SkImage, surface: SkSurface, w: number, h: number, paint: ReturnType<typeof Skia.Paint>): Pixels | null {
const canvas = surface.getCanvas();
canvas.clear(Skia.Color('black'));
canvas.drawImageRect(src, Skia.XYWHRect(0, 0, src.width(), src.height()), Skia.XYWHRect(0, 0, w, h), paint);
return canvas.readPixels(0, 0, { width: w, height: h, colorType: ColorType.RGBA_8888, alphaType: AlphaType.Unpremul });
}
/**
* Average a hand-held burst of the same scene into one JPEG.
*
* `frames` are capture paths, the first one being the frame the shutter already
* took. Returns the cache path of the merged file, or null when the merge is not
* worth it or cannot be done — motion too large, a frame that will not decode,
* a frame of a different size — in which case the caller keeps the plain capture.
* The merged file carries the first frame's EXIF block, so the export still
* sees the camera and the capture instant it stamps into the finished photo.
*/
export async function mergeBurst(frames: string[]): Promise<string | null> {
if (frames.length < 2) return null;
const owned: { dispose(): void }[] = [];
const keep = <T extends { dispose(): void }>(item: T): T => {
owned.push(item);
return item;
};
try {
// Frame 0 sets the geometry: CameraX returns every frame at the same size,
// so a frame that disagrees is not part of this burst.
const first = await loadFrame(frames[0]);
if (!first) return null;
const W = first.width();
const H = first.height();
first.dispose(); // geometry only: the loop decodes frame 0 for real
if (!(W > 0 && H > 0)) return null;
const cw = Math.max(32, Math.round(W / COARSE_DIV));
const ch = Math.max(32, Math.round(H / COARSE_DIV));
const fw = Math.max(64, Math.round(W / FINE_DIV));
const fh = Math.max(64, Math.round(H / FINE_DIV));
const coarseSurface = Skia.Surface.Make(cw, ch);
const fineSurface = Skia.Surface.Make(fw, fh);
const accum = surfaceFor(W, H);
if (!coarseSurface || !fineSurface || !accum) {
coarseSurface?.dispose();
fineSurface?.dispose();
accum?.dispose();
return null;
}
keep(coarseSurface);
keep(fineSurface);
keep(accum);
const srcPaint = keep(Skia.Paint()); // an opaque source, unscaled
// The accumulator IS the output: each frame is drawn into it once its offset
// is known and released straight after, so at most one decoded 12MP frame
// (48MB) and one surface are alive at any moment. The 1/N alpha is what turns
// a plus-blend into an average — accumulating at full brightness would clip
// every highlight long before the third frame landed.
const addPaint = keep(Skia.Paint());
addPaint.setBlendMode(BlendMode.Plus);
addPaint.setAlphaf(1 / frames.length);
accum.getCanvas().clear(Skia.Color('transparent'));
let refCoarse: Profiles | null = null;
let refCoarsePixels: Pixels | null = null;
let refFine: Profiles | null = null;
const offsets: { x: number; y: number }[] = [];
for (const path of frames) {
const image = await loadFrame(path);
if (!image) return null;
try {
if (image.width() !== W || image.height() !== H) return null;
const coarsePixels = rasterize(image, coarseSurface, cw, ch, srcPaint);
const finePixels = rasterize(image, fineSurface, fw, fh, srcPaint);
if (!coarsePixels || !finePixels) return null;
let x = 0;
let y = 0;
if (refCoarse && refCoarsePixels && refFine) {
const coarseNow = profilesOf(coarsePixels, cw, ch);
const fineNow = profilesOf(finePixels, fw, fh);
// Profiles first: they are the only stage cheap enough to search a
// ±80 px range. Then the pixels confirm the result — that 2D pass is
// what keeps a periodic scene from locking onto the wrong repetition,
// and it keeps (0,0) as a candidate, so an unreadable pair merges
// unshifted rather than wrongly shifted.
const seed = nearest2D(
refCoarsePixels,
coarsePixels,
cw,
ch,
bestShift(refCoarse.cols, coarseNow.cols, COARSE_RADIUS),
bestShift(refCoarse.rows, coarseNow.rows, COARSE_RADIUS),
COARSE_REFINE,
);
// 1/8-scale alignment is only good to ±8 full-resolution pixels; the
// 1/4-scale profiles, searched around it, trim that to ±2.
const seedScale = COARSE_DIV / FINE_DIV;
x = bestShift(refFine.cols, fineNow.cols, FINE_RADIUS, seed.x * seedScale) * FINE_DIV;
y = bestShift(refFine.rows, fineNow.rows, FINE_RADIUS, seed.y * seedScale) * FINE_DIV;
} else {
refCoarse = profilesOf(coarsePixels, cw, ch);
refCoarsePixels = coarsePixels;
refFine = profilesOf(finePixels, fw, fh);
}
offsets.push({ x, y });
accum.getCanvas().drawImage(image, x, y, addPaint);
} finally {
image.dispose();
}
}
const minX = Math.min(...offsets.map((o) => o.x));
const maxX = Math.max(...offsets.map((o) => o.x));
const minY = Math.min(...offsets.map((o) => o.y));
const maxY = Math.max(...offsets.map((o) => o.y));
if (maxX - minX > W * MAX_SPREAD || maxY - minY > H * MAX_SPREAD) return null;
const accumulated = accum.makeImageSnapshot();
if (!accumulated) return null;
keep(accumulated);
// The frames disagree at the borders by exactly the spread of their offsets,
// so keep the intersection: no edge is left carrying one frame's worth of
// exposure against the average of the rest.
const outW = W - (maxX - minX);
const outH = H - (maxY - minY);
const cropped = surfaceFor(outW, outH);
if (!cropped) return null;
keep(cropped);
cropped
.getCanvas()
.drawImageRect(accumulated, Skia.XYWHRect(minX, minY, outW, outH), Skia.XYWHRect(0, 0, outW, outH), srcPaint);
const merged = cropped.makeImageSnapshot();
if (!merged) return null;
keep(merged);
// Quality 90, the same the photo output produces: one more JPEG generation
// on a file that the export decodes and re-encodes anyway.
const bytes = merged.encodeToBytes(ImageFormat.JPEG, 90);
if (!bytes) return null;
const path = `${FileSystem.cacheDirectory}burst_${Date.now()}.jpg`;
new File(path).write(bytes);
await stampExifOnFile(path, {}, uriOf(frames[0]));
return path;
} catch (e) {
console.warn('Burst merge failed:', e);
return null;
} finally {
// Reverse creation order, like exportEngine: a snapshot is what holds the
// 48MB buffer, everything else is a handle onto it.
for (let i = owned.length - 1; i >= 0; i--) {
try {
owned[i].dispose();
} catch {
// A buffer that is already gone must not cost the user the photo.
}
}
}
}
+42
View File
@@ -0,0 +1,42 @@
// Runnable self-check: `node --no-warnings src/utils/deviceFloor.check.ts`.
// Covers the budget maths only — the RAM reading itself needs the device.
import assert from 'node:assert';
import {
canBurst,
floorRaster,
FLOOR_EXPORT_PIXELS,
isFloorDevice,
maxExportPixels,
} from './deviceFloor.ts';
// Tier boundaries: unknown (0, the platform refused) is never a floor device,
// and the 4 GB mid-range keeps the full path.
assert.strictEqual(isFloorDevice(0), false, 'unknown RAM must keep full quality');
assert.strictEqual(isFloorDevice(1024), true, 'Redmi Go / Nokia 1 / J2 Core');
assert.strictEqual(isFloorDevice(2048), true, 'Go programme ceiling');
assert.strictEqual(isFloorDevice(3072), false);
assert.strictEqual(isFloorDevice(12288), false, '12S Ultra');
// Caps follow the tier.
assert.strictEqual(maxExportPixels(0), Infinity);
assert.strictEqual(maxExportPixels(12288), Infinity);
assert.strictEqual(maxExportPixels(1024), FLOOR_EXPORT_PIXELS);
assert.strictEqual(canBurst(1024), false);
assert.strictEqual(canBurst(0), true);
// The floor device's own 3264x2448 (8 MP) capture, and the 12S Ultra's 3000x4000.
const go = floorRaster(3264, 2448, FLOOR_EXPORT_PIXELS);
assert.ok(go.width < 3264, 'floor capture is resampled');
assert.ok(go.width * go.height <= FLOOR_EXPORT_PIXELS, 'stays inside the cap');
assert.ok(go.width * go.height > FLOOR_EXPORT_PIXELS * 0.9, 'and does not overshoot it');
// Aspect is preserved to within a pixel on both orientations.
assert.ok(Math.abs(go.width / go.height - 3264 / 2448) < 0.002, '4:3 kept');
const tall = floorRaster(3000, 4000, 4_000_000);
assert.ok(Math.abs(tall.width / tall.height - 3000 / 4000) < 0.002, '3:4 kept');
// Untouched when it already fits, and never zero-sized on a degenerate raster.
assert.deepStrictEqual(floorRaster(2000, 1500, FLOOR_EXPORT_PIXELS), { width: 2000, height: 1500 });
assert.deepStrictEqual(floorRaster(1, 1, FLOOR_EXPORT_PIXELS), { width: 1, height: 1 });
assert.deepStrictEqual(floorRaster(0, 0, 100), { width: 0, height: 0 });
console.log('deviceFloor.check: 17 assertions passed');
+55
View File
@@ -0,0 +1,55 @@
// The floor the whole pipeline is budgeted against: the cheapest Android that
// clears this app's minimum. What sets the minimum: minSdk 26 (below it
// vision-camera's NativeBufferHelper throws "HardwareBuffers require minSdk 26
// or higher!" on every frame), a 32-bit armeabi-v7a build (the shipped APK
// carries both ABIs), Camera2 with EXIF, and Skia's GL backend, which links
// -lGLESv2 (see node_modules/@shopify/react-native-skia/android/CMakeLists.txt).
// Every API 26 phone clears the GL bar, so the floor is the Android Go, 1 GB
// class: Redmi Go (Snapdragon 425, Adreno 308, 5.8-7.2 GFLOPS / 5.3 GB/s,
// 8 MP), Nokia 1 (MT6737M, Mali-T720, 5 MP) and Galaxy J2 Core (Exynos 7570,
// 8 MP) — 4x Cortex-A53 at 1.1-1.4 GHz, 1 GB RAM, 8 GB storage, 480p-720p
// screen. Nothing weaker can install.
//
// What that floor affords, at 4 bytes per RGBA8888 pixel:
// 8 MP raster 32 MB -> the export's live surfaces alone = 96 MB
// one full-frame pass 64 MB of traffic @ 5.3 GB/s ~ 12 ms
// 4 slow A53 cores ~1/6 of a current flagship core
// Hence, here: one resample at the door instead of a full-resolution surface
// chain, and no 3-frame burst (six 8 MP decodes plus a CPU alignment on four
// slow cores is seconds of shutter lag on the device class least able to afford
// it). The live preview needs no cap — Viewfinder already forces the frame
// stream to the 4:3 size nearest 1280x960.
//
// No state and no native import: the RAM reading comes from
// photoMeta.devicePerf() and is passed in, so this module also runs under plain
// node (see deviceFloor.check.ts).
export const FLOOR_RAM_MB = 2048;
export const FLOOR_EXPORT_PIXELS = 4_000_000;
// ramMb 0 = the platform would not say. Unknown is never a floor device: the app
// must not downgrade a phone it could not measure.
export const isFloorDevice = (ramMb: number): boolean => ramMb > 0 && ramMb <= FLOOR_RAM_MB;
// Most pixels the export may paint. 4 MP (2336x1752) still prints 10x15 cm at
// 300 dpi and halves every surface behind it — the 12S Ultra's 12 MP capture is
// untouched, since it is not a floor device.
// ponytail: downscale rather than tile — tile the render the day a floor user
// needs the full 8 MP file.
export const maxExportPixels = (ramMb: number): number =>
isFloorDevice(ramMb) ? FLOOR_EXPORT_PIXELS : Infinity;
// ponytail: one capability, all or nothing — add a 2-frame CPU-lite merge if a
// floor device ever reports it can afford one.
export const canBurst = (ramMb: number): boolean => !isFloorDevice(ramMb);
// Scale a raster down to at most `cap` pixels, keeping its aspect. Whole pixels
// only: the surface allocator and the EXIF stamp both read these back.
export function floorRaster(
w: number,
h: number,
cap: number
): { width: number; height: number } {
if (!(w > 0 && h > 0) || !(w * h > cap)) return { width: w, height: h };
const s = Math.sqrt(cap / (w * h));
return { width: Math.max(1, Math.round(w * s)), height: Math.max(1, Math.round(h * s)) };
}
+57
View File
@@ -0,0 +1,57 @@
// Runnable self-check for the one reader the low-light burst gate leans on.
// expo-file-system has no host implementation, so this covers readJpegIso
// offline: node --no-warnings src/utils/exifWrite.check.ts
import { readJpegIso } from './exifWrite.ts';
// A 44-byte TIFF: IFD0 holds the Exif pointer, the Exif IFD holds 0x8827 —
// the two-hop shape a camera writes. `type` is the EXIF type id (3 SHORT,
// 4 LONG); a wrong-IFD or wrong-endian reader fails here.
function tiffWithIso(iso: number, type: 3 | 4, be = false): Uint8Array {
const t = new Uint8Array(44);
const u16 = (p: number, v: number) => (be ? (t[p] = v >> 8, t[p + 1] = v & 0xff) : (t[p] = v & 0xff, t[p + 1] = v >> 8));
const u32 = (p: number, v: number) =>
be
? (t[p] = v >>> 24, t[p + 1] = (v >>> 16) & 0xff, t[p + 2] = (v >>> 8) & 0xff, t[p + 3] = v & 0xff)
: (t[p] = v & 0xff, t[p + 1] = (v >>> 8) & 0xff, t[p + 2] = (v >>> 16) & 0xff, t[p + 3] = v >>> 24);
t[0] = be ? 0x4d : 0x49;
t[1] = be ? 0x4d : 0x49;
u16(2, 42);
u32(4, 8);
u16(8, 1); // IFD0: one entry
u16(10, 0x8769); u16(12, 4); u32(14, 1); u32(18, 26); // -> Exif IFD at 26
u32(22, 0); // no IFD1
u16(26, 1); // Exif IFD: one entry
u16(28, 0x8827); u16(30, type); u32(32, 1);
if (type === 3) u16(36, iso);
else u32(36, iso);
u32(40, 0);
return t;
}
function jpegWith(tiff: Uint8Array): Uint8Array {
const len = 2 + 6 + tiff.length;
const j = new Uint8Array(6 + len); // SOI + marker/len + the length's own payload
j[0] = 0xff; j[1] = 0xd8; // SOI
j[2] = 0xff; j[3] = 0xe1; // APP1
j[4] = len >> 8; j[5] = len & 0xff;
j.set([0x45, 0x78, 0x69, 0x66, 0, 0], 6);
j.set(tiff, 12);
return j;
}
const checks: [string, boolean][] = [
['SHORT ISO read back', readJpegIso(jpegWith(tiffWithIso(1234, 3))) === 1234],
['LONG ISO read back', readJpegIso(jpegWith(tiffWithIso(2000, 4))) === 2000],
['big-endian ISO read back', readJpegIso(jpegWith(tiffWithIso(800, 3, true))) === 800],
['ISO 0 means unknown', readJpegIso(jpegWith(tiffWithIso(0, 3))) === null],
['an unreadable file is not an ISO', readJpegIso(new Uint8Array([0xff, 0xd8, 0xff, 0xd9])) === null],
['a non-JPEG is not an ISO', readJpegIso(new Uint8Array([1, 2, 3])) === null],
];
let bad = 0;
for (const [name, ok] of checks) {
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}`);
if (!ok) bad++;
}
if (bad) throw new Error(`exifWrite: ${bad} check(s) failed`);
console.log('\nexifWrite: all checks passed');
+24
View File
@@ -299,6 +299,30 @@ function findExifSegment(jpeg: Uint8Array): { start: number; end: number; tiff:
return null;
}
// --- read ------------------------------------------------------------------
/**
* The ISO the camera metered for this JPEG — EXIF 0x8827
* (PhotographicSensitivity) — or null when the file carries none. The capture
* path cannot ask the camera: CameraX hands back a path and nothing else, and
* the controller's `iso` reads 0.0 for every still. So the low-light gate reads
* it back off the file it just wrote, and a file without the tag simply never
* bursts.
*/
export function readJpegIso(jpeg: Uint8Array): number | null {
const seg = findExifSegment(jpeg);
if (!seg) return null;
const iso = parseTiff(seg.tiff).exif.find((e) => e.tag === 0x8827);
if (!iso) return null;
// parseTiff normalizes every value to little-endian, so SHORT and LONG read
// with the same shifts.
const n =
iso.type === LONG || iso.type === SLONG
? (iso.data[0] | (iso.data[1] << 8) | (iso.data[2] << 16) | (iso.data[3] << 24)) >>> 0
: (iso.data[0] | (iso.data[1] << 8)) >>> 0;
return n > 0 && n < 1000000 ? n : null; // 0 and vendor noise both mean "unknown"
}
// --- serialize -------------------------------------------------------------
function serializeTiff(ifd0: Entry[], exif: Entry[], gps: Entry[], interop: Entry[]): Uint8Array {
+33 -1
View File
@@ -10,7 +10,8 @@ import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader';
import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_W, WALLFRAME_H, WALLFRAME_LAND_W, WALLFRAME_LAND_H } from './frameUtils';
import { patchJpegDpi } from './jpegDpi';
import { writeJpegExif } from './exifWrite';
import { nextPhotoPath, readPhotoBytes, getDeviceIdentity } from './photoMeta';
import { nextPhotoPath, readPhotoBytes, getDeviceIdentity, devicePerf } from './photoMeta';
import { floorRaster, maxExportPixels } from './deviceFloor';
import { ensureMediaLibraryPermission } from './mediaPermission';
import RecipescamExport from '../../modules/recipescam-export';
@@ -246,6 +247,37 @@ export async function processAndExportPhoto(
}
let width = skImage.width();
let height = skImage.height();
// 1c. Floor-device ceiling (see deviceFloor.ts). Everything below keeps more
// than one full-resolution surface alive — paint target, snapshot, sharpen —
// and one 8 MP raster is 32 MB, which is what a 1 GB Go phone pages or OOMs
// on mid-export. Resample once, here at the door, so every pass behind it
// (crop, tone, glow, grain, sharpen) runs on the smaller raster; the aspect
// is unchanged, so the ratio crop, frame layout and cover maths all stand.
// A surface that will not allocate leaves the raster alone: the full-size
// chain below is the one we are already on.
const capped = floorRaster(width, height, maxExportPixels(devicePerf().ramMb));
if (capped.width !== width) {
const shrinkSurface = createSurface(capped.width, capped.height);
if (shrinkSurface) {
own(shrinkSurface);
shrinkSurface.getCanvas().drawImageRect(
skImage,
Skia.XYWHRect(0, 0, width, height),
Skia.XYWHRect(0, 0, capped.width, capped.height),
own(Skia.Paint())
);
const shrunk = shrinkSurface.makeImageSnapshot();
if (shrunk) {
own(shrunk);
// Superseded, like the aspect crop above.
release(owned, shrinkSurface);
release(owned, skImage);
skImage = shrunk;
width = capped.width;
height = capped.height;
}
}
}
// 1b. Aspect-ratio crop (composition): center-largest upright crop of the
// requested ratio. Runs BEFORE any processing so frame/GPS/grain/sharpen
// all apply to exactly the pixels the user framed. Wall Frame is exempt —
+23
View File
@@ -3,6 +3,7 @@
import { File } from 'expo-file-system';
import * as FileSystem from 'expo-file-system/legacy';
import RecipescamExport from '../../modules/recipescam-export';
import { isFloorDevice } from './deviceFloor';
import { writeJpegExif, SOFTWARE, type DeviceIdentity, type ExifStamp } from './exifWrite';
// RC_<yyyymmdd_hhmmss>[_exported].jpg — the name the gallery shows for a saved
@@ -50,6 +51,28 @@ export function getDeviceIdentity(): DeviceIdentity | null {
return deviceIdentity;
}
// Device facts the capture and render paths budget against (deviceFloor.ts):
// total RAM and CPU cores. Lives next to deviceInfo because that is where the
// native handle already is; read once, the numbers cannot change while the app
// runs. ramMb 0 = the platform refused, which deviceFloor reads as "not a floor
// device" so an unmeasurable phone keeps the full-quality path.
let devicePerfCache: { ramMb: number; cores: number } | null = null;
export function devicePerf(): { ramMb: number; cores: number } {
if (!devicePerfCache) {
try {
devicePerfCache = RecipescamExport.devicePerf();
} catch (e) {
console.warn('devicePerf unavailable, full-quality path:', e);
devicePerfCache = { ramMb: 0, cores: 0 };
}
// One line, so a floor device in the field can be confirmed from logcat:
// "adb logcat -s ReactNativeJS | grep floor".
const { ramMb, cores } = devicePerfCache;
console.info(`devicePerf: ramMb=${ramMb} cores=${cores} floor=${isFloorDevice(ramMb)}`);
}
return devicePerfCache;
}
// Stamp EXIF into a file already on disk (the native engine writes the JPEG
// itself, so those bytes never pass through JS). Never throws — a metadata
// failure must not cost the user the photo.
+4 -1
View File
@@ -1,6 +1,9 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
"strict": true,
// burstAlign.check.ts runs under node's own type stripping, where the import
// must name the .ts file. Type-only flag; expo base already sets noEmit.
"allowImportingTsExtensions": true
}
}