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