99069aa837
The web form (recipes-web f17cb08) now folds its six categorical groups and
carries EXPOSURE, EV, WHITE and BLACK. WHITE/BLACK do not exist in
ColorAdjustments on either side, so the note spells out the two model fields
and the wh/bl uniforms the tone pass needs — an input row without them would be
dead. A TODO in RecipeCreateModal's header points here.
192 lines
8.1 KiB
Markdown
192 lines
8.1 KiB
Markdown
# 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 `<details>`/`<summary>` 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<Record<string, boolean>>` 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.
|