38 KiB
8 — PORT BỘ CÔNG CỤ CHỈNH ẢNH TỪ WEB SANG APP ANDROID
Nhánh đích: feat/vision-camera-v5 (base khi viết: d5e5da4). Ngày: 2026-09-26.
Nguồn đối chiếu: nhánh recipes-web, commit 6814b05 (bản web đã chạy và đo được).
Tài liệu này là spec để code, không phải báo cáo. Mỗi mục có: nguồn web (file/hàm), hiện trạng Android, việc phải làm, mã mẫu, và tiêu chí nghiệm thu. Toàn bộ số dòng đã đối chiếu trực tiếp trên hai nhánh tại thời điểm viết.
Cách lấy nguồn tham chiếu: nhánh
feat/vision-camera-v5không chứa thư mụcdocker/frontend. Đọc bản web bằng:git show origin/recipes-web:docker/frontend/shared/utils/toneShader.ts git show origin/recipes-web:docker/frontend/src/engine/exportEngine.tsĐường dẫn "web" trong tài liệu này luôn tính từ
docker/frontend/.
0. Phạm vi và bảng khoảng trống
Bảy hạng mục người dùng yêu cầu, theo đúng thứ tự sẽ làm:
| # | Hạng mục | Web có (nguồn) | Android hiện có | Việc phải làm |
|---|---|---|---|---|
| 1 | EXPOSURE / EV / HIGHLIGHT | shared/utils/toneShader.ts EXPOSURE_SKSL + TONE_SKSL; shared/utils/colorUtils.ts EV_PER_UNIT/SIM_EXPOSURE_BIAS_EV/exposureStops (171-193) |
EV nhét trong ma trận màu (src/utils/colorUtils.ts:425-426, applyExposureGain:444-455); highlight knee smoothstep(0.65,1.0) gain 0.22 không có (1-t) (src/utils/toneShader.ts:131,134) |
Tách EV sang pass linear; port đúng knee/(1-t) của web; áp luôn fix thứ tự pass của 4_FIX_HIGHLIGHT_ORDER.md |
| 2 | AUTO (auto setting) | src/ui/Histogram.tsx readHistogram + autoExposureStops; src/App.tsx:2497-2504 |
Không có (ROTATE có AUTO riêng cho straighten — khác việc) | Histogram 256 bin từ pixel ảnh gốc + autoExposureStops, ghi vào exposureCompensation |
| 3 | WHITE / BLACK trong WB | TONE_SKSL web dòng 168-169; uniform wh/bl (78-79, 338-339, 390-391, 433-434, 454, 466-467); paramDefs.ts:137,148 |
Không có whites/blacks. paramDefs.wb chỉ có temperature,tint (src/utils/paramDefs.ts:109-132) |
Thêm 2 uniform + 2 entry paramDefs.wb |
| 4 | TONE CURVE | shared/utils/toneCurve.ts (203 dòng) + src/ui/ToneCurvePanel.tsx |
Không có | Port toán + LUT + panel graph |
| 5 | GRADIENT MASK | shared/utils/gradientMask.ts (169 dòng) + App.tsx gradientChips:2750/maskRulers:2355 |
Không có | Port shader + geometry + UI chip/strip/cột chỉnh |
| 6 | FIX (HEAL, MOSAIC) | shared/utils/heal.ts (324), mosaic.ts (104), brush.ts (26) |
Không có | Port shader + pixel sampler + UI |
| 7 | H-FLIP / V-FLIP trong ROTATE | shared/utils/skiaImage.ts flipSkImage:84, applyPhotoRotation:114 |
applyPhotoRotation(image, quarter, straighten) — không có flip (src/utils/skiaImage.ts:69) |
Thêm flip + state + UI strip |
0.1 Đã có sẵn trên nhánh này — KHÔNG làm lại
Các commit c8e2b6e…d5e5da4 đã port xong và không nằm trong phạm vi tài liệu này:
- HSL selective colour mixer (
cbd0178): đã cóhslBands/hslHue/hslSat/hslLumtrongColorAdjustments(src/types/index.ts:77-104),HSL_BANDS/hslBandGaps/sanitizeHslBandstrongcolorUtils.ts:11-115, band block trongTONE_SKSL(toneShader.ts:51-58,76-82,194) và tabHSLtrongToolRail.tsx:18. ⇒ Bỏ mọi ghi chú cũ kiểu "HSL web-only". - Stock
classic-vividvàmono-high-contrast(c8e2b6e,85b55dc): đã có trongBaseFilter(src/types/index.ts:69) và đã có entryFILM_TONE(src/utils/toneShader.ts:299-322, gồm'mono-high-contrast': { sh: -0.32, hl: 0.26 }). STOCK_BIAS(colorUtils.ts:161-168) đã thay vai trò củaSIM_EXPOSURE_BIAS_EV+SIM_CONTRAST_BIASbên web, nhưng còn ở đơn vị cũ — xem mục 3.3.
Ghi chú phạm vi khác: ba nhánh light/chroma, split-tone, Color Chrome, vibrance trong
TONE_SKSL Android đã khớp web — không đụng.
1. Hiện trạng Android
1.1 Ba đường render — mọi pass mới phải khớp cả ba
| Đường | File | Đặc điểm |
|---|---|---|
| Export JS/Skia (imperative) | src/utils/exportEngine.ts → processAndExportPhoto |
Toàn quyền: surface phụ, snapshot, LUT, cache effect theo count. Đây là đường "chuẩn" để so. |
| Preview library (declarative) | src/components/Viewfinder.tsx, libPhoto() (3127-3200) |
Chỉ lồng được <Shader source={effect}> quanh một chuỗi shader của ảnh. Không chèn được pass giữa tuỳ ý. |
| Camera preview (worklet) | Viewfinder.tsx <SkiaCamera onFrame={handleFrame}>, drawFullFrame |
Mỗi frame; tránh thêm pass nặng. |
| Export native Kotlin | src/utils/nativeExport.ts:47 + modules/recipescam-export/android/.../RecipescamExportModule.kt |
Bật bằng EXPO_PUBLIC_NATIVE_EXPORT=1 (App.tsx:61). Chỉ dùng khi pro (App.tsx:1376). |
Viewfinder.tsx:461 dựng ảnh tĩnh đã quay cho chế độ library bằng memo
applyPhotoRotation(loadedImage, photoRotation, photoStraighten).
1.2 Hạ tầng đã có
Skia.RuntimeEffect.Make+makeShaderWithChildren+<Shader source>— đã dùng (exportEngine.ts:371-380,Viewfinder.tsx:3141-3146).- Mẫu đọc pixel:
src/utils/horizon.ts:18-56—Skia.Surface.Make(w,h)(32) →drawImageRectOptionsdownscale →makeImageSnapshot()→readPixels()(50) (Uint8Array RGBA hoặc Float32Array). Dùng lại cho histogram/AUTO vàfindHealSource. - Undo:
App.tsxcommit(coalesce, mutate)(757-775,PEEK_GAP_MS = 700tại751),applyLook(804),handleUndo(820),Look(719-720) — thêm field vàoLooklà tự động sống qua undo/redo/session (lookNowtại728). - Chip UI:
AdjustmentPanel.tsxchipRow(867),paramChips(475),groupDefs(652),groupDefs.rotate(713-733),rotateChip()(791-803). RecipeCreateModal.handleSavespread...seedAdj, nên knob không có row trong form vẫn được lưu (không bị zero).
1.3 Điều KHÔNG có (phải dựng mới)
Không có: brush, heal, mosaic, mask vẽ tay, curve editor, flip. Không SVG, không
react-native-gesture-handler — mọi touch đi qua RN responder. Không i18n (chuỗi
hardcode tiếng Anh, giữ nguyên).
exportEngine.ts không cache RuntimeEffect theo count: chỉ có watermarkFonts
(184) ở module scope, mọi effect khác dựng lại trong hàm. Heal/mosaic/mask bắt buộc
phải cache — xem mục 2.4.
1.4 Thứ tự pass hiện tại của exportEngine.ts
218 1 nạp ảnh → 241 1 rotate → 281 1b crop → 250 1c deviceFloor → 337 2 surface
350 3 color matrix (+ STOCK_BIAS, +2^EV gain) ← CÙNG paint với 3b/3c: SAI (xem doc 4)
359 3b tone shader → 392 3c cinema
414 4 denoise/clarity/soften → 477 5 draw → 489 5b HDF
572 6 grain → 619 6b vignette → 648 7 frame
832 8 watermark → 924 9 snapshot/JPEG/EXIF → 976 10 file → 988 11 gallery
4_FIX_HIGHLIGHT_ORDER.md đã chỉ ra SkPaint chạy shader TRƯỚC colorFilter, nên
ma trận (mang exposure) hiện đang áp sau tone → HIGHLIGHT bị xoá sạch. Kiểm tra
grep -rn gradeThrough src/ → rỗng, fix này chưa được áp. Mục 3 dưới đây áp nó
cùng lúc với việc tách EV.
2. Quy tắc chung cho mọi pass mới
2.1 Thứ tự pass đích
rotate(+flip) → crop → matrix → [EXPOSURE linear] → tone(hl/sh/wh/bl) → cinema
→ curve → denoise/clarity/soften → draw → HDF
→ masks → heal → mosaic → grain → vignette → frame → watermark
2.2 Sai lệch có chủ ý so với web
Web đặt masks → heal → mosaic sau grain và vignette
(exportEngine.ts:708-800). Android đặt chúng trước grain/vignette. Lý do:
- Preview library chỉ lồng được shader trong chuỗi shader của ảnh — mask/heal/mosaic
phải nằm trong chuỗi đó, tức trước lớp grain/vignette vốn là node riêng
(
Viewfinder.tsx:3230-3233/3288-3291grain,renderVignette:2809,3237,3295,3348). - Grain/vignette chạy sau sẽ phủ đều lên vết sửa, nên vết heal/mosaic trông tự nhiên hơn (nhiễu hạt chồng lên đúng như mọi vùng khác).
Điều phải giữ bằng mọi giá: masks chạy trước heal (web có comment giải thích ở
gradientMask.ts:149-155 và exportEngine.ts:708-737) — vết heal phải mượn pixel đã
mang ánh sáng của mask.
2.3 Điểm chèn
| Đường | Vị trí chèn |
|---|---|
exportEngine.ts |
Ngay sau khối vignette, trước // 7. Frame. (648) |
Viewfinder.tsx |
Trong libPhoto() (3127), sau <Blur>/clarity và trước HDF glow |
nativeExport.ts / Kotlin |
Theo 4_FIX_HIGHLIGHT_ORDER.md §4.3 rồi nối tiếp |
2.4 Quy ước code
- Shader dựng theo số lượng phần tử (
healSkSL(n),mosaicSkSL(n),gradientMaskSkSL(n)) và cache effect theo count — copy mẫu webexportEngine.ts:154-210(healEffectFor/mosaicEffectFor/maskEffectFor). - Mọi pass mới phải có nhánh "tắt": uniform toàn 0 / danh sách rỗng ⇒ không tạo surface, không đổi đường vẽ cũ. Đây là điều kiện để không regress ảnh hiện tại.
- Không đổi ngữ nghĩa
evFromCamera(exportEngine.ts:35,353): ảnh chụp từ camera đã nhận AE bias phần cứng, không cộng thêm EV củaexposureCompensation.
3. EXPOSURE / EV / HIGHLIGHT
3.1 Nguồn web
shared/utils/toneShader.ts—EXPOSURE_SKSL: linear hoá sRGB (0.04045/12.92+2.4), nhânexp2(ev), encode lại. Chạy giữa ảnh đã grade và tone shader.shared/utils/colorUtils.ts:171-193:SIM_EXPOSURE_BIAS_EV = { 'leica-vivid': 0.25 },SIM_CONTRAST_BIAS = { 'mono-high-contrast': 4 }EV_PER_UNIT = 0.25→ núm EXPOSURE ±10 = ±2.5 EVexposureStops(adj, baseFilter) = (adj.exposure ?? 0) * EV_PER_UNIT + bias- Comment khẳng định: EV/biến thiên sim không nằm trong ma trận màu.
exportEngine.ts:458-476— bước 3: ma trận riêng một ảnh, rồievStops = exposureStops() + userEv.
3.2 Hiện trạng Android (sai)
src/utils/colorUtils.ts:
186 const exposure = adj.exposure + (STOCK_BIAS[baseFilter]?.exposure ?? 0);
425 const expScale = 1 + (exposure / 10) * 0.2; // -10 → 0.8x, +10 → 1.2x
426 const expOffset = (exposure / 10) * 0.15;
444 export function applyExposureGain(matrix, evStops) // gain 2^ev trong gamma-space
exposureCompensation ±3 EV (src/types/index.ts:104), áp qua applyExposureGain tại
exportEngine.ts:353-357 và nativeExport.ts:61. Kết quả: +1 EV đẩy mid-grey 0.5 →
1.0 (cháy), đúng như web đã sửa.
STOCK_BIAS['leica-vivid'] = { exposure: 2 } (colorUtils.ts:163) là đơn vị cũ: web
đo ra ≈ 0.25 EV và đã chuyển sang thang stops.
3.3 Việc phải làm
Bước A — tách EV khỏi ma trận.
colorUtils.ts:186— bỏ+ (STOCK_BIAS[baseFilter]?.exposure ?? 0); xoá khốiexpScale/expOffset(425-438) khỏigetSkiaColorMatrix; xoáapplyExposureGain(444-455) khỏi mọi caller. GiữSTOCK_BIAS.contrast(mono-high-contrast: 4) — web cũng giữSIM_CONTRAST_BIAStrong ma trận.- Đổi
STOCK_BIAS['leica-vivid']từ{ exposure: 2 }→ bỏ khỏiSTOCK_BIAS, và thêm đúng bản web:
// src/utils/colorUtils.ts
export const EV_PER_UNIT = 0.25; // núm EXPOSURE ±10 = ±2.5 EV (web: giống hệt)
const SIM_EXPOSURE_BIAS_EV: Partial<Record<BaseFilter, number>> = { 'leica-vivid': 0.25 };
/** EV người dùng (núm), biến thiên sim, và EV thô — cộng ở thang stops. */
export function exposureStops(
adj: ColorAdjustments, baseFilter: BaseFilter | undefined, userEv: number
): number {
return (adj.exposure ?? 0) * EV_PER_UNIT
+ (SIM_EXPOSURE_BIAS_EV[baseFilter ?? 'none'] ?? 0)
+ userEv;
}
Bước B — thêm EXPOSURE_SKSL vào src/utils/toneShader.ts (copy nguyên văn web,
giữ comment gốc):
export const EXPOSURE_SKSL = `
uniform shader src;
uniform float ev;
vec3 toLinear(vec3 c) {
return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c));
}
vec3 toEncoded(vec3 c) {
return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c));
}
vec4 main(vec2 xy) {
vec4 c = src.eval(xy);
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
return vec4(clamp(toEncoded(toLinear(rgb) * exp2(ev)), 0.0, 1.0), c.a);
}
`;
Bước C — áp 4_FIX_HIGHLIGHT_ORDER.md §4.1/§4.2/§4.3 nguyên trạng. Ma trận phải
thành ảnh riêng (gradeThrough) trước khi tone/cinema đọc. Đồng thời bỏ lần áp ma
trận thứ hai trong khối HDF (exportEngine.ts:529,549, Viewfinder.tsx:1369,1387).
Thứ tự sau khi sửa: matrix → exposure(linear) → tone → cinema. Exposure chèn cùng chỗ
với ma trận: gradeImage (kết quả gradeThrough) → shader exposure → tone shader con.
ev = exposureStops(adjustments, recipe.baseFilter, evFromCamera ? 0 : exposureCompensation).
Với evFromCamera thì vẫn giữ phần núm EXPOSURE + biến thiên sim (chúng chưa từng
đi qua phần cứng), chỉ bỏ exposureCompensation.
3.4 Nghiệm thu
- Parity với web trên cùng ảnh + cùng recipe (so pixel, không so cảm giác).
- Mid-grey +1 EV cho ra ≈ 0.69 encoded, không cháy 1.00.
- Bảng p95 của
4_FIX_HIGHLIGHT_ORDER.md§5: nguồn gradient dọc 0.98→0.30,HIGHLIGHT −10→ p95 0.780,HIGHLIGHT −10 + EXPOSURE +10→ p95 vẫn 0.780, midtone 0.502 → 0.722. leica-vividsau khi sửa sáng thêm ≈ 0.25 EV so với trước (đo trên cùng ảnh), và giống bản web.- Chạy đủ ba đường (export JS, preview library, Kotlin) và so ảnh với nhau.
Thay đổi hành vi phải báo trước: núm EXPOSURE từ "±10 đơn vị ≈ ×0.8…×1.2" thành "±2.5 EV". Recipe cũ đặt EXPOSURE ≠ 0 sẽ sáng/tối hơn rõ rệt. Web đã chấp nhận đánh đổi này (
colorUtils.tscomment "every recipe already saved with a non-zero EXPOSURE gets brighter with it").
4. HIGHLIGHT
4.1 Nguồn web (TONE_SKSL)
57 float hlMask = smoothstep(0.50, 1.00, t);
60 float o = t + hl * hlMask * (1.0 - t) + sh * 0.34 * shMask;
63 o -= dr * 0.18 * hlMask * t;
Knee 0.50, biên độ 1.0 (không scale), dạng (1-t) (càng gần trắng càng ít
đẩy) ⇒ monotonic, giữ được vùng sáng không cháy. shMask = 1 - smoothstep(0, 0.55, t).
4.2 Hiện trạng Android (src/utils/toneShader.ts)
131 float hlMask = smoothstep(0.65, 1.00, t);
134 float o = t + hl * 0.22 * hlMask + sh * 0.34 * shMask;
Knee 0.65, gain 0.22, cộng thẳng không có (1-t) ⇒ vùng sáng đã cháy vẫn bị đẩy lên và
thanh "chết" đúng như 4_FIX_HIGHLIGHT_ORDER.md §1 mô tả.
4.3 Việc phải làm
Thay hai dòng 131 và 134 bằng bản web. Giữ sh * 0.34 (hai bên giống nhau). Không
đổi dr (dòng 137, đã khớp). Cập nhật comment đầu file (mô tả knee 0.65) cho khớp.
FILM_TONE (toneShader.ts:299-322) đã có đủ sáu stock web hỗ trợ, gồm
classic-vivid và mono-high-contrast — không đụng.
4.4 Nghiệm thu
HIGHLIGHT −10trên ảnh có vùng trắng đẩy p95 xuống ~0.78 và không đổi midtone (so với ảnh gốc, mid lệch < 0.01).HIGHLIGHT +10rồi−10quay về đúng ảnh gốc (đối xứng, sai số ≤ 1/255).mono-high-contrast(dùnghl: 0.26) giữ nguyên diện mạo sau khi đổi knee — kiểm bằng ảnh so trước/sau.
5. WHITE / BLACK trong WB
5.1 Nguồn web
TONE_SKSL — chèn sau nhánh lightness/chroma, trước split-tone:
168 vec3 dk = 1.0 - rgb;
169 rgb = clamp(rgb + bl * 0.18 * dk * dk * dk + wh * 0.18 * rgb * rgb * rgb, 0.0, 1.0);
Trọng số cubic theo khoảng cách của kênh tới đầu mút: trong tối kênh tối nhất dịch
nhiều nhất, trong sáng kênh sáng nhất dịch nhiều nhất ⇒ kéo R/G/B về chung một toe và
shoulder — đây là động tác white balance, không phải thêm một thanh tone. Monotonic:
đạo hàm 1 − 3×0.18 = 0.46 tại đầu mút, không thể đảo chiều.
Uniform web: uniform float wh; uniform float bl; (toneShader.ts:78-79), nhận
Math.max(-1, Math.min(1, (adj.whites ?? 0) / 10)) (390-391), đưa vào mảng đúng thứ tự
khai báo sau dr, hl, sh (454), và toneIsActive thêm u.wh !== 0 || u.bl !== 0
(466-467).
shared/utils/paramDefs.ts:137,148 — thứ tự WB: temperature, tint, whites (label
WHITE), blacks (BLACK), min/max ±10, step 1, display: sign.
5.2 Việc phải làm
src/types/index.ts— thêmwhites?: number; // -10..+10vàblacks?: numbervàoColorAdjustments(interface ở dòng77), đặt ngay sautint/wbBluecho khớp web.src/utils/toneShader.ts:- thêm
uniform float wh; uniform float bl;ngay sauuniform float sh;(dòng66); - thêm khối
dkở đúng vị trí web (sau nhánh lightness/chroma, trước split-tone ở147); - thêm
wh/blvàoToneUniforms(275),getToneUniforms(325, chia 10 và kẹp ±1),toneUniformArray(388, đúng thứ tự khai báo),toneIsActive(396).
- thêm
src/utils/paramDefs.ts:109-132— thêm 2 entry vàowbsautint, labelWHITE/BLACK, ±10 step 1,display: sign,get: (a) => a.whites ?? 0,set: (v) => ({ whites: v })(tương tựblacks).chipRowcủa tab WB (AdjustmentPanel.tsx:957) tự hiện chip — không phải sửa UI.RecipeCreateModal— không cần thêm row:handleSaveđã spreadseedAdj.
5.3 Nghiệm thu
WHITE +10chỉ nâng vùng sáng, vùng tối và mid lệch < 1/255.BLACK +10chỉ nâng vùng tối (đen thành xám nhẹ), vùng sáng lệch < 1/255.- Trên ảnh xám trung tính,
WHITE −10không sinh cast màu (ba kênh dịch như nhau). - Recipe cũ (không có
whites/blacks) render y hệt trước khi sửa.
6. AUTO (auto setting)
6.1 Nguồn web
src/ui/Histogram.tsx:
const BINS = 256; // 0..255
const SAMPLE = 320; // downscale cạnh dài trước khi bin
export const AUTO_EV_MAX = 2.5;
export function autoExposureStops(lum: number[]): number {
let sum = 0, weighted = 0;
for (let i = 0; i < lum.length; i++) { sum += lum[i]; weighted += (i / (lum.length - 1)) * lum[i]; }
const avg = Math.max(0.001, sum > 0 ? weighted / sum : 0);
const stops = Math.log2(0.48 / avg);
return Math.max(-AUTO_EV_MAX, Math.min(AUTO_EV_MAX, stops));
}
Luma weights 0.2126/0.7152/0.0722 (đúng trọng số shader dùng). Target 0.48, guard
≥ 0.001, kẹp ±2.5 EV.
src/App.tsx:2497-2504 autoExposure(): đọc histogram của ảnh gốc đã load, rồi
setAdjustment({ exposureCompensation: Math.round(stops * 10) / 10 }). Chip AUTO đứng
đầu row tab LIGHT (App.tsx:2586-2587).
6.2 Việc phải làm (Android)
- Thêm
src/utils/histogram.ts:
export const AUTO_EV_MAX = 2.5;
// Pixel đã downscale (cạnh dài 320) từ readPixels — cùng mẫu horizon.ts:32-56.
export function lumaBins(rgba: Uint8Array | Float32Array, isFloat: boolean): number[] { /* 256 bin */ }
export function autoExposureStops(lum: number[]): number { /* y như web */ }
Nguồn pixel: Skia.Surface.Make + drawImageRectOptions downscale về cạnh dài 320
trên ảnh gốc đã load (rotation không đổi histogram, không cần applyPhotoRotation),
theo đúng mẫu src/utils/horizon.ts:32-56. Nhớ dispose() surface/snapshot — Hermes
không tự thu hồi buffer native (exportEngine.ts:113-121).
- Chip
AUTOđứng đầu row LIGHT:AdjustmentPanel.tsx:951hiện làchipRow([...paramChips(paramDefs.iq), groupChip('dr')])→ đổi thànhchipRow([autoChip(), ...paramChips(paramDefs.iq), groupChip('dr')]), nhãnAUTO. onPress: chạyautoExposureStops→handleUpdateAdjustments({ exposureCompensation: ev })(App.tsx:869). Đi quahandleUpdateAdjustmentsđể cócommit(true, …)⇒ một bước undo.
6.3 Nghiệm thu
- Idempotent: bấm AUTO hai lần liên tiếp ra cùng một giá trị EV (vì đọc ảnh gốc, không đọc ảnh đã áp EV).
- Ảnh phơi sáng đúng (avg ≈ 0.48) ⇒ EV ≈ 0 (|EV| ≤ 0.1).
- Ảnh tối đen ⇒ EV = +2.5 (không
Infinity, khôngNaN). - Ảnh cháy trắng ⇒ EV = −2.5.
- Undo một lần quay về EV trước khi bấm.
Ngoài phạm vi: web chỉ auto EV. Không auto contrast/white balance/tone.
7. TONE CURVE
7.1 Nguồn web — shared/utils/toneCurve.ts (203 dòng)
| Hàm/hằng | Vai trò |
|---|---|
CURVE_CHANNELS = ['rgb','r','g','b'] |
master + 3 kênh |
CURVE_LUT_SIZE = 256, IDENTITY_CURVE = [[0,0],[1,1]] |
LUT 256 mức |
CURVE_MIN_GAP = 0.02, FLAT = 0.002 |
khoảng cách điểm tối thiểu; ngưỡng "phẳng" |
curvePoints(curve, ch) |
clamp01 + sort + ghim hai đầu về (0,0) và (1,1) |
curveIsActive(curve) |
có kênh nào lệch FLAT khỏi đường chéo |
tangents(pts) |
tiếp tuyến Fritsch–Carlson (monotone cubic) |
sampleCurve(pts, x), curveLut(curve) |
master ∘ kênh → Uint8Array RGBA, A=255 |
CURVE_SKSL |
2 child (src, lut), sample v * 255 + 0.5 |
addCurvePoint |
thêm điểm trên đường (click không nhảy giá trị) |
moveCurvePoint |
hai đầu bị ghim theo trục x |
removeCurvePoint, isFlatCurve |
xoá điểm, kiểm tra phẳng |
curveLut áp theo thứ tự master ∘ channel.
7.2 UI web — src/ui/ToneCurvePanel.tsx (357), src/App.tsx
- Graph
SIZE = 224,STEPS = 64vẽ đường,HIT = 11pxbán kính bắt điểm. - Tabs
RGB / R / G / B+RESET({ }= xoá toàn bộ curve). - Histogram vẽ sau lưu đồ (nền).
- Kéo card bằng head; pointer capture khi kéo điểm.
put()xoá channel khi phẳng (không lưu curve rỗng).- Chip
TONE CURVEnằm sau group D.RANGE trong row LIGHT; amber khicurveIsActive.
7.3 Việc phải làm (Android)
src/utils/toneCurve.ts— port nguyên toán + hằng số +CURVE_SKSL.curveLuttrảUint8Array(256 * 4).- Dựng LUT thành ảnh Skia cho shader:
Skia.Image.MakeImage({ width: 256, height: 1, colorType: RGBA_8888, alphaType: Premul }, data, 256 * 4); shader con thứ hai làlutImage.makeShaderOptions(TileMode.Clamp, TileMode.Clamp, FilterMode.Linear, MipmapMode.None)— như web (exportEngine.ts:533-560, khối 3e). ColorAdjustments.toneCurve?: ToneCurve.- Chip
TONE CURVEtrong row LIGHT saugroupChip('dr')(AdjustmentPanel.tsx:951); mở panel graph khiopenParam === 'curve'; amber khicurveIsActive. - Panel: graph 224px dựng bằng Skia (
<Canvas>+<Path>+<Circle>), tabs RGB/R/G/B, RESET, histogram nền (dùng lại hàm của mục 6), kéo điểm bằng RN responder trên<View>phủ (không có pointer capture sẵn — dùngPanResponder), tap-trên-đường để thêm điểm, double-tap để xoá (CURVE_MIN_GAP 0.02, HIT 11px). - Một cử chỉ kéo = một bước undo: copy cách
handleUpdateAdjustmentsgộp (App.tsx:869,PEEK_GAP_MS = 700tại751).
7.4 Nghiệm thu
- Curve identity ⇒ ảnh không đổi (diff = 0) và pass bị bỏ (không tạo LUT/surface).
- Master kéo 1/4 → 0 làm nửa dưới tối đúng theo đường monotone;
Rkéo xuống ⇒ ảnh ngả cyan, và chỉ kênh đỏ đổi. Fritsch–Carlsonkhông overshoot: LUT monotone khi các điểm vào monotone.- Export == preview (cùng LUT).
- Rủi ro cao cần kiểm trên device: nhánh declarative
<Shader source={curveEffect}> {graded}</Shader>cần 2 child shader (chuỗi đã grade +ImageShadercủa LUT vớifit="none"vàrect={rect(0, 0, 256, 1)}). Nếu@shopify/react-native-skiakhông nhậnrectnhư mong đợi, fallback: render curve trong đường imperative cho preview tĩnh (dùng đúnggradeThroughcủa mục 3), và giữ declarative chỉ khi tone/cinema. Đường export imperative thì không có rủi ro này.
8. GRADIENT MASK
8.1 Nguồn web — shared/utils/gradientMask.ts (169 dòng)
Hằng số: MASK_KIND = { linear: 0, radial: 1 }, MASK_EXPOSURE_MAX = 5,
MASK_DEFAULT_FEATHER = 0.5, MASK_MIN = 0.01.
maskUniforms — layout (3n + 1) × vec4:
| offset | nội dung |
|---|---|
i * 4 |
[x, y, ex, ey] — gốc (pin) và đầu kéo (linear) |
(n + i) * 4 |
[rx, ry, angle, feather] — ellipse (radial) |
(2n + i) * 4 |
[exposure, contrast / 10, saturation / 10, kind] |
3n * 4 |
[width, height, 0, 0] — khung mà các phân số là của nó |
Alpha (web, maskBlock): linear a = smoothstep(0, 1, clamp(dot(pos − pin·size, d) / |d|²));
radial st = (pos − center·size) / size.x, xoay −angle, d = length(r / max(rx, ry, 1e-5)),
a = 1 − smoothstep(max(0, 1 − feather), 1, d). maskAdjust = exposure (luỹ thừa 2)
→ contrast quanh 0.5 → saturation như mix khỏi luma REC-709, rồi clamp.
Chạy trước HEAL (gradientMask.ts:149-155, exportEngine.ts:708-737).
readMasks lọc: kind hợp lệ, kẹp trong khung, và loại mask suy biến — linear có
hypot(ex−x, ey−y) > MASK_MIN, radial có rx > 0 && ry > 0.
8.2 UI web (App.tsx)
- Chip
GRADIENT MASKtrong row FX, amber khimasks.length > 0(2636). gradientChips(2750-2765):LINEAR/RADIAL/CLEAR.- Cột chỉnh mask (
3136-3155) +maskRulers(2355-2406): EXPOSURE ±5 EV step 0.1, CONTRAST ±10, SATURATION ±10, FEATHER 0-100% (chỉ radial), DELETE. - Geometry kéo:
maskPin/maskFromDrag/maskDragged(121-230). setMaskKnob(1123-1145).
8.3 Việc phải làm (Android)
src/utils/gradientMask.ts— port hằng số +readMasks+maskUniforms+maskAdjust+gradientMaskSkSL(count).- Thêm uniform
origin. Web vẽ lên canvas chính là ảnh, nênposđã là toạ độ ảnh. Preview Android códispRect(Viewfinder.tsx:1705,3331) nên shader phải dùngpos − origintrước khi chiasize. Góioriginvàovec4 sizeđang có ([width, height, originX, originY]) để không đổi layout 3n+1. GradientMaskinterface (kind, x, y, ex, ey, rx, ry, angle, feather, exposure, contrast, saturation) +ColorAdjustments.masks?: GradientMask[].- Pass: cache
maskEffectFor(n)(mẫu webexportEngine.ts:204-210), snapshot → clear → vẽ lại như web (exportEngine.ts:708-737). - UI: chip
GRADIENT MASKtrong row FX + stripLINEAR/RADIAL/CLEAR- cột chỉnh (EXPOSURE ±5 EV 0.1 / CONTRAST ±10 / SATURATION ±10 / FEATHER 0-100%
chỉ radial / DELETE). Touch: RN responder trên
<View>phủ — kéo pin/kéo đầu/đổi bán kính ellipse; vẽ outline + handle + đường chéo bằng Skia.
- cột chỉnh (EXPOSURE ±5 EV 0.1 / CONTRAST ±10 / SATURATION ±10 / FEATHER 0-100%
chỉ radial / DELETE). Touch: RN responder trên
- Drag <
MASK_MINkhi thả ⇒ huỷ, không tạo mask (khớpreadMasks).
8.4 Nghiệm thu
+2 EVtrong mask làm vùng trong mask sáng ≈ 2 stop, vùng ngoài lệch < 1/255.- Hai mask chồng nhau: mask thứ hai đọc kết quả mask thứ nhất (đúng "stack").
- Kéo thả < 0.01 không sinh mask rác trong state.
- MASK chạy trước HEAL: heal đặt trong mask mượn pixel đã mang ánh sáng mask (ảnh kiểm: vết heal trên vùng mask tối không còn sáng hơn nền).
- FEATHER = 0 (radial) ⇒ rìa cứng,
anhảy 1 → 0 tạid = 1. - Toạ độ đúng khi zoom/pan: mask dán vào ảnh, không dán vào khung nhìn (kiểm bằng cách zoom rồi so vị trí outline với pixel).
9. FIX — HEAL và MOSAIC
9.1 Nguồn web
shared/utils/heal.ts (324 dòng) — mọi hằng số phải giữ nguyên:
HEAL_FEATHER 0.85 HEAL_DEFAULT_R 0.012
SEARCH_DISTANCES [2.6, 4.2, 6.5] SEARCH_DIRS 8 RING_R 1.15 RING_PAD 1
RING_TAPS: 12 điểm vành, lấy MEDIAN INSIDE_TAPS: 16
LIGHT_GATE 20 LIGHT_WEIGHT 3 BLEND_TAPS 16
healUniforms—(2n + 1)vec4 (mỗi spot 2 vec4: vị trí/bán kính + nguồnsx,sy).healSkSL(count)—correctiontheo góc, samplepos + ps − pd.findHealSource(sample, x, y, r)— 8 hướng × 3 khoảng × bản mirror, tính điểm lệch sáng có trọng số (LIGHT_WEIGHT 3,LIGHT_GATE 20), từ chối (null) nếu không có nguồn hợp lệ.
shared/utils/mosaic.ts (104) — MOSAIC_CELL 0.02, MOSAIC_DEFAULT_R 0.05;
mosaicUniforms (n + 1) vec4, vec4 cuối [width, height, cell, 0],
cell = max(1, MOSAIC_CELL * width); mosaicSkSL(count) lấy ô
img.eval((floor(pos / cell) + 0.5) * cell), rim cứng.
shared/utils/brush.ts (26) — BRUSH_MIN_R 0.003, BRUSH_MAX_R 0.25,
BRUSH_SPACING 0.6, wheelBrushR(r, deltaY).
Types web: HealSpot { x, y, r, sx, sy }, MosaicSpot { x, y, r }.
9.2 UI web (App.tsx)
- Chip
FIXamber khi heal hoặc mosaic có spot;fixChips(2715-2745):HEAL/MOSAICkèm readout%,CLEAR,brushTool. - Chọn spot HEAL để kéo và có nút
×xoá; MOSAIC không kéo được. spotUnderbắt theo bán kínhmax(4, r * box.width).- Một cử chỉ vẽ = một bước undo.
9.3 Việc phải làm (Android)
src/utils/brush.ts,heal.ts,mosaic.ts— port nguyên hằng số và công thức, gồmfindHealSource.- Pixel sampler cho
findHealSource: dùng đúng mẫuhorizon.ts:32-56— render ảnh (đã grade, chưa mask/heal) xuống surface cạnh dài ~512,readPixels()→Uint8ArrayRGBA. Đây là "sample" đầu vào. Không dùng ảnh preview đã áp mask. - UI: chip
FIXtrong row FX + stripHEAL/CLEAR/MOSAIC/CLEAR. Android không có con lăn ⇒ thêm một row sliderSIZE(thaywheelBrushR), map tuyến tính vàoBRUSH_MIN_R..BRUSH_MAX_R. - HEAL: chạm/kéo tạo spot, chạm vào spot để chọn, kéo để dời, nút
×để xoá. MOSAIC: chạm/kéo tạo spot, không dời. Một cử chỉ =commit(true, …)một bước undo. - Shader theo count + cache (
healEffectFor(n),mosaicEffectFor(n)), snapshot → clear → vẽ lại. Thứ tự: masks → heal → mosaic (webexportEngine.ts:708-737(masks),740-775(heal),776-800(mosaic)). - Nếu
findHealSourcetrảnull: giữ nguyên pixel gốc (không bịa nguồn).
9.4 Nghiệm thu
- Xoá hạt bụi trên nền phẳng (trời, tường): vết sửa không lộ, seam viền ≤ ~2/255 so với nền (đo dọc vành spot).
- Không có nguồn hợp lệ (hạt nằm trên biên tương phản cao) ⇒ ảnh không đổi.
- Mosaic rim cứng — kiểm pixel tại rìa: nhảy bậc, không gradient.
- 30 spot vẫn render (đo thời gian; cache effect hoạt động, không dựng lại shader mỗi frame).
- Export == preview.
10. H-FLIP / V-FLIP trong ROTATE
10.1 Nguồn web — shared/utils/skiaImage.ts
84 export function flipSkImage(image, horizontal, vertical)
canvas.scale(h ? -1 : 1, v ? -1 : 1);
canvas.translate(h ? -w : 0, v ? -h : 0);
canvas.drawImage(image, 0, 0);
114 export function applyPhotoRotation(image, quarter = 0, straighten = 0, flipH = false, flipV = false)
turn (rotateSkImage90 × turns) → straighten (rotateSkImageBy) → flip
Comment web: "a flip is what the user sees, so it mirrors the photo as it stands,
whatever turn and angle are already on it" ⇒ flip áp sau turn + straighten.
rotateSkImage90 đổi chiều rộng/cao; flip không đổi dims.
10.2 Hiện trạng Android
src/utils/skiaImage.ts:69-73 — applyPhotoRotation(image, quarter = 0, straighten = 0),
không flip. Callers phải sửa:
src/utils/exportEngine.ts:241src/components/Viewfinder.tsx:461(memo) và:803(detectTilt(applyPhotoRotation(src.image, src.quarter, 0)))
10.3 Việc phải làm
src/utils/skiaImage.ts— thêmflipSkImage+ hai tham sốflipH/flipVchoapplyPhotoRotation, copy nguyên web (gồmdispose()các bản trung gian).- State:
photoFlipH/photoFlipV(boolean) cạnhphotoRotation/photoStraighten(App.tsx:104-105); thêm vàoLook(719-720),lookNow()(728),applyLook(804-812), "look đã đổi" (699-700), vàExportOptions(exportEngine.ts:91-92). - Truyền xuống: options export (
App.tsx:1753-1754),Viewfinder(App.tsx:1983-1984và2100-2101),nativeExport. - RESET của strip ROTATE phải reset cả hai flip (như đang reset turn + angle —
AdjustmentPanel.tsx:727-729). - UI:
groupDefs.rotate.options(AdjustmentPanel.tsx:718-726) thêm{ v: 'fliph', d: 'H-FLIP' }và{ v: 'flipv', d: 'V-FLIP' };onPick(730-745) toggle cờ.rotateChip()(791-803) thêm phầnH-FLIP/V-FLIPvàopartsvà vàoamberValue. Lưu ý: strip ROTATE hiện là radio (mộtvalue),valueđang làopenParam === 'straighten' ? 'straighten' : String(photoRotation)(716). Thêm hai toggle thìvaluephải ưu tiênfliph/flipvkhi cờ bật — cùng cáchstraightenđang chiếmvalue.
10.4 Nghiệm thu
H-FLIP↔V-FLIP: mirror đúng trục (kiểm 4 pixel góc đối xứng).H-FLIP + V-FLIPpixel-identical với180°(diff = 0 trên ảnh không đối xứng).- Dims không đổi sau flip (kể cả khi đang có turn 90 và straighten ≠ 0).
- Crop vẫn hợp lệ:
cropRectáp sau rotate+flip, không tràn khung. - Export == preview.
- Sống qua reload/session và qua undo (một lần bấm = một bước undo).
11. Kiểm thử chung và parity gate
- Parity gate ba đường (theo
PLAN.md): cùng ảnh + cùng recipe → export JS, preview library (ảnh tĩnh), và Kotlin (nếuEXPO_PUBLIC_NATIVE_EXPORT=1) phải khớp (đo diff pixel, ngưỡng đã dùng trước đây trong repo). - Không regress: một recipe cũ (chỉ matrix + tone + grain, không mask/heal/curve/flip) phải render y hệt trước và sau khi port. Đây là test hồi quy bắt buộc chạy đầu tiên.
- Bảng số của doc 4 phải tái lập được (mục 3.4).
- Idempotence: AUTO bấm hai lần ra cùng EV; ROTATE
AUTO(straighten) bấm hai lần ra cùng góc (đã có). - Undo: mỗi knob mới (whites/blacks/curve/mask/heal/mosaic/flip) đều phải undo được một bước và redo khớp.
- Session: mọi field mới sống qua reload (đi qua
Look/session serialise). - Perf: đo fps preview library sau khi thêm pass; mask + heal + mosaic + curve cùng
lúc là trường hợp nặng nhất. Nếu tụt quá ngưỡng, dùng phương án rẻ của doc 4 §4.2
(nhân gain bên trong SKSL) — nhưng không phá thứ tự
matrix → exposure → tone.
12. Checklist thứ tự làm và rủi ro
Thứ tự cố định — mỗi bước phải xanh test trước khi sang bước sau:
| Bước | Việc | Phụ thuộc | Rủi ro |
|---|---|---|---|
| A | §3 tách EV + EXPOSURE_SKSL + áp doc 4 (gradeThrough, bỏ ma trận lần hai ở HDF) |
— | Vuốt: đây là thay đổi chạm mọi ảnh. Làm riêng, có test hồi quy (11.2). |
| B | §4 knee HIGHLIGHT (2 dòng) | A | Thấp; nhưng phải làm sau A mới thấy đúng. |
| C | §5 WHITE/BLACK (uniform + paramDefs) | B | Thấp. Chỉ thêm uniform, không đổi layout cũ. |
| D | §10 H-FLIP/V-FLIP | — | Thấp; chạm state/session nên phải kiểm reload + undo. |
| E | §6 AUTO | A | Trung bình; readPixels tốn — đo thời gian, dispose() đủ. |
| F | §7 TONE CURVE | A | Cao ở nhánh declarative (2 child shader). Fallback imperative. |
| G | §8 GRADIENT MASK | A | Trung bình; uniform origin là điểm dễ sai toạ độ. |
| H | §9 FIX (HEAL/MOSAIC) | G | Cao nhất: pixel sampler + findHealSource + brush UI mới. |
Rủi ro tổng:
- Hermes không thu hồi buffer native — mọi surface/snapshot/image mới đều phải
dispose()tường minh (xem comment đầuexportEngine.ts:113-121). - Declarative preview không chèn pass tuỳ ý — mask/heal/mosaic/curve đều phải nằm trong chuỗi shader của ảnh; nếu một pass không lồng được, chuyển nhánh preview tĩnh sang imperative.
RuntimeEffectdựng lại mỗi frame là bug hiệu năng kinh điển — cache theo count.
13. Những gì KHÔNG port
- Per-mask invert / highlight-shadow weighting — web hiện không có; không thêm.
grainSize— web-only ở thời điểm này.- Chuột lăn đổi cỡ brush (
wheelBrushR) — Android dùng row slider SIZE. - Halation — không có trong phạm vi 7 hạng mục.
hslOn/band HSL,classic-vivid,mono-high-contrast,STOCK_BIAS.contrast— đã port xong trên nhánh này (mục 0.1). Không làm lại, chỉ điều chỉnhSTOCK_BIAS.exposurecủaleica-vividtheo mục 3.3.