Files
RecipesCam/8_EDITOR_TOOLS_PORT.md
T

716 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.