# 6 — CREATE RECIPES: gấp nhóm + thêm EXPOSURE / EV / WHITE / BLACK Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06. Nguồn đối chiếu: nhánh `recipes-web`, commit **`f17cb08`** ("web: fold the create form's groups and add the white/black point rows") — bản web đã làm, đã đo và đang chạy ở `http://localhost:8090/app`. Commit `cf4b01d` là việc đổi màu tên recipe đang chọn trong FAVORITED; bản app **đã có sẵn** (`AdjustmentPanel.tsx`, tab `favorites`: `active: r.id === currentRecipeId`) nên không cần làm lại. Đây là ghi chú yêu cầu, **chưa implement trên app**. Web đã xong và là bản mẫu để đối chiếu từng dòng. --- ## 1. Mục tiêu Form CREATE RECIPES đã dài thành một cuộn dọc. Hai việc: 1. **Gấp được** sáu nhóm phân loại: SIMULATION, DYNAMIC RANGE, GRAIN EFFECT, COLOR CHROME EFFECT, COLOR CHROME EFFECT BLUE, WHITE BALANCE. 2. **Thêm bốn ô số**: EXPOSURE, EV, WHITE, BLACK. Thứ tự hàng giữ nguyên như điện thoại đang có, chỉ chèn thêm — trừ `EXPOSURE COMP.` đổi tên thành `EV` (cùng một tham số `exposureCompensation`, chỉ khác nhãn; web đã đổi để hai chữ EXPOSURE/EV không lẫn nhau). --- ## 2. Việc 1 — gấp nhóm (thuần UI, chỉ `RecipeCreateModal.tsx`) Web dùng `
`/`` gốc của trình duyệt: không state, không thư viện, trình duyệt tự giữ cờ mở/đóng. App dùng React Native nên phải tự làm: một `useState>` cho sáu nhóm, nhãn nhóm bấm được, mũi tên xoay 90° khi mở, và phần thân chỉ render khi nhóm đang mở. Ranh giới từng nhóm trong `RecipeCreateModal.tsx` (số dòng theo bản hiện tại): | Nhóm | Hàng nằm trong | | --- | --- | | SIMULATION | dải chip `FILM_SIMS` | | DYNAMIC RANGE | dải chip `DR_OPTS` | | GRAIN EFFECT | dải chip `GRAIN_OPTS` | | COLOR CHROME EFFECT | dải chip `CC_OPTS` | | COLOR CHROME EFFECT BLUE | dải chip `CC_OPTS` | | WHITE BALANCE | dải chip `WB_PRESETS` **+ hai ô WB SHIFT RED/BLUE** | Ngoài nhóm (luôn hiện): NAME, lưới số (`fieldSets`), hàng ISO, SAVE/EXPORT. Khối WB SHIFT đi cùng WHITE BALANCE — về mặt nhiếp ảnh nó là một phần của WB. Mặc định **mở cả sáu** để form không đổi hành vi cho tới khi người dùng tự gấp. CSS web đã dùng (đối chiếu nếu muốn giống hệt): ```css .create-sec { display: flex; flex-direction: column; gap: 6px; } .create-sec > summary { display: flex; align-items: center; gap: 6px; cursor: pointer; list-style: none; user-select: none; } .create-sec > summary::-webkit-details-marker { display: none; } .create-sec > summary::before { content: '▸'; font-size: 9px; transition: transform 0.15s ease; } .create-sec[open] > summary::before { transform: rotate(90deg); } ``` --- ## 3. Việc 2 — bốn ô số `fieldSets` hiện tại: ```ts { label: 'HIGHLIGHT', value: fHigh, set: setFHigh }, { label: 'SHADOW', value: fShadow, set: setFShadow }, { label: 'COLOR', value: fColor, set: setFColor }, { label: 'NOISE RED.', value: fNr, set: setFNr, ph: '-10..10' }, { label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '-10..10' }, { label: 'CLARITY', value: fClarity, set: setFClarity }, { label: 'EXPOSURE COMP.', value: fEv, set: setFEv, ph: 'EV' }, ``` Web sau khi sửa (đúng thứ tự này): ```ts { label: 'HIGHLIGHT', value: fHigh, set: setFHigh }, { label: 'SHADOW', value: fShadow, set: setFShadow }, { label: 'WHITE', value: fWhites, set: setFWhites }, { label: 'BLACK', value: fBlacks, set: setFBlacks }, { label: 'COLOR', value: fColor, set: setFColor }, { label: 'NOISE RED.', value: fNr, set: setFNr, ph: '-10..10' }, { label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '-10..10' }, { label: 'CLARITY', value: fClarity, set: setFClarity }, { label: 'EXPOSURE', value: fExposure, set: setFExposure }, { label: 'EV', value: fEv, set: setFEv, ph: '±3 EV' }, ``` Thêm ba state (`fWhites`, `fBlacks`, `fExposure`) song song với các `f*` đang có, seed từ `seedAdj`/`valuesOf`, và **bắt buộc** thêm vào `handleSave` — thiếu một dòng là ô vừa nhập bị `...seedAdj` ghi đè và recipe lưu ra khác màn hình: ```ts whites: int(fWhites, simAdj.whites ?? 0, -10, 10), blacks: int(fBlacks, simAdj.blacks ?? 0, -10, 10), exposure: int(fExposure, simAdj.exposure, -10, 10), ``` `EXPOSURE` là `adjustments.exposure` (-10..10, độ lợi ma trận) — đã có sẵn trong model và trong tab LIGHT, chỉ thiếu hàng trong CREATE. `EV` là `exposureCompensation` (-3..+3 EV) như hàng `EXPOSURE COMP.` cũ. --- ## 4. `WHITE` / `BLACK` là tham số MỚI — phải thêm cả model lẫn shader Không có `whites`/`blacks` ở đâu trong app (đã grep cả hai nhánh). Nếu chỉ vẽ ô nhập mà không đụng engine thì đó là **ô chết**: nhập xong không ảnh nào đổi. ### 4.1 `src/types/index.ts` — `ColorAdjustments` ```ts highlight: number; // -10 to +10 (- pulls highlights down / roll, + brightens) shadow: number; // -10 to +10 (+ lifts shadows, - deepens) whites?: number; // -10 to +10 (white point: the very top of the tone curve) blacks?: number; // -10 to +10 (black point: the very toe of the tone curve) ``` Để **optional** (`?`) — recipe cũ trong máy không có hai khóa này vẫn đọc được. ### 4.2 `src/utils/toneShader.ts` — `TONE_SKSL` Khai báo uniform ngay sau `sh` (thứ tự này phải khớp `toneUniformArray`): ```glsl uniform float wh; uniform float bl; ``` Trong `main`, chèn **sau** hai dòng Dynamic Range và **trước** `o = clamp(o, …)`: ```glsl // White/black point. The HL/SH knees above already spend the slope budget // (each just under 1.0), so these two use cubic end-weights instead of // another smoothstep: only the last/first stretch of the ramp moves, and the // derivative stays >= 1 - 3*0.18 = 0.46 for every combination, so the curve // can never invert. + lifts that end (faded blacks / brighter whites), // - digs it in (true black / rolled white). o += bl * 0.18 * (1.0 - t) * (1.0 - t) * (1.0 - t); o += wh * 0.18 * t * t * t; ``` Vì sao **không** dùng thêm một cặp `smoothstep` như HL/SH: hai knee 0.65..1.00 và 0.00..0.55 đã tiêu gần hết "ngân sách độ dốc" (mỗi cái ~0.94/1.0); thêm một knee nữa là đẩy tổng độ dốc qua 1.0 và đường cong **đảo** (điểm sáng hơn ra tối hơn). Cubic end-weight chỉ chạm ~đoạn cuối của dải, đạo hàm luôn ≥ 0.46. ### 4.3 Bốn chỗ còn lại trong cùng file ```ts export interface ToneUniforms { dr: number; hl: number; sh: number; wh: number; // -1..1 (adjustments.whites / 10 — white point) bl: number; // -1..1 (adjustments.blacks / 10 — black point) vib: number; … } ``` ```ts const wh = Math.max(-1, Math.min(1, (adj.whites ?? 0) / 10)); const bl = Math.max(-1, Math.min(1, (adj.blacks ?? 0) / 10)); ``` `getToneUniforms` trả thêm `wh, bl`; `toneIsActive` thêm `u.wh !== 0 || u.bl !== 0` (không thêm thì pass tone bị bỏ qua và hai ô không có tác dụng); và `toneUniformArray` — **thứ tự phần tử phải trùng khai báo GLSL**: ```ts return [u.dr, u.hl, u.sh, u.wh, u.bl, u.vib, u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb]; ``` `dr`/`hl`/`sh` hai nhánh giống nhau; chỉ 4 khối ở mục 4.2–4.3 là mới. --- ## 5. Kiểm thử (đo được, không đoán) Web đã đo bằng ảnh thật (`/tmp/w0/p2.jpg`): `WHITE +10` nâng độ chói decile trên cùng, `BLACK +10` nâng decile dưới cùng và **không** đụng đỉnh, `BLACK -10` hạ decile dưới. Vòng round-trip `.recipe` (XML xor16-v1) giữ nguyên `whites = 4`, `blacks = -3`, `exposureCompensation = 2` qua chính importer của điện thoại. Bản app cần đo lại trên **cả ba đường vẽ** (engine Skia xuất file, native export, preview viewfinder) như ghi chú 5 đã nêu: cùng một cặp WHITE/BLACK phải ra cùng một ảnh.