diff --git a/9_RECIPE_COMPAT.md b/9_RECIPE_COMPAT.md new file mode 100644 index 0000000..dc57159 --- /dev/null +++ b/9_RECIPE_COMPAT.md @@ -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.