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 against6814b05. 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 whenc0aaa67gave 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 at3b92e4e(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:
@@ -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.
|
||||
Reference in New Issue
Block a user