Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe79d75669 | |||
| f3d26e3275 | |||
| d5e5da49b7 | |||
| 588fbf77d9 |
@@ -0,0 +1,715 @@
|
||||
# 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:
|
||||
>
|
||||
> ```sh
|
||||
> 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:
|
||||
|
||||
```ts
|
||||
// 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):
|
||||
|
||||
```ts
|
||||
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`:
|
||||
|
||||
```ts
|
||||
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`:
|
||||
|
||||
```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`).
|
||||
|
||||
2. 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`.
|
||||
3. `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.
|
||||
@@ -0,0 +1,264 @@
|
||||
# 9 — RECIPE / QR: NHỮNG GÌ WEB VỪA SỬA VÀ APP ANDROID PHẢI KHỚP
|
||||
|
||||
Nhánh đích: `feat/vision-camera-v5` (base khi viết: **`f3d26e3`**). Ngày: 2026-09-29.
|
||||
Nguồn đối chiếu: nhánh **`recipes-web`**, commit **`3b92e4e`** — bản web đã chạy ở
|
||||
`http://127.0.0.1:8090`, bundle live `index-J8ISFv_z.js`.
|
||||
|
||||
Bốn commit web trong đợt này, tất cả ngày 2026-09-29:
|
||||
|
||||
```
|
||||
34f8601 studio: the develop column becomes five panels, and the four tone knobs move knots instead of channels
|
||||
c0aaa67 Studio: fold WB and FX into LIGHT, and put every develop slider on -100..+100
|
||||
bd57dd7 EXPOSURE reads in stops: -5..+5, two decimals, on an unchanged store
|
||||
3b92e4e A gradient mask can carry the LIGHT column's white balance now: COLOR TEMP and TINT
|
||||
```
|
||||
|
||||
Tài liệu 8 (`8_EDITOR_TOOLS_PORT.md`) đối chiếu web ở `6814b05`. Tài liệu này ghi **phần
|
||||
lệch phát sinh sau đó** và, quan trọng hơn cho đội Android, **cái gì trong file `.recipe`
|
||||
(QR) đã đổi / chưa đổi**. Lệnh đọc nguồn web vẫn như doc 8:
|
||||
|
||||
```sh
|
||||
git show origin/recipes-web:docker/frontend/shared/utils/gradientMask.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 0. Bảng khoảng trống
|
||||
|
||||
| # | Web vừa đổi gì | Nguồn web | Ảnh hưởng `.recipe` | Android phải làm |
|
||||
|---|----------------|-----------|---------------------|------------------|
|
||||
| 1 | EXPOSURE đọc theo **stop** (−5..+5, 2 số lẻ) | `shared/utils/paramDefs.ts:53-66`, `shared/utils/colorUtils.ts:201` | **Store không đổi** (±10 unit, 1 unit = 0.25 EV) | Chỉ UI/đơn vị hiển thị. Recipe cũ đọc y nguyên |
|
||||
| 2 | Mọi slider "develop" thành **−100..+100** | `shared/utils/paramDefs.ts:40-43,160,184` | **Store không đổi** (±10) — ×10 là của slider | Không bắt buộc. Không port thì recipe vẫn khớp |
|
||||
| 3 | Rail **10 tab → 8 tab** (WB, FX gộp vào LIGHT) | `src/ui/ToolRail.tsx:15-27` | Không | Không (layout, không vào recipe) |
|
||||
| 4 | Bốn knob tone thành **knot của một ramp** + knee giữ hue | `shared/utils/toneShader.ts:109,196-221,242-247` | Không có field mới, nhưng **look đổi** | Port theo doc 8 §4/§5 (xem §3.3) |
|
||||
| 5 | Mask gradient mang **COLOR TEMP + TINT** | `shared/utils/gradientMask.ts:63-65,116-117,166-167,201,320`, `shared/types/index.ts:139-175,228` | **Có 2 field mới trong `adjustments.masks[]`** | Port mask (doc 8 §8) + 2 field + 1 array uniform `wb` |
|
||||
|
||||
Điểm đáng chú ý cho đội Android: **bốn trong năm thay đổi không đụng tới payload**. Chỉ mục 5
|
||||
thêm field. Nhưng mục 1 + 4 làm **cùng một recipe cho ra look khác** giữa hai bên — xem §3.3.
|
||||
|
||||
---
|
||||
|
||||
## 1. Đơn vị: slider web vs store (và vs Android)
|
||||
|
||||
Web tách hẳn **đơn vị slider** khỏi **đơn vị store**: `paramDefs.ts` có `get`/`set` dịch hai
|
||||
chiều, nên một recipe viết trước đợt này vẫn đọc ra đúng look.
|
||||
|
||||
```ts
|
||||
// shared/utils/paramDefs.ts:40-43
|
||||
const HUNDRED = new Set(['contrast', 'color', 'vibrance', 'highlight', 'shadow', 'whites',
|
||||
'blacks', 'tint', 'denoise', 'clarity', 'dehaze', 'sharpening']);
|
||||
const deepen = (def) => HUNDRED.has(def.key)
|
||||
? { ...def, min: -100, max: 100, get: (a) => def.get(a) * 10, set: (v) => def.set(v / 10) }
|
||||
: def;
|
||||
```
|
||||
|
||||
```ts
|
||||
// shared/utils/paramDefs.ts:53-66
|
||||
{ key: 'exposure', min: -5, max: 5, step: 0.01, display: twoStops, // '+0.50' / '-0.25'
|
||||
get: (a) => (a.exposure ?? 0) * EV_PER_UNIT,
|
||||
set: (v) => ({ exposure: Math.round((v / EV_PER_UNIT) * 1e4) / 1e4 }) }
|
||||
```
|
||||
|
||||
| Trường | Đơn vị store (web **và** Android) | Slider web (`3b92e4e`) | Slider Android (`src/utils/paramDefs.ts`) |
|
||||
|--------|-----------------------------------|------------------------|-------------------------------------------|
|
||||
| `adjustments.exposure` | ±10 unit, 1 unit = **0.25 EV** (`EV_PER_UNIT`, `colorUtils.ts:201`) | −5..+5 **stop**, 2 số lẻ | −10..+10, step 1, `display: sign` (`paramDefs.ts:32-41`) |
|
||||
| `contrast` `highlight` `shadow` `whites` `blacks` `tint` `denoise` `clarity` `dehaze` `sharpening` `vibrance` `color` | ±10 | −100..+100 (`HUNDRED` + `deepen`) | ±10, step 1 |
|
||||
| `temperature` | 2500..10000 K | 2500..10000, step 100, `'${v}K'` (`paramDefs.ts:163-172`) | 2500..10000 (đã khớp) |
|
||||
| `masks[].exposure` | **chính là EV**, ±5 (`MASK_EXPOSURE_MAX`, `gradientMask.ts:45`) | −5..+5, step 0.1 | — (chưa có mask) |
|
||||
| `masks[].temperature` | 2500..10000 K, **default 5500** (`NEUTRAL_K`, `gradientMask.ts:63`) | 2500..10000, step 100 | — |
|
||||
| `masks[].tint` | ±10 (default 0) | −100..+100 (maskKnobRow ×10, `App.tsx:2474-2492`) | — |
|
||||
|
||||
**Hệ quả cho Android:** ba dòng đầu **không cần làm gì để recipe khớp** — store giữ nguyên
|
||||
thang cũ. Chỉ nên đổi UI nếu muốn hai bên trông giống nhau (Android `EXPOSURE` ±10 unit =
|
||||
±2.5 EV, web đọc thành −5..+5 stop; cùng một con số lưu, khác nhãn). Chú ý `EV_PER_UNIT`
|
||||
**đã export** ở cả hai phía — dùng nó, đừng viết lại `0.25`.
|
||||
|
||||
Chú ý riêng: `masks[].exposure` **không** theo thang ±10 của `adjustments.exposure`. Nó là
|
||||
EV trực tiếp vì shader đọc `pow(2.0, a.x)`. Đây là chỗ dễ sai nhất khi port.
|
||||
|
||||
---
|
||||
|
||||
## 2. Cái gì thực sự đi qua file `.recipe` / QR
|
||||
|
||||
Không đổi gì so với `7_SCAN_QR.md`: **QR chỉ chứa URL** `GET /api/photos/:id/preset.recipe`,
|
||||
file trả về là XML bọc payload xor16-v1 (`docker/backend/src/recipeFile.ts`, bản sao của
|
||||
`shared/utils/recipeShare.ts`; nguồn sự thật là bản web).
|
||||
|
||||
Hình dạng shareable — **web và Android giống hệt nhau, từng chữ**:
|
||||
|
||||
```ts
|
||||
// recipeShare.ts:59-77 (web) == src/utils/recipeShare.ts:59-77 (Android)
|
||||
const shareable = { name, baseFilter, adjustments, frameId, useGeotag };
|
||||
```
|
||||
|
||||
`adjustments` được gửi **nguyên khối**. Nghĩa là:
|
||||
|
||||
1. **Field mới tự chảy qua.** Web ghi `adjustments.masks[].temperature/tint`; không cần bump
|
||||
`version="1"`, không cần đổi `ALGORITHM`, không cần salt mới.
|
||||
2. **Android hiện giữ được field lạ.** `importRecipeXml` làm
|
||||
`{ ...DEFAULT_ADJUSTMENTS, ...raw.adjustments }` rồi **chỉ** sanitize `hslBands`
|
||||
(`recipeShare.ts:97-102`). `masks` vào bộ nhớ, sống qua `Look`/session, và **xuất lại
|
||||
nguyên vẹn** vì `exportRecipeXml` gửi lại chính `recipe.adjustments`.
|
||||
⇒ Web → Android → Web **không mất mask**. Đây là hành vi phải **khoá bằng test**, không
|
||||
phải tính năng tình cờ (xem §4.2).
|
||||
3. **Nhưng Android không render mask** (`grep -n masks src/types/index.ts` → rỗng;
|
||||
`ColorAdjustments` `src/types/index.ts:81-105` không có `masks`). Ảnh xuất từ Android vì
|
||||
thế **thiếu toàn bộ mask** dù recipe có. Đó là việc của doc 8 §8, không phải lỗi format.
|
||||
4. `RecipeCreateModal.handleSave` spread `...seedAdj`, nên mở một recipe import được rồi bấm
|
||||
LƯU **không** làm rơi `masks`. Phải giữ đúng như vậy sau khi thêm field (đừng liệt kê tay
|
||||
từng knob).
|
||||
5. Web clamp khi **đọc** (`readMasks`, `gradientMask.ts:87-121`), không clamp khi ghi. Android
|
||||
cũng nên clamp ở `readMasks` tương đương, không nhét clamp vào `importRecipeXml`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Việc Android phải làm
|
||||
|
||||
### 3.1 Tách `whiteBalanceGain` (thuần refactor, kết quả không đổi)
|
||||
|
||||
Android **đã có** công thức WB frame-wide, viết inline trong `getSkiaColorMatrix`:
|
||||
|
||||
```
|
||||
src/utils/colorUtils.ts:384-394
|
||||
const rgbTemp = kelvinToRGB(temperature);
|
||||
const tintMagenta = (tint / 10) * 0.08;
|
||||
const wbGain = normalizeGainLuma([
|
||||
rgbTemp.r * (1 + tintMagenta),
|
||||
rgbTemp.g * (1 - tintMagenta),
|
||||
rgbTemp.b * (1 + tintMagenta),
|
||||
]);
|
||||
```
|
||||
|
||||
Web đã rút đúng khối này ra thành một hàm export, **vì nó được hỏi hai lần** (frame-wide và
|
||||
từng mask). Việc cần làm: chép y nguyên chữ ký web, cho `getSkiaColorMatrix` gọi lại nó.
|
||||
|
||||
```ts
|
||||
// shared/utils/colorUtils.ts:130-137 (web) — chép sang src/utils/colorUtils.ts
|
||||
export function whiteBalanceGain(temperature: number, tint: number): { r: number; g: number; b: number } {
|
||||
const rgbTemp = kelvinToRGB(temperature); // kelvinToRGB đã có ở Android:117
|
||||
const tintMagenta = (tint / 10) * 0.08;
|
||||
return normalizeGainLuma([ // đã có ở Android:147
|
||||
rgbTemp.r * (1 + tintMagenta),
|
||||
rgbTemp.g * (1 - tintMagenta),
|
||||
rgbTemp.b * (1 + tintMagenta),
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
Gain tại 5500 K / tint 0 = `(1, 1, 1)` chính xác (`kelvinToRGB(5500) === kelvinToRGB(5500)`,
|
||||
rồi `normalizeGainLuma`). `KELVIN_TAME = 0.5` nằm trong `kelvinToRGB`, không đụng.
|
||||
|
||||
**Nghiệm thu bắt buộc:** `getSkiaColorMatrix` trước và sau refactor phải ra **cùng mảng 20
|
||||
số** với mọi cặp `(baseFilter, temperature, tint)` trong lưới thử (kể cả `wbRed/wbBlue`, kể
|
||||
cả stock mono — nhánh `isMonochromeBase` phải bỏ qua WB **trước khi** gọi hàm).
|
||||
|
||||
### 3.2 Mask gradient (doc 8 §8) + hai field mới
|
||||
|
||||
Port theo doc 8 §8, thêm đúng ba điểm so với spec cũ:
|
||||
|
||||
1. `GradientMask` thêm `temperature?: number` (2500..10000) và `tint?: number` (±10) —
|
||||
giống `shared/types/index.ts:174-175` (comment web ở `:170-173` giải thích: cùng đơn vị,
|
||||
cùng gain, chỉ khác vùng đọc).
|
||||
2. `readMasks` resolve default: `temperature = clampK(num(m.temperature, 5500))`,
|
||||
`tint = clampA(num(m.tint, 0))` (`gradientMask.ts:116-117`) — mask lưu trước đó ⇒ gain 1.
|
||||
3. Uniform: thêm **một mảng `float4` nữa**, `wb[count]`, **sau** `fx[count]`; buffer thành
|
||||
`6*n + 1 (+1 nếu air)` float4, mảng `wb` ở index `5*n + i`, `size` (width/height) ở `6*n`,
|
||||
`air` ở `6*n+1` (`gradientMask.ts:132-171, 314-322`).
|
||||
|
||||
Shader — một dòng, ngay sau EXPOSURE, trước CONTRAST:
|
||||
|
||||
```sksl
|
||||
half3 maskAdjust(half3 c, half3 wb, float4 a, float4 tone, float4 fx, half dark /*, half3 blur, float3 air*/) {
|
||||
c = c * half(pow(2.0, a.x)); // EXPOSURE, EV
|
||||
c = clamp(c * wb, half3(0.0), half3(1.0)); // ← MỚI: WB của mask
|
||||
c = (c - half(0.5)) * half(1.0 + a.y) + half(0.5);
|
||||
...
|
||||
```
|
||||
|
||||
`wb` là gain **hoisted lên JS**, không tính trong SkSL: fit Kelvin là một đường cong, viết lại
|
||||
trong shader là bản sao thứ hai của nó. Call site: `maskAdjust(c.rgb, wb[i].rgb, adj[i], tone[i], fx[i], dark)`.
|
||||
|
||||
**Chiều đã đo trên web** (đừng đảo): 2500 K = **lạnh**, 10000 K = **ấm**. Trong mask, đặt
|
||||
10000 K đo được R/B `1.250 → 1.724`; pixel ngoài mask `0.994 → 0.994` (không đổi). Đó là
|
||||
bằng chứng mask-ảnh-hưởng-cục-bộ phải tái lập được.
|
||||
|
||||
### 3.3 Cảnh báo lệch: tone **trong mask** chưa bằng tone frame-wide
|
||||
|
||||
Đây là điểm dễ hiểu sai nhất khi đọc song song doc 8 và bản web mới nhất.
|
||||
|
||||
- Frame-wide (`shared/utils/toneShader.ts:242-247`, từ `c0aaa67`): knee giữ hue, không còn
|
||||
`cg = clamp(o / max(t, 0.0004), 0.55, 1.35)`:
|
||||
```sksl
|
||||
float k = 1.0;
|
||||
float hiC = max(max(rgb.r, rgb.g), rgb.b);
|
||||
float loC = min(min(rgb.r, rgb.g), rgb.b);
|
||||
if (hiC > t) k = min(k, (1.0 - o) / (hiC - t));
|
||||
if (loC < t) k = min(k, o / (t - loC));
|
||||
rgb = clamp(vec3(o) + (rgb - vec3(t)) * k, 0.0, 1.0);
|
||||
```
|
||||
và bốn knob tone là **knot của một ramp** (`TONE_ANCHOR = 0.25`; `blMask/shMask/whMask/hlMask`
|
||||
tại `:196-199`; `a0..a4` tại `:214-221`).
|
||||
- Trong mask (`shared/utils/gradientMask.ts:218-220`): **vẫn công thức cũ**
|
||||
`half cg = clamp(lifted / max(l, 0.0004), 0.55, 1.35); c = clamp(lifted + (c - l) * cg, …)`,
|
||||
và mask **không** có knot ramp. Comment ngay trên đó (`:207-213`) vẫn nói "TONE_SKSL's own
|
||||
cg" — **comment đã cũ** sau `c0aaa67`.
|
||||
|
||||
⇒ Trên web hiện tại, HIGHLIGHT của một mask **không** phải cùng một phép move như HIGHLIGHT
|
||||
toàn khung, đúng cái điều comment hứa. Đã hỏi chủ dự án có align không — chưa có quyết định.
|
||||
Cho tới lúc đó:
|
||||
|
||||
- **Android port mask theo bản web đang chạy** (công thức `cg` cũ trong mask), nếu mục tiêu là
|
||||
parity với web **hôm nay**.
|
||||
- Nếu chủ dự án chốt align, việc cần làm là thay khối `:218-220` bằng công thức `k` giữ hue —
|
||||
và khi đó **mask phải chạy trước** phép move tone nào khác của khung (`masks` đứng **sau**
|
||||
tone frame-wide trong chuỗi pass, xem doc 8 §2.1). Ghi lại ở đây để cả hai bên cùng đổi một
|
||||
lần, tránh web sửa trước rồi Android port theo bản cũ.
|
||||
|
||||
### 3.4 Port tối thiểu nếu chỉ muốn "giữ field, chưa render"
|
||||
|
||||
Nếu ưu tiên là **không mất dữ liệu khi chia sẻ QR** trước khi kịp port shader mask, chỉ cần:
|
||||
|
||||
```ts
|
||||
// src/types/index.ts — đủ để TS không nuốt field khi ai đó truy cập
|
||||
masks?: GradientMask[];
|
||||
```
|
||||
|
||||
Ở runtime Android **đã** giữ field rồi (§2.2), nên thay đổi này là để code đọc/ghi tường minh,
|
||||
không phải để cứu dữ liệu. **Không** thêm sanitize cho `masks` ở giai đoạn này — sanitize nửa
|
||||
vời sẽ âm thầm cắt dữ liệu mà web vẫn gửi (ví dụ bỏ mask chưa có `temperature`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Tiêu chí nghiệm thu
|
||||
|
||||
1. **Round-trip QR, không mất field.** Recipe web có mask + WB (ví dụ 8000 K / tint −3):
|
||||
web xuất `.recipe` → `importRecipeXml` Android → `exportRecipeXml` Android → import lại
|
||||
web. So `adjustments.masks` sau khi bỏ salt: phải **bằng byte** bản gốc, gồm cả
|
||||
`temperature`, `tint`, `kind`, `angle`, `feather`, `exposure` (EV), và thứ tự các mask.
|
||||
2. **Không có test nào phụ thuộc vào việc "field lạ bị bỏ".** Thêm một test khoá hành vi §2.2
|
||||
(một field lạ bất kỳ trong `adjustments` phải sống qua import → export).
|
||||
3. **Clamp đúng biên.** Mask gửi `temperature: 99999` ⇒ đọc ra 10000; `2000` ⇒ 2500; `tint: 40`
|
||||
⇒ 10. Mask **không** có hai field ⇒ gain `(1,1,1)` và ảnh **không đổi một pixel** so với
|
||||
trước khi port.
|
||||
4. **Refactor WB trung tính.** `getSkiaColorMatrix` ra cùng 20 số trước/sau §3.1, trên lưới
|
||||
`(baseFilter × temperature × tint)` gồm cả stock mono.
|
||||
5. **WB của mask là cục bộ.** Đặt 10000 K: pixel **trong** mask đổi tỉ lệ R/B (ấm lên), pixel
|
||||
**ngoài** mask **không đổi** (web đo `1.250 → 1.724` trong mask, `0.994 → 0.994` ngoài mask).
|
||||
6. **Đơn vị EV của mask.** `masks[].exposure = +1` phải sáng đúng **1 stop** (không phải 1/4
|
||||
stop như nếu lỡ dùng thang ±10 của `adjustments.exposure`).
|
||||
7. **Parity ba đường** như doc 8 §11.1, và **look cũ không regress** (recipe chỉ có matrix +
|
||||
tone + grain phải render y hệt trước/sau).
|
||||
8. **Tone:** nếu chủ dự án chốt align tone trong mask (§3.3), phải có số đo hue trước/sau
|
||||
tương đương bảng trong commit `c0aaa67` — HIGHLIGHT −100 Δhue `0.00°` trên mọi patch.
|
||||
|
||||
---
|
||||
|
||||
## 5. Những gì KHÔNG làm
|
||||
|
||||
- **Không đổi `version` / `ALGORITHM` / salt** của envelope `.recipe`. `test/security.mjs`
|
||||
(backend 134/134) khoá hình dạng này.
|
||||
- **Không nhét mask vào QR**: mã QR vẫn chỉ là URL (§2). Recipe nằm ở nội dung file tải về.
|
||||
- **Không commit đi theo đợt này:** `preview`/`mask` UI của web chỉ đổi layout, không có gì
|
||||
để port sang app.
|
||||
- **Không port thang −100..+100** sang Android chỉ để "giống web" — đó là lựa chọn UI, store
|
||||
hai bên đã khớp; đổi thì phải đổi cả `paramDefs`, cả chip readout, cả test ảnh.
|
||||
@@ -29,6 +29,10 @@ import type { PickedPhotoAsset } from './src/utils/photoGps';
|
||||
import { PARAM_DEFS } from './src/utils/paramDefs';
|
||||
import { LITE_FAVORITE_LIMIT, LITE_MARK, LITE_RECIPE_LIMIT, proLookInUse } from './src/utils/entitlement';
|
||||
import { initProUnlock, useProUnlock } from './src/utils/proUnlock';
|
||||
import { readJpegIso } from './src/utils/exifWrite';
|
||||
import { readPhotoBytes, devicePerf } from './src/utils/photoMeta';
|
||||
import { BURST_FRAMES, BURST_MIN_ISO, mergeBurst } from './src/utils/burstMerge';
|
||||
import { canBurst } from './src/utils/deviceFloor';
|
||||
import UltraWide from './modules/recipescam-ultrawide';
|
||||
import RecipescamExport from './modules/recipescam-export';
|
||||
import VolumeShutter from './modules/recipescam-volume';
|
||||
@@ -56,13 +60,6 @@ const CAMERA_SETTINGS_KEY = 'camera_settings';
|
||||
// decides which engine ships.
|
||||
const NATIVE_EXPORT = process.env.EXPO_PUBLIC_NATIVE_EXPORT === '1';
|
||||
|
||||
// B1: the shutter's CameraX capture mode. 'balanced' ships as the default —
|
||||
// measured on the 12S Ultra at the same 3000x4000, a frame takes ~340 ms
|
||||
// against ~540 ms for 'quality', with no resolution loss, because this device
|
||||
// has ZSL (supportsSpeedQualityPrioritization) and 'balanced'/speed ride it.
|
||||
// EXPO_PUBLIC_CAPTURE_MODE still overrides, but ONLY in dev bundles: Metro
|
||||
// inlines EXPO_PUBLIC_*, the release HBC build does not (B0 finding) — never
|
||||
// gate shipped behaviour on it.
|
||||
// How long a shutter may wait for a GPS fix before exporting without it. The
|
||||
// photo is never held hostage by the receiver: indoors a Balanced fix takes
|
||||
// 20s+ (measured 25s on the V2430), which is the whole reason a capture used to
|
||||
@@ -78,10 +75,6 @@ const loadRenderer = (native: boolean) =>
|
||||
native
|
||||
? import('./src/utils/nativeExport').then((m) => m.processAndExportPhotoNative)
|
||||
: import('./src/utils/exportEngine').then((m) => m.processAndExportPhoto);
|
||||
const CAPTURE_MODE =
|
||||
process.env.EXPO_PUBLIC_CAPTURE_MODE === 'speed' || process.env.EXPO_PUBLIC_CAPTURE_MODE === 'quality'
|
||||
? process.env.EXPO_PUBLIC_CAPTURE_MODE
|
||||
: 'balanced';
|
||||
|
||||
export default function App() {
|
||||
const [mode, setMode] = useState<'camera' | 'library'>('camera');
|
||||
@@ -342,7 +335,17 @@ export default function App() {
|
||||
// Full-resolution photo output — attached to the live <Camera> in Viewfinder.
|
||||
// Photo capture is resolution-independent from the Skia preview output.
|
||||
const photoOutput = usePhotoOutput({
|
||||
qualityPrioritization: CAPTURE_MODE,
|
||||
// LATENCY, measured: 'balanced' is CAPTURE_MODE_MINIMIZE_LATENCY (one
|
||||
// frame, no CameraX still-image post-processing); 'quality' is
|
||||
// CAPTURE_MODE_MAXIMIZE_QUALITY, which adds in-camera multi-frame noise
|
||||
// reduction plus reprocessing. Same scene at ISO ~410 on the 12S Ultra,
|
||||
// 3 shots per arm: in-camera START->onImageCaptured 291 ms vs 499 ms,
|
||||
// tap->file on disk 797 ms vs 1025 ms (-22%), and the two JPEG sets were
|
||||
// indistinguishable (flat-area sigma 1.08 vs 1.09, edge energy -3%).
|
||||
// Dark scenes are handled by this app's own burst + merge below (ISO >=
|
||||
// 800), so CameraX MFNR would only ever be a second, redundant pass.
|
||||
// Revert to 'quality' if a high-ISO A/B ever shows merge losing to MFNR.
|
||||
qualityPrioritization: 'balanced',
|
||||
});
|
||||
// Second photo output for RAW DNG sidecars (RATIO panel → RAW on). Kept as
|
||||
// a live output only while rawEnabled — CameraX cannot bind two ImageCapture
|
||||
@@ -1495,6 +1498,56 @@ export default function App() {
|
||||
)
|
||||
).filePath;
|
||||
}
|
||||
// Low light, hand-held: one frame is mostly sensor noise, and no OEM stack is
|
||||
// reachable from here (the extension probe found the vendor pipelines closed to a
|
||||
// third-party client). So take the frames and average them ourselves. Four ways
|
||||
// out, checked before an extra exposure is spent: the LED is the light source
|
||||
// (a burst would ride it while it recharges), the ultra-wide still came from the
|
||||
// native Camera2 session, a held burst is the user asking for ONE frame, and RAW
|
||||
// needs the second ImageCapture output CameraX will not bind alongside this one.
|
||||
// canBurst: on a 1 GB floor device (deviceFloor.ts) three raws plus the
|
||||
// alignment rasters are seconds of shutter lag, so the plain frame stands.
|
||||
// Set when the low-light burst below starts its merge. The merge is not
|
||||
// awaited on the shutter path: the export task awaits it instead, so the
|
||||
// next shot is accepted while the frames are still being averaged.
|
||||
let burstMerge: Promise<string | null> | null = null;
|
||||
if (!ultraWideShot && !opts?.burst && !rawEnabled && !(flashAvailable && flashMode !== 'off') && canBurst(devicePerf().ramMb)) {
|
||||
try {
|
||||
// ISO is the only signal this app can read for ambient light: the controller
|
||||
// reports 0.0 for every still, so it comes off the file the shutter just
|
||||
// wrote. No tag, no burst.
|
||||
const jpeg = await readPhotoBytes(toUri(filePath));
|
||||
const iso = jpeg ? readJpegIso(jpeg) : null;
|
||||
if (iso !== null && iso >= BURST_MIN_ISO) {
|
||||
const frames = [filePath];
|
||||
for (let i = 1; i < BURST_FRAMES; i++) {
|
||||
const extra = await photoOutput.capturePhotoToFile(
|
||||
// No second shutter sound and no flash: three clicks for one photo
|
||||
// reads as a malfunction.
|
||||
{ enableShutterSound: false, flashMode: 'off' },
|
||||
{},
|
||||
);
|
||||
frames.push(extra.filePath);
|
||||
}
|
||||
// Averaging three 12 MP frames costs ~1.4 s measured on the 12S Ultra
|
||||
// (three decodes, two alignment stages, one draw pass, one JPEG
|
||||
// encode) and all three frames are already in hand, so the merge
|
||||
// rides the FIFO export queue below instead of the shutter path:
|
||||
// the next shot is accepted as soon as the exposures land, and only
|
||||
// the export of this photo waits on the average. A refused merge
|
||||
// (null: drift too large, a frame unreadable) keeps the shutter frame.
|
||||
// ponytail: a merge in the queue and a following capture can each
|
||||
// hold a 12 MP frame (~48 MB); the FIFO serializes the merge against
|
||||
// the render, not against the camera. Re-serialize if a low-RAM body
|
||||
// starts swapping.
|
||||
burstMerge = mergeBurst(frames);
|
||||
}
|
||||
} catch (burstError) {
|
||||
// A failed burst costs two wasted exposures, never the photo.
|
||||
console.warn('Low-light burst skipped:', burstError);
|
||||
}
|
||||
}
|
||||
|
||||
const sourceUri = toUri(filePath);
|
||||
|
||||
// RAW DNG sidecar: fire the second exposure immediately after the JPEG
|
||||
@@ -1574,7 +1627,10 @@ export default function App() {
|
||||
frameWindowZoom: frameZoom,
|
||||
};
|
||||
const paint = await loadRenderer(nativeExport);
|
||||
return paint(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options);
|
||||
// The burst average lands here, in the queue: the export waits for it,
|
||||
// the shutter did not. sourceUri is the frame the shutter already wrote.
|
||||
const source = burstMerge ? toUri((await burstMerge) ?? filePath) : sourceUri;
|
||||
return paint(source, recipeArgs, selectedFrame, useGeotag, captureGps, options);
|
||||
};
|
||||
enqueueExport(async () => {
|
||||
try {
|
||||
|
||||
@@ -82,6 +82,8 @@ Exit: user QA đạt → mặc định native; legacy chỉ fallback.
|
||||
- Nhắc: nếu npm install chạy lại → node_modules vision-camera về pristine, áp lại patch:
|
||||
`cd RecipesCam && git apply patches/vision-camera-shutter-silent.patch`
|
||||
(artifact = diff `HybridPhotoOutput.kt`: shutter OFF thật silent — bỏ OR mustPlayShutterSound).
|
||||
`cd RecipesCam && git apply patches/vision-camera-isp-off.patch`
|
||||
(artifact = 3 hunk, 2 file: `HybridPhotoOutput.kt` cho mọi still JPEG, `modules/recipescam-ultrawide/.../UltraWideModule.kt` cho đường 0.5x — cùng cặp cờ NOISE_REDUCTION_MODE + EDGE_MODE của ISP = OFF).
|
||||
|
||||
## Rủi ro / ghi chú
|
||||
- Local module phải bắt chước đúng cấu trúc expo package (đã đọc expo-media-library: `plugins { id 'com.android.library'; id 'expo-module-gradle-plugin' }`, expo-module.config.json khai báo class Kotlin; autolinking quét `./modules`).
|
||||
|
||||
+15
@@ -1,5 +1,6 @@
|
||||
package com.locphamtran.recipescamera.export
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -206,6 +207,20 @@ class RecipescamExportModule : Module() {
|
||||
)
|
||||
}
|
||||
|
||||
// Floor-device gate (src/utils/deviceFloor.ts): total RAM decides whether a
|
||||
// full-resolution surface chain and the 3-frame burst fit. One cheap query
|
||||
// and no permission; a platform that will not answer returns 0, which JS
|
||||
// reads as "unknown" and keeps the full-quality path.
|
||||
Function("devicePerf") { ->
|
||||
val am = appContext.reactContext?.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
|
||||
val mem = ActivityManager.MemoryInfo()
|
||||
val known = if (am != null) { am.getMemoryInfo(mem); true } else false
|
||||
mapOf(
|
||||
"ramMb" to (if (known) (mem.totalMem / (1024L * 1024L)).toInt() else 0),
|
||||
"cores" to Runtime.getRuntime().availableProcessors(),
|
||||
)
|
||||
}
|
||||
|
||||
// P0 spike harness: copy a bundled drawable asset (e.g. "wallframe", the
|
||||
// 3117x4000 artwork — representative 12MP decode) into cacheDir and hand the
|
||||
// real file path back to JS, which then runs decodeEncodeAsync on it. This
|
||||
|
||||
@@ -105,6 +105,13 @@ export interface RecipescamExportModule {
|
||||
* codename, marketing name, manufacturer, model. Empty strings when absent.
|
||||
*/
|
||||
deviceInfo(): { device: string; marketName: string; manufacturer: string; model: string };
|
||||
/**
|
||||
* Device facts the capture and render paths budget against (see
|
||||
* src/utils/deviceFloor.ts): total RAM in MB and CPU core count. `ramMb` is 0
|
||||
* when the platform refused the read — callers treat 0 as "unknown", never as
|
||||
* a floor device, so an unmeasurable phone keeps the full-quality path.
|
||||
*/
|
||||
devicePerf(): { ramMb: number; cores: number };
|
||||
|
||||
/**
|
||||
* The image a system share handed this launch (AndroidManifest
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
diff --git a/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/outputs/HybridPhotoOutput.kt b/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/outputs/HybridPhotoOutput.kt
|
||||
index 883fa50..e0248e8 100644
|
||||
--- a/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/outputs/HybridPhotoOutput.kt
|
||||
+++ b/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/outputs/HybridPhotoOutput.kt
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.margelo.nitro.camera.hybrids.outputs
|
||||
|
||||
+import android.hardware.camera2.CameraMetadata
|
||||
+import android.hardware.camera2.CaptureRequest
|
||||
import android.media.MediaActionSound
|
||||
import android.util.Log
|
||||
+import androidx.camera.camera2.interop.Camera2Interop
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageCapture
|
||||
import androidx.camera.core.resolutionselector.ResolutionSelector
|
||||
@@ -108,6 +111,21 @@
|
||||
// Capture Mode (Quality, Speed, ...)
|
||||
val captureMode = options.qualityPrioritization.toCaptureMode()
|
||||
setCaptureMode(captureMode)
|
||||
+
|
||||
+ // The ISP's own noise reduction and edge enhancement, off for every
|
||||
+ // still (patches/vision-camera-isp-off.patch, the shutter-silent
|
||||
+ // patch's twin). The vendor pair is tuned to hide sensor noise in a
|
||||
+ // screen-sized JPEG and charges the fine detail for it; this app
|
||||
+ // sharpens and grains the file itself with Skia, so it would rather
|
||||
+ // have the texture and the noise than the wax. Camera2Interop because
|
||||
+ // the CameraX builders have no typed setter for either key. DNG never
|
||||
+ // passes through the ISP, so the option is a no-op on the RAW output.
|
||||
+ Camera2Interop.Extender(this)
|
||||
+ .setCaptureRequestOption(
|
||||
+ CaptureRequest.NOISE_REDUCTION_MODE,
|
||||
+ CameraMetadata.NOISE_REDUCTION_MODE_OFF,
|
||||
+ )
|
||||
+ .setCaptureRequestOption(CaptureRequest.EDGE_MODE, CameraMetadata.EDGE_MODE_OFF)
|
||||
|
||||
if (options.previewImageTargetSize != null) {
|
||||
setPostviewEnabled(true)
|
||||
diff --git a/modules/recipescam-ultrawide/android/src/main/java/com/locphamtran/recipescamera/ultrawide/UltraWideModule.kt b/modules/recipescam-ultrawide/android/src/main/java/com/locphamtran/recipescamera/ultrawide/UltraWideModule.kt
|
||||
index c4bf38d..39308e3 100644
|
||||
--- a/modules/recipescam-ultrawide/android/src/main/java/com/locphamtran/recipescamera/ultrawide/UltraWideModule.kt
|
||||
+++ b/modules/recipescam-ultrawide/android/src/main/java/com/locphamtran/recipescamera/ultrawide/UltraWideModule.kt
|
||||
@@ -8,6 +8,7 @@ import android.hardware.camera2.CameraCaptureSession
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.hardware.camera2.CameraDevice
|
||||
import android.hardware.camera2.CameraManager
|
||||
+import android.hardware.camera2.CameraMetadata
|
||||
import android.hardware.camera2.CaptureRequest
|
||||
import android.hardware.camera2.CaptureResult
|
||||
import android.hardware.camera2.TotalCaptureResult
|
||||
@@ -554,6 +555,15 @@ class UltraWidePreviewView(context: Context, appContext: AppContext) : ExpoView(
|
||||
req.set(CaptureRequest.JPEG_ORIENTATION, sensorOrientation)
|
||||
req.set(CaptureRequest.FLASH_MODE, flashMode(flash))
|
||||
req.set(CaptureRequest.CONTROL_AE_MODE, flashAeMode(flash))
|
||||
+ // The ISP's own noise reduction and edge enhancement, off for the
|
||||
+ // ultra-wide still too (patches/vision-camera-isp-off.patch), so 0.5x
|
||||
+ // lands in the same file as the other lenses: the app sharpens and
|
||||
+ // grains with Skia, it does not want the vendor wax.
|
||||
+ req.set(
|
||||
+ CaptureRequest.NOISE_REDUCTION_MODE,
|
||||
+ CameraMetadata.NOISE_REDUCTION_MODE_OFF
|
||||
+ )
|
||||
+ req.set(CaptureRequest.EDGE_MODE, CameraMetadata.EDGE_MODE_OFF)
|
||||
// The live preview carries the EV as a hardware AE bias (see setEv); the
|
||||
// file must not, or the lens bias and the export's software gain would
|
||||
// stack into a double EV. Pin the bias off and let the precapture trigger
|
||||
@@ -0,0 +1,107 @@
|
||||
// Self-check for the burst alignment maths — the one part of the low-light
|
||||
// merge that is decided by arithmetic rather than by Skia, and the part a wrong
|
||||
// answer turns into a visibly doubled photo. Run it after touching
|
||||
// burstAlign.ts (or the constants that feed it):
|
||||
// node src/utils/burstAlign.check.ts
|
||||
// Exits non-zero on the first failed expectation.
|
||||
|
||||
import { bestShift, nearest2D, profilesOf, sadAt, type Pixels } from './burstAlign.ts';
|
||||
|
||||
function check(name: string, ok: boolean, detail = ''): void {
|
||||
if (!ok) {
|
||||
console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`ok ${name}`);
|
||||
}
|
||||
|
||||
// Deterministic structure (a hash, not Math.random) so a failure is repeatable.
|
||||
function texture(w: number, h: number, seed: number): Pixels {
|
||||
const px = new Uint8Array(w * h * 4);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const n = Math.sin(x * 12.9898 + y * 78.233 + seed) * 43758.5453;
|
||||
const v = 96 + Math.floor((n - Math.floor(n)) * 128);
|
||||
const p = (y * w + x) * 4;
|
||||
px[p] = v;
|
||||
px[p + 1] = v;
|
||||
px[p + 2] = v;
|
||||
px[p + 3] = 255;
|
||||
}
|
||||
}
|
||||
return px;
|
||||
}
|
||||
|
||||
// `a` cut down to what a camera would see of `b` after a shift of (dx, dy).
|
||||
function shifter(a: Pixels, w: number, h: number, dx: number, dy: number): Pixels {
|
||||
const px = new Uint8Array(w * h * 4);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const sx = x - dx;
|
||||
const sy = y - dy;
|
||||
if (sx < 0 || sy < 0 || sx >= w || sy >= h) continue;
|
||||
const d = (y * w + x) * 4;
|
||||
const s = (sy * w + sx) * 4;
|
||||
px[d] = a[s];
|
||||
px[d + 1] = a[s + 1];
|
||||
px[d + 2] = a[s + 2];
|
||||
px[d + 3] = 255;
|
||||
}
|
||||
}
|
||||
return px;
|
||||
}
|
||||
|
||||
const W = 192;
|
||||
const H = 128;
|
||||
const ref = texture(W, H, 1);
|
||||
const refProfile = profilesOf(ref, W, H);
|
||||
|
||||
// 1) A known shift comes back out of the profiles, as the offset that draws the
|
||||
// frame back onto the reference: content moved right by 7 is pulled left by 7.
|
||||
{
|
||||
const cur = shifter(ref, W, H, 7, -4);
|
||||
const curProfile = profilesOf(cur, W, H);
|
||||
const dx = bestShift(refProfile.cols, curProfile.cols, 12);
|
||||
const dy = bestShift(refProfile.rows, curProfile.rows, 12);
|
||||
check('bestShift undoes a (7, -4) move', dx === -7 && dy === 4, `got (${dx}, ${dy})`);
|
||||
}
|
||||
|
||||
// 2) The 2D confirmation returns the translation, not the seed, when they agree.
|
||||
{
|
||||
const cur = shifter(ref, W, H, -5, 3);
|
||||
const curProfile = profilesOf(cur, W, H);
|
||||
const seedX = bestShift(refProfile.cols, curProfile.cols, 12);
|
||||
const seedY = bestShift(refProfile.rows, curProfile.rows, 12);
|
||||
const best = nearest2D(ref, cur, W, H, seedX, seedY, 2);
|
||||
check('nearest2D confirms it on the pixels', best.x === 5 && best.y === -3, `got (${best.x}, ${best.y})`);
|
||||
}
|
||||
|
||||
// 3) A featureless pair — an unreadable scene, or a flat wall in the dark —
|
||||
// merges unshifted instead of being pushed somewhere by noise.
|
||||
{
|
||||
const flat: Pixels = new Uint8Array(W * H * 4).fill(200);
|
||||
for (let i = 3; i < flat.length; i += 4) flat[i] = 255;
|
||||
const best = nearest2D(flat, flat, W, H, 9, 9, 2);
|
||||
check('flat pair stays at (0, 0)', best.x === 0 && best.y === 0, `got (${best.x}, ${best.y})`);
|
||||
}
|
||||
|
||||
// 4) A candidate that keeps less than half the frame cannot win on a flat error.
|
||||
{
|
||||
const a: Pixels = new Uint8Array(W * H * 4).fill(10);
|
||||
const b: Pixels = new Uint8Array(W * H * 4).fill(10);
|
||||
check('disjoint candidate rejected', sadAt(a, b, W, H, W, 0) === Infinity);
|
||||
check('full-overlap candidate scored', sadAt(a, b, W, H, 0, 0) === 0);
|
||||
}
|
||||
|
||||
// 5) The fine stage searches around the coarse estimate: the true offset is
|
||||
// found from a seed that is already close, and a seed that is wrong can only
|
||||
// pull the answer as far as the window allows — never back to the truth.
|
||||
{
|
||||
const cur = shifter(ref, W, H, 2, 0);
|
||||
const curProfile = profilesOf(cur, W, H);
|
||||
check('expect-centred search finds -2 from a seed of -2', bestShift(refProfile.cols, curProfile.cols, 1, -2) === -2);
|
||||
const off = bestShift(refProfile.cols, curProfile.cols, 1, 5);
|
||||
check('a wrong seed stays inside its window', Math.abs(off - 5) <= 1, `got ${off}`);
|
||||
}
|
||||
|
||||
console.log('\nburstAlign: all checks passed');
|
||||
@@ -0,0 +1,135 @@
|
||||
// Alignment maths for the low-light hand-held burst. Pure — no Skia, no React
|
||||
// Native — so the check beside it runs on plain node:
|
||||
// node src/utils/burstAlign.check.ts
|
||||
//
|
||||
// Frames taken within a second of each other differ by translation only (the
|
||||
// zoom and the lens do not change between them), so the estimate is one shift
|
||||
// per axis, read off the row and column luminance PROFILES of a downscaled
|
||||
// frame. Correlating two profiles costs O(w+h) per candidate offset against
|
||||
// O(w*h) for 2D block matching — the difference between a few hundred ms and
|
||||
// minutes of JS thread for a 12MP burst. The 2D check then confirms the profile
|
||||
// estimate against the actual pixels, so a periodic scene (blinds, railings,
|
||||
// a tiled wall) cannot lock the estimator onto the wrong repetition — and (0,0)
|
||||
// stays a candidate throughout, so a scene neither stage can read merges
|
||||
// unshifted rather than wrongly shifted.
|
||||
|
||||
export interface Profiles {
|
||||
/** Mean luminance of each row, top to bottom. */
|
||||
rows: Float32Array;
|
||||
/** Mean luminance of each column, left to right. */
|
||||
cols: Float32Array;
|
||||
}
|
||||
|
||||
/** RGBA pixels as they come out of Skia's readPixels (8-bit or float). */
|
||||
export type Pixels = Uint8Array | Float32Array;
|
||||
|
||||
// Rec.709 luma on the unmanaged 8-bit values. A monotone luminance proxy is all
|
||||
// a correlation needs — this is not a colour-managed Y.
|
||||
const LUMA_R = 0.2126;
|
||||
const LUMA_G = 0.7152;
|
||||
const LUMA_B = 0.0722;
|
||||
|
||||
export function profilesOf(rgba: Pixels, w: number, h: number): Profiles {
|
||||
const rows = new Float32Array(h);
|
||||
const cols = new Float32Array(w);
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w * 4;
|
||||
let sum = 0;
|
||||
for (let x = 0; x < w; x++) {
|
||||
const p = row + x * 4;
|
||||
const l = rgba[p] * LUMA_R + rgba[p + 1] * LUMA_G + rgba[p + 2] * LUMA_B;
|
||||
sum += l;
|
||||
cols[x] += l;
|
||||
}
|
||||
rows[y] = sum / w;
|
||||
}
|
||||
for (let x = 0; x < w; x++) cols[x] /= h;
|
||||
return { rows, cols };
|
||||
}
|
||||
|
||||
/**
|
||||
* The shift of `cur` against `ref`, in profile samples, searched over
|
||||
* `expect ± max` and scored by mean absolute difference over the overlap.
|
||||
* Mean, not sum, so a candidate that only matches on a handful of pixels is not
|
||||
* favoured; overlaps shorter than half the signal are rejected outright.
|
||||
* Convention: `ref[i] ≈ cur[i - shift]`.
|
||||
*/
|
||||
export function bestShift(ref: Float32Array, cur: Float32Array, max: number, expect = 0): number {
|
||||
const center = Math.round(expect);
|
||||
const lo = Math.max(-(cur.length - 1), center - max);
|
||||
const hi = Math.min(ref.length - 1, center + max);
|
||||
let best = center;
|
||||
let bestErr = Infinity;
|
||||
for (let s = lo; s <= hi; s++) {
|
||||
const from = Math.max(0, s);
|
||||
const to = Math.min(ref.length, cur.length + s);
|
||||
if (to - from < ref.length / 2) continue;
|
||||
let err = 0;
|
||||
for (let i = from; i < to; i++) err += Math.abs(ref[i] - cur[i - s]);
|
||||
err /= to - from;
|
||||
if (err < bestErr) {
|
||||
bestErr = err;
|
||||
best = s;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mean absolute luminance difference between two downscaled frames at a
|
||||
* candidate shift, over the overlap — the same convention as `bestShift`
|
||||
* (`a[y][x] ≈ b[y - dy][x - dx]`). `Infinity` when the candidate keeps less than
|
||||
* half the frame: a nearly disjoint pair would otherwise score a suspiciously
|
||||
* low error on whatever flat area it did keep.
|
||||
*/
|
||||
export function sadAt(a: Pixels, b: Pixels, w: number, h: number, dx: number, dy: number): number {
|
||||
const x0 = Math.max(0, dx);
|
||||
const y0 = Math.max(0, dy);
|
||||
const x1 = Math.min(w, w + dx);
|
||||
const y1 = Math.min(h, h + dy);
|
||||
if (x1 <= x0 || y1 <= y0) return Infinity;
|
||||
if ((x1 - x0) * (y1 - y0) < (w * h) / 2) return Infinity;
|
||||
let sum = 0;
|
||||
for (let y = y0; y < y1; y++) {
|
||||
const ra = (y * w + x0) * 4;
|
||||
const rb = ((y - dy) * w + (x0 - dx)) * 4;
|
||||
for (let x = x0; x < x1; x++) {
|
||||
const pa = ra + (x - x0) * 4;
|
||||
const pb = rb + (x - x0) * 4;
|
||||
const la = a[pa] * LUMA_R + a[pa + 1] * LUMA_G + a[pa + 2] * LUMA_B;
|
||||
const lb = b[pb] * LUMA_R + b[pb + 1] * LUMA_G + b[pb + 2] * LUMA_B;
|
||||
sum += Math.abs(la - lb);
|
||||
}
|
||||
}
|
||||
return sum / ((x1 - x0) * (y1 - y0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a profile estimate on the pixels: search `±radius` around it, keeping
|
||||
* (0,0) in the running, and return the best 2D match. This is what makes a
|
||||
* profile lock onto a real translation rather than onto a repeated pattern —
|
||||
* and what makes "no shift" win whenever the pixels do not support a shift.
|
||||
*/
|
||||
export function nearest2D(
|
||||
ref: Pixels,
|
||||
cur: Pixels,
|
||||
w: number,
|
||||
h: number,
|
||||
seedX: number,
|
||||
seedY: number,
|
||||
radius: number
|
||||
): { x: number; y: number } {
|
||||
let best = { x: 0, y: 0 };
|
||||
let bestErr = sadAt(ref, cur, w, h, 0, 0);
|
||||
for (let dy = seedY - radius; dy <= seedY + radius; dy++) {
|
||||
for (let dx = seedX - radius; dx <= seedX + radius; dx++) {
|
||||
if (dx === 0 && dy === 0) continue;
|
||||
const err = sadAt(ref, cur, w, h, dx, dy);
|
||||
if (err < bestErr) {
|
||||
bestErr = err;
|
||||
best = { x: dx, y: dy };
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// Low-light hand-held burst: N exposures of the same scene averaged into one
|
||||
// JPEG, aligned first. This is the cheap end of what the stock camera does — an
|
||||
// OEM stacks its own frames inside the HAL (and the extension probe showed no
|
||||
// third-party client is served on the 12S/14 Ultra), so the only way to a
|
||||
// multi-frame denoise from an app is to take the frames itself. The exposures
|
||||
// come from the same photo output the shutter already uses, and the average
|
||||
// buys about sqrt(N) off the sensor noise: ~1.7x for three frames, which is the
|
||||
// difference between an ISO 1900 frame and one shot at ISO 700.
|
||||
//
|
||||
// Cost, all of it on the shutter path in the dark: N-1 extra exposures (~0.5s
|
||||
// each in QUALITY mode), two decodes per frame (one for the alignment rasters,
|
||||
// one for the merge draw — deliberately never more than one 12MP frame in
|
||||
// memory at a time, see exportEngine's surface note) and one JPEG encode.
|
||||
|
||||
import { Skia, AlphaType, BlendMode, ColorType, ImageFormat, type SkImage, type SkSurface } from '@shopify/react-native-skia';
|
||||
import { File } from 'expo-file-system';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { bestShift, nearest2D, profilesOf, type Profiles, type Pixels } from './burstAlign';
|
||||
import { stampExifOnFile } from './photoMeta';
|
||||
|
||||
// The ISO at which one frame stops being enough. Above it the shutter output is
|
||||
// dominated by sensor noise — the 12S Ultra metered ISO 1918 on a dim indoor
|
||||
// shot, where the stock camera stacks and the app could not. Below it there is
|
||||
// nothing to win, so the plain (and much faster) capture stands.
|
||||
// ponytail: one threshold, no shutter-speed term — CameraX exposes no exposure
|
||||
// reading to a third-party app. Make it adaptive if ISO 800 ever misjudges a body.
|
||||
export const BURST_MIN_ISO = 800;
|
||||
// Total exposures. Three, not the four or five a HAL would stack: 1/3 is exactly
|
||||
// representable as an 8-bit paint alpha (85/255), so the average carries no gain
|
||||
// error, and two extra frames are as much hand-held drift as the merge tolerates.
|
||||
export const BURST_FRAMES = 3;
|
||||
|
||||
// Alignment scales: coarse (1/8) finds the offset over a wide range, fine (1/4)
|
||||
// trims it to ±2 full-resolution pixels. Both are CPU surfaces — a downscaled
|
||||
// raster is cheap there, and a GPU readback is not.
|
||||
const COARSE_DIV = 8;
|
||||
const FINE_DIV = 4;
|
||||
const COARSE_RADIUS = 10; // ±80 px of a 3000 px frame
|
||||
const COARSE_REFINE = 2; // 2D neighbourhood of the profile estimate, ±16 px
|
||||
const FINE_RADIUS = 5; // ±20 px around the coarse estimate, in 1/4-scale pixels
|
||||
// Motion beyond this share of the frame is a different picture, not drift: the
|
||||
// intersection crop would eat the photo, and the average would ghost.
|
||||
const MAX_SPREAD = 0.02;
|
||||
|
||||
// GPU when a context exists (the merge draws three 12MP images), CPU otherwise —
|
||||
// the same fallback exportEngine makes, for the same cold-start reason.
|
||||
function surfaceFor(width: number, height: number): SkSurface | null {
|
||||
return Skia.Surface.MakeOffscreen(width, height) ?? Skia.Surface.Make(width, height);
|
||||
}
|
||||
|
||||
// Capture outputs a bare path; expo-file-system's File wants a scheme.
|
||||
const uriOf = (path: string): string => (/^[a-z][a-z0-9+.-]*:\/\//i.test(path) ? path : `file://${path}`);
|
||||
|
||||
async function loadFrame(path: string): Promise<SkImage | null> {
|
||||
try {
|
||||
const bytes = await new File(uriOf(path)).bytes();
|
||||
const data = Skia.Data.fromBytes(bytes);
|
||||
const image = Skia.Image.MakeImageFromEncoded(data);
|
||||
data.dispose();
|
||||
return image;
|
||||
} catch (e) {
|
||||
console.warn('Burst frame decode failed:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// One downscaled RGBA raster of a frame, for the alignment stages.
|
||||
function rasterize(src: SkImage, surface: SkSurface, w: number, h: number, paint: ReturnType<typeof Skia.Paint>): Pixels | null {
|
||||
const canvas = surface.getCanvas();
|
||||
canvas.clear(Skia.Color('black'));
|
||||
canvas.drawImageRect(src, Skia.XYWHRect(0, 0, src.width(), src.height()), Skia.XYWHRect(0, 0, w, h), paint);
|
||||
return canvas.readPixels(0, 0, { width: w, height: h, colorType: ColorType.RGBA_8888, alphaType: AlphaType.Unpremul });
|
||||
}
|
||||
|
||||
/**
|
||||
* Average a hand-held burst of the same scene into one JPEG.
|
||||
*
|
||||
* `frames` are capture paths, the first one being the frame the shutter already
|
||||
* took. Returns the cache path of the merged file, or null when the merge is not
|
||||
* worth it or cannot be done — motion too large, a frame that will not decode,
|
||||
* a frame of a different size — in which case the caller keeps the plain capture.
|
||||
* The merged file carries the first frame's EXIF block, so the export still
|
||||
* sees the camera and the capture instant it stamps into the finished photo.
|
||||
*/
|
||||
export async function mergeBurst(frames: string[]): Promise<string | null> {
|
||||
if (frames.length < 2) return null;
|
||||
const owned: { dispose(): void }[] = [];
|
||||
const keep = <T extends { dispose(): void }>(item: T): T => {
|
||||
owned.push(item);
|
||||
return item;
|
||||
};
|
||||
try {
|
||||
// Frame 0 sets the geometry: CameraX returns every frame at the same size,
|
||||
// so a frame that disagrees is not part of this burst.
|
||||
const first = await loadFrame(frames[0]);
|
||||
if (!first) return null;
|
||||
const W = first.width();
|
||||
const H = first.height();
|
||||
first.dispose(); // geometry only: the loop decodes frame 0 for real
|
||||
if (!(W > 0 && H > 0)) return null;
|
||||
|
||||
const cw = Math.max(32, Math.round(W / COARSE_DIV));
|
||||
const ch = Math.max(32, Math.round(H / COARSE_DIV));
|
||||
const fw = Math.max(64, Math.round(W / FINE_DIV));
|
||||
const fh = Math.max(64, Math.round(H / FINE_DIV));
|
||||
const coarseSurface = Skia.Surface.Make(cw, ch);
|
||||
const fineSurface = Skia.Surface.Make(fw, fh);
|
||||
const accum = surfaceFor(W, H);
|
||||
if (!coarseSurface || !fineSurface || !accum) {
|
||||
coarseSurface?.dispose();
|
||||
fineSurface?.dispose();
|
||||
accum?.dispose();
|
||||
return null;
|
||||
}
|
||||
keep(coarseSurface);
|
||||
keep(fineSurface);
|
||||
keep(accum);
|
||||
|
||||
const srcPaint = keep(Skia.Paint()); // an opaque source, unscaled
|
||||
// The accumulator IS the output: each frame is drawn into it once its offset
|
||||
// is known and released straight after, so at most one decoded 12MP frame
|
||||
// (48MB) and one surface are alive at any moment. The 1/N alpha is what turns
|
||||
// a plus-blend into an average — accumulating at full brightness would clip
|
||||
// every highlight long before the third frame landed.
|
||||
const addPaint = keep(Skia.Paint());
|
||||
addPaint.setBlendMode(BlendMode.Plus);
|
||||
addPaint.setAlphaf(1 / frames.length);
|
||||
accum.getCanvas().clear(Skia.Color('transparent'));
|
||||
|
||||
let refCoarse: Profiles | null = null;
|
||||
let refCoarsePixels: Pixels | null = null;
|
||||
let refFine: Profiles | null = null;
|
||||
const offsets: { x: number; y: number }[] = [];
|
||||
|
||||
for (const path of frames) {
|
||||
const image = await loadFrame(path);
|
||||
if (!image) return null;
|
||||
try {
|
||||
if (image.width() !== W || image.height() !== H) return null;
|
||||
const coarsePixels = rasterize(image, coarseSurface, cw, ch, srcPaint);
|
||||
const finePixels = rasterize(image, fineSurface, fw, fh, srcPaint);
|
||||
if (!coarsePixels || !finePixels) return null;
|
||||
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
if (refCoarse && refCoarsePixels && refFine) {
|
||||
const coarseNow = profilesOf(coarsePixels, cw, ch);
|
||||
const fineNow = profilesOf(finePixels, fw, fh);
|
||||
// Profiles first: they are the only stage cheap enough to search a
|
||||
// ±80 px range. Then the pixels confirm the result — that 2D pass is
|
||||
// what keeps a periodic scene from locking onto the wrong repetition,
|
||||
// and it keeps (0,0) as a candidate, so an unreadable pair merges
|
||||
// unshifted rather than wrongly shifted.
|
||||
const seed = nearest2D(
|
||||
refCoarsePixels,
|
||||
coarsePixels,
|
||||
cw,
|
||||
ch,
|
||||
bestShift(refCoarse.cols, coarseNow.cols, COARSE_RADIUS),
|
||||
bestShift(refCoarse.rows, coarseNow.rows, COARSE_RADIUS),
|
||||
COARSE_REFINE,
|
||||
);
|
||||
// 1/8-scale alignment is only good to ±8 full-resolution pixels; the
|
||||
// 1/4-scale profiles, searched around it, trim that to ±2.
|
||||
const seedScale = COARSE_DIV / FINE_DIV;
|
||||
x = bestShift(refFine.cols, fineNow.cols, FINE_RADIUS, seed.x * seedScale) * FINE_DIV;
|
||||
y = bestShift(refFine.rows, fineNow.rows, FINE_RADIUS, seed.y * seedScale) * FINE_DIV;
|
||||
} else {
|
||||
refCoarse = profilesOf(coarsePixels, cw, ch);
|
||||
refCoarsePixels = coarsePixels;
|
||||
refFine = profilesOf(finePixels, fw, fh);
|
||||
}
|
||||
|
||||
offsets.push({ x, y });
|
||||
accum.getCanvas().drawImage(image, x, y, addPaint);
|
||||
} finally {
|
||||
image.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
const minX = Math.min(...offsets.map((o) => o.x));
|
||||
const maxX = Math.max(...offsets.map((o) => o.x));
|
||||
const minY = Math.min(...offsets.map((o) => o.y));
|
||||
const maxY = Math.max(...offsets.map((o) => o.y));
|
||||
if (maxX - minX > W * MAX_SPREAD || maxY - minY > H * MAX_SPREAD) return null;
|
||||
|
||||
const accumulated = accum.makeImageSnapshot();
|
||||
if (!accumulated) return null;
|
||||
keep(accumulated);
|
||||
// The frames disagree at the borders by exactly the spread of their offsets,
|
||||
// so keep the intersection: no edge is left carrying one frame's worth of
|
||||
// exposure against the average of the rest.
|
||||
const outW = W - (maxX - minX);
|
||||
const outH = H - (maxY - minY);
|
||||
const cropped = surfaceFor(outW, outH);
|
||||
if (!cropped) return null;
|
||||
keep(cropped);
|
||||
cropped
|
||||
.getCanvas()
|
||||
.drawImageRect(accumulated, Skia.XYWHRect(minX, minY, outW, outH), Skia.XYWHRect(0, 0, outW, outH), srcPaint);
|
||||
const merged = cropped.makeImageSnapshot();
|
||||
if (!merged) return null;
|
||||
keep(merged);
|
||||
|
||||
// Quality 90, the same the photo output produces: one more JPEG generation
|
||||
// on a file that the export decodes and re-encodes anyway.
|
||||
const bytes = merged.encodeToBytes(ImageFormat.JPEG, 90);
|
||||
if (!bytes) return null;
|
||||
const path = `${FileSystem.cacheDirectory}burst_${Date.now()}.jpg`;
|
||||
new File(path).write(bytes);
|
||||
await stampExifOnFile(path, {}, uriOf(frames[0]));
|
||||
return path;
|
||||
} catch (e) {
|
||||
console.warn('Burst merge failed:', e);
|
||||
return null;
|
||||
} finally {
|
||||
// Reverse creation order, like exportEngine: a snapshot is what holds the
|
||||
// 48MB buffer, everything else is a handle onto it.
|
||||
for (let i = owned.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
owned[i].dispose();
|
||||
} catch {
|
||||
// A buffer that is already gone must not cost the user the photo.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Runnable self-check: `node --no-warnings src/utils/deviceFloor.check.ts`.
|
||||
// Covers the budget maths only — the RAM reading itself needs the device.
|
||||
import assert from 'node:assert';
|
||||
import {
|
||||
canBurst,
|
||||
floorRaster,
|
||||
FLOOR_EXPORT_PIXELS,
|
||||
isFloorDevice,
|
||||
maxExportPixels,
|
||||
} from './deviceFloor.ts';
|
||||
|
||||
// Tier boundaries: unknown (0, the platform refused) is never a floor device,
|
||||
// and the 4 GB mid-range keeps the full path.
|
||||
assert.strictEqual(isFloorDevice(0), false, 'unknown RAM must keep full quality');
|
||||
assert.strictEqual(isFloorDevice(1024), true, 'Redmi Go / Nokia 1 / J2 Core');
|
||||
assert.strictEqual(isFloorDevice(2048), true, 'Go programme ceiling');
|
||||
assert.strictEqual(isFloorDevice(3072), false);
|
||||
assert.strictEqual(isFloorDevice(12288), false, '12S Ultra');
|
||||
|
||||
// Caps follow the tier.
|
||||
assert.strictEqual(maxExportPixels(0), Infinity);
|
||||
assert.strictEqual(maxExportPixels(12288), Infinity);
|
||||
assert.strictEqual(maxExportPixels(1024), FLOOR_EXPORT_PIXELS);
|
||||
assert.strictEqual(canBurst(1024), false);
|
||||
assert.strictEqual(canBurst(0), true);
|
||||
|
||||
// The floor device's own 3264x2448 (8 MP) capture, and the 12S Ultra's 3000x4000.
|
||||
const go = floorRaster(3264, 2448, FLOOR_EXPORT_PIXELS);
|
||||
assert.ok(go.width < 3264, 'floor capture is resampled');
|
||||
assert.ok(go.width * go.height <= FLOOR_EXPORT_PIXELS, 'stays inside the cap');
|
||||
assert.ok(go.width * go.height > FLOOR_EXPORT_PIXELS * 0.9, 'and does not overshoot it');
|
||||
// Aspect is preserved to within a pixel on both orientations.
|
||||
assert.ok(Math.abs(go.width / go.height - 3264 / 2448) < 0.002, '4:3 kept');
|
||||
const tall = floorRaster(3000, 4000, 4_000_000);
|
||||
assert.ok(Math.abs(tall.width / tall.height - 3000 / 4000) < 0.002, '3:4 kept');
|
||||
|
||||
// Untouched when it already fits, and never zero-sized on a degenerate raster.
|
||||
assert.deepStrictEqual(floorRaster(2000, 1500, FLOOR_EXPORT_PIXELS), { width: 2000, height: 1500 });
|
||||
assert.deepStrictEqual(floorRaster(1, 1, FLOOR_EXPORT_PIXELS), { width: 1, height: 1 });
|
||||
assert.deepStrictEqual(floorRaster(0, 0, 100), { width: 0, height: 0 });
|
||||
|
||||
console.log('deviceFloor.check: 17 assertions passed');
|
||||
@@ -0,0 +1,55 @@
|
||||
// The floor the whole pipeline is budgeted against: the cheapest Android that
|
||||
// clears this app's minimum. What sets the minimum: minSdk 26 (below it
|
||||
// vision-camera's NativeBufferHelper throws "HardwareBuffers require minSdk 26
|
||||
// or higher!" on every frame), a 32-bit armeabi-v7a build (the shipped APK
|
||||
// carries both ABIs), Camera2 with EXIF, and Skia's GL backend, which links
|
||||
// -lGLESv2 (see node_modules/@shopify/react-native-skia/android/CMakeLists.txt).
|
||||
// Every API 26 phone clears the GL bar, so the floor is the Android Go, 1 GB
|
||||
// class: Redmi Go (Snapdragon 425, Adreno 308, 5.8-7.2 GFLOPS / 5.3 GB/s,
|
||||
// 8 MP), Nokia 1 (MT6737M, Mali-T720, 5 MP) and Galaxy J2 Core (Exynos 7570,
|
||||
// 8 MP) — 4x Cortex-A53 at 1.1-1.4 GHz, 1 GB RAM, 8 GB storage, 480p-720p
|
||||
// screen. Nothing weaker can install.
|
||||
//
|
||||
// What that floor affords, at 4 bytes per RGBA8888 pixel:
|
||||
// 8 MP raster 32 MB -> the export's live surfaces alone = 96 MB
|
||||
// one full-frame pass 64 MB of traffic @ 5.3 GB/s ~ 12 ms
|
||||
// 4 slow A53 cores ~1/6 of a current flagship core
|
||||
// Hence, here: one resample at the door instead of a full-resolution surface
|
||||
// chain, and no 3-frame burst (six 8 MP decodes plus a CPU alignment on four
|
||||
// slow cores is seconds of shutter lag on the device class least able to afford
|
||||
// it). The live preview needs no cap — Viewfinder already forces the frame
|
||||
// stream to the 4:3 size nearest 1280x960.
|
||||
//
|
||||
// No state and no native import: the RAM reading comes from
|
||||
// photoMeta.devicePerf() and is passed in, so this module also runs under plain
|
||||
// node (see deviceFloor.check.ts).
|
||||
export const FLOOR_RAM_MB = 2048;
|
||||
export const FLOOR_EXPORT_PIXELS = 4_000_000;
|
||||
|
||||
// ramMb 0 = the platform would not say. Unknown is never a floor device: the app
|
||||
// must not downgrade a phone it could not measure.
|
||||
export const isFloorDevice = (ramMb: number): boolean => ramMb > 0 && ramMb <= FLOOR_RAM_MB;
|
||||
|
||||
// Most pixels the export may paint. 4 MP (2336x1752) still prints 10x15 cm at
|
||||
// 300 dpi and halves every surface behind it — the 12S Ultra's 12 MP capture is
|
||||
// untouched, since it is not a floor device.
|
||||
// ponytail: downscale rather than tile — tile the render the day a floor user
|
||||
// needs the full 8 MP file.
|
||||
export const maxExportPixels = (ramMb: number): number =>
|
||||
isFloorDevice(ramMb) ? FLOOR_EXPORT_PIXELS : Infinity;
|
||||
|
||||
// ponytail: one capability, all or nothing — add a 2-frame CPU-lite merge if a
|
||||
// floor device ever reports it can afford one.
|
||||
export const canBurst = (ramMb: number): boolean => !isFloorDevice(ramMb);
|
||||
|
||||
// Scale a raster down to at most `cap` pixels, keeping its aspect. Whole pixels
|
||||
// only: the surface allocator and the EXIF stamp both read these back.
|
||||
export function floorRaster(
|
||||
w: number,
|
||||
h: number,
|
||||
cap: number
|
||||
): { width: number; height: number } {
|
||||
if (!(w > 0 && h > 0) || !(w * h > cap)) return { width: w, height: h };
|
||||
const s = Math.sqrt(cap / (w * h));
|
||||
return { width: Math.max(1, Math.round(w * s)), height: Math.max(1, Math.round(h * s)) };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Runnable self-check for the one reader the low-light burst gate leans on.
|
||||
// expo-file-system has no host implementation, so this covers readJpegIso
|
||||
// offline: node --no-warnings src/utils/exifWrite.check.ts
|
||||
import { readJpegIso } from './exifWrite.ts';
|
||||
|
||||
// A 44-byte TIFF: IFD0 holds the Exif pointer, the Exif IFD holds 0x8827 —
|
||||
// the two-hop shape a camera writes. `type` is the EXIF type id (3 SHORT,
|
||||
// 4 LONG); a wrong-IFD or wrong-endian reader fails here.
|
||||
function tiffWithIso(iso: number, type: 3 | 4, be = false): Uint8Array {
|
||||
const t = new Uint8Array(44);
|
||||
const u16 = (p: number, v: number) => (be ? (t[p] = v >> 8, t[p + 1] = v & 0xff) : (t[p] = v & 0xff, t[p + 1] = v >> 8));
|
||||
const u32 = (p: number, v: number) =>
|
||||
be
|
||||
? (t[p] = v >>> 24, t[p + 1] = (v >>> 16) & 0xff, t[p + 2] = (v >>> 8) & 0xff, t[p + 3] = v & 0xff)
|
||||
: (t[p] = v & 0xff, t[p + 1] = (v >>> 8) & 0xff, t[p + 2] = (v >>> 16) & 0xff, t[p + 3] = v >>> 24);
|
||||
t[0] = be ? 0x4d : 0x49;
|
||||
t[1] = be ? 0x4d : 0x49;
|
||||
u16(2, 42);
|
||||
u32(4, 8);
|
||||
u16(8, 1); // IFD0: one entry
|
||||
u16(10, 0x8769); u16(12, 4); u32(14, 1); u32(18, 26); // -> Exif IFD at 26
|
||||
u32(22, 0); // no IFD1
|
||||
u16(26, 1); // Exif IFD: one entry
|
||||
u16(28, 0x8827); u16(30, type); u32(32, 1);
|
||||
if (type === 3) u16(36, iso);
|
||||
else u32(36, iso);
|
||||
u32(40, 0);
|
||||
return t;
|
||||
}
|
||||
|
||||
function jpegWith(tiff: Uint8Array): Uint8Array {
|
||||
const len = 2 + 6 + tiff.length;
|
||||
const j = new Uint8Array(6 + len); // SOI + marker/len + the length's own payload
|
||||
j[0] = 0xff; j[1] = 0xd8; // SOI
|
||||
j[2] = 0xff; j[3] = 0xe1; // APP1
|
||||
j[4] = len >> 8; j[5] = len & 0xff;
|
||||
j.set([0x45, 0x78, 0x69, 0x66, 0, 0], 6);
|
||||
j.set(tiff, 12);
|
||||
return j;
|
||||
}
|
||||
|
||||
const checks: [string, boolean][] = [
|
||||
['SHORT ISO read back', readJpegIso(jpegWith(tiffWithIso(1234, 3))) === 1234],
|
||||
['LONG ISO read back', readJpegIso(jpegWith(tiffWithIso(2000, 4))) === 2000],
|
||||
['big-endian ISO read back', readJpegIso(jpegWith(tiffWithIso(800, 3, true))) === 800],
|
||||
['ISO 0 means unknown', readJpegIso(jpegWith(tiffWithIso(0, 3))) === null],
|
||||
['an unreadable file is not an ISO', readJpegIso(new Uint8Array([0xff, 0xd8, 0xff, 0xd9])) === null],
|
||||
['a non-JPEG is not an ISO', readJpegIso(new Uint8Array([1, 2, 3])) === null],
|
||||
];
|
||||
|
||||
let bad = 0;
|
||||
for (const [name, ok] of checks) {
|
||||
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}`);
|
||||
if (!ok) bad++;
|
||||
}
|
||||
if (bad) throw new Error(`exifWrite: ${bad} check(s) failed`);
|
||||
console.log('\nexifWrite: all checks passed');
|
||||
@@ -299,6 +299,30 @@ function findExifSegment(jpeg: Uint8Array): { start: number; end: number; tiff:
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- read ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The ISO the camera metered for this JPEG — EXIF 0x8827
|
||||
* (PhotographicSensitivity) — or null when the file carries none. The capture
|
||||
* path cannot ask the camera: CameraX hands back a path and nothing else, and
|
||||
* the controller's `iso` reads 0.0 for every still. So the low-light gate reads
|
||||
* it back off the file it just wrote, and a file without the tag simply never
|
||||
* bursts.
|
||||
*/
|
||||
export function readJpegIso(jpeg: Uint8Array): number | null {
|
||||
const seg = findExifSegment(jpeg);
|
||||
if (!seg) return null;
|
||||
const iso = parseTiff(seg.tiff).exif.find((e) => e.tag === 0x8827);
|
||||
if (!iso) return null;
|
||||
// parseTiff normalizes every value to little-endian, so SHORT and LONG read
|
||||
// with the same shifts.
|
||||
const n =
|
||||
iso.type === LONG || iso.type === SLONG
|
||||
? (iso.data[0] | (iso.data[1] << 8) | (iso.data[2] << 16) | (iso.data[3] << 24)) >>> 0
|
||||
: (iso.data[0] | (iso.data[1] << 8)) >>> 0;
|
||||
return n > 0 && n < 1000000 ? n : null; // 0 and vendor noise both mean "unknown"
|
||||
}
|
||||
|
||||
// --- serialize -------------------------------------------------------------
|
||||
|
||||
function serializeTiff(ifd0: Entry[], exif: Entry[], gps: Entry[], interop: Entry[]): Uint8Array {
|
||||
|
||||
@@ -10,7 +10,8 @@ import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader';
|
||||
import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_W, WALLFRAME_H, WALLFRAME_LAND_W, WALLFRAME_LAND_H } from './frameUtils';
|
||||
import { patchJpegDpi } from './jpegDpi';
|
||||
import { writeJpegExif } from './exifWrite';
|
||||
import { nextPhotoPath, readPhotoBytes, getDeviceIdentity } from './photoMeta';
|
||||
import { nextPhotoPath, readPhotoBytes, getDeviceIdentity, devicePerf } from './photoMeta';
|
||||
import { floorRaster, maxExportPixels } from './deviceFloor';
|
||||
import { ensureMediaLibraryPermission } from './mediaPermission';
|
||||
import RecipescamExport from '../../modules/recipescam-export';
|
||||
|
||||
@@ -246,6 +247,37 @@ export async function processAndExportPhoto(
|
||||
}
|
||||
let width = skImage.width();
|
||||
let height = skImage.height();
|
||||
// 1c. Floor-device ceiling (see deviceFloor.ts). Everything below keeps more
|
||||
// than one full-resolution surface alive — paint target, snapshot, sharpen —
|
||||
// and one 8 MP raster is 32 MB, which is what a 1 GB Go phone pages or OOMs
|
||||
// on mid-export. Resample once, here at the door, so every pass behind it
|
||||
// (crop, tone, glow, grain, sharpen) runs on the smaller raster; the aspect
|
||||
// is unchanged, so the ratio crop, frame layout and cover maths all stand.
|
||||
// A surface that will not allocate leaves the raster alone: the full-size
|
||||
// chain below is the one we are already on.
|
||||
const capped = floorRaster(width, height, maxExportPixels(devicePerf().ramMb));
|
||||
if (capped.width !== width) {
|
||||
const shrinkSurface = createSurface(capped.width, capped.height);
|
||||
if (shrinkSurface) {
|
||||
own(shrinkSurface);
|
||||
shrinkSurface.getCanvas().drawImageRect(
|
||||
skImage,
|
||||
Skia.XYWHRect(0, 0, width, height),
|
||||
Skia.XYWHRect(0, 0, capped.width, capped.height),
|
||||
own(Skia.Paint())
|
||||
);
|
||||
const shrunk = shrinkSurface.makeImageSnapshot();
|
||||
if (shrunk) {
|
||||
own(shrunk);
|
||||
// Superseded, like the aspect crop above.
|
||||
release(owned, shrinkSurface);
|
||||
release(owned, skImage);
|
||||
skImage = shrunk;
|
||||
width = capped.width;
|
||||
height = capped.height;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 1b. Aspect-ratio crop (composition): center-largest upright crop of the
|
||||
// requested ratio. Runs BEFORE any processing so frame/GPS/grain/sharpen
|
||||
// all apply to exactly the pixels the user framed. Wall Frame is exempt —
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { File } from 'expo-file-system';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import RecipescamExport from '../../modules/recipescam-export';
|
||||
import { isFloorDevice } from './deviceFloor';
|
||||
import { writeJpegExif, SOFTWARE, type DeviceIdentity, type ExifStamp } from './exifWrite';
|
||||
|
||||
// RC_<yyyymmdd_hhmmss>[_exported].jpg — the name the gallery shows for a saved
|
||||
@@ -50,6 +51,28 @@ export function getDeviceIdentity(): DeviceIdentity | null {
|
||||
return deviceIdentity;
|
||||
}
|
||||
|
||||
// Device facts the capture and render paths budget against (deviceFloor.ts):
|
||||
// total RAM and CPU cores. Lives next to deviceInfo because that is where the
|
||||
// native handle already is; read once, the numbers cannot change while the app
|
||||
// runs. ramMb 0 = the platform refused, which deviceFloor reads as "not a floor
|
||||
// device" so an unmeasurable phone keeps the full-quality path.
|
||||
let devicePerfCache: { ramMb: number; cores: number } | null = null;
|
||||
export function devicePerf(): { ramMb: number; cores: number } {
|
||||
if (!devicePerfCache) {
|
||||
try {
|
||||
devicePerfCache = RecipescamExport.devicePerf();
|
||||
} catch (e) {
|
||||
console.warn('devicePerf unavailable, full-quality path:', e);
|
||||
devicePerfCache = { ramMb: 0, cores: 0 };
|
||||
}
|
||||
// One line, so a floor device in the field can be confirmed from logcat:
|
||||
// "adb logcat -s ReactNativeJS | grep floor".
|
||||
const { ramMb, cores } = devicePerfCache;
|
||||
console.info(`devicePerf: ramMb=${ramMb} cores=${cores} floor=${isFloorDevice(ramMb)}`);
|
||||
}
|
||||
return devicePerfCache;
|
||||
}
|
||||
|
||||
// Stamp EXIF into a file already on disk (the native engine writes the JPEG
|
||||
// itself, so those bytes never pass through JS). Never throws — a metadata
|
||||
// failure must not cost the user the photo.
|
||||
|
||||
+4
-1
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
"strict": true,
|
||||
// burstAlign.check.ts runs under node's own type stripping, where the import
|
||||
// must name the .ts file. Type-only flag; expo base already sets noEmit.
|
||||
"allowImportingTsExtensions": true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user