Files
RecipesCam/9_RECIPE_COMPAT.md
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

15 KiB
Raw Permalink Blame History

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:

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.

// 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;
// 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ữ:

// 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ó.

// 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:

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):
    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:

// 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.