android: spec the web editor tools to port (exposure/EV/highlight, AUTO, WB white/black, tone curve, gradient mask, FIX, flips)
This commit is contained in:
@@ -0,0 +1,715 @@
|
||||
# 8 — PORT BỘ CÔNG CỤ CHỈNH ẢNH TỪ WEB SANG APP ANDROID
|
||||
|
||||
Nhánh đích: `feat/vision-camera-v5` (base khi viết: **`d5e5da4`**). Ngày: 2026-09-26.
|
||||
Nguồn đối chiếu: nhánh **`recipes-web`**, commit **`6814b05`** (bản web đã chạy và đo được).
|
||||
|
||||
Tài liệu này là **spec để code**, không phải báo cáo. Mỗi mục có: nguồn web (file/hàm),
|
||||
hiện trạng Android, việc phải làm, mã mẫu, và **tiêu chí nghiệm thu**. Toàn bộ số dòng
|
||||
đã đối chiếu trực tiếp trên hai nhánh tại thời điểm viết.
|
||||
|
||||
> **Cách lấy nguồn tham chiếu:** nhánh `feat/vision-camera-v5` **không chứa** thư mục
|
||||
> `docker/frontend`. Đọc bản web bằng:
|
||||
>
|
||||
> ```sh
|
||||
> git show origin/recipes-web:docker/frontend/shared/utils/toneShader.ts
|
||||
> git show origin/recipes-web:docker/frontend/src/engine/exportEngine.ts
|
||||
> ```
|
||||
>
|
||||
> Đường dẫn "web" trong tài liệu này luôn tính từ `docker/frontend/`.
|
||||
|
||||
---
|
||||
|
||||
## 0. Phạm vi và bảng khoảng trống
|
||||
|
||||
Bảy hạng mục người dùng yêu cầu, theo đúng thứ tự sẽ làm:
|
||||
|
||||
| # | Hạng mục | Web có (nguồn) | Android hiện có | Việc phải làm |
|
||||
|---|----------|----------------|-----------------|----------------|
|
||||
| 1 | EXPOSURE / EV / HIGHLIGHT | `shared/utils/toneShader.ts` `EXPOSURE_SKSL` + `TONE_SKSL`; `shared/utils/colorUtils.ts` `EV_PER_UNIT`/`SIM_EXPOSURE_BIAS_EV`/`exposureStops` (`171-193`) | EV nhét trong ma trận màu (`src/utils/colorUtils.ts:425-426`, `applyExposureGain:444-455`); highlight knee `smoothstep(0.65,1.0)` gain `0.22` không có `(1-t)` (`src/utils/toneShader.ts:131,134`) | Tách EV sang pass linear; port đúng knee/`(1-t)` của web; **áp luôn fix thứ tự pass của `4_FIX_HIGHLIGHT_ORDER.md`** |
|
||||
| 2 | AUTO (auto setting) | `src/ui/Histogram.tsx` `readHistogram` + `autoExposureStops`; `src/App.tsx:2497-2504` | Không có (ROTATE có AUTO riêng cho straighten — khác việc) | Histogram 256 bin từ pixel ảnh gốc + `autoExposureStops`, ghi vào `exposureCompensation` |
|
||||
| 3 | WHITE / BLACK trong WB | `TONE_SKSL` web dòng `168-169`; uniform `wh`/`bl` (`78-79`, `338-339`, `390-391`, `433-434`, `454`, `466-467`); `paramDefs.ts:137,148` | Không có `whites`/`blacks`. `paramDefs.wb` chỉ có `temperature`,`tint` (`src/utils/paramDefs.ts:109-132`) | Thêm 2 uniform + 2 entry `paramDefs.wb` |
|
||||
| 4 | TONE CURVE | `shared/utils/toneCurve.ts` (203 dòng) + `src/ui/ToneCurvePanel.tsx` | Không có | Port toán + LUT + panel graph |
|
||||
| 5 | GRADIENT MASK | `shared/utils/gradientMask.ts` (169 dòng) + `App.tsx` `gradientChips:2750`/`maskRulers:2355` | Không có | Port shader + geometry + UI chip/strip/cột chỉnh |
|
||||
| 6 | FIX (HEAL, MOSAIC) | `shared/utils/heal.ts` (324), `mosaic.ts` (104), `brush.ts` (26) | Không có | Port shader + pixel sampler + UI |
|
||||
| 7 | H-FLIP / V-FLIP trong ROTATE | `shared/utils/skiaImage.ts` `flipSkImage:84`, `applyPhotoRotation:114` | `applyPhotoRotation(image, quarter, straighten)` — **không có flip** (`src/utils/skiaImage.ts:69`) | Thêm flip + state + UI strip |
|
||||
|
||||
### 0.1 Đã có sẵn trên nhánh này — KHÔNG làm lại
|
||||
|
||||
Các commit `c8e2b6e`…`d5e5da4` đã port xong và **không** nằm trong phạm vi tài liệu này:
|
||||
|
||||
- **HSL selective colour mixer** (`cbd0178`): đã có `hslBands`/`hslHue`/`hslSat`/`hslLum`
|
||||
trong `ColorAdjustments` (`src/types/index.ts:77-104`), `HSL_BANDS`/`hslBandGaps`/
|
||||
`sanitizeHslBands` trong `colorUtils.ts:11-115`, band block trong `TONE_SKSL`
|
||||
(`toneShader.ts:51-58,76-82,194`) và tab `HSL` trong `ToolRail.tsx:18`.
|
||||
⇒ **Bỏ** mọi ghi chú cũ kiểu "HSL web-only".
|
||||
- **Stock `classic-vivid` và `mono-high-contrast`** (`c8e2b6e`, `85b55dc`): đã có trong
|
||||
`BaseFilter` (`src/types/index.ts:69`) **và** đã có entry `FILM_TONE`
|
||||
(`src/utils/toneShader.ts:299-322`, gồm `'mono-high-contrast': { sh: -0.32, hl: 0.26 }`).
|
||||
- **`STOCK_BIAS`** (`colorUtils.ts:161-168`) đã thay vai trò của `SIM_EXPOSURE_BIAS_EV` +
|
||||
`SIM_CONTRAST_BIAS` bên web, nhưng còn ở **đơn vị cũ** — xem mục 3.3.
|
||||
|
||||
Ghi chú phạm vi khác: ba nhánh light/chroma, split-tone, Color Chrome, vibrance trong
|
||||
`TONE_SKSL` Android **đã khớp** web — không đụng.
|
||||
|
||||
---
|
||||
|
||||
## 1. Hiện trạng Android
|
||||
|
||||
### 1.1 Ba đường render — mọi pass mới phải khớp cả ba
|
||||
|
||||
| Đường | File | Đặc điểm |
|
||||
|-------|------|----------|
|
||||
| Export JS/Skia (imperative) | `src/utils/exportEngine.ts` → `processAndExportPhoto` | Toàn quyền: surface phụ, snapshot, LUT, cache effect theo count. Đây là đường "chuẩn" để so. |
|
||||
| Preview library (declarative) | `src/components/Viewfinder.tsx`, `libPhoto()` (`3127-3200`) | Chỉ lồng được `<Shader source={effect}>` quanh một chuỗi shader của ảnh. **Không** chèn được pass giữa tuỳ ý. |
|
||||
| Camera preview (worklet) | `Viewfinder.tsx` `<SkiaCamera onFrame={handleFrame}>`, `drawFullFrame` | Mỗi frame; tránh thêm pass nặng. |
|
||||
| Export native Kotlin | `src/utils/nativeExport.ts:47` + `modules/recipescam-export/android/.../RecipescamExportModule.kt` | Bật bằng `EXPO_PUBLIC_NATIVE_EXPORT=1` (`App.tsx:61`). Chỉ dùng khi `pro` (`App.tsx:1376`). |
|
||||
|
||||
`Viewfinder.tsx:461` dựng ảnh tĩnh đã quay cho chế độ library bằng memo
|
||||
`applyPhotoRotation(loadedImage, photoRotation, photoStraighten)`.
|
||||
|
||||
### 1.2 Hạ tầng đã có
|
||||
|
||||
- `Skia.RuntimeEffect.Make` + `makeShaderWithChildren` + `<Shader source>` — đã dùng
|
||||
(`exportEngine.ts:371-380`, `Viewfinder.tsx:3141-3146`).
|
||||
- Mẫu đọc pixel: `src/utils/horizon.ts:18-56` — `Skia.Surface.Make(w,h)` (`32`) →
|
||||
`drawImageRectOptions` downscale → `makeImageSnapshot()` → `readPixels()` (`50`)
|
||||
(Uint8Array RGBA hoặc Float32Array). Dùng lại cho histogram/AUTO và `findHealSource`.
|
||||
- Undo: `App.tsx` `commit(coalesce, mutate)` (`757-775`, `PEEK_GAP_MS = 700` tại `751`),
|
||||
`applyLook` (`804`), `handleUndo` (`820`), `Look` (`719-720`) — thêm field vào `Look`
|
||||
là tự động sống qua undo/redo/session (`lookNow` tại `728`).
|
||||
- Chip UI: `AdjustmentPanel.tsx` `chipRow` (`867`), `paramChips` (`475`),
|
||||
`groupDefs` (`652`), `groupDefs.rotate` (`713-733`), `rotateChip()` (`791-803`).
|
||||
- `RecipeCreateModal.handleSave` spread `...seedAdj`, nên knob **không có row** trong form
|
||||
vẫn được lưu (không bị zero).
|
||||
|
||||
### 1.3 Điều KHÔNG có (phải dựng mới)
|
||||
|
||||
Không có: brush, heal, mosaic, mask vẽ tay, curve editor, flip. Không SVG, không
|
||||
`react-native-gesture-handler` — mọi touch đi qua RN responder. Không i18n (chuỗi
|
||||
hardcode tiếng Anh, giữ nguyên).
|
||||
|
||||
`exportEngine.ts` **không** cache `RuntimeEffect` theo count: chỉ có `watermarkFonts`
|
||||
(`184`) ở module scope, mọi effect khác dựng lại trong hàm. Heal/mosaic/mask bắt buộc
|
||||
phải cache — xem mục 2.4.
|
||||
|
||||
### 1.4 Thứ tự pass hiện tại của `exportEngine.ts`
|
||||
|
||||
```
|
||||
218 1 nạp ảnh → 241 1 rotate → 281 1b crop → 250 1c deviceFloor → 337 2 surface
|
||||
350 3 color matrix (+ STOCK_BIAS, +2^EV gain) ← CÙNG paint với 3b/3c: SAI (xem doc 4)
|
||||
359 3b tone shader → 392 3c cinema
|
||||
414 4 denoise/clarity/soften → 477 5 draw → 489 5b HDF
|
||||
572 6 grain → 619 6b vignette → 648 7 frame
|
||||
832 8 watermark → 924 9 snapshot/JPEG/EXIF → 976 10 file → 988 11 gallery
|
||||
```
|
||||
|
||||
`4_FIX_HIGHLIGHT_ORDER.md` đã chỉ ra `SkPaint` chạy **shader TRƯỚC colorFilter**, nên
|
||||
ma trận (mang exposure) hiện đang áp **sau** tone → HIGHLIGHT bị xoá sạch. Kiểm tra
|
||||
`grep -rn gradeThrough src/` → **rỗng**, fix này **chưa được áp**. Mục 3 dưới đây áp nó
|
||||
cùng lúc với việc tách EV.
|
||||
|
||||
---
|
||||
|
||||
## 2. Quy tắc chung cho mọi pass mới
|
||||
|
||||
### 2.1 Thứ tự pass đích
|
||||
|
||||
```
|
||||
rotate(+flip) → crop → matrix → [EXPOSURE linear] → tone(hl/sh/wh/bl) → cinema
|
||||
→ curve → denoise/clarity/soften → draw → HDF
|
||||
→ masks → heal → mosaic → grain → vignette → frame → watermark
|
||||
```
|
||||
|
||||
### 2.2 Sai lệch **có chủ ý** so với web
|
||||
|
||||
Web đặt `masks → heal → mosaic` **sau** grain và vignette
|
||||
(`exportEngine.ts:708-800`). Android đặt chúng **trước** grain/vignette. Lý do:
|
||||
|
||||
1. Preview library chỉ lồng được shader trong chuỗi shader của ảnh — mask/heal/mosaic
|
||||
phải nằm trong chuỗi đó, tức trước lớp grain/vignette vốn là node riêng
|
||||
(`Viewfinder.tsx:3230-3233`/`3288-3291` grain, `renderVignette:2809,3237,3295,3348`).
|
||||
2. Grain/vignette chạy sau sẽ **phủ đều** lên vết sửa, nên vết heal/mosaic trông tự
|
||||
nhiên hơn (nhiễu hạt chồng lên đúng như mọi vùng khác).
|
||||
|
||||
Điều phải giữ bằng mọi giá: `masks` chạy **trước** `heal` (web có comment giải thích ở
|
||||
`gradientMask.ts:149-155` và `exportEngine.ts:708-737`) — vết heal phải mượn pixel **đã
|
||||
mang ánh sáng của mask**.
|
||||
|
||||
### 2.3 Điểm chèn
|
||||
|
||||
| Đường | Vị trí chèn |
|
||||
|-------|-------------|
|
||||
| `exportEngine.ts` | Ngay sau khối vignette, trước `// 7. Frame.` (`648`) |
|
||||
| `Viewfinder.tsx` | Trong `libPhoto()` (`3127`), sau `<Blur>`/clarity và trước HDF glow |
|
||||
| `nativeExport.ts` / Kotlin | Theo `4_FIX_HIGHLIGHT_ORDER.md` §4.3 rồi nối tiếp |
|
||||
|
||||
### 2.4 Quy ước code
|
||||
|
||||
- Shader dựng theo **số lượng** phần tử (`healSkSL(n)`, `mosaicSkSL(n)`,
|
||||
`gradientMaskSkSL(n)`) và **cache** effect theo count — copy mẫu web
|
||||
`exportEngine.ts:154-210` (`healEffectFor`/`mosaicEffectFor`/`maskEffectFor`).
|
||||
- Mọi pass mới phải có nhánh "tắt": uniform toàn 0 / danh sách rỗng ⇒ **không** tạo
|
||||
surface, **không** đổi đường vẽ cũ. Đây là điều kiện để không regress ảnh hiện tại.
|
||||
- Không đổi ngữ nghĩa `evFromCamera` (`exportEngine.ts:35,353`): ảnh chụp từ camera đã
|
||||
nhận AE bias phần cứng, **không** cộng thêm EV của `exposureCompensation`.
|
||||
|
||||
---
|
||||
|
||||
## 3. EXPOSURE / EV / HIGHLIGHT
|
||||
|
||||
### 3.1 Nguồn web
|
||||
|
||||
- `shared/utils/toneShader.ts` — `EXPOSURE_SKSL`: linear hoá sRGB (`0.04045/12.92` +
|
||||
`2.4`), nhân `exp2(ev)`, encode lại. Chạy **giữa** ảnh đã grade và tone shader.
|
||||
- `shared/utils/colorUtils.ts:171-193`:
|
||||
- `SIM_EXPOSURE_BIAS_EV = { 'leica-vivid': 0.25 }`, `SIM_CONTRAST_BIAS = { 'mono-high-contrast': 4 }`
|
||||
- `EV_PER_UNIT = 0.25` → núm EXPOSURE ±10 = ±2.5 EV
|
||||
- `exposureStops(adj, baseFilter) = (adj.exposure ?? 0) * EV_PER_UNIT + bias`
|
||||
- Comment khẳng định: EV/biến thiên sim **không** nằm trong ma trận màu.
|
||||
- `exportEngine.ts:458-476` — bước 3: ma trận riêng một ảnh, rồi `evStops = exposureStops() + userEv`.
|
||||
|
||||
### 3.2 Hiện trạng Android (sai)
|
||||
|
||||
`src/utils/colorUtils.ts`:
|
||||
|
||||
```
|
||||
186 const exposure = adj.exposure + (STOCK_BIAS[baseFilter]?.exposure ?? 0);
|
||||
425 const expScale = 1 + (exposure / 10) * 0.2; // -10 → 0.8x, +10 → 1.2x
|
||||
426 const expOffset = (exposure / 10) * 0.15;
|
||||
444 export function applyExposureGain(matrix, evStops) // gain 2^ev trong gamma-space
|
||||
```
|
||||
|
||||
`exposureCompensation` ±3 EV (`src/types/index.ts:104`), áp qua `applyExposureGain` tại
|
||||
`exportEngine.ts:353-357` và `nativeExport.ts:61`. Kết quả: +1 EV đẩy mid-grey 0.5 →
|
||||
**1.0** (cháy), đúng như web đã sửa.
|
||||
|
||||
`STOCK_BIAS['leica-vivid'] = { exposure: 2 }` (`colorUtils.ts:163`) là **đơn vị cũ**: web
|
||||
đo ra ≈ **0.25 EV** và đã chuyển sang thang stops.
|
||||
|
||||
### 3.3 Việc phải làm
|
||||
|
||||
**Bước A — tách EV khỏi ma trận.**
|
||||
|
||||
1. `colorUtils.ts:186` — bỏ `+ (STOCK_BIAS[baseFilter]?.exposure ?? 0)`; xoá khối
|
||||
`expScale`/`expOffset` (`425-438`) khỏi `getSkiaColorMatrix`; xoá
|
||||
`applyExposureGain` (`444-455`) khỏi mọi caller. Giữ `STOCK_BIAS.contrast`
|
||||
(`mono-high-contrast: 4`) — web cũng giữ `SIM_CONTRAST_BIAS` trong ma trận.
|
||||
2. Đổi `STOCK_BIAS['leica-vivid']` từ `{ exposure: 2 }` → **bỏ khỏi `STOCK_BIAS`**, và
|
||||
thêm đúng bản web:
|
||||
|
||||
```ts
|
||||
// src/utils/colorUtils.ts
|
||||
export const EV_PER_UNIT = 0.25; // núm EXPOSURE ±10 = ±2.5 EV (web: giống hệt)
|
||||
const SIM_EXPOSURE_BIAS_EV: Partial<Record<BaseFilter, number>> = { 'leica-vivid': 0.25 };
|
||||
|
||||
/** EV người dùng (núm), biến thiên sim, và EV thô — cộng ở thang stops. */
|
||||
export function exposureStops(
|
||||
adj: ColorAdjustments, baseFilter: BaseFilter | undefined, userEv: number
|
||||
): number {
|
||||
return (adj.exposure ?? 0) * EV_PER_UNIT
|
||||
+ (SIM_EXPOSURE_BIAS_EV[baseFilter ?? 'none'] ?? 0)
|
||||
+ userEv;
|
||||
}
|
||||
```
|
||||
|
||||
**Bước B — thêm `EXPOSURE_SKSL`** vào `src/utils/toneShader.ts` (copy nguyên văn web,
|
||||
giữ comment gốc):
|
||||
|
||||
```ts
|
||||
export const EXPOSURE_SKSL = `
|
||||
uniform shader src;
|
||||
uniform float ev;
|
||||
vec3 toLinear(vec3 c) {
|
||||
return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c));
|
||||
}
|
||||
vec3 toEncoded(vec3 c) {
|
||||
return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c));
|
||||
}
|
||||
vec4 main(vec2 xy) {
|
||||
vec4 c = src.eval(xy);
|
||||
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
|
||||
return vec4(clamp(toEncoded(toLinear(rgb) * exp2(ev)), 0.0, 1.0), c.a);
|
||||
}
|
||||
`;
|
||||
```
|
||||
|
||||
**Bước C — áp `4_FIX_HIGHLIGHT_ORDER.md` §4.1/§4.2/§4.3 nguyên trạng.** Ma trận phải
|
||||
thành ảnh riêng (`gradeThrough`) trước khi tone/cinema đọc. Đồng thời **bỏ lần áp ma
|
||||
trận thứ hai** trong khối HDF (`exportEngine.ts:529,549`, `Viewfinder.tsx:1369,1387`).
|
||||
|
||||
Thứ tự sau khi sửa: `matrix → exposure(linear) → tone → cinema`. Exposure chèn cùng chỗ
|
||||
với ma trận: `gradeImage` (kết quả `gradeThrough`) → shader exposure → tone shader con.
|
||||
`ev = exposureStops(adjustments, recipe.baseFilter, evFromCamera ? 0 : exposureCompensation)`.
|
||||
Với `evFromCamera` thì **vẫn** giữ phần núm EXPOSURE + biến thiên sim (chúng chưa từng
|
||||
đi qua phần cứng), chỉ bỏ `exposureCompensation`.
|
||||
|
||||
### 3.4 Nghiệm thu
|
||||
|
||||
1. Parity với web trên **cùng ảnh + cùng recipe** (so pixel, không so cảm giác).
|
||||
2. Mid-grey +1 EV cho ra **≈ 0.69** encoded, **không** cháy 1.00.
|
||||
3. Bảng p95 của `4_FIX_HIGHLIGHT_ORDER.md` §5: nguồn gradient dọc 0.98→0.30,
|
||||
`HIGHLIGHT −10` → p95 **0.780**, `HIGHLIGHT −10 + EXPOSURE +10` → p95 **vẫn 0.780**,
|
||||
midtone 0.502 → 0.722.
|
||||
4. `leica-vivid` sau khi sửa sáng thêm **≈ 0.25 EV** so với trước (đo trên cùng ảnh), và
|
||||
**giống** bản web.
|
||||
5. Chạy đủ ba đường (export JS, preview library, Kotlin) và so ảnh với nhau.
|
||||
|
||||
> **Thay đổi hành vi phải báo trước:** núm EXPOSURE từ "±10 đơn vị ≈ ×0.8…×1.2" thành
|
||||
> "±2.5 EV". Recipe cũ đặt EXPOSURE ≠ 0 sẽ sáng/tối hơn rõ rệt. Web đã chấp nhận đánh
|
||||
> đổi này (`colorUtils.ts` comment "every recipe already saved with a non-zero EXPOSURE
|
||||
> gets brighter with it").
|
||||
|
||||
---
|
||||
|
||||
## 4. HIGHLIGHT
|
||||
|
||||
### 4.1 Nguồn web (`TONE_SKSL`)
|
||||
|
||||
```
|
||||
57 float hlMask = smoothstep(0.50, 1.00, t);
|
||||
60 float o = t + hl * hlMask * (1.0 - t) + sh * 0.34 * shMask;
|
||||
63 o -= dr * 0.18 * hlMask * t;
|
||||
```
|
||||
|
||||
Knee **0.50**, biên độ **1.0** (không scale), dạng **`(1-t)`** (càng gần trắng càng ít
|
||||
đẩy) ⇒ monotonic, giữ được vùng sáng không cháy. `shMask = 1 - smoothstep(0, 0.55, t)`.
|
||||
|
||||
### 4.2 Hiện trạng Android (`src/utils/toneShader.ts`)
|
||||
|
||||
```
|
||||
131 float hlMask = smoothstep(0.65, 1.00, t);
|
||||
134 float o = t + hl * 0.22 * hlMask + sh * 0.34 * shMask;
|
||||
```
|
||||
|
||||
Knee 0.65, gain 0.22, cộng thẳng không có `(1-t)` ⇒ vùng sáng đã cháy vẫn bị đẩy lên và
|
||||
thanh "chết" đúng như `4_FIX_HIGHLIGHT_ORDER.md` §1 mô tả.
|
||||
|
||||
### 4.3 Việc phải làm
|
||||
|
||||
Thay hai dòng `131` và `134` bằng bản web. Giữ `sh * 0.34` (hai bên giống nhau). Không
|
||||
đổi `dr` (dòng `137`, đã khớp). Cập nhật comment đầu file (mô tả knee 0.65) cho khớp.
|
||||
|
||||
`FILM_TONE` (`toneShader.ts:299-322`) đã có đủ sáu stock web hỗ trợ, gồm
|
||||
`classic-vivid` và `mono-high-contrast` — **không đụng**.
|
||||
|
||||
### 4.4 Nghiệm thu
|
||||
|
||||
- `HIGHLIGHT −10` trên ảnh có vùng trắng đẩy p95 xuống ~0.78 và **không** đổi midtone
|
||||
(so với ảnh gốc, mid lệch < 0.01).
|
||||
- `HIGHLIGHT +10` rồi `−10` quay về đúng ảnh gốc (đối xứng, sai số ≤ 1/255).
|
||||
- `mono-high-contrast` (dùng `hl: 0.26`) giữ nguyên diện mạo sau khi đổi knee — kiểm bằng
|
||||
ảnh so trước/sau.
|
||||
|
||||
---
|
||||
|
||||
## 5. WHITE / BLACK trong WB
|
||||
|
||||
### 5.1 Nguồn web
|
||||
|
||||
`TONE_SKSL` — chèn **sau** nhánh lightness/chroma, trước split-tone:
|
||||
|
||||
```
|
||||
168 vec3 dk = 1.0 - rgb;
|
||||
169 rgb = clamp(rgb + bl * 0.18 * dk * dk * dk + wh * 0.18 * rgb * rgb * rgb, 0.0, 1.0);
|
||||
```
|
||||
|
||||
Trọng số **cubic** theo khoảng cách của kênh tới đầu mút: trong tối kênh tối nhất dịch
|
||||
nhiều nhất, trong sáng kênh sáng nhất dịch nhiều nhất ⇒ kéo R/G/B về chung một toe và
|
||||
shoulder — đây là **động tác white balance**, không phải thêm một thanh tone. Monotonic:
|
||||
đạo hàm `1 − 3×0.18 = 0.46` tại đầu mút, không thể đảo chiều.
|
||||
|
||||
Uniform web: `uniform float wh; uniform float bl;` (`toneShader.ts:78-79`), nhận
|
||||
`Math.max(-1, Math.min(1, (adj.whites ?? 0) / 10))` (`390-391`), đưa vào mảng đúng thứ tự
|
||||
khai báo sau `dr, hl, sh` (`454`), và `toneIsActive` thêm `u.wh !== 0 || u.bl !== 0`
|
||||
(`466-467`).
|
||||
|
||||
`shared/utils/paramDefs.ts:137,148` — thứ tự WB: `temperature, tint, whites` (label
|
||||
`WHITE`), `blacks` (`BLACK`), min/max ±10, step 1, `display: sign`.
|
||||
|
||||
### 5.2 Việc phải làm
|
||||
|
||||
1. `src/types/index.ts` — thêm `whites?: number; // -10..+10` và `blacks?: number` vào
|
||||
`ColorAdjustments` (interface ở dòng `77`), đặt ngay sau `tint`/`wbBlue` cho khớp web.
|
||||
2. `src/utils/toneShader.ts`:
|
||||
- thêm `uniform float wh; uniform float bl;` **ngay sau** `uniform float sh;` (dòng `66`);
|
||||
- thêm khối `dk` ở đúng vị trí web (sau nhánh lightness/chroma, trước split-tone ở `147`);
|
||||
- thêm `wh`/`bl` vào `ToneUniforms` (`275`), `getToneUniforms` (`325`, chia 10 và kẹp ±1),
|
||||
`toneUniformArray` (`388`, đúng thứ tự khai báo), `toneIsActive` (`396`).
|
||||
3. `src/utils/paramDefs.ts:109-132` — thêm 2 entry vào `wb` sau `tint`, label
|
||||
`WHITE`/`BLACK`, ±10 step 1, `display: sign`, `get: (a) => a.whites ?? 0`,
|
||||
`set: (v) => ({ whites: v })` (tương tự `blacks`). `chipRow` của tab WB
|
||||
(`AdjustmentPanel.tsx:957`) tự hiện chip — không phải sửa UI.
|
||||
4. `RecipeCreateModal` — **không cần** thêm row: `handleSave` đã spread `seedAdj`.
|
||||
|
||||
### 5.3 Nghiệm thu
|
||||
|
||||
- `WHITE +10` chỉ nâng vùng sáng, vùng tối và mid lệch < 1/255.
|
||||
- `BLACK +10` chỉ nâng vùng tối (đen thành xám nhẹ), vùng sáng lệch < 1/255.
|
||||
- Trên ảnh xám trung tính, `WHITE −10` **không** sinh cast màu (ba kênh dịch như nhau).
|
||||
- Recipe cũ (không có `whites`/`blacks`) render **y hệt** trước khi sửa.
|
||||
|
||||
---
|
||||
|
||||
## 6. AUTO (auto setting)
|
||||
|
||||
### 6.1 Nguồn web
|
||||
|
||||
`src/ui/Histogram.tsx`:
|
||||
|
||||
```ts
|
||||
const BINS = 256; // 0..255
|
||||
const SAMPLE = 320; // downscale cạnh dài trước khi bin
|
||||
export const AUTO_EV_MAX = 2.5;
|
||||
|
||||
export function autoExposureStops(lum: number[]): number {
|
||||
let sum = 0, weighted = 0;
|
||||
for (let i = 0; i < lum.length; i++) { sum += lum[i]; weighted += (i / (lum.length - 1)) * lum[i]; }
|
||||
const avg = Math.max(0.001, sum > 0 ? weighted / sum : 0);
|
||||
const stops = Math.log2(0.48 / avg);
|
||||
return Math.max(-AUTO_EV_MAX, Math.min(AUTO_EV_MAX, stops));
|
||||
}
|
||||
```
|
||||
|
||||
Luma weights `0.2126/0.7152/0.0722` (đúng trọng số shader dùng). Target **0.48**, guard
|
||||
`≥ 0.001`, kẹp ±2.5 EV.
|
||||
|
||||
`src/App.tsx:2497-2504` `autoExposure()`: đọc histogram của **ảnh gốc đã load**, rồi
|
||||
`setAdjustment({ exposureCompensation: Math.round(stops * 10) / 10 })`. Chip `AUTO` đứng
|
||||
**đầu** row tab LIGHT (`App.tsx:2586-2587`).
|
||||
|
||||
### 6.2 Việc phải làm (Android)
|
||||
|
||||
1. Thêm `src/utils/histogram.ts`:
|
||||
|
||||
```ts
|
||||
export const AUTO_EV_MAX = 2.5;
|
||||
|
||||
// Pixel đã downscale (cạnh dài 320) từ readPixels — cùng mẫu horizon.ts:32-56.
|
||||
export function lumaBins(rgba: Uint8Array | Float32Array, isFloat: boolean): number[] { /* 256 bin */ }
|
||||
export function autoExposureStops(lum: number[]): number { /* y như web */ }
|
||||
```
|
||||
|
||||
Nguồn pixel: `Skia.Surface.Make` + `drawImageRectOptions` downscale về cạnh dài **320**
|
||||
trên **ảnh gốc đã load** (rotation không đổi histogram, không cần `applyPhotoRotation`),
|
||||
theo đúng mẫu `src/utils/horizon.ts:32-56`. Nhớ `dispose()` surface/snapshot — Hermes
|
||||
không tự thu hồi buffer native (`exportEngine.ts:113-121`).
|
||||
|
||||
2. Chip `AUTO` **đứng đầu** row LIGHT: `AdjustmentPanel.tsx:951` hiện là
|
||||
`chipRow([...paramChips(paramDefs.iq), groupChip('dr')])` → đổi thành
|
||||
`chipRow([autoChip(), ...paramChips(paramDefs.iq), groupChip('dr')])`, nhãn `AUTO`.
|
||||
3. `onPress`: chạy `autoExposureStops` → `handleUpdateAdjustments({ exposureCompensation: ev })`
|
||||
(`App.tsx:869`). Đi qua `handleUpdateAdjustments` để có `commit(true, …)` ⇒ **một bước undo**.
|
||||
|
||||
### 6.3 Nghiệm thu
|
||||
|
||||
- **Idempotent:** bấm AUTO hai lần liên tiếp ra cùng một giá trị EV (vì đọc ảnh gốc,
|
||||
không đọc ảnh đã áp EV).
|
||||
- Ảnh phơi sáng đúng (avg ≈ 0.48) ⇒ EV ≈ 0 (|EV| ≤ 0.1).
|
||||
- Ảnh tối đen ⇒ EV = +2.5 (không `Infinity`, không `NaN`).
|
||||
- Ảnh cháy trắng ⇒ EV = −2.5.
|
||||
- Undo một lần quay về EV trước khi bấm.
|
||||
|
||||
Ngoài phạm vi: web **chỉ** auto EV. Không auto contrast/white balance/tone.
|
||||
|
||||
---
|
||||
|
||||
## 7. TONE CURVE
|
||||
|
||||
### 7.1 Nguồn web — `shared/utils/toneCurve.ts` (203 dòng)
|
||||
|
||||
| Hàm/hằng | Vai trò |
|
||||
|----------|---------|
|
||||
| `CURVE_CHANNELS = ['rgb','r','g','b']` | master + 3 kênh |
|
||||
| `CURVE_LUT_SIZE = 256`, `IDENTITY_CURVE = [[0,0],[1,1]]` | LUT 256 mức |
|
||||
| `CURVE_MIN_GAP = 0.02`, `FLAT = 0.002` | khoảng cách điểm tối thiểu; ngưỡng "phẳng" |
|
||||
| `curvePoints(curve, ch)` | clamp01 + sort + ghim hai đầu về (0,0) và (1,1) |
|
||||
| `curveIsActive(curve)` | có kênh nào lệch `FLAT` khỏi đường chéo |
|
||||
| `tangents(pts)` | tiếp tuyến **Fritsch–Carlson** (monotone cubic) |
|
||||
| `sampleCurve(pts, x)`, `curveLut(curve)` | master ∘ kênh → `Uint8Array` RGBA, A=255 |
|
||||
| `CURVE_SKSL` | 2 child (`src`, `lut`), sample `v * 255 + 0.5` |
|
||||
| `addCurvePoint` | thêm điểm **trên** đường (click không nhảy giá trị) |
|
||||
| `moveCurvePoint` | hai đầu bị ghim theo trục x |
|
||||
| `removeCurvePoint`, `isFlatCurve` | xoá điểm, kiểm tra phẳng |
|
||||
|
||||
`curveLut` áp theo thứ tự **master ∘ channel**.
|
||||
|
||||
### 7.2 UI web — `src/ui/ToneCurvePanel.tsx` (357), `src/App.tsx`
|
||||
|
||||
- Graph `SIZE = 224`, `STEPS = 64` vẽ đường, `HIT = 11px` bán kính bắt điểm.
|
||||
- Tabs `RGB / R / G / B` + `RESET` (`{ }` = xoá toàn bộ curve).
|
||||
- Histogram vẽ **sau** lưu đồ (nền).
|
||||
- Kéo card bằng head; pointer capture khi kéo điểm.
|
||||
- `put()` xoá channel khi phẳng (không lưu curve rỗng).
|
||||
- Chip `TONE CURVE` nằm sau group D.RANGE trong row LIGHT; amber khi `curveIsActive`.
|
||||
|
||||
### 7.3 Việc phải làm (Android)
|
||||
|
||||
1. `src/utils/toneCurve.ts` — port nguyên toán + hằng số + `CURVE_SKSL`. `curveLut` trả
|
||||
`Uint8Array(256 * 4)`.
|
||||
2. Dựng LUT thành ảnh Skia cho shader: `Skia.Image.MakeImage({ width: 256, height: 1,
|
||||
colorType: RGBA_8888, alphaType: Premul }, data, 256 * 4)`; shader con thứ hai là
|
||||
`lutImage.makeShaderOptions(TileMode.Clamp, TileMode.Clamp, FilterMode.Linear,
|
||||
MipmapMode.None)` — như web (`exportEngine.ts:533-560`, khối 3e).
|
||||
3. `ColorAdjustments.toneCurve?: ToneCurve`.
|
||||
4. Chip `TONE CURVE` trong row LIGHT **sau** `groupChip('dr')` (`AdjustmentPanel.tsx:951`);
|
||||
mở panel graph khi `openParam === 'curve'`; amber khi `curveIsActive`.
|
||||
5. Panel: graph 224px dựng bằng Skia (`<Canvas>` + `<Path>` + `<Circle>`), tabs
|
||||
RGB/R/G/B, RESET, histogram nền (dùng lại hàm của mục 6), kéo điểm bằng RN responder
|
||||
trên `<View>` phủ (không có pointer capture sẵn — dùng `PanResponder`), tap-trên-đường
|
||||
để thêm điểm, double-tap để xoá (`CURVE_MIN_GAP 0.02`, HIT 11px).
|
||||
6. Một cử chỉ kéo = **một** bước undo: copy cách `handleUpdateAdjustments` gộp
|
||||
(`App.tsx:869`, `PEEK_GAP_MS = 700` tại `751`).
|
||||
|
||||
### 7.4 Nghiệm thu
|
||||
|
||||
- Curve identity ⇒ ảnh **không đổi** (diff = 0) và pass bị bỏ (không tạo LUT/surface).
|
||||
- Master kéo 1/4 → 0 làm nửa dưới tối đúng theo đường monotone; `R` kéo xuống ⇒ ảnh ngả
|
||||
cyan, và **chỉ** kênh đỏ đổi.
|
||||
- `Fritsch–Carlson` không overshoot: LUT monotone khi các điểm vào monotone.
|
||||
- Export == preview (cùng LUT).
|
||||
- **Rủi ro cao cần kiểm trên device:** nhánh declarative `<Shader source={curveEffect}>
|
||||
{graded}</Shader>` cần **2 child shader** (chuỗi đã grade + `ImageShader` của LUT với
|
||||
`fit="none"` và `rect={rect(0, 0, 256, 1)}`). Nếu `@shopify/react-native-skia` không
|
||||
nhận `rect` như mong đợi, fallback: render curve trong đường **imperative** cho preview
|
||||
tĩnh (dùng đúng `gradeThrough` của mục 3), và giữ declarative chỉ khi tone/cinema.
|
||||
Đường export imperative thì không có rủi ro này.
|
||||
|
||||
---
|
||||
|
||||
## 8. GRADIENT MASK
|
||||
|
||||
### 8.1 Nguồn web — `shared/utils/gradientMask.ts` (169 dòng)
|
||||
|
||||
Hằng số: `MASK_KIND = { linear: 0, radial: 1 }`, `MASK_EXPOSURE_MAX = 5`,
|
||||
`MASK_DEFAULT_FEATHER = 0.5`, `MASK_MIN = 0.01`.
|
||||
|
||||
`maskUniforms` — layout `(3n + 1) × vec4`:
|
||||
|
||||
| offset | nội dung |
|
||||
|--------|----------|
|
||||
| `i * 4` | `[x, y, ex, ey]` — gốc (pin) và đầu kéo (linear) |
|
||||
| `(n + i) * 4` | `[rx, ry, angle, feather]` — ellipse (radial) |
|
||||
| `(2n + i) * 4` | `[exposure, contrast / 10, saturation / 10, kind]` |
|
||||
| `3n * 4` | `[width, height, 0, 0]` — khung mà các phân số là của nó |
|
||||
|
||||
Alpha (web, `maskBlock`): linear `a = smoothstep(0, 1, clamp(dot(pos − pin·size, d) / |d|²))`;
|
||||
radial `st = (pos − center·size) / size.x`, xoay `−angle`, `d = length(r / max(rx, ry, 1e-5))`,
|
||||
`a = 1 − smoothstep(max(0, 1 − feather), 1, d)`. `maskAdjust` = exposure (luỹ thừa 2)
|
||||
→ contrast quanh 0.5 → saturation như mix khỏi luma REC-709, rồi clamp.
|
||||
Chạy **trước** HEAL (`gradientMask.ts:149-155`, `exportEngine.ts:708-737`).
|
||||
|
||||
`readMasks` lọc: kind hợp lệ, kẹp trong khung, và loại mask suy biến — linear có
|
||||
`hypot(ex−x, ey−y) > MASK_MIN`, radial có `rx > 0 && ry > 0`.
|
||||
|
||||
### 8.2 UI web (`App.tsx`)
|
||||
|
||||
- Chip `GRADIENT MASK` trong row FX, amber khi `masks.length > 0` (`2636`).
|
||||
- `gradientChips` (`2750-2765`): `LINEAR` / `RADIAL` / `CLEAR`.
|
||||
- Cột chỉnh mask (`3136-3155`) + `maskRulers` (`2355-2406`): EXPOSURE ±5 EV step 0.1,
|
||||
CONTRAST ±10, SATURATION ±10, FEATHER 0-100% (chỉ radial), DELETE.
|
||||
- Geometry kéo: `maskPin` / `maskFromDrag` / `maskDragged` (`121-230`).
|
||||
- `setMaskKnob` (`1123-1145`).
|
||||
|
||||
### 8.3 Việc phải làm (Android)
|
||||
|
||||
1. `src/utils/gradientMask.ts` — port hằng số + `readMasks` + `maskUniforms` +
|
||||
`maskAdjust` + `gradientMaskSkSL(count)`.
|
||||
2. **Thêm uniform `origin`.** Web vẽ lên canvas chính là ảnh, nên `pos` đã là toạ độ ảnh.
|
||||
Preview Android có `dispRect` (`Viewfinder.tsx:1705,3331`) nên shader phải dùng
|
||||
`pos − origin` trước khi chia `size`. Gói `origin` vào `vec4 size` đang có
|
||||
(`[width, height, originX, originY]`) để **không** đổi layout 3n+1.
|
||||
3. `GradientMask` interface (kind, x, y, ex, ey, rx, ry, angle, feather, exposure,
|
||||
contrast, saturation) + `ColorAdjustments.masks?: GradientMask[]`.
|
||||
4. Pass: cache `maskEffectFor(n)` (mẫu web `exportEngine.ts:204-210`), snapshot → clear →
|
||||
vẽ lại như web (`exportEngine.ts:708-737`).
|
||||
5. UI: chip `GRADIENT MASK` trong row FX + strip `LINEAR`/`RADIAL`/`CLEAR`
|
||||
+ cột chỉnh (EXPOSURE ±5 EV 0.1 / CONTRAST ±10 / SATURATION ±10 / FEATHER 0-100%
|
||||
chỉ radial / DELETE). Touch: RN responder trên `<View>` phủ — kéo pin/kéo đầu/đổi
|
||||
bán kính ellipse; vẽ outline + handle + đường chéo bằng Skia.
|
||||
6. Drag < `MASK_MIN` khi thả ⇒ huỷ, không tạo mask (khớp `readMasks`).
|
||||
|
||||
### 8.4 Nghiệm thu
|
||||
|
||||
- `+2 EV` trong mask làm vùng trong mask sáng **≈ 2 stop**, vùng ngoài lệch < 1/255.
|
||||
- Hai mask chồng nhau: mask thứ hai đọc kết quả mask thứ nhất (đúng "stack").
|
||||
- Kéo thả < 0.01 không sinh mask rác trong state.
|
||||
- MASK chạy trước HEAL: heal đặt trong mask mượn pixel đã mang ánh sáng mask (ảnh kiểm:
|
||||
vết heal trên vùng mask tối không còn sáng hơn nền).
|
||||
- FEATHER = 0 (radial) ⇒ rìa cứng, `a` nhảy 1 → 0 tại `d = 1`.
|
||||
- Toạ độ đúng khi zoom/pan: mask dán vào **ảnh**, không dán vào khung nhìn
|
||||
(kiểm bằng cách zoom rồi so vị trí outline với pixel).
|
||||
|
||||
---
|
||||
|
||||
## 9. FIX — HEAL và MOSAIC
|
||||
|
||||
### 9.1 Nguồn web
|
||||
|
||||
`shared/utils/heal.ts` (324 dòng) — mọi hằng số phải giữ nguyên:
|
||||
|
||||
```
|
||||
HEAL_FEATHER 0.85 HEAL_DEFAULT_R 0.012
|
||||
SEARCH_DISTANCES [2.6, 4.2, 6.5] SEARCH_DIRS 8 RING_R 1.15 RING_PAD 1
|
||||
RING_TAPS: 12 điểm vành, lấy MEDIAN INSIDE_TAPS: 16
|
||||
LIGHT_GATE 20 LIGHT_WEIGHT 3 BLEND_TAPS 16
|
||||
```
|
||||
|
||||
- `healUniforms` — `(2n + 1)` vec4 (mỗi spot 2 vec4: vị trí/bán kính + nguồn `sx,sy`).
|
||||
- `healSkSL(count)` — `correction` theo góc, sample `pos + ps − pd`.
|
||||
- `findHealSource(sample, x, y, r)` — 8 hướng × 3 khoảng × bản mirror, tính điểm lệch
|
||||
sáng có trọng số (`LIGHT_WEIGHT 3`, `LIGHT_GATE 20`), từ chối (`null`) nếu không có
|
||||
nguồn hợp lệ.
|
||||
|
||||
`shared/utils/mosaic.ts` (104) — `MOSAIC_CELL 0.02`, `MOSAIC_DEFAULT_R 0.05`;
|
||||
`mosaicUniforms` `(n + 1)` vec4, vec4 cuối `[width, height, cell, 0]`,
|
||||
`cell = max(1, MOSAIC_CELL * width)`; `mosaicSkSL(count)` lấy ô
|
||||
`img.eval((floor(pos / cell) + 0.5) * cell)`, **rim cứng**.
|
||||
|
||||
`shared/utils/brush.ts` (26) — `BRUSH_MIN_R 0.003`, `BRUSH_MAX_R 0.25`,
|
||||
`BRUSH_SPACING 0.6`, `wheelBrushR(r, deltaY)`.
|
||||
|
||||
Types web: `HealSpot { x, y, r, sx, sy }`, `MosaicSpot { x, y, r }`.
|
||||
|
||||
### 9.2 UI web (`App.tsx`)
|
||||
|
||||
- Chip `FIX` amber khi heal hoặc mosaic có spot; `fixChips` (`2715-2745`): `HEAL` /
|
||||
`MOSAIC` kèm readout `%`, `CLEAR`, `brushTool`.
|
||||
- Chọn spot HEAL để kéo và có nút `×` xoá; MOSAIC **không** kéo được.
|
||||
- `spotUnder` bắt theo bán kính `max(4, r * box.width)`.
|
||||
- Một cử chỉ vẽ = một bước undo.
|
||||
|
||||
### 9.3 Việc phải làm (Android)
|
||||
|
||||
1. `src/utils/brush.ts`, `heal.ts`, `mosaic.ts` — port **nguyên hằng số và công thức**,
|
||||
gồm `findHealSource`.
|
||||
2. Pixel sampler cho `findHealSource`: dùng đúng mẫu `horizon.ts:32-56` — render ảnh
|
||||
(đã grade, **chưa** mask/heal) xuống surface cạnh dài ~512, `readPixels()` →
|
||||
`Uint8Array` RGBA. Đây là "sample" đầu vào. **Không** dùng ảnh preview đã áp mask.
|
||||
3. UI: chip `FIX` trong row FX + strip `HEAL` / `CLEAR` / `MOSAIC` / `CLEAR`.
|
||||
Android **không có con lăn** ⇒ thêm một row slider `SIZE` (thay `wheelBrushR`), map
|
||||
tuyến tính vào `BRUSH_MIN_R..BRUSH_MAX_R`.
|
||||
4. HEAL: chạm/kéo tạo spot, chạm vào spot để chọn, kéo để dời, nút `×` để xoá.
|
||||
MOSAIC: chạm/kéo tạo spot, không dời. Một cử chỉ = `commit(true, …)` một bước undo.
|
||||
5. Shader theo count + cache (`healEffectFor(n)`, `mosaicEffectFor(n)`), snapshot → clear
|
||||
→ vẽ lại. Thứ tự: **masks → heal → mosaic** (web `exportEngine.ts:708-737` (masks), `740-775` (heal), `776-800` (mosaic)).
|
||||
6. Nếu `findHealSource` trả `null`: **giữ nguyên** pixel gốc (không bịa nguồn).
|
||||
|
||||
### 9.4 Nghiệm thu
|
||||
|
||||
- Xoá hạt bụi trên nền phẳng (trời, tường): vết sửa không lộ, seam viền ≤ ~2/255 so với
|
||||
nền (đo dọc vành spot).
|
||||
- Không có nguồn hợp lệ (hạt nằm trên biên tương phản cao) ⇒ ảnh **không đổi**.
|
||||
- Mosaic rim **cứng** — kiểm pixel tại rìa: nhảy bậc, không gradient.
|
||||
- 30 spot vẫn render (đo thời gian; cache effect hoạt động, không dựng lại shader mỗi frame).
|
||||
- Export == preview.
|
||||
|
||||
---
|
||||
|
||||
## 10. H-FLIP / V-FLIP trong ROTATE
|
||||
|
||||
### 10.1 Nguồn web — `shared/utils/skiaImage.ts`
|
||||
|
||||
```
|
||||
84 export function flipSkImage(image, horizontal, vertical)
|
||||
canvas.scale(h ? -1 : 1, v ? -1 : 1);
|
||||
canvas.translate(h ? -w : 0, v ? -h : 0);
|
||||
canvas.drawImage(image, 0, 0);
|
||||
114 export function applyPhotoRotation(image, quarter = 0, straighten = 0, flipH = false, flipV = false)
|
||||
turn (rotateSkImage90 × turns) → straighten (rotateSkImageBy) → flip
|
||||
```
|
||||
|
||||
Comment web: *"a flip is what the user sees, so it mirrors the photo as it stands,
|
||||
whatever turn and angle are already on it"* ⇒ flip áp **sau** turn + straighten.
|
||||
`rotateSkImage90` đổi chiều rộng/cao; flip **không** đổi dims.
|
||||
|
||||
### 10.2 Hiện trạng Android
|
||||
|
||||
`src/utils/skiaImage.ts:69-73` — `applyPhotoRotation(image, quarter = 0, straighten = 0)`,
|
||||
không flip. Callers phải sửa:
|
||||
- `src/utils/exportEngine.ts:241`
|
||||
- `src/components/Viewfinder.tsx:461` (memo) và `:803` (`detectTilt(applyPhotoRotation(src.image, src.quarter, 0))`)
|
||||
|
||||
### 10.3 Việc phải làm
|
||||
|
||||
1. `src/utils/skiaImage.ts` — thêm `flipSkImage` + hai tham số `flipH`/`flipV` cho
|
||||
`applyPhotoRotation`, copy nguyên web (gồm `dispose()` các bản trung gian).
|
||||
2. State: `photoFlipH`/`photoFlipV` (boolean) cạnh `photoRotation`/`photoStraighten`
|
||||
(`App.tsx:104-105`); thêm vào `Look` (`719-720`), `lookNow()` (`728`), `applyLook`
|
||||
(`804-812`), "look đã đổi" (`699-700`), và `ExportOptions` (`exportEngine.ts:91-92`).
|
||||
3. Truyền xuống: options export (`App.tsx:1753-1754`), `Viewfinder` (`App.tsx:1983-1984`
|
||||
và `2100-2101`), `nativeExport`.
|
||||
4. RESET của strip ROTATE phải reset **cả hai** flip (như đang reset turn + angle —
|
||||
`AdjustmentPanel.tsx:727-729`).
|
||||
5. UI: `groupDefs.rotate.options` (`AdjustmentPanel.tsx:718-726`) thêm
|
||||
`{ v: 'fliph', d: 'H-FLIP' }` và `{ v: 'flipv', d: 'V-FLIP' }`; `onPick`
|
||||
(`730-745`) toggle cờ. `rotateChip()` (`791-803`) thêm phần `H-FLIP`/`V-FLIP` vào
|
||||
`parts` và vào `amberValue`.
|
||||
**Lưu ý:** strip ROTATE hiện là radio (một `value`), `value` đang là
|
||||
`openParam === 'straighten' ? 'straighten' : String(photoRotation)` (`716`). Thêm hai
|
||||
toggle thì `value` phải ưu tiên `fliph`/`flipv` khi cờ bật — cùng cách `straighten`
|
||||
đang chiếm `value`.
|
||||
|
||||
### 10.4 Nghiệm thu
|
||||
|
||||
- `H-FLIP` ↔ `V-FLIP`: mirror đúng trục (kiểm 4 pixel góc đối xứng).
|
||||
- `H-FLIP + V-FLIP` **pixel-identical** với `180°` (diff = 0 trên ảnh không đối xứng).
|
||||
- Dims **không** đổi sau flip (kể cả khi đang có turn 90 và straighten ≠ 0).
|
||||
- Crop vẫn hợp lệ: `cropRect` áp sau rotate+flip, không tràn khung.
|
||||
- Export == preview.
|
||||
- Sống qua reload/session và qua undo (một lần bấm = một bước undo).
|
||||
|
||||
---
|
||||
|
||||
## 11. Kiểm thử chung và parity gate
|
||||
|
||||
1. **Parity gate ba đường** (theo `PLAN.md`): cùng ảnh + cùng recipe → export JS,
|
||||
preview library (ảnh tĩnh), và Kotlin (nếu `EXPO_PUBLIC_NATIVE_EXPORT=1`) phải khớp
|
||||
(đo diff pixel, ngưỡng đã dùng trước đây trong repo).
|
||||
2. **Không regress:** một recipe cũ (chỉ matrix + tone + grain, không mask/heal/curve/flip)
|
||||
phải render **y hệt** trước và sau khi port. Đây là test hồi quy bắt buộc chạy đầu tiên.
|
||||
3. **Bảng số của doc 4** phải tái lập được (mục 3.4).
|
||||
4. **Idempotence:** AUTO bấm hai lần ra cùng EV; ROTATE `AUTO` (straighten) bấm hai lần
|
||||
ra cùng góc (đã có).
|
||||
5. **Undo:** mỗi knob mới (whites/blacks/curve/mask/heal/mosaic/flip) đều phải undo được
|
||||
một bước và redo khớp.
|
||||
6. **Session:** mọi field mới sống qua reload (đi qua `Look`/session serialise).
|
||||
7. **Perf:** đo fps preview library sau khi thêm pass; mask + heal + mosaic + curve cùng
|
||||
lúc là trường hợp nặng nhất. Nếu tụt quá ngưỡng, dùng phương án rẻ của doc 4 §4.2
|
||||
(nhân gain bên trong SKSL) — nhưng **không** phá thứ tự `matrix → exposure → tone`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Checklist thứ tự làm và rủi ro
|
||||
|
||||
Thứ tự cố định — mỗi bước phải xanh test trước khi sang bước sau:
|
||||
|
||||
| Bước | Việc | Phụ thuộc | Rủi ro |
|
||||
|------|------|-----------|--------|
|
||||
| A | §3 tách EV + `EXPOSURE_SKSL` + **áp doc 4** (`gradeThrough`, bỏ ma trận lần hai ở HDF) | — | Vuốt: đây là thay đổi chạm mọi ảnh. Làm riêng, có test hồi quy (11.2). |
|
||||
| B | §4 knee HIGHLIGHT (2 dòng) | A | Thấp; nhưng phải làm sau A mới thấy đúng. |
|
||||
| C | §5 WHITE/BLACK (uniform + paramDefs) | B | Thấp. Chỉ thêm uniform, không đổi layout cũ. |
|
||||
| D | §10 H-FLIP/V-FLIP | — | Thấp; chạm state/session nên phải kiểm reload + undo. |
|
||||
| E | §6 AUTO | A | Trung bình; `readPixels` tốn — đo thời gian, `dispose()` đủ. |
|
||||
| F | §7 TONE CURVE | A | Cao ở nhánh declarative (2 child shader). Fallback imperative. |
|
||||
| G | §8 GRADIENT MASK | A | Trung bình; uniform `origin` là điểm dễ sai toạ độ. |
|
||||
| H | §9 FIX (HEAL/MOSAIC) | G | Cao nhất: pixel sampler + `findHealSource` + brush UI mới. |
|
||||
|
||||
Rủi ro tổng:
|
||||
- **Hermes không thu hồi buffer native** — mọi surface/snapshot/image mới đều phải
|
||||
`dispose()` tường minh (xem comment đầu `exportEngine.ts:113-121`).
|
||||
- **Declarative preview không chèn pass tuỳ ý** — mask/heal/mosaic/curve đều phải nằm
|
||||
trong chuỗi shader của ảnh; nếu một pass không lồng được, chuyển nhánh preview tĩnh
|
||||
sang imperative.
|
||||
- **`RuntimeEffect` dựng lại mỗi frame** là bug hiệu năng kinh điển — cache theo count.
|
||||
|
||||
---
|
||||
|
||||
## 13. Những gì KHÔNG port
|
||||
|
||||
- **Per-mask invert / highlight-shadow weighting** — web hiện không có; không thêm.
|
||||
- **`grainSize`** — web-only ở thời điểm này.
|
||||
- **Chuột lăn đổi cỡ brush** (`wheelBrushR`) — Android dùng row slider SIZE.
|
||||
- **Halation** — không có trong phạm vi 7 hạng mục.
|
||||
- **`hslOn`/band HSL, `classic-vivid`, `mono-high-contrast`, `STOCK_BIAS.contrast`** —
|
||||
**đã** port xong trên nhánh này (mục 0.1). Không làm lại, chỉ điều chỉnh
|
||||
`STOCK_BIAS.exposure` của `leica-vivid` theo mục 3.3.
|
||||
Reference in New Issue
Block a user