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>
15 KiB
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à:
- Field mới tự chảy qua. Web ghi
adjustments.masks[].temperature/tint; không cần bumpversion="1", không cần đổiALGORITHM, không cần salt mới. - Android hiện giữ được field lạ.
importRecipeXmllàm{ ...DEFAULT_ADJUSTMENTS, ...raw.adjustments }rồi chỉ sanitizehslBands(recipeShare.ts:97-102).masksvào bộ nhớ, sống quaLook/session, và xuất lại nguyên vẹn vìexportRecipeXmlgửi lại chínhrecipe.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). - Nhưng Android không render mask (
grep -n masks src/types/index.ts→ rỗng;ColorAdjustmentssrc/types/index.ts:81-105khô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. RecipeCreateModal.handleSavespread...seedAdj, nên mở một recipe import được rồi bấm LƯU không làm rơimasks. Phải giữ đúng như vậy sau khi thêm field (đừng liệt kê tay từng knob).- Web clamp khi đọc (
readMasks,gradientMask.ts:87-121), không clamp khi ghi. Android cũng nên clamp ởreadMaskstương đương, không nhét clamp vàoimportRecipeXml.
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ũ:
GradientMaskthêmtemperature?: number(2500..10000) vàtint?: number(±10) — giốngshared/types/index.ts:174-175(comment web ở:170-173giải thích: cùng đơn vị, cùng gain, chỉ khác vùng đọc).readMasksresolve default:temperature = clampK(num(m.temperature, 5500)),tint = clampA(num(m.tint, 0))(gradientMask.ts:116-117) — mask lưu trước đó ⇒ gain 1.- Uniform: thêm một mảng
float4nữa,wb[count], saufx[count]; buffer thành6*n + 1 (+1 nếu air)float4, mảngwbở index5*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òncg = clamp(o / max(t, 0.0004), 0.55, 1.35):và bốn knob tone là knot của một ramp (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);TONE_ANCHOR = 0.25;blMask/shMask/whMask/hlMasktại:196-199;a0..a4tạ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ũ sauc0aaa67.
⇒ 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
cgcũ 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-220bằng công thứckgiữ 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
- Round-trip QR, không mất field. Recipe web có mask + WB (ví dụ 8000 K / tint −3):
web xuất
.recipe→importRecipeXmlAndroid →exportRecipeXmlAndroid → import lại web. Soadjustments.maskssau 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. - 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
adjustmentsphải sống qua import → export). - 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. - Refactor WB trung tính.
getSkiaColorMatrixra cùng 20 số trước/sau §3.1, trên lưới(baseFilter × temperature × tint)gồm cả stock mono. - 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.724trong mask,0.994 → 0.994ngoài mask). - Đơn vị EV của mask.
masks[].exposure = +1phải sáng đúng 1 stop (không phải 1/4 stop như nếu lỡ dùng thang ±10 củaadjustments.exposure). - 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).
- 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 Δhue0.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/maskUI 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.