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).
38 KiB
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:
- 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.
- Anchor cố định 1 nguồn.
HSL_BANDS(cho chip) vàBAND_BLOCK(cho shader) sinh từ cùng bảng quahslBandGaps()⇒ anchor không thể lệch. - Gate xám.
gate = smoothstep(0.0, 0.08, hsl.y)— pixel có saturation < 8% bị loại khỏi weight, vìrgb2hsltrả 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. glKHÔNG gate. Lightness toàn ảnh (gl) được cộng sau gate, nên ảnh đã bị-SATrút về xám vẫn còn+LUM.- Monochrome tắt mixer.
if (!colour) hslOn = 0;— stock đen trắng không có hue để chọn. - 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. - 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):
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):
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
hslBandslà 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):
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
import { BaseFilter, ColorAdjustments } from '../types';
import { HSL_BANDS, hslBandGaps } from './colorUtils';
Thêm trước export const TONE_SKSL:
// 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):
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:
// 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à:
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:
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ướchsl.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):
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 {:
// 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):
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
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:
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:
import { sanitizeHslBands } from './colorUtils';
Trong importRecipeXml, trước dòng const frameId: FrameId = ...:
// 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.getAllRecipesmerge{ ...DEFAULT_ADJUSTMENTS, ...r.adjustments }— đủ cho 3 field toàn ảnh.hslBandstừ storage là do chính app ghi nên không cần sanitize; nếu muốn chắc, gọi thêmsanitizeHslBandstrongfill.
6. src/utils/defaultRecipes.ts
Thêm 3 field vào DEFAULT_ADJUSTMENTS (sau vibrance nếu có, trước exposureCompensation):
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ũ:
// dòng ~499
const toneSync = useMemo(
() => createSynchronizable<number[]>(new Array(10).fill(0)),
[],
);
// dòng ~1270
const tone = toneSync.getDirty();
const hasTone =
toneEffect != null &&
(tone[0] !== 0 || tone[1] !== 0 || ... || tone[11] !== 0);
Sửa:
new Array(40).fill(0)— đúng bằng độ dàitoneUniformArray.hasTonethêmtone[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):
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 sangmakeShaderWithChildren(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
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):
{ 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:
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):
// 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):
// 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
type TabId(dòng 20) thêm| 'hsl'.paramDefs: Record<TabId, ParamDef[]>thêmhsl: []hoặc xử lý riêng trongchipRow(xem dưới). Ba knob toàn ảnh không vàoPARAM_DEFS— chúng đọc/ghi field củaadjustmentsnhưng label làHUE IMAGE/SAT IMAGE/LUM IMAGE, và mở row qua cùng cơ chếopenParamvớiPARAM_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.
// 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),
};
}),
- Thêm
case 'hsl'vào switchchipRow:
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);
}
ChipDefcần thêmcolor?: string(chip màu). TrongrenderChip, khic.colorcó mặt, vẽ một dot màu trước label (Android dùngViewvớibackgroundColor):
{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ữ.
- 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ỗopenParamDefrow đang render), chỉ khi tab hsl:
{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:
// 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:
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ộtPressable(hoặcViewvớionStartShouldSetResponder) phủ đúngimageFitRect, 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:
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-iconcủa web là SVG; Android dùnglucide-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:
// 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
skiaImagemột lần vàreadPixelstrê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 đó:
{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:
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áivalue = band[i],onValueChange = (v) => setBandKnob(i, v), và reset về 0 (web: double-click; Android: thêm mộtTouchableOpacitynhỏ hoặconSlidingCompletekhi |v| < 0.5 ⇒ 0). DùngSlider(@react-native-community/slider) vớiminimumValue={-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 chipPICKở trạng tháidisabled(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àoentitlement.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 quaadjustmentskhi SAVE.
9. Kiểm tra
9.1 Trên Android (self-check + unit)
HSL_BANDS/hslBandGaps— bất biến partition of unity:
// 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).
-
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
-
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). -
Shader compile —
Skia.RuntimeEffect.Make(TONE_SKSL)không throw. Bắt buộc, vì${BAND_BLOCK}nội suy vào chuỗi. -
toneUniformArray—length === 40;hslOnở index 12;gh/gs/glở 37/38/39. -
toneIsActive—{ ...all zero, hslOn: 1 }→true;{ ...all zero }→false. -
getToneUniforms—baseFilter: 'monochrome'+hslBandscó giá trị ⇒hslOn === 0;colour+hslLum: 10⇒gl === 1,hslOn === 1. -
Round-trip
.recipe— recipe cóhslBands: { blue: [3,-2,5], red: [0,0,0] }export rồi import:redbiến mất,bluegiữ nguyên;hslHue/Sat/Lumsống qua vòng. -
Máy / emulator (kiểm tra số, không nhìn ảnh):
- Preview camera với
+10dải RED và ảnh có mảng đỏ: sample pixel đỏ trước/sau bằngreadPixelstrên cùng toạ độ ⇒ hue trôi ~+30°. -10SAT dải RED trên vùng đỏ ⇒ saturation về ~0;+10LUM ⇒ lightness tăng ~0.25.hslLum = +10trên một ảnh đãsaturation = -10: lightness vẫn phải tăng (kiểm traglkhông bị gate).- Monochrome stock: uniform HSL toàn 0 bất kể
hslBandscó gì. - Export ra file: pixel cuối phải khớp pixel preview (cùng một
toneUniformArray).
- Preview camera với
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)
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.Viewfinderbuffer (7.1) — làm trước UI, nếu không preview camera sẽ vỡ → 7.ToolRail+AdjustmentPanelchip/readout → 8.Viewfindereyedropper + panel + state App → 9. máy thật.