428e7fa682
The ten PHOTO STYLE sims now carry nothing but their stock's own grade, and each is named for the stock it stands for: PROVIA, VELVIA, CLASSIC CHROME, CLASSIC VIVID (Velvia spliced with Classic Chrome at the blue row), CLASSIC NEGATIVE, ASTIA, ETERNA, ACROS, LC STREETLIFE CLASSIC, LC STREETLIFE VIVID. Grain, clarity, saturation and light moves were dropped from their `adjustments`, so a sim is a clean starting point and the general knobs read their defaults while the look still lands on the pixels. LC STREETLIFE VIVID keeps the one brightness step its stock needs, but as SIM_EXPOSURE_BIAS in colorUtils rather than as an adjustment: it is folded in where the Exposure slider applies, so the picture gets the lift and the parameter stays at 0. Also in this checkpoint: the watermark/GPS boxes and their colour pickers, the WATERMARK chip column, the real admin stats, and the fix that stopped presets from doubling and a frame from refusing to come off when a photo was reopened (/file is the finished render, /base the editable pixels).
866 lines
38 KiB
Markdown
866 lines
38 KiB
Markdown
# 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<Record<HslBandId, HslBand>>; // 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<Record<HslBandId, HslBand>> | undefined {
|
||
if (!raw || typeof raw !== 'object') return undefined;
|
||
const out: Partial<Record<HslBandId, HslBand>> = {};
|
||
for (const band of HSL_BANDS) {
|
||
const v = (raw as Record<string, unknown>)[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<number[]>(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 `<Shader>`) — 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 `<Shader uniforms>` 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<HslBandId>('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<ColorAdjustments>);
|
||
};
|
||
```
|
||
|
||
`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<TabId, ParamDef[]>` 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 ? <View style={{ width: 10, height: 10, borderRadius: 999, backgroundColor: c.color, marginRight: 6 }} /> : 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' && (
|
||
<View className="flex-row items-center gap-2 px-3 py-1">
|
||
{hslSample ? (
|
||
<>
|
||
<View style={{ width: 22, height: 22, borderRadius: 4, borderWidth: 1, borderColor: '#3f3f46',
|
||
backgroundColor: `rgb(${hslSample.r}, ${hslSample.g}, ${hslSample.b})` }} />
|
||
<Text className="font-mono text-[11px] text-zinc-400">
|
||
R {hslSample.r} · G {hslSample.g} · B {hslSample.b}
|
||
</Text>
|
||
<Text className="font-mono text-[11px] text-zinc-400">
|
||
{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)}%
|
||
</Text>
|
||
<Text className="font-mono text-[11px] text-amber-400">{bandName}</Text>
|
||
</>
|
||
) : (
|
||
<Text className="font-mono text-[10px] tracking-widest text-zinc-500">
|
||
BẤM PICK RỒI CHỌN MỘT MÀU TRÊN ẢNH
|
||
</Text>
|
||
)}
|
||
</View>
|
||
)}
|
||
```
|
||
|
||
`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: `<Pipette size={22} color="#fff" />`).
|
||
- 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 && (
|
||
<View
|
||
pointerEvents="box-none"
|
||
style={{
|
||
position: 'absolute',
|
||
left: 0, top: 0, width: rectW, height: rectH,
|
||
}}
|
||
>
|
||
<View
|
||
style={{
|
||
position: 'absolute',
|
||
left: hslPanelAt.fx * rectW,
|
||
top: hslPanelAt.fy * rectH,
|
||
transform: [
|
||
{ translateX: -PANEL_HALF_W }, // translate(-50%)
|
||
{ translateY: hslPanelAt.fy > 0.55 ? -(PANEL_H + 14) : 14 },
|
||
],
|
||
}}
|
||
>
|
||
{hslPanel}
|
||
</View>
|
||
</View>
|
||
)}
|
||
```
|
||
|
||
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.
|