# Port HSL (selective colour mixer) — `docker/frontend` → Android Tài liệu này mô tả **chính xác** cách đưa tính năng HSL của bản web (`docker/frontend/`) sang app Android (Expo + Skia, source ở repo root). Web là **bản tham chiếu**: `docker/frontend/shared/` là bản copy của `src/` bên Android nhưng đã đi trước. Hướng port là: ``` docker/frontend/shared/** → src/** docker/frontend/src/** → src/components/**, App.tsx ``` --- ## 0. Phạm vi — chỉ HSL Diff giữa `src/` và `docker/frontend/shared/` **không chỉ có HSL**. Các delta sau nằm cùng file nhưng **KHÔNG thuộc tài liệu này**, tuyệt đối không port kèm: | Delta web-only | Nơi xuất hiện | Kết luận | |---|---|---| | WB white/black point (`whites`/`blacks`) | `types`, `paramDefs`, `toneShader` (`wh`/`bl`), `defaultRecipes` | **BỎ** | | Base filter `classic-vivid` (`sim-classic-vivid`) | `types`, `toneShader` `FILM_TONE`, `defaultRecipes` | **BỎ** | | Frame `wallframe-landscape` | `types` `FrameId`, `recipeShare` `FRAMES`, chips ROTATE/wall | **BỎ** | | `GLOW_T0/GLOW_T1` 0.55/0.85 → 0.45/0.75 | `toneShader` | **BỎ** — đây là chỉnh demo của web; Android giữ `0.55/0.85` | | `exifWrite.ts` diff (22 dòng) | — | không liên quan HSL | Chỉ port: **types HSL**, **`colorUtils` HSL**, **`toneShader` mixer**, **`recipeShare` sanitize**, **`defaultRecipes` 3 field toàn ảnh**, **UI tab HSL**. --- ## 1. Ngữ nghĩa (đọc trước khi code) Mixer chọn màu theo **8 dải hue**. Mỗi dải có 3 knob `[hue, sat, lum]`, mỗi knob `-10..+10`: - **hue** — xoay hue, ±30° khi full (`acc.x * 30/360`). - **sat** — scale saturation, `-10` = xám (`s * (1 + acc.y)`, `acc.y = -1`). - **lum** — cộng lightness, ±0.25 khi full (`acc.z * 0.25`), additive nên không đảo ramp. Ba knob **toàn ảnh** (`hslHue`, `hslSat`, `hslLum`) là "seed" của accumulator: mọi hue nhận full weight. Quy tắc bất biến — port phải giữ đúng: 1. **Partition of unity.** Weight mỗi dải là tent tuyến tính: full tại anchor của nó, về 0 tại anchor hai dải kề. Hai tent kề cắt nhau đúng tại 0.5 ở midpoint ⇒ 8 weight **cộng lại bằng 1 tại mọi hue**. Không pixel nào bị tính hai lần, không hue nào rơi vào khe. 2. **Anchor cố định 1 nguồn.** `HSL_BANDS` (cho chip) và `BAND_BLOCK` (cho shader) sinh từ cùng bảng qua `hslBandGaps()` ⇒ anchor không thể lệch. 3. **Gate xám.** `gate = smoothstep(0.0, 0.08, hsl.y)` — pixel có saturation < 8% bị loại khỏi weight, vì `rgb2hsl` trả hue 0 cho xám và nếu không gate thì **mọi pixel trung tính sẽ chạy theo dải RED**. 4. **`gl` KHÔNG gate.** Lightness toàn ảnh (`gl`) được cộng **sau** gate, nên ảnh đã bị `-SAT` rút về xám vẫn còn `+LUM`. 5. **Monochrome tắt mixer.** `if (!colour) hslOn = 0;` — stock đen trắng không có hue để chọn. 6. **Dải toàn 0 bị xoá.** Band `[0,0,0]` không bao giờ vào recipe; kéo knob về 0 thì dải biến mất không để lại dấu. 7. **Mixer chạy CUỐI**, sau split-tone/kv và sau vibrance — để band edit được đánh giá trên màu người dùng thực sự sample. --- ## 2. `src/types/index.ts` Thêm 2 type (đặt cạnh `BaseFilter`/`ColorAdjustments`): ```ts export type HslBandId = 'red' | 'orange' | 'yellow' | 'green' | 'aqua' | 'blue' | 'purple' | 'magenta'; // [hue, sat, lum], mỗi số -10..+10; dải toàn 0 bị xoá khỏi recipe export type HslBand = [number, number, number]; ``` Trong `interface ColorAdjustments` (sau `vibrance?`, trước `exposureCompensation`): ```ts hslBands?: Partial>; // selective colour: mỗi dải [hue, sat, lum], -10..+10 hslHue?: number; // -10..+10 xoay hue toàn ảnh hslSat?: number; // -10..+10 scale saturation toàn ảnh hslLum?: number; // -10..+10 cộng lightness toàn ảnh ``` > `hslBands` là **một field lồng**, không phải 24 field phẳng — đây là lý do nó cần gate riêng khi import (mục 5). --- ## 3. `src/utils/colorUtils.ts` File Android hiện chỉ có `kelvinToRGB`, `getSkiaColorMatrix`, `applyExposureGain`. Thêm nguyên khối sau **lên đầu file** (sau import): ```ts import { ColorAdjustments, BaseFilter, HslBand, HslBandId } from '../types'; // 8 dải của mixer chọn màu, và anchor hue mỗi dải đứng (độ trên vòng HSL). // Anchor cố tình không đều: red/orange/yellow cách nhau 30° vì mắt phân biệt // kỹ vùng này, còn phía blue là một khúc 60°. TONE_SKSL dựng đúng 8 weight này // từ bảng dưới, nên anchor chip sửa và anchor shader mask không thể lệch. // // `label` là chữ chip in ra — chuỗi của app, KHÔNG nằm trong i18n. export const HSL_BANDS: { id: HslBandId; hue: number; label: string }[] = [ { id: 'red', hue: 0, label: 'RED' }, { id: 'orange', hue: 30, label: 'ORANGE' }, { id: 'yellow', hue: 60, label: 'YELLOW' }, { id: 'green', hue: 120, label: 'GREEN' }, { id: 'aqua', hue: 180, label: 'AQUA' }, { id: 'blue', hue: 240, label: 'BLUE' }, { id: 'purple', hue: 280, label: 'PURPLE' }, { id: 'magenta', hue: 320, label: 'MAGENTA' }, ]; // Hue (độ) của hai dải kề mỗi dải, đúng như TONE_SKSL cần. Suy ra từ HSL_BANDS // để hai bảng không thể cãi nhau: tent weight của một dải về 0 tại anchor của // hai dải kề, khiến 8 weight là partition of unity trên vòng hue (cộng = 1 tại // mọi hue). export function hslBandGaps(): { id: HslBandId; hue: number; left: number; right: number }[] { const n = HSL_BANDS.length; return HSL_BANDS.map((b, i) => { const prev = HSL_BANDS[(i - 1 + n) % n].hue; const next = HSL_BANDS[(i + 1) % n].hue; // Red (0°) wrap: dải kề trái là magenta 320°, tức 40° về sau. return { id: b.id, hue: b.hue, left: (b.hue - prev + 360) % 360, right: (next - b.hue + 360) % 360 }; }); } // 0..255 sRGB → HSL. Hue theo độ, saturation/lightness 0..1 — chiều ngược của // hsl2rgb trong shader, và là thứ readout của eyedropper in ra. export function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } { const R = r / 255; const G = g / 255; const B = b / 255; const mx = Math.max(R, G, B); const mn = Math.min(R, G, B); const l = (mx + mn) / 2; const d = mx - mn; if (d < 1e-6) return { h: 0, s: 0, l }; const s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn); let h: number; if (mx === R) h = ((G - B) / d + (G < B ? 6 : 0)) * 60; else if (mx === G) h = ((B - R) / d + 2) * 60; else h = ((R - G) / d + 4) * 60; return { h, s, l }; } // Dải mà một hue sample thuộc về: anchor gần nhất. Các dải chồng nhau trong // shader, nên hàm này chỉ quyết định ruler đang trỏ vào dải nào. export function nearestHslBand(hue: number): HslBandId { const wrapped = ((hue % 360) + 360) % 360; let best = HSL_BANDS[0]; let bestD = 361; for (const band of HSL_BANDS) { const d = Math.abs(((wrapped - band.hue + 540) % 360) - 180); if (d < bestD) { bestD = d; best = band; } } return best.id; } // Một band triple như khi lưu: số nguyên -10..10, dải toàn 0 bị xoá để recipe // chỉ mang theo thứ người dùng thực sự đổi. const clamp10 = (v: unknown): number => typeof v === 'number' && Number.isFinite(v) ? Math.max(-10, Math.min(10, Math.round(v))) : 0; export function sanitizeHslBands(raw: unknown): Partial> | undefined { if (!raw || typeof raw !== 'object') return undefined; const out: Partial> = {}; for (const band of HSL_BANDS) { const v = (raw as Record)[band.id]; if (!Array.isArray(v) || v.length !== 3) continue; const triple: HslBand = [clamp10(v[0]), clamp10(v[1]), clamp10(v[2])]; if (triple[0] || triple[1] || triple[2]) out[band.id] = triple; } return Object.keys(out).length ? out : undefined; } ``` Không sửa `getSkiaColorMatrix`: mixer nằm ở tone shader, matrix 4×5 không làm được per-pixel hue. --- ## 4. `src/utils/toneShader.ts` — shader mixer Đây là phần lõi. Sáu sửa đổi, theo thứ tự trong file. ### 4.1 Import + `BAND_BLOCK` ```ts import { BaseFilter, ColorAdjustments } from '../types'; import { HSL_BANDS, hslBandGaps } from './colorUtils'; ``` Thêm **trước** `export const TONE_SKSL`: ```ts // Tám dòng band của mixer trong TONE_SKSL, sinh từ HSL_BANDS để anchor và gap // trong shader chính là số mà chip dựng ra. Mỗi dòng đọc 3 giá trị của dải nó // với index HẰNG — SkSL chỉ index uniform array bằng hằng, nên block này phải // unroll chứ không loop được. const BAND_BLOCK = hslBandGaps() .map( (b, i) => ` float w${i} = bandW(hd, ${b.hue.toFixed(1)}, ${b.left.toFixed(1)}, ${b.right.toFixed(1)}) * gate; acc += vec3(w${i} * hslH[${i}], w${i} * hslS[${i}], w${i} * hslL[${i}]);\n` ) .join(''); ``` ### 4.2 Comment header Thêm vào khối comment đầu file (cạnh `vib`): ``` // hslOn/hslH/hslS/hslL - mixer chọn màu: 8 dải hue, mỗi dải một hue shift, một // saturation scale và một lightness offset (-1..1, từ knob -10..10). Dải // nào sở hữu pixel nào được quyết định Ở ĐÂY, per-pixel theo hue — nên // khác mọi thứ phía trên, 8 dải không phải một phép move toàn cục và // không thể nằm trong colour matrix. Xem band block ở cuối TONE_SKSL. // gh/gs/gl - move toàn ảnh của mixer: đúng ba đại lượng đó cho TOÀN ảnh, nên // chúng chỉ là giá trị khởi tạo của accumulator và mọi hue nhận full // weight. Lightness KHÔNG bị gate theo saturation (khác band), nên ảnh bị // -SAT rút về xám vẫn trả lời +LUM. ``` ### 4.3 Uniform khai báo trong `TONE_SKSL` Thêm **sau `uniform float ccb;`** (Android không có `wh`/`bl`, nên HSL nối thẳng sau `ccb`): ```glsl uniform float hslOn; uniform float hslH[8]; uniform float hslS[8]; uniform float hslL[8]; uniform float gh; uniform float gs; uniform float gl; ``` Rồi **ngay trước `vec4 main(vec2 xy)`**, thêm 4 helper + mixer vào chuỗi SkSL: ```glsl // sRGB <-> HSL. Mixer làm việc trong HSL vì đó là không gian knob được đặt tên // theo: một hue shift không được đổi độ sáng của màu, và một lightness move // không được đổi hue — đúng thứ mà scale RGB làm sai. vec3 rgb2hsl(vec3 c) { float mx = max(max(c.r, c.g), c.b); float mn = min(min(c.r, c.g), c.b); float l = (mx + mn) * 0.5; float d = mx - mn; if (d < 0.00001) return vec3(0.0, 0.0, l); float s = l > 0.5 ? d / max(0.00001, 2.0 - mx - mn) : d / max(0.00001, mx + mn); float h; if (mx == c.r) h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0); else if (mx == c.g) h = (c.b - c.r) / d + 2.0; else h = (c.r - c.g) / d + 4.0; return vec3(h / 6.0, s, l); } float hueChannel(float p, float q, float t) { t = fract(t); if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t; if (t < 0.5) return q; if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0; return p; } vec3 hsl2rgb(vec3 hsl) { if (hsl.y < 0.00001) return vec3(hsl.z); float q = hsl.z < 0.5 ? hsl.z * (1.0 + hsl.y) : hsl.z + hsl.y - hsl.z * hsl.y; float p = 2.0 * hsl.z - q; return vec3( hueChannel(p, q, hsl.x + 1.0 / 3.0), hueChannel(p, q, hsl.x), hueChannel(p, q, hsl.x - 1.0 / 3.0) ); } // Mức sở hữu một hue của một dải: full tại anchor của dải, giảm tuyến tính về 0 // tại anchor hai dải kề (gap không đều — red cách orange 30° và cách magenta // 40°). Tính tuyến tính là mấu chốt: hai tent kề cắt nhau đúng 0.5 tại midpoint, // nên 8 weight cộng lại bằng 1 tại mọi hue. Không pixel nào bị tính hai lần, // không pixel nào rơi vào khe, và hue nằm đúng anchor nhận full giá trị của dải // đó thay vì một phần. float bandW(float hue, float anchor, float gapL, float gapR) { float d = mod(hue - anchor + 180.0, 360.0) - 180.0; return d <= 0.0 ? max(0.0, 1.0 + d / gapL) : max(0.0, 1.0 - d / gapR); } ``` ### 4.4 Mixer trong `main()` — thay đoạn kết Trong `main`, đoạn cuối Android hiện là: ```glsl float kv = 1.0 + vib * 0.75 * (1.0 - chroma); return vec4(clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0), c.a); } ``` Đổi thành: ```glsl float kv = 1.0 + vib * 0.75 * (1.0 - chroma); rgb = clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0); // Selective colour theo dải hue — move CUỐI, để một band edit được đánh giá // trên đúng màu người dùng sample từ render. // // Pixel xám bị loại trước khi đọc weight: rgb2hsl trả hue 0 cho nó, nên nếu // không gate thì MỌI pixel trung tính trong khung sẽ bị coi là đỏ nguyên chất // và trôi theo dải red. Dưới 8% saturation thì cũng không có hue để move. // // Ba accumulator là giá trị band nhân mức sở hữu, nên một hue nằm giữa hai // anchor nhận hỗn hợp tỉ lệ của hai edit — đúng blend mà weight đã cộng ra. // Hue là phép xoay (±30° khi full), saturation là scale (0 = xám tại -10), // lightness là additive (±0.25 khi full) nên không thể đảo ramp. if (hslOn > 0.5) { vec3 hsl = rgb2hsl(rgb); float gate = smoothstep(0.0, 0.08, hsl.y); float hd = hsl.x * 360.0; // Move toàn ảnh là seed: mọi hue nhận hue turn và saturation scale ở full // weight, các dải cộng thêm phần của mình lên trên. Lightness được cộng // bên dưới KHÔNG gate, nên nó vẫn nâng một màu đã bị -SAT rút về xám. vec3 acc = vec3(gh, gs, 0.0) * gate; ${BAND_BLOCK} hsl.x = fract(hsl.x + acc.x * (30.0 / 360.0)); hsl.y = clamp(hsl.y * (1.0 + acc.y), 0.0, 1.0); hsl.z = clamp(hsl.z + (acc.z + gl) * 0.25, 0.0, 1.0); rgb = hsl2rgb(hsl); } return vec4(clamp(rgb, 0.0, 1.0), c.a); } ``` > `${BAND_BLOCK}` phải nằm trong template literal — 8 dòng sinh ra nối liền ngay trước `hsl.x =`. Giữ đúng dạng đó, đừng thụt lề lại: SkSL không quan tâm, nhưng giữ khớp bản web để diff sau này đọc được. ### 4.5 `ToneUniforms` Thêm vào interface (sau `ccb`): ```ts hslOn: number; // 1 khi có band hoặc move toàn ảnh được set (0 = skip mixer) hslH: number[]; // 8 × -1..1 theo dải, thứ tự HSL_BANDS (±30° hue khi full) hslS: number[]; // 8 × -1..1 (saturation scale, -1 = xám) hslL: number[]; // 8 × -1..1 (lightness additive, ±0.25 khi full) gh: number; // -1..1 xoay hue toàn ảnh (±30° khi full) gs: number; // -1..1 scale saturation toàn ảnh gl: number; // -1..1 cộng lightness toàn ảnh (±0.25 khi full, ungated) ``` ### 4.6 `getToneUniforms` Trong thân hàm, **trước `return {`**: ```ts // Selective colour: một slot mỗi dải, theo thứ tự HSL_BANDS, để flat buffer // khớp với array của shader. Dải người dùng chưa move giữ ba số 0 và chỉ tốn // slot của nó. const bands = adj.hslBands ?? {}; const tenth = (v: unknown) => typeof v === 'number' && Number.isFinite(v) ? Math.max(-1, Math.min(1, v / 10)) : 0; const hslH: number[] = []; const hslS: number[] = []; const hslL: number[] = []; let hslOn = 0; for (const band of HSL_BANDS) { const v = bands[band.id]; const [h, s, l] = v ? [tenth(v[0]), tenth(v[1]), tenth(v[2])] : [0, 0, 0]; hslH.push(h); hslS.push(s); hslL.push(l); if (h || s || l) hslOn = 1; } // Stock monochrome không có hue để chọn theo. if (!colour) hslOn = 0; // Move toàn ảnh của mixer, mọi hue nhận full weight. const gh = tenth(adj.hslHue); const gs = tenth(adj.hslSat); const gl = tenth(adj.hslLum); if (colour && (gh || gs || gl)) hslOn = 1; ``` Và thêm vào object return (cuối, sau `ccb`): ```ts hslOn, hslH, hslS, hslL, gh, gs, gl, ``` > `colour` đã có sẵn trong hàm (`const colour = baseFilter !== 'monochrome'`). Đặt khối HSL **sau** dòng đó. ### 4.7 `toneUniformArray` ```ts export function toneUniformArray(u: ToneUniforms): number[] { return [ u.dr, u.hl, u.sh, u.vib, u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb, u.hslOn, ...u.hslH, ...u.hslS, ...u.hslL, u.gh, u.gs, u.gl, ]; } ``` Tổng độ dài: `12 + 1 + 8 + 8 + 8 + 3 = 40`. ### 4.8 `toneIsActive` `u.hslOn !== 0 ||` là **clause đầu tiên**: ```ts export function toneIsActive(u: ToneUniforms): boolean { return ( u.hslOn !== 0 || u.dr !== 0 || ... // phần còn lại giữ nguyên ); } ``` --- ## 5. `src/utils/recipeShare.ts` Import: ```ts import { sanitizeHslBands } from './colorUtils'; ``` Trong `importRecipeXml`, **trước** dòng `const frameId: FrameId = ...`: ```ts // Mixer là adjustment duy nhất đến dưới dạng object lồng, nên là cái duy nhất // cần gate riêng: band id lạ bị bỏ, mọi giá trị bị kẹp về range knob, và một // dải để nguyên 0 bị xoá thay vì mang theo vô ích. adjustments.hslBands = sanitizeHslBands(raw.adjustments.hslBands); ``` `exportRecipeXml` **không cần sửa**: `adjustments` được nhân bản nguyên khối vào payload JSON, `hslBands` tự đi theo. Format `.recipe` (XML bọc hex xorshift) giữ nguyên `version="1"`. > `storageUtils.getAllRecipes` merge `{ ...DEFAULT_ADJUSTMENTS, ...r.adjustments }` — đủ cho 3 field toàn ảnh. `hslBands` từ storage là do chính app ghi nên không cần sanitize; nếu muốn chắc, gọi thêm `sanitizeHslBands` trong `fill`. --- ## 6. `src/utils/defaultRecipes.ts` Thêm 3 field vào `DEFAULT_ADJUSTMENTS` (sau `vibrance` nếu có, trước `exposureCompensation`): ```ts hslHue: 0, hslSat: 0, hslLum: 0, ``` Không thêm `hslBands` vào đây — nó optional và `undefined` mới là "không có dải nào"; thêm `hslBands: {}` sẽ làm `differs()` trong `App.tsx` báo dirty sai. --- ## 7. Uniform buffer — các điểm Android phải sửa thêm `exportEngine.ts` **không cần sửa**: nó gọi `toneUniformArray(tone)` + `toneIsActive(tone)`, cả hai đã tự mang HSL. `nativeExport.ts` (dev probe, `EXPO_PUBLIC_NATIVE_EXPORT`) **không mang HSL** — giống `colorChrome`, nó chỉ truyền `toneDr/toneHl/toneSh` vào `PhotoAdjust`. Ghi rõ trong comment cạnh comment "port them into applyTone()" hiện có; không cần làm gì thêm cho tới khi native path thành đường chính. ### 7.1 `src/components/Viewfinder.tsx` — BẮT BUỘC, dễ sót Camera worklet đọc flat buffer qua `toneSync`, có hai chỗ hardcode theo độ dài cũ: ```tsx // dòng ~499 const toneSync = useMemo( () => createSynchronizable(new Array(10).fill(0)), [], ); ``` ```tsx // dòng ~1270 const tone = toneSync.getDirty(); const hasTone = toneEffect != null && (tone[0] !== 0 || tone[1] !== 0 || ... || tone[11] !== 0); ``` Sửa: 1. `new Array(40).fill(0)` — đúng bằng độ dài `toneUniformArray`. 2. `hasTone` thêm `tone[12] !== 0` (đây là `hslOn`). Nếu bỏ sót, worklet đọc `undefined` ở các slot mới ⇒ uniform `NaN` ⇒ **cả tone pass hỏng trên preview camera**, không chỉ HSL. Library (declarative ``) — thêm key vào object `toneUniforms` (dòng ~2999): ```tsx const toneUniforms = { dr: toneParams[0], hl: toneParams[1], sh: toneParams[2], vib: toneParams[3], shTr: toneParams[4], shTg: toneParams[5], shTb: toneParams[6], hlTr: toneParams[7], hlTg: toneParams[8], hlTb: toneParams[9], cc: toneParams[10], ccb: toneParams[11], hslOn: toneParams[12], hslH: toneParams.slice(13, 21), hslS: toneParams.slice(21, 29), hslL: toneParams.slice(29, 37), gh: toneParams[37], gs: toneParams[38], gl: toneParams[39], }; ``` `toneOn = toneParams.some((v) => v !== 0)` giữ nguyên — buffer phẳng nên vẫn đúng. > **Cần xác nhận trên máy:** nhánh declarative `` của RN Skia phải nhận **array** uniform (`float hslH[8]`). Nếu nó flatten/không nhận, chuyển nhánh library preview sang `makeShaderWithChildren(toneUniformArray(tone), [imageShader])` như camera/export đã làm — cùng một nguồn số, bỏ hẳn object named-uniform. --- ## 8. UI Android ### 8.1 `src/components/ToolRail.tsx` ```ts export type TabId = 'recipes' | 'favorites' | 'iq' | 'wb' | 'filters' | 'hsl' | 'frame'; ``` Thêm vào `TOOLS`, **giữa `filters` và `frame`** (web đặt HSL cùng nhóm màu): ```ts { id: 'filters', label: 'FX' }, { id: 'hsl', label: 'HSL' }, { id: 'frame', label: 'FRAME' }, ``` ### 8.2 State — nâng lên `App.tsx` Web để state HSL trong `App`. Android cũng phải vậy, vì **cả `AdjustmentPanel` (chip) lẫn `Viewfinder` (eyedropper + panel nổi) đều cần**: ```tsx const [hslBand, setHslBand] = useState('red'); // dải ruler đang sửa const [picking, setPicking] = useState(false); // eyedropper đã arm chưa const [hslSample, setHslSample] = useState<{ r: number; g: number; b: number } | null>(null); const [hslPickedAt, setHslPickedAt] = useState<{ fx: number; fy: number } | null>(null); // vị trí trên ảnh ``` Truyền xuống `Viewfinder`: `picking`, `hslPickedAt`, `onPickColor`, và một node `hslPanel` (JSX panel, xem 8.5). Truyền xuống `AdjustmentPanel`: `hslBand`, `onHslBand`, `picking`, `onPicking`, `hslSample`, `adjustments`, `onUpdateAdjustments` (đã có). Hai setter (đặt cạnh nhau, cả hai gọi `onUpdateAdjustments`): ```tsx // Một band triple như khi lưu. Dải về [0,0,0] bị XOÁ, và khi không còn dải nào // thì field trả về undefined (không phải {}) để `differs(adjustments, // DEFAULT_ADJUSTMENTS)` trong resetDirty không báo dirty sai. const setBandKnob = (which: 0 | 1 | 2, v: number) => { const band = adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand); const next: HslBand = [band[0], band[1], band[2]]; next[which] = Math.round(v); const bands = { ...adjustments.hslBands }; if (next[0] === 0 && next[1] === 0 && next[2] === 0) delete bands[hslBand]; else bands[hslBand] = next; onUpdateAdjustments({ hslBands: Object.keys(bands).length ? bands : undefined }); }; // Move toàn ảnh: một cửa cho cả ba knob, nên ruler và field nó ghi luôn là cùng // một giá trị dù mở từ đâu. const setHslGlobal = (which: 0 | 1 | 2, v: number) => { const key = which === 0 ? 'hslHue' : which === 1 ? 'hslSat' : 'hslLum'; onUpdateAdjustments({ [key]: Math.max(-10, Math.min(10, Math.round(v))) } as Partial); }; ``` `pickColor` (App đưa xuống Viewfinder): ```tsx // Báo cáo của eyedropper: in màu đọc được, trỏ ruler về dải của màu đó, treo // panel lên đúng điểm nó được đọc, rồi cất dụng cụ đi — một lần pick một màu. const pickColor = (rgb: { r: number; g: number; b: number }, at: { fx: number; fy: number }) => { setHslSample(rgb); setHslBand(nearestHslBand(rgbToHsl(rgb.r, rgb.g, rgb.b).h)); setHslPickedAt(at); setPicking(false); }; ``` ### 8.3 `src/components/AdjustmentPanel.tsx` 1. `type TabId` (dòng 20) thêm `| 'hsl'`. 2. `paramDefs: Record` thêm `hsl: []` **hoặc** xử lý riêng trong `chipRow` (xem dưới). Ba knob toàn ảnh **không** vào `PARAM_DEFS` — chúng đọc/ghi field của `adjustments` nhưng label là `HUE IMAGE`/`SAT IMAGE`/`LUM IMAGE`, và mở row qua cùng cơ chế `openParam` với `PARAM_GROUP` để nút back đóng row. Cách gọn nhất, khớp cấu trúc sẵn có: mở rộng `paramDefs.hsl` bằng 3 `ParamDef` với `key` là `'hsl.h' | 'hsl.s' | 'hsl.l'` và `onChange: (v) => setHslGlobal(which, v)`. Khi đó `openParamDef` + row slider sẵn có tự hoạt động, chip hiện value, RESET/back hoạt động. ```tsx // Ba knob toàn ảnh của mixer không nằm trong PARAM_DEFS: chúng đọc/ghi move // của CHÍNH ẢNH chứ không phải một field phẳng của adjustments, và khác panel // trên ảnh, chúng không gắn với dải mà mixer đang trỏ. hsl: [0, 1, 2].map((i) => { const own = [a.hslHue ?? 0, a.hslSat ?? 0, a.hslLum ?? 0]; const key = i === 0 ? 'hsl.h' : i === 1 ? 'hsl.s' : 'hsl.l'; const name = i === 0 ? 'HUE' : i === 1 ? 'SAT' : 'LUM'; return { key, label: `${name} IMAGE`, value: own[i], default: 0, min: -10, max: 10, step: 1, display: (v: number) => (v > 0 ? `+${v}` : String(v)), onChange: (v: number) => onSetHslGlobal(i as 0 | 1 | 2, v), }; }), ``` 3. Thêm `case 'hsl'` vào switch `chipRow`: ```tsx case 'hsl': { // PICK arm eyedropper; 8 chip dải chọn dải mà ruler sửa; ruler do ba // knob bên dưới mở. Ba knob đó là move toàn ảnh, không phải của dải: // chip dải chọn màu cho panel trên ảnh, còn HUE/SAT/LUM dưới divider // move mọi hue trong khung cùng lúc. return chipRow([ { key: 'hsl-pick', label: 'PICK', active: picking, onPress: () => onSetPicking(!picking) }, ...HSL_BANDS.map((b): ChipDef => { const band = a.hslBands?.[b.id]; const moved = !!band && (band[0] !== 0 || band[1] !== 0 || band[2] !== 0); return { key: `hsl-band-${b.id}`, label: b.label, // Chip gọi tên một màu nên nó hiện đúng màu đó. color: hslToHex(b.hue, 70, 50), active: hslBand === b.id, amberValue: hslBand !== b.id && moved, onPress: () => onSetHslBand(b.id), }; }), // Divider giữ ba slider toàn ảnh ra ngoài hàng màu: hàng trên chọn // một màu, ba cái này move tất cả. { key: 'hsl-image', label: 'IMAGE', disabled: true, active: false, onPress: () => {} }, ...paramChips(paramDefs.hsl), ], 8); } ``` 4. `ChipDef` cần thêm `color?: string` (chip màu). Trong `renderChip`, khi `c.color` có mặt, vẽ một dot màu trước label (Android dùng `View` với `backgroundColor`): ```tsx {c.color ? : null} ``` Chip màu vẫn giữ text label (RED/ORANGE/…) — dot là phụ, không thay chữ. 5. Readout khi `activeTab === 'hsl'`: Android không có cột phụ như web. Đặt một **dòng readout mỏng ngay trên hàng chip** (chỗ `openParamDef` row đang render), chỉ khi tab hsl: ```tsx {activeTab === 'hsl' && ( {hslSample ? ( <> R {hslSample.r} · G {hslSample.g} · B {hslSample.b} {Math.round(rgbToHsl(hslSample.r, hslSample.g, hslSample.b).h)}° ·{' '} {Math.round(rgbToHsl(hslSample.r, hslSample.g, hslSample.b).s * 100)}% ·{' '} {Math.round(rgbToHsl(hslSample.r, hslSample.g, hslSample.b).l * 100)}% {bandName} ) : ( BẤM PICK RỒI CHỌN MỘT MÀU TRÊN ẢNH )} )} ``` `bandName = HSL_BANDS.find((b) => b.id === hslBand)?.label ?? ''`. ### 8.4 `hslToHex` (dùng cho chip màu) Copy nguyên từ web (`docker/frontend/src/App.tsx` dòng 108) vào `src/utils/colorUtils.ts` hoặc ngay trong `AdjustmentPanel.tsx`: ```ts // HSL -> #rrggbb. Chip gọi tên một màu nên nó phải hiện đúng màu đó; hex là // format duy nhất RN nhận thẳng từ chuỗi. export function hslToHex(h: number, s: number, l: number): string { const k = (n: number) => (n + h / 30) % 12; const a = (s / 100) * Math.min(l / 100, 1 - l / 100); const chan = (n: number) => Math.max(0, Math.min(255, Math.round(255 * (l / 100 - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)))))) .toString(16) .padStart(2, '0'); return `#${chan(0)}${chan(8)}${chan(4)}`; } ``` ### 8.5 Eyedropper + panel nổi — `src/components/Viewfinder.tsx` Đây là phần **chưa có tương ứng Android**, phải viết mới. **Props thêm:** ```tsx picking?: boolean; onPickColor?: (rgb: { r: number; g: number; b: number }, at: { fx: number; fy: number }) => void; hslPanel?: React.ReactNode; hslPanelAt?: { fx: number; fy: number } | null; ``` **Lớp pick.** Web đặt một `div` phủ đúng **box của ảnh** (`box` = rect ảnh sau zoom/pan, không phải cả canvas). Android đã có `imageFitRect` và sẵn một touch layer trong library mode (`onLibTouchStart` với toạ độ fraction). Cách port đúng và ít code nhất: - Khi `picking`, thêm một `Pressable` (hoặc `View` với `onStartShouldSetResponder`) phủ **đúng `imageFitRect`**, chặn touch để pan/zoom không khởi động. - Toạ độ fraction tính từ rect **của lớp đó**, không phải của canvas: ```tsx const fx = (e.nativeEvent.locationX) / rectW; const fy = (e.nativeEvent.locationY) / rectH; if (!(fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1)) return; ``` - Hiện một icon eyedropper đi theo ngón tay (`pick-icon` của web là SVG; Android dùng `lucide-react-native` đã có sẵn: ``). - Chỉ arm trong **library mode**. Camera mode không có pixel để sample (và preview là luồng sống). **Lấy màu.** Web sample chính **ảnh preview đã render** (`previewUrl`), không phải file gốc — để màu đọc được đúng là màu đang thấy. Android tương đương: snapshot canvas đang vẽ. Đường ngắn nhất: ```tsx // canvasRef = useCanvasRef() trên canvas library đang vẽ `graded`. const shot = canvasRef.current?.makeImageSnapshot(); // ảnh đã render const px = await shot.readPixels(Math.floor(fx * shot.width()), Math.floor(fy * shot.height()), { width: 1, height: 1, colorType: ColorType.RGBA_8888, alphaType: AlphaType.Unpremul, }); onPickColor({ r: px[0], g: px[1], b: px[2] }, { fx, fy }); ``` Đọc **1 pixel** từ snapshot là đủ và rẻ; cache snapshot trong lúc `picking` để mỗi lần chạm không phải snapshot lại. > **ponytail:** snapshot canvas mỗi lần pick là đủ cho một lần chạm/ảnh; nếu độ trễ cảm nhận được trên máy thật, chuyển sang decode `skiaImage` một lần và `readPixels` trên đó — nhưng khi đó màu sample là màu **gốc**, không phải màu đã grade, và hue có thể lệch khỏi thứ người dùng nhìn thấy nếu các knob khác đã move. Chỉ hạ cấp khi có số đo. **Panel nổi.** Web đặt panel tại `left = fx*100%`, `top = fy*100%` **trong box ảnh**, với `transform: translate(-50%, 14px)`, hoặc `translate(-50%, calc(-100% - 14px))` khi `fy > 0.55` (điểm nằm thấp thì panel treo lên trên để không rơi khỏi ảnh). Android absolute-position trong chính box đó: ```tsx {hslPanel && hslPanelAt && ( 0.55 ? -(PANEL_H + 14) : 14 }, ], }} > {hslPanel} )} ``` Panel dừng pointer để không rơi xuống lớp pan của ảnh. ### 8.6 Panel nội dung (`hslPanel`) Web dựng `pickPanel` ở `App.tsx`; Android dựng trong `AdjustmentPanel` (nó đã có `Slider` + haptics) rồi App truyền node xuống Viewfinder, hoặc dựng thẳng trong App. Nội dung: - **Swatch** — màu của sample sau khi áp knob của dải đang chọn: ```tsx const picked = hslSample ? rgbToHsl(hslSample.r, hslSample.g, hslSample.b) : null; const band = adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand); const mixedHex = picked ? hslToHex( picked.h + band[0], Math.max(0, Math.min(100, picked.s * 100 + band[1])), Math.max(0, Math.min(100, picked.l * 100 + band[2])), ) : null; ``` - **Tên dải + hex**, nút đóng `✕` (`onPress={() => setHslPickedAt(null)}`). - **Ba MiniSlider** `HUE` / `SAT` / `LUM`, mỗi cái `value = band[i]`, `onValueChange = (v) => setBandKnob(i, v)`, và reset về 0 (web: double-click; Android: thêm một `TouchableOpacity` nhỏ hoặc `onSlidingComplete` khi |v| < 0.5 ⇒ 0). Dùng `Slider` (`@react-native-community/slider`) với `minimumValue={-10} maximumValue={10} step={1}`. ### 8.7 `resetDirty` — `App.tsx` `differs()` là **shallow**. Với `setBandKnob` trả `hslBands: undefined` khi rỗng (8.2), `differs(adjustments, DEFAULT_ADJUSTMENTS)` đã bắt đúng cả `hslBands` lẫn `hslHue/hslSat/hslLum`. **Không cần thêm clause** — miễn là đừng để `hslBands: {}`. Nếu vì lý do nào đó muốn giữ `{}`, thì phải thêm `|| Object.keys(adjustments.hslBands ?? {}).length > 0` (đúng như web) — nhưng cách `undefined` sạch hơn. ### 8.8 Camera mode + PRO gate — quyết định - **Camera mode:** mixer tự động chạy trong preview camera (7.1 sửa xong `toneSync`), nên tab HSL cứ hiện ở cả hai mode. **PICK + panel chỉ có nghĩa ở library** — ở camera mode cho chip `PICK` ở trạng thái `disabled` (chip xám, không mở). Đây là điểm khác web (web library-only) và là chỗ dễ tranh cãi nhất — **xác nhận với người dùng nếu muốn khác**. - **PRO gate:** web **không** gate HSL (`proLookInUse()` chỉ phủ `PRO_FRAMES` + GPS + HDF; tab HSL mở cho guest). Port giữ nguyên: **HSL là tính năng LITE**, không thêm vào `entitlement.ts`. - **`RecipeCreateModal`:** không cần thêm dòng HSL. Web cũng không có HSL trong form CREATE — mixer chỉ chỉnh trên ảnh, recipe lưu qua `adjustments` khi SAVE. --- ## 9. Kiểm tra ### 9.1 Trên Android (self-check + unit) 1. **`HSL_BANDS` / `hslBandGaps`** — bất biến partition of unity: ```ts // Với mọi hue 0..359, tổng 8 tent weight phải = 1 (±1e-6). const gaps = hslBandGaps(); for (let h = 0; h < 360; h += 1) { const sum = gaps.reduce((s, b) => { const d = ((h - b.hue + 540) % 360) - 180; const w = d <= 0 ? Math.max(0, 1 + d / b.left) : Math.max(0, 1 - d / b.right); return s + w; }, 0); if (Math.abs(sum - 1) > 1e-6) throw new Error(`weights != 1 at ${h}: ${sum}`); } ``` Và `hslBandGaps()` trả `red.left = 40`, `red.right = 30` (wrap đúng). 2. **`sanitizeHslBands`**: - `{ red: [99, -99, 0] }` → `{ red: [10, -10, 0] }` - `{ red: [0,0,0] }` → `undefined` - `{ red: [1,2] }`, `{ nope: [1,2,3] }` → bỏ - `null` / `'x'` → `undefined` 3. **`rgbToHsl` ↔ `nearestHslBand`**: đỏ thuần `(255,0,0)` → `red`; `(255,128,0)` → `orange`; xám `(128,128,128)` → `s = 0`, `nearestHslBand(0) = 'red'` (rồi shader gate nó đi). 4. **Shader compile** — `Skia.RuntimeEffect.Make(TONE_SKSL)` không throw. Bắt buộc, vì `${BAND_BLOCK}` nội suy vào chuỗi. 5. **`toneUniformArray`** — `length === 40`; `hslOn` ở index 12; `gh/gs/gl` ở 37/38/39. 6. **`toneIsActive`** — `{ ...all zero, hslOn: 1 }` → `true`; `{ ...all zero }` → `false`. 7. **`getToneUniforms`** — `baseFilter: 'monochrome'` + `hslBands` có giá trị ⇒ `hslOn === 0`; `colour` + `hslLum: 10` ⇒ `gl === 1`, `hslOn === 1`. 8. **Round-trip `.recipe`** — recipe có `hslBands: { blue: [3,-2,5], red: [0,0,0] }` export rồi import: `red` biến mất, `blue` giữ nguyên; `hslHue/Sat/Lum` sống qua vòng. 9. **Máy / emulator** (kiểm tra số, không nhìn ảnh): - Preview camera với `+10` dải RED và ảnh có mảng đỏ: sample pixel đỏ trước/sau bằng `readPixels` trên cùng toạ độ ⇒ hue trôi ~+30°. - `-10` SAT dải RED trên vùng đỏ ⇒ saturation về ~0; `+10` LUM ⇒ lightness tăng ~0.25. - `hslLum = +10` trên một ảnh đã `saturation = -10`: lightness vẫn phải tăng (kiểm tra `gl` không bị gate). - Monochrome stock: uniform HSL toàn 0 bất kể `hslBands` có gì. - Export ra file: pixel cuối phải khớp pixel preview (cùng một `toneUniformArray`). ### 9.2 Tham chiếu hành vi từ web Các script e2e của web là **hợp đồng hành vi** để đối chiếu, nằm trong scratchpad: ``` /home/locpham/.penguin/data/default_project/agents/default_agent/scratchpad/session-2026-09-06-18-30-42-c24c8c7b/ hsl-test.cjs # chips dải, setBandKnob, xoá dải toàn 0 hsl-panel-test.cjs # PICK -> pickColor -> panel nổi -> knob panel hsl-shader-test.cjs # partition of unity + kết quả shader theo hue hsl-bundle.cjs # build standalone hsl-entry.ts # entry ``` Web đã deploy (`docker/frontend`) là bản chạy được để so từng bước: mở tab `hsl`, PICK một màu, kéo HUE/SAT/LUM trên panel và trên ruler `IMAGE`, đối chiếu `R n · G n · B n` / `h° · s% · l%`. --- ## 10. Thứ tự thực hiện (đề xuất) 1. `types` → 2. `colorUtils` (HSL_BANDS … sanitize) → 3. `toneShader` (4.1–4.8) → 4. self-check 9.1.1–9.1.7 chạy pass → 5. `defaultRecipes` + `recipeShare` → 6. `Viewfinder` buffer (7.1) — **làm trước UI**, nếu không preview camera sẽ vỡ → 7. `ToolRail` + `AdjustmentPanel` chip/readout → 8. `Viewfinder` eyedropper + panel + state App → 9. máy thật.