android: spec the web editor tools to port (exposure/EV/highlight, AUTO, WB white/black, tone curve, gradient mask, FIX, flips)

This commit is contained in:
2026-09-26 11:49:02 +07:00
parent d5e5da49b7
commit f3d26e3275
+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.