docs: hand the Android team the recipe/QR deltas the studio just took

The five changes recipes-web landed on 2026-09-29 (34f8601, c0aaa67, bd57dd7,
3b92e4e) only put two new fields into the shareable payload, but four of them
change the look a given recipe produces — so both halves of the story belong in
one note, next to doc 8 which was written against 6814b05.

What the note says:

- The slider/store split. Web now reads EXPOSURE as stops (-5..+5, two decimals)
  and every develop knob as -100..+100, but paramDefs translates both ways and
  the stored units did not move: exposure is still +-10 units of 0.25 EV, every
  other knob is still +-10. Android can keep its +-10 sliders and still be
  byte-compatible, so the doc says that instead of asking for a UI port.
- The one place a unit really does differ: masks[].exposure is the EV itself
  (MASK_EXPOSURE_MAX = 5) because the shader reads pow(2.0, a.x). Getting that
  wrong is a factor of four, so it gets its own row and its own acceptance test.
- The recipe format did not change. The shareable object is still
  {name, baseFilter, adjustments, frameId, useGeotag}, adjustments still travels
  whole, and Android already keeps unknown fields through import -> Look ->
  export because only hslBands is sanitized. So masks survive web -> Android ->
  web today; that is worth an explicit round-trip test rather than being relied
  on as an accident.
- The two new mask fields: temperature (2500..10000 K, NEUTRAL_K = 5500) and
  tint (+-10), resolved in readMasks so a mask stored before them carries a gain
  of exactly (1,1,1), plus the extra float4 wb[count] uniform and the one line
  c = clamp(c * wb, 0, 1) right after EXPOSURE. Gain is hoisted to JS on purpose:
  the Kelvin fit is a curve and a second copy of it in SkSL is a second answer.
- whiteBalanceGain is the same arithmetic Android already has inline in
  getSkiaColorMatrix, so the port is a lift-and-shift with a bit-identical
  matrix as its acceptance test.

One divergence is called out rather than papered over: gradientMask.ts still
runs the old cg = clamp(lifted / max(l, 0.0004), 0.55, 1.35) for the tone knobs
inside a mask, and its comment still claims that is TONE_SKSL's own gain — which
stopped being true when c0aaa67 gave the frame-wide shader the hue-preserving
knee and the five-knot ramp. A mask's HIGHLIGHT is therefore not the frame-wide
HIGHLIGHT on a smaller area, and the note says which side of that the Android
port should follow until the project owner decides.

Verified: every file:line, commit hash and unit in the note was read back out of
origin/recipes-web at 3b92e4e (toneShader.ts, colorUtils.ts, paramDefs.ts,
gradientMask.ts, types/index.ts, ToolRail.tsx, App.tsx, recipeShare.ts) and out
of this branch (src/utils/colorUtils.ts:384-394, src/types/index.ts:81-105,
src/utils/recipeShare.ts:59-110), including grep runs showing Android has no
masks field at all. The measured WB numbers quoted (1.250 -> 1.724 inside the
mask, 0.994 -> 0.994 outside) are the ones the studio's own mask-wb check
produced against the live build.

ponytail: no code touched, one file. The mask-tone alignment is left as an open
question with both candidate behaviours written down, because choosing for the
owner would bake a look decision into two codebases at once.

Co-authored-by: PenguinHarness <noreply@penguin.local>
This commit is contained in:
2026-09-29 17:42:34 +07:00
parent f3d26e3275
commit fe79d75669
+264
View File
@@ -0,0 +1,264 @@
# 9 — RECIPE / QR: NHỮNG GÌ WEB VỪA SỬA VÀ APP ANDROID PHẢI KHỚP
Nhánh đích: `feat/vision-camera-v5` (base khi viết: **`f3d26e3`**). Ngày: 2026-09-29.
Nguồn đối chiếu: nhánh **`recipes-web`**, commit **`3b92e4e`** — bản web đã chạy ở
`http://127.0.0.1:8090`, bundle live `index-J8ISFv_z.js`.
Bốn commit web trong đợt này, tất cả ngày 2026-09-29:
```
34f8601 studio: the develop column becomes five panels, and the four tone knobs move knots instead of channels
c0aaa67 Studio: fold WB and FX into LIGHT, and put every develop slider on -100..+100
bd57dd7 EXPOSURE reads in stops: -5..+5, two decimals, on an unchanged store
3b92e4e A gradient mask can carry the LIGHT column's white balance now: COLOR TEMP and TINT
```
Tài liệu 8 (`8_EDITOR_TOOLS_PORT.md`) đối chiếu web ở `6814b05`. Tài liệu này ghi **phần
lệch phát sinh sau đó** và, quan trọng hơn cho đội Android, **cái gì trong file `.recipe`
(QR) đã đổi / chưa đổi**. Lệnh đọc nguồn web vẫn như doc 8:
```sh
git show origin/recipes-web:docker/frontend/shared/utils/gradientMask.ts
```
---
## 0. Bảng khoảng trống
| # | Web vừa đổi gì | Nguồn web | Ảnh hưởng `.recipe` | Android phải làm |
|---|----------------|-----------|---------------------|------------------|
| 1 | EXPOSURE đọc theo **stop** (−5..+5, 2 số lẻ) | `shared/utils/paramDefs.ts:53-66`, `shared/utils/colorUtils.ts:201` | **Store không đổi** (±10 unit, 1 unit = 0.25 EV) | Chỉ UI/đơn vị hiển thị. Recipe cũ đọc y nguyên |
| 2 | Mọi slider "develop" thành **−100..+100** | `shared/utils/paramDefs.ts:40-43,160,184` | **Store không đổi** (±10) — ×10 là của slider | Không bắt buộc. Không port thì recipe vẫn khớp |
| 3 | Rail **10 tab → 8 tab** (WB, FX gộp vào LIGHT) | `src/ui/ToolRail.tsx:15-27` | Không | Không (layout, không vào recipe) |
| 4 | Bốn knob tone thành **knot của một ramp** + knee giữ hue | `shared/utils/toneShader.ts:109,196-221,242-247` | Không có field mới, nhưng **look đổi** | Port theo doc 8 §4/§5 (xem §3.3) |
| 5 | Mask gradient mang **COLOR TEMP + TINT** | `shared/utils/gradientMask.ts:63-65,116-117,166-167,201,320`, `shared/types/index.ts:139-175,228` | **Có 2 field mới trong `adjustments.masks[]`** | Port mask (doc 8 §8) + 2 field + 1 array uniform `wb` |
Điểm đáng chú ý cho đội Android: **bốn trong năm thay đổi không đụng tới payload**. Chỉ mục 5
thêm field. Nhưng mục 1 + 4 làm **cùng một recipe cho ra look khác** giữa hai bên — xem §3.3.
---
## 1. Đơn vị: slider web vs store (và vs Android)
Web tách hẳn **đơn vị slider** khỏi **đơn vị store**: `paramDefs.ts` có `get`/`set` dịch hai
chiều, nên một recipe viết trước đợt này vẫn đọc ra đúng look.
```ts
// shared/utils/paramDefs.ts:40-43
const HUNDRED = new Set(['contrast', 'color', 'vibrance', 'highlight', 'shadow', 'whites',
'blacks', 'tint', 'denoise', 'clarity', 'dehaze', 'sharpening']);
const deepen = (def) => HUNDRED.has(def.key)
? { ...def, min: -100, max: 100, get: (a) => def.get(a) * 10, set: (v) => def.set(v / 10) }
: def;
```
```ts
// shared/utils/paramDefs.ts:53-66
{ key: 'exposure', min: -5, max: 5, step: 0.01, display: twoStops, // '+0.50' / '-0.25'
get: (a) => (a.exposure ?? 0) * EV_PER_UNIT,
set: (v) => ({ exposure: Math.round((v / EV_PER_UNIT) * 1e4) / 1e4 }) }
```
| Trường | Đơn vị store (web **và** Android) | Slider web (`3b92e4e`) | Slider Android (`src/utils/paramDefs.ts`) |
|--------|-----------------------------------|------------------------|-------------------------------------------|
| `adjustments.exposure` | ±10 unit, 1 unit = **0.25 EV** (`EV_PER_UNIT`, `colorUtils.ts:201`) | −5..+5 **stop**, 2 số lẻ | −10..+10, step 1, `display: sign` (`paramDefs.ts:32-41`) |
| `contrast` `highlight` `shadow` `whites` `blacks` `tint` `denoise` `clarity` `dehaze` `sharpening` `vibrance` `color` | ±10 | −100..+100 (`HUNDRED` + `deepen`) | ±10, step 1 |
| `temperature` | 2500..10000 K | 2500..10000, step 100, `'${v}K'` (`paramDefs.ts:163-172`) | 2500..10000 (đã khớp) |
| `masks[].exposure` | **chính là EV**, ±5 (`MASK_EXPOSURE_MAX`, `gradientMask.ts:45`) | −5..+5, step 0.1 | — (chưa có mask) |
| `masks[].temperature` | 2500..10000 K, **default 5500** (`NEUTRAL_K`, `gradientMask.ts:63`) | 2500..10000, step 100 | — |
| `masks[].tint` | ±10 (default 0) | −100..+100 (maskKnobRow ×10, `App.tsx:2474-2492`) | — |
**Hệ quả cho Android:** ba dòng đầu **không cần làm gì để recipe khớp** — store giữ nguyên
thang cũ. Chỉ nên đổi UI nếu muốn hai bên trông giống nhau (Android `EXPOSURE` ±10 unit =
±2.5 EV, web đọc thành −5..+5 stop; cùng một con số lưu, khác nhãn). Chú ý `EV_PER_UNIT`
**đã export** ở cả hai phía — dùng nó, đừng viết lại `0.25`.
Chú ý riêng: `masks[].exposure` **không** theo thang ±10 của `adjustments.exposure`. Nó là
EV trực tiếp vì shader đọc `pow(2.0, a.x)`. Đây là chỗ dễ sai nhất khi port.
---
## 2. Cái gì thực sự đi qua file `.recipe` / QR
Không đổi gì so với `7_SCAN_QR.md`: **QR chỉ chứa URL** `GET /api/photos/:id/preset.recipe`,
file trả về là XML bọc payload xor16-v1 (`docker/backend/src/recipeFile.ts`, bản sao của
`shared/utils/recipeShare.ts`; nguồn sự thật là bản web).
Hình dạng shareable — **web và Android giống hệt nhau, từng chữ**:
```ts
// recipeShare.ts:59-77 (web) == src/utils/recipeShare.ts:59-77 (Android)
const shareable = { name, baseFilter, adjustments, frameId, useGeotag };
```
`adjustments` được gửi **nguyên khối**. Nghĩa là:
1. **Field mới tự chảy qua.** Web ghi `adjustments.masks[].temperature/tint`; không cần bump
`version="1"`, không cần đổi `ALGORITHM`, không cần salt mới.
2. **Android hiện giữ được field lạ.** `importRecipeXml` làm
`{ ...DEFAULT_ADJUSTMENTS, ...raw.adjustments }` rồi **chỉ** sanitize `hslBands`
(`recipeShare.ts:97-102`). `masks` vào bộ nhớ, sống qua `Look`/session, và **xuất lại
nguyên vẹn** vì `exportRecipeXml` gửi lại chính `recipe.adjustments`.
⇒ Web → Android → Web **không mất mask**. Đây là hành vi phải **khoá bằng test**, không
phải tính năng tình cờ (xem §4.2).
3. **Nhưng Android không render mask** (`grep -n masks src/types/index.ts` → rỗng;
`ColorAdjustments` `src/types/index.ts:81-105` không có `masks`). Ảnh xuất từ Android vì
thế **thiếu toàn bộ mask** dù recipe có. Đó là việc của doc 8 §8, không phải lỗi format.
4. `RecipeCreateModal.handleSave` spread `...seedAdj`, nên mở một recipe import được rồi bấm
LƯU **không** làm rơi `masks`. Phải giữ đúng như vậy sau khi thêm field (đừng liệt kê tay
từng knob).
5. Web clamp khi **đọc** (`readMasks`, `gradientMask.ts:87-121`), không clamp khi ghi. Android
cũng nên clamp ở `readMasks` tương đương, không nhét clamp vào `importRecipeXml`.
---
## 3. Việc Android phải làm
### 3.1 Tách `whiteBalanceGain` (thuần refactor, kết quả không đổi)
Android **đã có** công thức WB frame-wide, viết inline trong `getSkiaColorMatrix`:
```
src/utils/colorUtils.ts:384-394
const rgbTemp = kelvinToRGB(temperature);
const tintMagenta = (tint / 10) * 0.08;
const wbGain = normalizeGainLuma([
rgbTemp.r * (1 + tintMagenta),
rgbTemp.g * (1 - tintMagenta),
rgbTemp.b * (1 + tintMagenta),
]);
```
Web đã rút đúng khối này ra thành một hàm export, **vì nó được hỏi hai lần** (frame-wide và
từng mask). Việc cần làm: chép y nguyên chữ ký web, cho `getSkiaColorMatrix` gọi lại nó.
```ts
// shared/utils/colorUtils.ts:130-137 (web) — chép sang src/utils/colorUtils.ts
export function whiteBalanceGain(temperature: number, tint: number): { r: number; g: number; b: number } {
const rgbTemp = kelvinToRGB(temperature); // kelvinToRGB đã có ở Android:117
const tintMagenta = (tint / 10) * 0.08;
return normalizeGainLuma([ // đã có ở Android:147
rgbTemp.r * (1 + tintMagenta),
rgbTemp.g * (1 - tintMagenta),
rgbTemp.b * (1 + tintMagenta),
]);
}
```
Gain tại 5500 K / tint 0 = `(1, 1, 1)` chính xác (`kelvinToRGB(5500) === kelvinToRGB(5500)`,
rồi `normalizeGainLuma`). `KELVIN_TAME = 0.5` nằm trong `kelvinToRGB`, không đụng.
**Nghiệm thu bắt buộc:** `getSkiaColorMatrix` trước và sau refactor phải ra **cùng mảng 20
số** với mọi cặp `(baseFilter, temperature, tint)` trong lưới thử (kể cả `wbRed/wbBlue`, kể
cả stock mono — nhánh `isMonochromeBase` phải bỏ qua WB **trước khi** gọi hàm).
### 3.2 Mask gradient (doc 8 §8) + hai field mới
Port theo doc 8 §8, thêm đúng ba điểm so với spec cũ:
1. `GradientMask` thêm `temperature?: number` (2500..10000) và `tint?: number` (±10) —
giống `shared/types/index.ts:174-175` (comment web ở `:170-173` giải thích: cùng đơn vị,
cùng gain, chỉ khác vùng đọc).
2. `readMasks` resolve default: `temperature = clampK(num(m.temperature, 5500))`,
`tint = clampA(num(m.tint, 0))` (`gradientMask.ts:116-117`) — mask lưu trước đó ⇒ gain 1.
3. Uniform: thêm **một mảng `float4` nữa**, `wb[count]`, **sau** `fx[count]`; buffer thành
`6*n + 1 (+1 nếu air)` float4, mảng `wb` ở index `5*n + i`, `size` (width/height) ở `6*n`,
`air` ở `6*n+1` (`gradientMask.ts:132-171, 314-322`).
Shader — một dòng, ngay sau EXPOSURE, trước CONTRAST:
```sksl
half3 maskAdjust(half3 c, half3 wb, float4 a, float4 tone, float4 fx, half dark /*, half3 blur, float3 air*/) {
c = c * half(pow(2.0, a.x)); // EXPOSURE, EV
c = clamp(c * wb, half3(0.0), half3(1.0)); // ← MỚI: WB của mask
c = (c - half(0.5)) * half(1.0 + a.y) + half(0.5);
...
```
`wb` là gain **hoisted lên JS**, không tính trong SkSL: fit Kelvin là một đường cong, viết lại
trong shader là bản sao thứ hai của nó. Call site: `maskAdjust(c.rgb, wb[i].rgb, adj[i], tone[i], fx[i], dark)`.
**Chiều đã đo trên web** (đừng đảo): 2500 K = **lạnh**, 10000 K = **ấm**. Trong mask, đặt
10000 K đo được R/B `1.250 → 1.724`; pixel ngoài mask `0.994 → 0.994` (không đổi). Đó là
bằng chứng mask-ảnh-hưởng-cục-bộ phải tái lập được.
### 3.3 Cảnh báo lệch: tone **trong mask** chưa bằng tone frame-wide
Đây là điểm dễ hiểu sai nhất khi đọc song song doc 8 và bản web mới nhất.
- Frame-wide (`shared/utils/toneShader.ts:242-247`, từ `c0aaa67`): knee giữ hue, không còn
`cg = clamp(o / max(t, 0.0004), 0.55, 1.35)`:
```sksl
float k = 1.0;
float hiC = max(max(rgb.r, rgb.g), rgb.b);
float loC = min(min(rgb.r, rgb.g), rgb.b);
if (hiC > t) k = min(k, (1.0 - o) / (hiC - t));
if (loC < t) k = min(k, o / (t - loC));
rgb = clamp(vec3(o) + (rgb - vec3(t)) * k, 0.0, 1.0);
```
và bốn knob tone là **knot của một ramp** (`TONE_ANCHOR = 0.25`; `blMask/shMask/whMask/hlMask`
tại `:196-199`; `a0..a4` tại `:214-221`).
- Trong mask (`shared/utils/gradientMask.ts:218-220`): **vẫn công thức cũ**
`half cg = clamp(lifted / max(l, 0.0004), 0.55, 1.35); c = clamp(lifted + (c - l) * cg, …)`,
và mask **không** có knot ramp. Comment ngay trên đó (`:207-213`) vẫn nói "TONE_SKSL's own
cg" — **comment đã cũ** sau `c0aaa67`.
⇒ Trên web hiện tại, HIGHLIGHT của một mask **không** phải cùng một phép move như HIGHLIGHT
toàn khung, đúng cái điều comment hứa. Đã hỏi chủ dự án có align không — chưa có quyết định.
Cho tới lúc đó:
- **Android port mask theo bản web đang chạy** (công thức `cg` cũ trong mask), nếu mục tiêu là
parity với web **hôm nay**.
- Nếu chủ dự án chốt align, việc cần làm là thay khối `:218-220` bằng công thức `k` giữ hue —
và khi đó **mask phải chạy trước** phép move tone nào khác của khung (`masks` đứng **sau**
tone frame-wide trong chuỗi pass, xem doc 8 §2.1). Ghi lại ở đây để cả hai bên cùng đổi một
lần, tránh web sửa trước rồi Android port theo bản cũ.
### 3.4 Port tối thiểu nếu chỉ muốn "giữ field, chưa render"
Nếu ưu tiên là **không mất dữ liệu khi chia sẻ QR** trước khi kịp port shader mask, chỉ cần:
```ts
// src/types/index.ts — đủ để TS không nuốt field khi ai đó truy cập
masks?: GradientMask[];
```
Ở runtime Android **đã** giữ field rồi (§2.2), nên thay đổi này là để code đọc/ghi tường minh,
không phải để cứu dữ liệu. **Không** thêm sanitize cho `masks` ở giai đoạn này — sanitize nửa
vời sẽ âm thầm cắt dữ liệu mà web vẫn gửi (ví dụ bỏ mask chưa có `temperature`).
---
## 4. Tiêu chí nghiệm thu
1. **Round-trip QR, không mất field.** Recipe web có mask + WB (ví dụ 8000 K / tint −3):
web xuất `.recipe` → `importRecipeXml` Android → `exportRecipeXml` Android → import lại
web. So `adjustments.masks` sau khi bỏ salt: phải **bằng byte** bản gốc, gồm cả
`temperature`, `tint`, `kind`, `angle`, `feather`, `exposure` (EV), và thứ tự các mask.
2. **Không có test nào phụ thuộc vào việc "field lạ bị bỏ".** Thêm một test khoá hành vi §2.2
(một field lạ bất kỳ trong `adjustments` phải sống qua import → export).
3. **Clamp đúng biên.** Mask gửi `temperature: 99999` ⇒ đọc ra 10000; `2000` ⇒ 2500; `tint: 40`
⇒ 10. Mask **không** có hai field ⇒ gain `(1,1,1)` và ảnh **không đổi một pixel** so với
trước khi port.
4. **Refactor WB trung tính.** `getSkiaColorMatrix` ra cùng 20 số trước/sau §3.1, trên lưới
`(baseFilter × temperature × tint)` gồm cả stock mono.
5. **WB của mask là cục bộ.** Đặt 10000 K: pixel **trong** mask đổi tỉ lệ R/B (ấm lên), pixel
**ngoài** mask **không đổi** (web đo `1.250 → 1.724` trong mask, `0.994 → 0.994` ngoài mask).
6. **Đơn vị EV của mask.** `masks[].exposure = +1` phải sáng đúng **1 stop** (không phải 1/4
stop như nếu lỡ dùng thang ±10 của `adjustments.exposure`).
7. **Parity ba đường** như doc 8 §11.1, và **look cũ không regress** (recipe chỉ có matrix +
tone + grain phải render y hệt trước/sau).
8. **Tone:** nếu chủ dự án chốt align tone trong mask (§3.3), phải có số đo hue trước/sau
tương đương bảng trong commit `c0aaa67` — HIGHLIGHT −100 Δhue `0.00°` trên mọi patch.
---
## 5. Những gì KHÔNG làm
- **Không đổi `version` / `ALGORITHM` / salt** của envelope `.recipe`. `test/security.mjs`
(backend 134/134) khoá hình dạng này.
- **Không nhét mask vào QR**: mã QR vẫn chỉ là URL (§2). Recipe nằm ở nội dung file tải về.
- **Không commit đi theo đợt này:** `preview`/`mask` UI của web chỉ đổi layout, không có gì
để port sang app.
- **Không port thang −100..+100** sang Android chỉ để "giống web" — đó là lựa chọn UI, store
hai bên đã khớp; đổi thì phải đổi cả `paramDefs`, cả chip readout, cả test ảnh.