Files
RecipesCam/9_RECIPE_COMPAT.md
T
3dtours fe79d75669 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>
2026-09-29 17:42:34 +07:00

265 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.