# 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.