diff --git a/4_FIX_HIGHLIGHT_ORDER.md b/4_FIX_HIGHLIGHT_ORDER.md new file mode 100644 index 0000000..e5baf0f --- /dev/null +++ b/4_FIX_HIGHLIGHT_ORDER.md @@ -0,0 +1,234 @@ +# 4 — FIX: HIGHLIGHT bị EXPOSURE kéo ngược (thứ tự pass màu sai) + +Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06. +Nguồn đối chiếu: nhánh `recipes-web`, commit **`8e6c149`** — bản web đã sửa và đo được. + +--- + +## 1. Hiện tượng + +Kéo **HIGHLIGHT −10** rồi **EXPOSURE +10**: vùng sáng đã được ghì xuống lại bị đẩy +trở lại mức cháy (1.0). Nhìn như thanh HIGHLIGHT "chết" — càng tăng EXPOSURE càng +mất khả năng giữ highlight. + +--- + +## 2. Nguyên nhân gốc + +Skia áp **shader TRƯỚC colorFilter** trong cùng một `SkPaint`. Ma trận màu +(chứa `adjustments.exposure` + gain EV) và tone shader đang nằm trên **cùng một +paint**, nên gain exposure chạy **sau** pass tone: + +1. Pass tone: pixel sáng 1.0 → HIGHLIGHT −10 ghì về **0.78** +2. Ma trận exposure: **×1.2 + 0.15** → 1.086 → **kẹp về 1.0** + +Thanh HIGHLIGHT vẫn chạy đúng — nó chỉ bị pass sau xoá sạch kết quả. + +### 2.1 `src/utils/exportEngine.ts` (engine JS/Skia — đường export) + +```ts +// dòng 328 +const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix)); +paint.setColorFilter(colorFilter); + +// dòng 330 — comment mô tả SAI thứ tự thực tế: +// "3b. Tone shader — DR / Highlight / Shadow curve on top of the matrix." +// dòng 354 +paint.setShader(toneShader); +``` + +Comment nói tone nằm "on top of the matrix", nhưng Skia làm ngược lại. + +### 2.2 `src/components/Viewfinder.tsx` (preview camera trực tiếp) + +Cùng lỗi, cùng một paint: + +```ts +// dòng 1207 +paint.setColorFilter(baseColorFilter); +// dòng 1303 +paint.setShader(fullShader); +``` + +Khối HDF (dòng ~1339) còn **cố tình nhân ma trận màu lần thứ hai**: + +```ts +Skia.ImageFilter.MakeCompose( + glowBlur, + keep(Skia.ImageFilter.MakeColorFilter(baseColorFilter, null)) // "bloom lands on GRADED pixels" +) +``` + +→ vệt bloom cũng bị đẩy sáng sai. Sau khi sửa phải bỏ phần nhân lại này. + +### 2.3 `modules/recipescam-export/.../RecipescamExportModule.kt` (module native Kotlin) + +Thứ tự pass hiện tại là **tone → cinema → matrix**, tức matrix chạy **cuối** — đúng +cùng một lỗi: + +| Dòng | Pass | +|---|---| +| 423–429 | `// 2. Tone pass` → `applyTone(bmp, dr, hl, sh)` | +| 434–438 | `// 3. Cinema seasonal grade` → `applyCinema(bmp, cinemaFlat)` | +| 443–452 | `// 4. Color matrix via paint pass` → `ColorMatrixColorFilter` | +| 423–426 | comment: "Engine order: the tone shader samples the base image, cinema samples the tone output, **then the color matrix filter applies on top**" | + +--- + +## 3. Bằng chứng đo được (bản web, trước và sau khi sửa) + +Nguồn: ảnh gradient dọc 0.98 → 0.30 (320×240 JPEG), đo p95 (vùng sáng) và trung vị +(midtone) của ảnh render. Engine thật, không phải mô phỏng. + +| Trường hợp | top (p95) | mid | +|---|---|---| +| nguồn | 1.000 | 0.502 | +| HIGHLIGHT −10 | 0.780 | 0.518 | +| EXPOSURE +10 | 1.000 | 0.769 | +| HIGHLIGHT −10 + EXPOSURE +10 — **một paint (lỗi)** | **1.000** | 0.722 | +| HIGHLIGHT −10 + EXPOSURE +10 — **hai pass (đã sửa)** | **0.780** | 0.722 | + +Sau khi sửa: midtone vẫn sáng lên (0.502 → 0.722) nhưng vùng sáng giữ nguyên 0.780. + +--- + +## 4. Cách sửa + +**Nguyên tắc:** ma trận màu phải tạo ra **ảnh đã grade** trước, rồi tone/cinema shader +mới lấy ảnh đó làm con. Đúng một câu: *matrix → tone → cinema*. + +### 4.1 `src/utils/exportEngine.ts` + +Thêm helper (đặt cạnh `screenSharpenImage`), rồi đổi bước 3: + +```ts +// Ma trận màu, áp riêng vào một ảnh mới. SkPaint chạy shader TRƯỚC colorFilter, +// nên pixel đã grade phải tồn tại như một ảnh trước khi tone/cinema đọc nó. +function gradeThrough(image: any, w: number, h: number, colorFilter: any): any { + const surf = createSurface(w, h); + if (!surf) return null; + try { + const p = Skia.Paint(); + p.setColorFilter(colorFilter); + surf.getCanvas().drawImage(image, 0, 0, p); + p.dispose(); + return surf.makeImageSnapshot() ?? null; + } finally { + surf.dispose(); + } +} +``` + +Trong `renderPhoto`: + +```ts +const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix)); +const matrixIsIdentity = evMatrix.every((v, i) => v === (i % 6 === 0 ? 1 : 0)); + +const tone = getToneUniforms(adjustments, recipe.baseFilter); +const cinemaOn = /* cinema && cinemaIsActive(cinema.flat) */; +let toneShader: any = null; +let paintShader: any = null; + +// Ma trận phải thành ảnh riêng khi có pass phía sau đọc lại pixel. +let gradeImage = skImage; +const graded = (toneIsActive(tone) || cinemaOn) && !matrixIsIdentity; +if (graded) { + const snapshot = gradeThrough(skImage, width, height, colorFilter); + if (snapshot) { own(snapshot); gradeImage = snapshot; } +} +const imageShaderOf = () => + own(gradeImage.makeShaderOptions( + Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None)); + +if (toneIsActive(tone)) { + toneShader = own(toneEffect.makeShaderWithChildren(toneUniformArray(tone), [imageShaderOf()])); +} +if (cinemaOn) { + const cs = own(cinemaEffect.makeShaderWithChildren(cinema.flat, [toneShader ?? imageShaderOf()])); + if (cs) paintShader = cs; +} +if (!paintShader && toneShader) paintShader = toneShader; + +// Chỉ MỘT trong hai được tô màu pixel: ảnh đã grade đã mang ma trận rồi, +// chỉ nhánh chưa grade mới cần colorFilter. +if (paintShader) paint.setShader(paintShader); +else if (!graded) paint.setColorFilter(colorFilter); +``` + +Và trong bước HDF (5b): bloom đọc pixel đã grade, nên **bỏ lần áp ma trận thứ hai**: + +```ts +const srcShader = paintShader ?? imageShaderOf(); +const bloomGrade = (inner?: any) => + graded ? undefined : own(Skia.ImageFilter.MakeColorFilter(inner ?? colorFilter, null)); +// ... +glowPaint.setImageFilter(grade ? own(Skia.ImageFilter.MakeCompose(blur, grade)) : blur); +``` + +Hai điều kiện đệm: khi **không có tone/cinema** hoặc **ma trận là identity** thì +`graded === false` → không tạo surface thừa, giữ nguyên đường vẽ cũ. + +### 4.2 `src/components/Viewfinder.tsx` (preview mỗi frame) + +Cùng nguyên tắc, nhưng đây là đường chạy **mỗi frame** nên phải cân nhắc chi phí: +ma trận phải được vẽ vào một surface phụ (hoặc texture trung gian) **trước**, rồi +tone/cinema shader lấy ảnh đó làm `imageShader` con. + +- Đổi `paint.setColorFilter(baseColorFilter)` + `paint.setShader(fullShader)` trên + cùng một paint thành: grade trước vào texture `gradedTex`, `fullShader` dựng từ + `gradedTex.makeShaderOptions(...)`. +- `hasTone === false && hasCinema === false` (hoặc ma trận identity): giữ đường vẽ + một-pass như hiện tại, đừng thêm surface. +- Bỏ `MakeColorFilter(baseColorFilter, null)` khỏi chuỗi imageFilter của glow — + bloom giờ đọc thẳng pixel đã grade. +- **Đo lại fps sau khi sửa**: thêm 1 pass fullscreen/frame. Nếu tụt quá ngưỡng + chấp nhận, phương án rẻ hơn là nhân hệ số gain exposure **bên trong SKSL của tone + shader** (trước khi áp curve) — nhưng chỉ khi vẫn giữ được thứ tự *film matrix → + curve*; đừng quay lại nhét exposure vào sau curve. + +### 4.3 `RecipescamExportModule.kt` (native) + +Đổi thứ tự các bước cho khớp: **matrix → tone → cinema**. Matrix (bước "4." hiện +tại) phải chạy trước bước tone "2.": + +```kotlin +// 2. Color matrix (grade phim + exposure) — PHẢI chạy trước tone. +val androidMatrix = FloatArray(20) { i -> if (i % 5 == 4) matrix[i] * 255f else matrix[i] } +val out = Bitmap.createBitmap(bmp.width, bmp.height, Bitmap.Config.ARGB_8888) +Canvas(out).drawBitmap(bmp, 0f, 0f, Paint().apply { colorFilter = ColorMatrixColorFilter(androidMatrix) }) +bmp.recycle() +bmp = out + +// 3. Tone (DR / highlight / shadow) trên pixel đã grade. +if (toneActive) applyTone(bmp, toneDr.toFloat(), toneHl.toFloat(), toneSh.toFloat()) + +// 4. Cinema seasonal grade. +if (cinemaActive) applyCinema(bmp, cinemaFlat) +``` + +`ensureMutable(bmp)` giữ nguyên vị trí (trước tone), và sửa lại comment dòng 423–426 +— bỏ câu "then the color matrix filter applies on top". + +--- + +## 5. Kiểm thử (bắt buộc trước khi coi là xong) + +1. Nguồn: gradient dọc 0.98 → 0.30 (320×240 JPEG) — có headroom để thấy highlight. +2. Export với `highlight = -10`, `exposure = +10`. +3. Kỳ vọng: **p95 ≈ 0.78** (KHÔNG được 1.00), midtone tăng rõ so với ảnh gốc. +4. Chạy đủ **cả ba đường** (export engine JS, preview camera, module native Kotlin) + và so ảnh với nhau — parity gate của `PLAN.md`. +5. Đối chiếu với bản web: `recipes-web` @ `8e6c149`, file + `docker/frontend/src/engine/exportEngine.ts` (helper `gradeThrough`). + +--- + +## 6. Ghi chú phụ + +- Lỗi này **không** nằm ở công thức tone (`toneShader.ts`) hay ma trận màu + (`colorUtils.ts`) — cả hai đều đúng. Chỉ sai **thứ tự thực thi**. +- `HIGHLIGHT +10` không lộ lỗi vì kết quả dù sao cũng kẹp về 1.0 — chỉ nhánh âm mới + nhìn thấy. Đừng "kiểm thử" bằng HIGHLIGHT dương. +- Bất kỳ pass nào khác đọc lại pixel sau tone/cinema (bloom HDF, grain, vignette) + đều phải đọc **ảnh đã grade**, và ma trận không được áp lần hai.