diff --git a/8_EDITOR_TOOLS_PORT.md b/8_EDITOR_TOOLS_PORT.md new file mode 100644 index 0000000..676c099 --- /dev/null +++ b/8_EDITOR_TOOLS_PORT.md @@ -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 `` quanh một chuỗi shader của ảnh. **Không** chèn được pass giữa tuỳ ý. | +| Camera preview (worklet) | `Viewfinder.tsx` ``, `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` + `` — đã 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 ``/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> = { '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 (`` + `` + ``), 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 `` 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 ` + {graded}` 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 `` 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.