Files
RecipesCam/8_EDITOR_TOOLS_PORT.md
T

38 KiB
Raw Blame History

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-v5 không chứa thư mục docker/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/hslLum trong ColorAdjustments (src/types/index.ts:77-104), HSL_BANDS/hslBandGaps/ sanitizeHslBands trong colorUtils.ts:11-115, band block trong TONE_SKSL (toneShader.ts:51-58,76-82,194) và tab HSL trong ToolRail.tsx:18. ⇒ Bỏ mọi ghi chú cũ kiểu "HSL web-only".
  • Stock classic-vivid và mono-high-contrast (c8e2b6e, 85b55dc): đã có trong BaseFilter (src/types/index.ts:69) và đã có entry FILM_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ủa SIM_EXPOSURE_BIAS_EV + SIM_CONTRAST_BIAS bê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) → drawImageRectOptions downscale → makeImageSnapshot() → readPixels() (50) (Uint8Array RGBA hoặc Float32Array). Dùng lại cho histogram/AUTO và findHealSource.
  • Undo: App.tsx commit(coalesce, mutate) (757-775, PEEK_GAP_MS = 700 tại 751), applyLook (804), handleUndo (820), Look (719-720) — thêm field vào Look là tự động sống qua undo/redo/session (lookNow tại 728).
  • Chip UI: AdjustmentPanel.tsx chipRow (867), paramChips (475), groupDefs (652), groupDefs.rotate (713-733), rotateChip() (791-803).
  • RecipeCreateModal.handleSave spread ...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:

  1. 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-3291 grain, renderVignette:2809,3237,3295,3348).
  2. 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 web exportEngine.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ủa exposureCompensation.

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ân exp2(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 EV
    • exposureStops(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ồi evStops = 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.

  1. colorUtils.ts:186 — bỏ + (STOCK_BIAS[baseFilter]?.exposure ?? 0); xoá khối expScale/expOffset (425-438) khỏi getSkiaColorMatrix; xoá applyExposureGain (444-455) khỏi mọi caller. Giữ STOCK_BIAS.contrast (mono-high-contrast: 4) — web cũng giữ SIM_CONTRAST_BIAS trong ma trận.
  2. Đổi STOCK_BIAS['leica-vivid'] từ { exposure: 2 } → bỏ khỏi STOCK_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

  1. Parity với web trên cùng ảnh + cùng recipe (so pixel, không so cảm giác).
  2. Mid-grey +1 EV cho ra ≈ 0.69 encoded, không cháy 1.00.
  3. 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.
  4. leica-vivid sau 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.
  5. 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.ts comment "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 −10 trê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 +10 rồi −10 quay về đúng ảnh gốc (đối xứng, sai số ≤ 1/255).
  • mono-high-contrast (dùng hl: 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

  1. src/types/index.ts — thêm whites?: number; // -10..+10 và blacks?: number vào ColorAdjustments (interface ở dòng 77), đặt ngay sau tint/wbBlue cho khớp web.
  2. src/utils/toneShader.ts:
    • thêm uniform float wh; uniform float bl; ngay sau uniform float sh; (dòng 66);
    • thêm khối dk ở đúng vị trí web (sau nhánh lightness/chroma, trước split-tone ở 147);
    • thêm wh/bl vào ToneUniforms (275), getToneUniforms (325, chia 10 và kẹp ±1), toneUniformArray (388, đúng thứ tự khai báo), toneIsActive (396).
  3. src/utils/paramDefs.ts:109-132 — thêm 2 entry vào wb sau tint, label WHITE/BLACK, ±10 step 1, display: sign, get: (a) => a.whites ?? 0, set: (v) => ({ whites: v }) (tương tự blacks). chipRow của tab WB (AdjustmentPanel.tsx:957) tự hiện chip — không phải sửa UI.
  4. RecipeCreateModal — không cần thêm row: handleSave đã spread seedAdj.

5.3 Nghiệm thu

  • WHITE +10 chỉ nâng vùng sáng, vùng tối và mid lệch < 1/255.
  • BLACK +10 chỉ 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 −10 khô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)

  1. 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).

  1. Chip AUTO đứng đầu row LIGHT: AdjustmentPanel.tsx:951 hiện là chipRow([...paramChips(paramDefs.iq), groupChip('dr')]) → đổi thành chipRow([autoChip(), ...paramChips(paramDefs.iq), groupChip('dr')]), nhãn AUTO.
  2. onPress: chạy autoExposureStops → handleUpdateAdjustments({ exposureCompensation: ev }) (App.tsx:869). Đi qua handleUpdateAdjustments để 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ông NaN).
  • Ả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 = 64 vẽ đường, HIT = 11px bá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 CURVE nằm sau group D.RANGE trong row LIGHT; amber khi curveIsActive.

7.3 Việc phải làm (Android)

  1. src/utils/toneCurve.ts — port nguyên toán + hằng số + CURVE_SKSL. curveLut trả Uint8Array(256 * 4).
  2. 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).
  3. ColorAdjustments.toneCurve?: ToneCurve.
  4. Chip TONE CURVE trong row LIGHT sau groupChip('dr') (AdjustmentPanel.tsx:951); mở panel graph khi openParam === 'curve'; amber khi curveIsActive.
  5. 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ùng PanResponder), tap-trên-đường để thêm điểm, double-tap để xoá (CURVE_MIN_GAP 0.02, HIT 11px).
  6. Một cử chỉ kéo = một bước undo: copy cách handleUpdateAdjustments gộp (App.tsx:869, PEEK_GAP_MS = 700 tại 751).

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; R kéo xuống ⇒ ảnh ngả cyan, và chỉ kênh đỏ đổi.
  • Fritsch–Carlson khô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 + ImageShader của LUT với fit="none" và rect={rect(0, 0, 256, 1)}). Nếu @shopify/react-native-skia không nhận rect như mong đợi, fallback: render curve trong đường imperative cho preview tĩnh (dùng đúng gradeThrough củ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 MASK trong row FX, amber khi masks.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)

  1. src/utils/gradientMask.ts — port hằng số + readMasks + maskUniforms + maskAdjust + gradientMaskSkSL(count).
  2. Thêm uniform origin. Web vẽ lên canvas chính là ảnh, nên pos đã là toạ độ ảnh. Preview Android có dispRect (Viewfinder.tsx:1705,3331) nên shader phải dùng pos − origin trước khi chia size. Gói origin vào vec4 size đang có ([width, height, originX, originY]) để không đổi layout 3n+1.
  3. GradientMask interface (kind, x, y, ex, ey, rx, ry, angle, feather, exposure, contrast, saturation) + ColorAdjustments.masks?: GradientMask[].
  4. Pass: cache maskEffectFor(n) (mẫu web exportEngine.ts:204-210), snapshot → clear → vẽ lại như web (exportEngine.ts:708-737).
  5. UI: chip GRADIENT MASK trong row FX + strip LINEAR/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.
  6. Drag < MASK_MIN khi thả ⇒ huỷ, không tạo mask (khớp readMasks).

8.4 Nghiệm thu

  • +2 EV trong 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, a nhảy 1 → 0 tại d = 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ồn sx,sy).
  • healSkSL(count) — correction theo góc, sample pos + 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 FIX amber khi heal hoặc mosaic có spot; fixChips (2715-2745): HEAL / MOSAIC kèm readout %, CLEAR, brushTool.
  • Chọn spot HEAL để kéo và có nút × xoá; MOSAIC không kéo được.
  • spotUnder bắt theo bán kính max(4, r * box.width).
  • Một cử chỉ vẽ = một bước undo.

9.3 Việc phải làm (Android)

  1. src/utils/brush.ts, heal.ts, mosaic.ts — port nguyên hằng số và công thức, gồm findHealSource.
  2. Pixel sampler cho findHealSource: dùng đúng mẫu horizon.ts:32-56 — render ảnh (đã grade, chưa mask/heal) xuống surface cạnh dài ~512, readPixels() → Uint8Array RGBA. Đây là "sample" đầu vào. Không dùng ảnh preview đã áp mask.
  3. UI: chip FIX trong row FX + strip HEAL / CLEAR / MOSAIC / CLEAR. Android không có con lăn ⇒ thêm một row slider SIZE (thay wheelBrushR), map tuyến tính vào BRUSH_MIN_R..BRUSH_MAX_R.
  4. 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.
  5. Shader theo count + cache (healEffectFor(n), mosaicEffectFor(n)), snapshot → clear → vẽ lại. Thứ tự: masks → heal → mosaic (web exportEngine.ts:708-737 (masks), 740-775 (heal), 776-800 (mosaic)).
  6. Nếu findHealSource trả 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:241
  • src/components/Viewfinder.tsx:461 (memo) và :803 (detectTilt(applyPhotoRotation(src.image, src.quarter, 0)))

10.3 Việc phải làm

  1. src/utils/skiaImage.ts — thêm flipSkImage + hai tham số flipH/flipV cho applyPhotoRotation, copy nguyên web (gồm dispose() các bản trung gian).
  2. State: photoFlipH/photoFlipV (boolean) cạnh photoRotation/photoStraighten (App.tsx:104-105); thêm vào Look (719-720), lookNow() (728), applyLook (804-812), "look đã đổi" (699-700), và ExportOptions (exportEngine.ts:91-92).
  3. Truyền xuống: options export (App.tsx:1753-1754), Viewfinder (App.tsx:1983-1984 và 2100-2101), nativeExport.
  4. RESET của strip ROTATE phải reset cả hai flip (như đang reset turn + angle — AdjustmentPanel.tsx:727-729).
  5. 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ần H-FLIP/V-FLIP vào parts và vào amberValue. Lưu ý: strip ROTATE hiện là radio (một value), value đang là openParam === 'straighten' ? 'straighten' : String(photoRotation) (716). Thêm hai toggle thì value phải ưu tiên fliph/flipv khi cờ bật — cùng cách straighten đang chiếm value.

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-FLIP pixel-identical với 180° (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

  1. Parity gate ba đường (theo PLAN.md): cùng ảnh + cùng recipe → export JS, preview library (ảnh tĩnh), và Kotlin (nếu EXPO_PUBLIC_NATIVE_EXPORT=1) phải khớp (đo diff pixel, ngưỡng đã dùng trước đây trong repo).
  2. 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.
  3. Bảng số của doc 4 phải tái lập được (mục 3.4).
  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ó).
  5. 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.
  6. Session: mọi field mới sống qua reload (đi qua Look/session serialise).
  7. 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 đầu exportEngine.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.
  • RuntimeEffect dự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ỉnh STOCK_BIAS.exposure của leica-vivid theo mục 3.3.