Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5e5da49b7 | |||
| 588fbf77d9 | |||
| 9f7d83276d | |||
| fd38269709 | |||
| 4863080dfe | |||
| 85b55dcf48 | |||
| 4ec4260cbd | |||
| c794283083 | |||
| cbd0178cda | |||
| c8e2b6ea5a | |||
| 1e1ead6f54 | |||
| e0526283ca | |||
| 99069aa837 | |||
| 82efc1f675 | |||
| 2bfe8f1211 |
@@ -0,0 +1,234 @@
|
||||
# 4 — FIX: HIGHLIGHT bị EXPOSURE kéo ngược (thứ tự pass màu sai)
|
||||
|
||||
Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06.
|
||||
Nguồn đối chiếu: nhánh `recipes-web`, commit **`8e6c149`** — bản web đã sửa và đo được.
|
||||
|
||||
---
|
||||
|
||||
## 1. Hiện tượng
|
||||
|
||||
Kéo **HIGHLIGHT −10** rồi **EXPOSURE +10**: vùng sáng đã được ghì xuống lại bị đẩy
|
||||
trở lại mức cháy (1.0). Nhìn như thanh HIGHLIGHT "chết" — càng tăng EXPOSURE càng
|
||||
mất khả năng giữ highlight.
|
||||
|
||||
---
|
||||
|
||||
## 2. Nguyên nhân gốc
|
||||
|
||||
Skia áp **shader TRƯỚC colorFilter** trong cùng một `SkPaint`. Ma trận màu
|
||||
(chứa `adjustments.exposure` + gain EV) và tone shader đang nằm trên **cùng một
|
||||
paint**, nên gain exposure chạy **sau** pass tone:
|
||||
|
||||
1. Pass tone: pixel sáng 1.0 → HIGHLIGHT −10 ghì về **0.78**
|
||||
2. Ma trận exposure: **×1.2 + 0.15** → 1.086 → **kẹp về 1.0**
|
||||
|
||||
Thanh HIGHLIGHT vẫn chạy đúng — nó chỉ bị pass sau xoá sạch kết quả.
|
||||
|
||||
### 2.1 `src/utils/exportEngine.ts` (engine JS/Skia — đường export)
|
||||
|
||||
```ts
|
||||
// dòng 328
|
||||
const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix));
|
||||
paint.setColorFilter(colorFilter);
|
||||
|
||||
// dòng 330 — comment mô tả SAI thứ tự thực tế:
|
||||
// "3b. Tone shader — DR / Highlight / Shadow curve on top of the matrix."
|
||||
// dòng 354
|
||||
paint.setShader(toneShader);
|
||||
```
|
||||
|
||||
Comment nói tone nằm "on top of the matrix", nhưng Skia làm ngược lại.
|
||||
|
||||
### 2.2 `src/components/Viewfinder.tsx` (preview camera trực tiếp)
|
||||
|
||||
Cùng lỗi, cùng một paint:
|
||||
|
||||
```ts
|
||||
// dòng 1207
|
||||
paint.setColorFilter(baseColorFilter);
|
||||
// dòng 1303
|
||||
paint.setShader(fullShader);
|
||||
```
|
||||
|
||||
Khối HDF (dòng ~1339) còn **cố tình nhân ma trận màu lần thứ hai**:
|
||||
|
||||
```ts
|
||||
Skia.ImageFilter.MakeCompose(
|
||||
glowBlur,
|
||||
keep(Skia.ImageFilter.MakeColorFilter(baseColorFilter, null)) // "bloom lands on GRADED pixels"
|
||||
)
|
||||
```
|
||||
|
||||
→ vệt bloom cũng bị đẩy sáng sai. Sau khi sửa phải bỏ phần nhân lại này.
|
||||
|
||||
### 2.3 `modules/recipescam-export/.../RecipescamExportModule.kt` (module native Kotlin)
|
||||
|
||||
Thứ tự pass hiện tại là **tone → cinema → matrix**, tức matrix chạy **cuối** — đúng
|
||||
cùng một lỗi:
|
||||
|
||||
| Dòng | Pass |
|
||||
|---|---|
|
||||
| 423–429 | `// 2. Tone pass` → `applyTone(bmp, dr, hl, sh)` |
|
||||
| 434–438 | `// 3. Cinema seasonal grade` → `applyCinema(bmp, cinemaFlat)` |
|
||||
| 443–452 | `// 4. Color matrix via paint pass` → `ColorMatrixColorFilter` |
|
||||
| 423–426 | comment: "Engine order: the tone shader samples the base image, cinema samples the tone output, **then the color matrix filter applies on top**" |
|
||||
|
||||
---
|
||||
|
||||
## 3. Bằng chứng đo được (bản web, trước và sau khi sửa)
|
||||
|
||||
Nguồn: ảnh gradient dọc 0.98 → 0.30 (320×240 JPEG), đo p95 (vùng sáng) và trung vị
|
||||
(midtone) của ảnh render. Engine thật, không phải mô phỏng.
|
||||
|
||||
| Trường hợp | top (p95) | mid |
|
||||
|---|---|---|
|
||||
| nguồn | 1.000 | 0.502 |
|
||||
| HIGHLIGHT −10 | 0.780 | 0.518 |
|
||||
| EXPOSURE +10 | 1.000 | 0.769 |
|
||||
| HIGHLIGHT −10 + EXPOSURE +10 — **một paint (lỗi)** | **1.000** | 0.722 |
|
||||
| HIGHLIGHT −10 + EXPOSURE +10 — **hai pass (đã sửa)** | **0.780** | 0.722 |
|
||||
|
||||
Sau khi sửa: midtone vẫn sáng lên (0.502 → 0.722) nhưng vùng sáng giữ nguyên 0.780.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cách sửa
|
||||
|
||||
**Nguyên tắc:** ma trận màu phải tạo ra **ảnh đã grade** trước, rồi tone/cinema shader
|
||||
mới lấy ảnh đó làm con. Đúng một câu: *matrix → tone → cinema*.
|
||||
|
||||
### 4.1 `src/utils/exportEngine.ts`
|
||||
|
||||
Thêm helper (đặt cạnh `screenSharpenImage`), rồi đổi bước 3:
|
||||
|
||||
```ts
|
||||
// Ma trận màu, áp riêng vào một ảnh mới. SkPaint chạy shader TRƯỚC colorFilter,
|
||||
// nên pixel đã grade phải tồn tại như một ảnh trước khi tone/cinema đọc nó.
|
||||
function gradeThrough(image: any, w: number, h: number, colorFilter: any): any {
|
||||
const surf = createSurface(w, h);
|
||||
if (!surf) return null;
|
||||
try {
|
||||
const p = Skia.Paint();
|
||||
p.setColorFilter(colorFilter);
|
||||
surf.getCanvas().drawImage(image, 0, 0, p);
|
||||
p.dispose();
|
||||
return surf.makeImageSnapshot() ?? null;
|
||||
} finally {
|
||||
surf.dispose();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Trong `renderPhoto`:
|
||||
|
||||
```ts
|
||||
const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix));
|
||||
const matrixIsIdentity = evMatrix.every((v, i) => v === (i % 6 === 0 ? 1 : 0));
|
||||
|
||||
const tone = getToneUniforms(adjustments, recipe.baseFilter);
|
||||
const cinemaOn = /* cinema && cinemaIsActive(cinema.flat) */;
|
||||
let toneShader: any = null;
|
||||
let paintShader: any = null;
|
||||
|
||||
// Ma trận phải thành ảnh riêng khi có pass phía sau đọc lại pixel.
|
||||
let gradeImage = skImage;
|
||||
const graded = (toneIsActive(tone) || cinemaOn) && !matrixIsIdentity;
|
||||
if (graded) {
|
||||
const snapshot = gradeThrough(skImage, width, height, colorFilter);
|
||||
if (snapshot) { own(snapshot); gradeImage = snapshot; }
|
||||
}
|
||||
const imageShaderOf = () =>
|
||||
own(gradeImage.makeShaderOptions(
|
||||
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None));
|
||||
|
||||
if (toneIsActive(tone)) {
|
||||
toneShader = own(toneEffect.makeShaderWithChildren(toneUniformArray(tone), [imageShaderOf()]));
|
||||
}
|
||||
if (cinemaOn) {
|
||||
const cs = own(cinemaEffect.makeShaderWithChildren(cinema.flat, [toneShader ?? imageShaderOf()]));
|
||||
if (cs) paintShader = cs;
|
||||
}
|
||||
if (!paintShader && toneShader) paintShader = toneShader;
|
||||
|
||||
// Chỉ MỘT trong hai được tô màu pixel: ảnh đã grade đã mang ma trận rồi,
|
||||
// chỉ nhánh chưa grade mới cần colorFilter.
|
||||
if (paintShader) paint.setShader(paintShader);
|
||||
else if (!graded) paint.setColorFilter(colorFilter);
|
||||
```
|
||||
|
||||
Và trong bước HDF (5b): bloom đọc pixel đã grade, nên **bỏ lần áp ma trận thứ hai**:
|
||||
|
||||
```ts
|
||||
const srcShader = paintShader ?? imageShaderOf();
|
||||
const bloomGrade = (inner?: any) =>
|
||||
graded ? undefined : own(Skia.ImageFilter.MakeColorFilter(inner ?? colorFilter, null));
|
||||
// ...
|
||||
glowPaint.setImageFilter(grade ? own(Skia.ImageFilter.MakeCompose(blur, grade)) : blur);
|
||||
```
|
||||
|
||||
Hai điều kiện đệm: khi **không có tone/cinema** hoặc **ma trận là identity** thì
|
||||
`graded === false` → không tạo surface thừa, giữ nguyên đường vẽ cũ.
|
||||
|
||||
### 4.2 `src/components/Viewfinder.tsx` (preview mỗi frame)
|
||||
|
||||
Cùng nguyên tắc, nhưng đây là đường chạy **mỗi frame** nên phải cân nhắc chi phí:
|
||||
ma trận phải được vẽ vào một surface phụ (hoặc texture trung gian) **trước**, rồi
|
||||
tone/cinema shader lấy ảnh đó làm `imageShader` con.
|
||||
|
||||
- Đổi `paint.setColorFilter(baseColorFilter)` + `paint.setShader(fullShader)` trên
|
||||
cùng một paint thành: grade trước vào texture `gradedTex`, `fullShader` dựng từ
|
||||
`gradedTex.makeShaderOptions(...)`.
|
||||
- `hasTone === false && hasCinema === false` (hoặc ma trận identity): giữ đường vẽ
|
||||
một-pass như hiện tại, đừng thêm surface.
|
||||
- Bỏ `MakeColorFilter(baseColorFilter, null)` khỏi chuỗi imageFilter của glow —
|
||||
bloom giờ đọc thẳng pixel đã grade.
|
||||
- **Đo lại fps sau khi sửa**: thêm 1 pass fullscreen/frame. Nếu tụt quá ngưỡng
|
||||
chấp nhận, phương án rẻ hơn là nhân hệ số gain exposure **bên trong SKSL của tone
|
||||
shader** (trước khi áp curve) — nhưng chỉ khi vẫn giữ được thứ tự *film matrix →
|
||||
curve*; đừng quay lại nhét exposure vào sau curve.
|
||||
|
||||
### 4.3 `RecipescamExportModule.kt` (native)
|
||||
|
||||
Đổi thứ tự các bước cho khớp: **matrix → tone → cinema**. Matrix (bước "4." hiện
|
||||
tại) phải chạy trước bước tone "2.":
|
||||
|
||||
```kotlin
|
||||
// 2. Color matrix (grade phim + exposure) — PHẢI chạy trước tone.
|
||||
val androidMatrix = FloatArray(20) { i -> if (i % 5 == 4) matrix[i] * 255f else matrix[i] }
|
||||
val out = Bitmap.createBitmap(bmp.width, bmp.height, Bitmap.Config.ARGB_8888)
|
||||
Canvas(out).drawBitmap(bmp, 0f, 0f, Paint().apply { colorFilter = ColorMatrixColorFilter(androidMatrix) })
|
||||
bmp.recycle()
|
||||
bmp = out
|
||||
|
||||
// 3. Tone (DR / highlight / shadow) trên pixel đã grade.
|
||||
if (toneActive) applyTone(bmp, toneDr.toFloat(), toneHl.toFloat(), toneSh.toFloat())
|
||||
|
||||
// 4. Cinema seasonal grade.
|
||||
if (cinemaActive) applyCinema(bmp, cinemaFlat)
|
||||
```
|
||||
|
||||
`ensureMutable(bmp)` giữ nguyên vị trí (trước tone), và sửa lại comment dòng 423–426
|
||||
— bỏ câu "then the color matrix filter applies on top".
|
||||
|
||||
---
|
||||
|
||||
## 5. Kiểm thử (bắt buộc trước khi coi là xong)
|
||||
|
||||
1. Nguồn: gradient dọc 0.98 → 0.30 (320×240 JPEG) — có headroom để thấy highlight.
|
||||
2. Export với `highlight = -10`, `exposure = +10`.
|
||||
3. Kỳ vọng: **p95 ≈ 0.78** (KHÔNG được 1.00), midtone tăng rõ so với ảnh gốc.
|
||||
4. Chạy đủ **cả ba đường** (export engine JS, preview camera, module native Kotlin)
|
||||
và so ảnh với nhau — parity gate của `PLAN.md`.
|
||||
5. Đối chiếu với bản web: `recipes-web` @ `8e6c149`, file
|
||||
`docker/frontend/src/engine/exportEngine.ts` (helper `gradeThrough`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Ghi chú phụ
|
||||
|
||||
- Lỗi này **không** nằm ở công thức tone (`toneShader.ts`) hay ma trận màu
|
||||
(`colorUtils.ts`) — cả hai đều đúng. Chỉ sai **thứ tự thực thi**.
|
||||
- `HIGHLIGHT +10` không lộ lỗi vì kết quả dù sao cũng kẹp về 1.0 — chỉ nhánh âm mới
|
||||
nhìn thấy. Đừng "kiểm thử" bằng HIGHLIGHT dương.
|
||||
- Bất kỳ pass nào khác đọc lại pixel sau tone/cinema (bloom HDF, grain, vignette)
|
||||
đều phải đọc **ảnh đã grade**, và ma trận không được áp lần hai.
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
# 5 — Đốt dải thông tin ("strip") vào ảnh xuất
|
||||
|
||||
Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06.
|
||||
Nguồn đối chiếu: nhánh `recipes-web`, commit **`b4d5d29`** — bản web đã làm và đo được.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mục tiêu
|
||||
|
||||
Ảnh **xuất ra** phải mang đúng dải thông tin mà app đang hiển thị trên thẻ recipe:
|
||||
một **tagline amber** `#TAG` nằm góc trên-trái **trên chính bức ảnh**, và một **dải
|
||||
caption nền tối** treo **bên dưới** ảnh, gồm **tên recipe** (đậm, sáng) và **dòng
|
||||
kỹ thuật** `ISO · GRAIN · WARMTH/MONO` (mono, xám).
|
||||
|
||||
Đây là bản sao của khối `.lp-shot` + `.lp-frame-meta` trên landing:
|
||||
|
||||
```html
|
||||
<div class="lp-shot">
|
||||
<img … />
|
||||
<span class="lp-tagline">#ACROS_100</span> <!-- amber, góc trên-trái -->
|
||||
</div>
|
||||
<div class="lp-frame-meta">
|
||||
<b>ACROS 100</b> <!-- tên recipe -->
|
||||
<span>ISO 400 · GRAIN 4 · WARMTH +2</span> <!-- dòng kỹ thuật, mono -->
|
||||
</div>
|
||||
```
|
||||
|
||||
Thuộc tính web (để đối chiếu màu/kích thước): `docker/frontend/src/styles/landing.css`
|
||||
dòng 288–291 — tagline `color: var(--lp-amber)`, meta `font-family: var(--lp-mono)`.
|
||||
|
||||
**Ranh giới quan trọng:** dải này CHỈ có trong **file xuất**. Preview / viewfinder /
|
||||
màn hình chỉnh sửa **không** được vẽ nó — nếu không, người dùng thấy hai lần.
|
||||
|
||||
---
|
||||
|
||||
## 2. Giá trị lấy từ đâu
|
||||
|
||||
Bản web tính trong một `useMemo` duy nhất (`recipes-web` @ `b4d5d29`,
|
||||
`docker/frontend/src/App.tsx`, dòng ~658–671):
|
||||
|
||||
```ts
|
||||
const name = (recipe.name || 'RECIPE').trim();
|
||||
const slug = name.replace(/[^\p{L}\p{N}]+/gu, '_').replace(/^_+|_+$/g, '').toUpperCase();
|
||||
const grain = Math.max(0, Math.round(recipe.adjustments.grain ?? 0));
|
||||
const warmth = Math.round((recipe.adjustments.temperature - 5500) / 250);
|
||||
const tail = recipe.baseFilter === 'monochrome'
|
||||
? 'MONO'
|
||||
: `WARMTH ${warmth >= 0 ? '+' : ''}${warmth}`;
|
||||
const strip = {
|
||||
tag: `#${slug || 'RECIPE'}`, // UPPERCASE, khoảng trắng -> '_'
|
||||
title: name, // giữ nguyên hoa/thường
|
||||
meta: `ISO ${iso ?? 'AUTO'} · GRAIN ${grain} · ${tail}`,
|
||||
};
|
||||
```
|
||||
|
||||
- `·` là **U+00B7 MIDDLE DOT**, có dấu cách hai bên.
|
||||
- `iso` là ISO đọc từ **EXIF của ảnh nguồn**; không có thì in `AUTO` (xem §5).
|
||||
- `grain` lấy thẳng từ knob `adjustments.grain` (0..10), **không** cộng phần re-grain
|
||||
của NOISE REDUCTION âm — dòng kỹ thuật mô tả *công thức*, không mô tả pass xuất.
|
||||
- `warmth` là bước nhảy 250K quanh mốc 5500K, luôn kèm dấu (`+2` / `-3`).
|
||||
- `baseFilter === 'monochrome'` thì thay hẳn `WARMTH …` bằng `MONO`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Hình học & màu (chính xác từng pixel)
|
||||
|
||||
Đặt `W`, `H` = chiều rộng/cao của ảnh **sau khi đã ghép frame** (ảnh export cuối cùng,
|
||||
trước encode). Mọi kích thước suy từ `W`:
|
||||
|
||||
| Đại lượng | Công thức |
|
||||
|---|---|
|
||||
| `band` | `round(W * 0.155)` |
|
||||
| `pad` | `round(W * 0.03)` |
|
||||
| `tagSize` | `round(W * 0.026)` |
|
||||
| `titleSize` | `round(W * 0.042)` |
|
||||
| `metaSize` | `round(W * 0.026)` |
|
||||
|
||||
Các bước vẽ, **theo đúng thứ tự**:
|
||||
|
||||
1. Tạo surface mới `(W, H + band)` — **ảnh cao thêm đúng `band`**. Vẽ ảnh cũ vào `(0, 0)`.
|
||||
2. **Tagline** trên ảnh, góc trên-trái, font mono:
|
||||
- bản `#000000` tại `(pad + 1, pad + tagSize + 1)` (drop shadow, để tag đọc được
|
||||
trên trời sáng),
|
||||
- bản `#f59e0b` (amber) tại `(pad, pad + tagSize)`.
|
||||
3. **Dải nền** dưới ảnh: hình chữ nhật `(0, H, W, band)` tô `#0b0b0b`.
|
||||
4. **Tên recipe** `#f2f2f2`, baseline `(pad, H + pad + titleSize)`.
|
||||
5. **Dòng kỹ thuật** `#9a9a9a`, baseline
|
||||
`(pad, H + pad + titleSize + round(metaSize * 1.7))` — tức cách baseline tên
|
||||
`round(metaSize * 1.7)`.
|
||||
|
||||
Font: **Cousine Regular** — cùng typeface app đã bundle
|
||||
(`assets/Cousine-Regular.ttf`; engine đã nạp sẵn trong `watermarkFonts.typeface`).
|
||||
|
||||
Ví dụ đo được (web): nguồn 1200×900 → file xuất **1200×1086** (`band = 186`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Ba đường phải sửa (parity gate)
|
||||
|
||||
Bản web chỉ có một engine; bản app có **ba** đường vẽ, cả ba phải ra **cùng một file**:
|
||||
|
||||
### 4.1 `src/utils/exportEngine.ts` — engine Skia (bước 9b)
|
||||
|
||||
Chèn ngay **trước** dòng encode (`let bytes = resultImage.encodeToBytes(...)`, ~dòng 917),
|
||||
sau bước snapshot + sharpen (bước 9 hiện tại, ~dòng 896):
|
||||
|
||||
```ts
|
||||
const caption = options?.caption ?? null; // thêm vào ExportOptions
|
||||
if (caption) {
|
||||
const W = resultImage.width();
|
||||
const band = Math.round(W * 0.155);
|
||||
const capSurface = createSurface(W, resultImage.height() + band);
|
||||
const loaded = watermarkFonts; // cùng typeface Cousine đã nạp
|
||||
if (capSurface && loaded) {
|
||||
own(capSurface);
|
||||
const cc = capSurface.getCanvas();
|
||||
cc.drawImage(resultImage, 0, 0, own(Skia.Paint()));
|
||||
|
||||
const pad = Math.round(W * 0.03);
|
||||
const tagSize = Math.round(W * 0.026);
|
||||
const tagFont = own(Skia.Font(loaded.typeface, tagSize));
|
||||
const shadow = own(Skia.Paint()); shadow.setColor(Skia.Color('#000000'));
|
||||
const amber = own(Skia.Paint()); amber.setColor(Skia.Color('#f59e0b'));
|
||||
cc.drawText(caption.tag, pad + 1, pad + tagSize + 1, shadow, tagFont);
|
||||
cc.drawText(caption.tag, pad, pad + tagSize, amber, tagFont);
|
||||
|
||||
const bandTop = resultImage.height();
|
||||
const bandPaint = own(Skia.Paint()); bandPaint.setColor(Skia.Color('#0b0b0b'));
|
||||
cc.drawRect(Skia.XYWHRect(0, bandTop, W, band), bandPaint);
|
||||
const titleSize = Math.round(W * 0.042);
|
||||
const metaSize = Math.round(W * 0.026);
|
||||
const titleFont = own(Skia.Font(loaded.typeface, titleSize));
|
||||
const metaFont = own(Skia.Font(loaded.typeface, metaSize));
|
||||
const titlePaint = own(Skia.Paint()); titlePaint.setColor(Skia.Color('#f2f2f2'));
|
||||
const metaPaint = own(Skia.Paint()); metaPaint.setColor(Skia.Color('#9a9a9a'));
|
||||
cc.drawText(caption.title, pad, bandTop + pad + titleSize, titlePaint, titleFont);
|
||||
cc.drawText(caption.meta, pad, bandTop + pad + titleSize + Math.round(metaSize * 1.7),
|
||||
metaPaint, metaFont);
|
||||
|
||||
const composed = own(capSurface.makeImageSnapshot());
|
||||
release(owned, resultImage); // đăng ký `own`/`release` như mọi buffer khác
|
||||
resultImage = composed;
|
||||
} else if (capSurface) {
|
||||
capSurface.dispose();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`ExportOptions` thêm:
|
||||
|
||||
```ts
|
||||
// Dải file mang theo: tagline amber trên ảnh + caption band bên dưới.
|
||||
// Để trống cho preview/thumbnail — chỉ đường xuất truyền vào.
|
||||
caption?: { tag: string; title: string; meta: string } | null;
|
||||
```
|
||||
|
||||
Và tại call-site xuất (App.tsx) truyền `caption: strip` (đúng object §2) — **không**
|
||||
truyền vào đường preview.
|
||||
|
||||
### 4.2 `src/utils/nativeExport.ts` + `RecipescamExportModule.kt` — đường native
|
||||
|
||||
Đường native không đi qua Skia, nên phải **vẽ band trong Kotlin**. Thêm `caption`
|
||||
vào `NativeExportOptions` và đẩy xuống map `adjust` (cùng chỗ với `geo`, `frameId`):
|
||||
|
||||
```ts
|
||||
caption: options?.caption ?? null, // tag/title/meta — null khi không xuất file
|
||||
```
|
||||
|
||||
Trong `RecipescamExportModule.kt`, chèn **giữa bước 7 (sharpen) và bước 8 (encode)**
|
||||
(~dòng 568, sau khối `if (sharpen > 0) { … }`), vẽ lên chính `render`:
|
||||
|
||||
```kotlin
|
||||
// 7b. Strip (parity với exportEngine #9b): band mới cao hơn ảnh.
|
||||
val cap = adjust["caption"] as? Map<*, *>
|
||||
if (cap != null) {
|
||||
val tag = cap["tag"] as? String
|
||||
val title = cap["title"] as? String
|
||||
val meta = cap["meta"] as? String
|
||||
if (tag != null && title != null && meta != null) {
|
||||
val W = render.width; val H = render.height
|
||||
val band = Math.round(W * 0.155f)
|
||||
val taller = Bitmap.createBitmap(W, H + band, Bitmap.Config.ARGB_8888)
|
||||
val cc = Canvas(taller)
|
||||
cc.drawBitmap(render, 0f, 0f, null)
|
||||
val pad = Math.round(W * 0.03f)
|
||||
val tagSize = Math.round(W * 0.026f)
|
||||
val titleSize = Math.round(W * 0.042f)
|
||||
val metaSize = Math.round(W * 0.026f)
|
||||
// Cùng typeface Cousine mà stampGeo đang dùng (raw assets_cousineregular).
|
||||
val face = geoTypefaceRes(ctx, "assets_cousineregular")
|
||||
// tagline: shadow đen lệch 1px rồi amber
|
||||
val shadow = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.BLACK; typeface = face; textSize = tagSize.toFloat() }
|
||||
val amber = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#f59e0b"); typeface = face; textSize = tagSize.toFloat() }
|
||||
cc.drawText(tag, (pad + 1).toFloat(), (pad + tagSize + 1).toFloat(), shadow)
|
||||
cc.drawText(tag, pad.toFloat(), (pad + tagSize).toFloat(), amber)
|
||||
// dải nền tối
|
||||
cc.drawRect(0f, H.toFloat(), W.toFloat(), (H + band).toFloat(),
|
||||
Paint().apply { color = Color.parseColor("#0b0b0b") })
|
||||
val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#f2f2f2"); typeface = face; textSize = titleSize.toFloat() }
|
||||
val metaPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#9a9a9a"); typeface = face; textSize = metaSize.toFloat() }
|
||||
cc.drawText(title, pad.toFloat(), (H + pad + titleSize).toFloat(), titlePaint)
|
||||
cc.drawText(meta, pad.toFloat(), (H + pad + titleSize + Math.round(metaSize * 1.7)).toFloat(), metaPaint)
|
||||
render.recycle()
|
||||
render = taller
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Lưu ý: band nằm **dưới frame** — RETRO POLAROID / WALL FRAME đã ghép xong trước đó
|
||||
(bước 6b), nên `H` lúc này là chiều cao tấm card/khung, không phải ảnh gốc. Web làm
|
||||
y hệt: bước 9b chạy sau khi frame đã ghép.
|
||||
|
||||
### 4.3 `src/components/Viewfinder.tsx` — preview
|
||||
|
||||
**Không** vẽ gì. Đây là điểm dễ sai nhất: dải thuộc về *file*, không thuộc về màn hình.
|
||||
|
||||
---
|
||||
|
||||
## 5. ISO từ EXIF
|
||||
|
||||
Bản web: `exifr.parse(bytes, { pick: ['ISO', 'ISOSpeedRatings'] })` →
|
||||
`number | null`; `null` in `AUTO` (`recipes-web` @ `b4d5d29`,
|
||||
`docker/frontend/src/engine/imageOps.ts`).
|
||||
|
||||
App hiện **chưa đọc tag ISO** (chỉ có GPS trong `src/utils/exifGps.ts` và ghi EXIF
|
||||
trong `exifWrite.ts`). Thêm một helper nhỏ cạnh `exifGps.ts` đọc APP1/TIFF tag
|
||||
`0x8827` (ISOSpeedRatings) từ bytes ảnh nguồn, hoặc lấy từ EXIF mà asset của ảnh đã
|
||||
nạp sẵn có. Chưa có thì trả `null` → dòng kỹ thuật in `ISO AUTO`.
|
||||
|
||||
Đừng bịa ISO từ knob: build này `ISO_SUPPORTED = false`
|
||||
(`src/components/RecipeCreateModal.tsx` dòng 74) — ISO trong dòng kỹ thuật là ISO
|
||||
**của máy ảnh lúc chụp**, không phải một lựa chọn trong app.
|
||||
|
||||
---
|
||||
|
||||
## 6. Kiểm thử (bắt buộc trước khi coi là xong)
|
||||
|
||||
1. Xuất ảnh có recipe đặt tên, grain > 0, temperature ≠ 5500.
|
||||
2. Kỳ vọng hình học: file cao hơn ảnh gốc đúng `round(W * 0.155)`; ví dụ nguồn
|
||||
1200×900 → **1200×1086**.
|
||||
3. Đo pixel: hàng trong dải nền tối (độ sáng < 0.2); **tagline amber** có pixel
|
||||
`#f59e0b` (đếm > 20 pixel) ở góc trên-trái *trên ảnh*; chữ `#f2f2f2` /
|
||||
`#9a9a9a` nằm trong dải dưới.
|
||||
4. Chạy **cả ba đường** — engine Skia, native Kotlin, và (đối chiếu) web @ `b4d5d29`.
|
||||
Band phải trùng khít giữa ba đường: cùng chiều cao, cùng màu, cùng baseline.
|
||||
5. Preview / viewfinder: khẳng định **không** xuất hiện dải (không có pixel amber
|
||||
`#f59e0b` ngoài watermark vốn có).
|
||||
6. Đối chiếu web: `docker/frontend/src/engine/exportEngine.ts` bước **9b**
|
||||
(`recipes-web` @ `b4d5d29`).
|
||||
|
||||
---
|
||||
|
||||
## 7. Ghi chú phụ
|
||||
|
||||
- Band đặt **sau** frame và **trước** encode — nếu vẽ trước frame, khung sẽ đè mất
|
||||
chữ; nếu vẽ sau encode, file không còn pixel để vẽ.
|
||||
- `band` tính từ **chiều rộng ảnh cuối**, không phải chiều rộng màn hình: ảnh 12MP
|
||||
và ảnh 1200px ra tỉ lệ chữ khác nhau, đúng như mong đợi.
|
||||
- Khi ảnh đã có watermark người dùng / GPS stamp, dải vẫn treo ngoài ảnh — không
|
||||
chồng lên các lớp đó.
|
||||
- Nếu sau này app có "lưu vào bộ sưu tập" tách khỏi "tải xuống": bản web cố ý lưu
|
||||
bản **sạch** (không đốt dải) cho lưới cộng đồng và chỉ đốt dải vào file tải xuống,
|
||||
để khung của lưới tự vẽ tagline từ nhãn đã lưu — tránh tag hai lần. Nếu app không
|
||||
có lưới cộng đồng thì cứ đốt thẳng vào file xuất.
|
||||
@@ -0,0 +1,191 @@
|
||||
# 6 — CREATE RECIPES: gấp nhóm + thêm EXPOSURE / EV / WHITE / BLACK
|
||||
|
||||
Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06.
|
||||
Nguồn đối chiếu: nhánh `recipes-web`, commit **`f17cb08`** ("web: fold the create
|
||||
form's groups and add the white/black point rows") — bản web đã làm, đã đo và
|
||||
đang chạy ở `http://localhost:8090/app`. Commit `cf4b01d` là việc đổi màu tên
|
||||
recipe đang chọn trong FAVORITED; bản app **đã có sẵn** (`AdjustmentPanel.tsx`,
|
||||
tab `favorites`: `active: r.id === currentRecipeId`) nên không cần làm lại.
|
||||
|
||||
Đây là ghi chú yêu cầu, **chưa implement trên app**. Web đã xong và là bản mẫu
|
||||
để đối chiếu từng dòng.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mục tiêu
|
||||
|
||||
Form CREATE RECIPES đã dài thành một cuộn dọc. Hai việc:
|
||||
|
||||
1. **Gấp được** sáu nhóm phân loại: SIMULATION, DYNAMIC RANGE, GRAIN EFFECT,
|
||||
COLOR CHROME EFFECT, COLOR CHROME EFFECT BLUE, WHITE BALANCE.
|
||||
2. **Thêm bốn ô số**: EXPOSURE, EV, WHITE, BLACK.
|
||||
|
||||
Thứ tự hàng giữ nguyên như điện thoại đang có, chỉ chèn thêm — trừ
|
||||
`EXPOSURE COMP.` đổi tên thành `EV` (cùng một tham số `exposureCompensation`,
|
||||
chỉ khác nhãn; web đã đổi để hai chữ EXPOSURE/EV không lẫn nhau).
|
||||
|
||||
---
|
||||
|
||||
## 2. Việc 1 — gấp nhóm (thuần UI, chỉ `RecipeCreateModal.tsx`)
|
||||
|
||||
Web dùng `<details>`/`<summary>` gốc của trình duyệt: không state, không
|
||||
thư viện, trình duyệt tự giữ cờ mở/đóng. App dùng React Native nên phải tự làm:
|
||||
một `useState<Record<string, boolean>>` cho sáu nhóm, nhãn nhóm bấm được, mũi
|
||||
tên xoay 90° khi mở, và phần thân chỉ render khi nhóm đang mở.
|
||||
|
||||
Ranh giới từng nhóm trong `RecipeCreateModal.tsx` (số dòng theo bản hiện tại):
|
||||
|
||||
| Nhóm | Hàng nằm trong |
|
||||
| --- | --- |
|
||||
| SIMULATION | dải chip `FILM_SIMS` |
|
||||
| DYNAMIC RANGE | dải chip `DR_OPTS` |
|
||||
| GRAIN EFFECT | dải chip `GRAIN_OPTS` |
|
||||
| COLOR CHROME EFFECT | dải chip `CC_OPTS` |
|
||||
| COLOR CHROME EFFECT BLUE | dải chip `CC_OPTS` |
|
||||
| WHITE BALANCE | dải chip `WB_PRESETS` **+ hai ô WB SHIFT RED/BLUE** |
|
||||
|
||||
Ngoài nhóm (luôn hiện): NAME, lưới số (`fieldSets`), hàng ISO, SAVE/EXPORT.
|
||||
Khối WB SHIFT đi cùng WHITE BALANCE — về mặt nhiếp ảnh nó là một phần của WB.
|
||||
|
||||
Mặc định **mở cả sáu** để form không đổi hành vi cho tới khi người dùng tự gấp.
|
||||
|
||||
CSS web đã dùng (đối chiếu nếu muốn giống hệt):
|
||||
|
||||
```css
|
||||
.create-sec { display: flex; flex-direction: column; gap: 6px; }
|
||||
.create-sec > summary { display: flex; align-items: center; gap: 6px; cursor: pointer; list-style: none; user-select: none; }
|
||||
.create-sec > summary::-webkit-details-marker { display: none; }
|
||||
.create-sec > summary::before { content: '▸'; font-size: 9px; transition: transform 0.15s ease; }
|
||||
.create-sec[open] > summary::before { transform: rotate(90deg); }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Việc 2 — bốn ô số
|
||||
|
||||
`fieldSets` hiện tại:
|
||||
|
||||
```ts
|
||||
{ label: 'HIGHLIGHT', value: fHigh, set: setFHigh },
|
||||
{ label: 'SHADOW', value: fShadow, set: setFShadow },
|
||||
{ label: 'COLOR', value: fColor, set: setFColor },
|
||||
{ label: 'NOISE RED.', value: fNr, set: setFNr, ph: '-10..10' },
|
||||
{ label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '-10..10' },
|
||||
{ label: 'CLARITY', value: fClarity, set: setFClarity },
|
||||
{ label: 'EXPOSURE COMP.', value: fEv, set: setFEv, ph: 'EV' },
|
||||
```
|
||||
|
||||
Web sau khi sửa (đúng thứ tự này):
|
||||
|
||||
```ts
|
||||
{ label: 'HIGHLIGHT', value: fHigh, set: setFHigh },
|
||||
{ label: 'SHADOW', value: fShadow, set: setFShadow },
|
||||
{ label: 'WHITE', value: fWhites, set: setFWhites },
|
||||
{ label: 'BLACK', value: fBlacks, set: setFBlacks },
|
||||
{ label: 'COLOR', value: fColor, set: setFColor },
|
||||
{ label: 'NOISE RED.', value: fNr, set: setFNr, ph: '-10..10' },
|
||||
{ label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '-10..10' },
|
||||
{ label: 'CLARITY', value: fClarity, set: setFClarity },
|
||||
{ label: 'EXPOSURE', value: fExposure, set: setFExposure },
|
||||
{ label: 'EV', value: fEv, set: setFEv, ph: '±3 EV' },
|
||||
```
|
||||
|
||||
Thêm ba state (`fWhites`, `fBlacks`, `fExposure`) song song với các `f*` đang
|
||||
có, seed từ `seedAdj`/`valuesOf`, và **bắt buộc** thêm vào `handleSave` — thiếu
|
||||
một dòng là ô vừa nhập bị `...seedAdj` ghi đè và recipe lưu ra khác màn hình:
|
||||
|
||||
```ts
|
||||
whites: int(fWhites, simAdj.whites ?? 0, -10, 10),
|
||||
blacks: int(fBlacks, simAdj.blacks ?? 0, -10, 10),
|
||||
exposure: int(fExposure, simAdj.exposure, -10, 10),
|
||||
```
|
||||
|
||||
`EXPOSURE` là `adjustments.exposure` (-10..10, độ lợi ma trận) — đã có sẵn trong
|
||||
model và trong tab LIGHT, chỉ thiếu hàng trong CREATE. `EV` là
|
||||
`exposureCompensation` (-3..+3 EV) như hàng `EXPOSURE COMP.` cũ.
|
||||
|
||||
---
|
||||
|
||||
## 4. `WHITE` / `BLACK` là tham số MỚI — phải thêm cả model lẫn shader
|
||||
|
||||
Không có `whites`/`blacks` ở đâu trong app (đã grep cả hai nhánh). Nếu chỉ vẽ ô
|
||||
nhập mà không đụng engine thì đó là **ô chết**: nhập xong không ảnh nào đổi.
|
||||
|
||||
### 4.1 `src/types/index.ts` — `ColorAdjustments`
|
||||
|
||||
```ts
|
||||
highlight: number; // -10 to +10 (- pulls highlights down / roll, + brightens)
|
||||
shadow: number; // -10 to +10 (+ lifts shadows, - deepens)
|
||||
whites?: number; // -10 to +10 (white point: the very top of the tone curve)
|
||||
blacks?: number; // -10 to +10 (black point: the very toe of the tone curve)
|
||||
```
|
||||
|
||||
Để **optional** (`?`) — recipe cũ trong máy không có hai khóa này vẫn đọc được.
|
||||
|
||||
### 4.2 `src/utils/toneShader.ts` — `TONE_SKSL`
|
||||
|
||||
Khai báo uniform ngay sau `sh` (thứ tự này phải khớp `toneUniformArray`):
|
||||
|
||||
```glsl
|
||||
uniform float wh;
|
||||
uniform float bl;
|
||||
```
|
||||
|
||||
Trong `main`, chèn **sau** hai dòng Dynamic Range và **trước** `o = clamp(o, …)`:
|
||||
|
||||
```glsl
|
||||
// White/black point. The HL/SH knees above already spend the slope budget
|
||||
// (each just under 1.0), so these two use cubic end-weights instead of
|
||||
// another smoothstep: only the last/first stretch of the ramp moves, and the
|
||||
// derivative stays >= 1 - 3*0.18 = 0.46 for every combination, so the curve
|
||||
// can never invert. + lifts that end (faded blacks / brighter whites),
|
||||
// - digs it in (true black / rolled white).
|
||||
o += bl * 0.18 * (1.0 - t) * (1.0 - t) * (1.0 - t);
|
||||
o += wh * 0.18 * t * t * t;
|
||||
```
|
||||
|
||||
Vì sao **không** dùng thêm một cặp `smoothstep` như HL/SH: hai knee 0.65..1.00
|
||||
và 0.00..0.55 đã tiêu gần hết "ngân sách độ dốc" (mỗi cái ~0.94/1.0); thêm một
|
||||
knee nữa là đẩy tổng độ dốc qua 1.0 và đường cong **đảo** (điểm sáng hơn ra tối
|
||||
hơn). Cubic end-weight chỉ chạm ~đoạn cuối của dải, đạo hàm luôn ≥ 0.46.
|
||||
|
||||
### 4.3 Bốn chỗ còn lại trong cùng file
|
||||
|
||||
```ts
|
||||
export interface ToneUniforms {
|
||||
dr: number; hl: number; sh: number;
|
||||
wh: number; // -1..1 (adjustments.whites / 10 — white point)
|
||||
bl: number; // -1..1 (adjustments.blacks / 10 — black point)
|
||||
vib: number; …
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
const wh = Math.max(-1, Math.min(1, (adj.whites ?? 0) / 10));
|
||||
const bl = Math.max(-1, Math.min(1, (adj.blacks ?? 0) / 10));
|
||||
```
|
||||
|
||||
`getToneUniforms` trả thêm `wh, bl`; `toneIsActive` thêm `u.wh !== 0 || u.bl !== 0`
|
||||
(không thêm thì pass tone bị bỏ qua và hai ô không có tác dụng); và
|
||||
`toneUniformArray` — **thứ tự phần tử phải trùng khai báo GLSL**:
|
||||
|
||||
```ts
|
||||
return [u.dr, u.hl, u.sh, u.wh, u.bl, u.vib,
|
||||
u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb];
|
||||
```
|
||||
|
||||
`dr`/`hl`/`sh` hai nhánh giống nhau; chỉ 4 khối ở mục 4.2–4.3 là mới.
|
||||
|
||||
---
|
||||
|
||||
## 5. Kiểm thử (đo được, không đoán)
|
||||
|
||||
Web đã đo bằng ảnh thật (`/tmp/w0/p2.jpg`): `WHITE +10` nâng độ chói decile trên
|
||||
cùng, `BLACK +10` nâng decile dưới cùng và **không** đụng đỉnh, `BLACK -10` hạ
|
||||
decile dưới. Vòng round-trip `.recipe` (XML xor16-v1) giữ nguyên
|
||||
`whites = 4`, `blacks = -3`, `exposureCompensation = 2` qua chính importer của
|
||||
điện thoại.
|
||||
|
||||
Bản app cần đo lại trên **cả ba đường vẽ** (engine Skia xuất file, native export,
|
||||
preview viewfinder) như ghi chú 5 đã nêu: cùng một cặp WHITE/BLACK phải ra cùng
|
||||
một ảnh.
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
# 7 — SCAN QR: quét mã trên trang web để import recipe vào app
|
||||
|
||||
Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06.
|
||||
Nguồn đối chiếu: nhánh `recipes-web`, commit **`8a889db`** ("web: the QR card
|
||||
hands out the look that made the photo") — bản web đã làm, đã đo, đang chạy ở
|
||||
`http://localhost:8090`.
|
||||
|
||||
Đây là ghi chú cho phần mềm Android **và đã implement trên nhánh này**. Khác
|
||||
các ghi chú 1–6 (chỉ nêu yêu cầu), ghi chú này ghi lại cả hai nửa: web đã mở ra
|
||||
cái gì, và app đọc cái đó bằng cách nào.
|
||||
|
||||
---
|
||||
|
||||
## 1. Web đã mở ra cái gì (phía `recipes-web`)
|
||||
|
||||
Trang admin có thêm **checkbox thứ tư `qr`** cạnh `strip / tester / creator`.
|
||||
Tick `qr` cho một ảnh nghĩa là: ảnh đó xuất hiện ở thẻ QR trên landing page.
|
||||
|
||||
Ảnh được tick `qr` phải có **preset** (`photos.recipe`, cột JSON). Khi có,
|
||||
thẻ QR trên landing trỏ tới một route công khai mới của backend:
|
||||
|
||||
```
|
||||
GET /api/photos/:id/preset.recipe
|
||||
```
|
||||
|
||||
| Trường hợp | Trả về |
|
||||
| --- | --- |
|
||||
| `id` không phải số, không có hàng, `recipe IS NULL`, hoặc ảnh không tick `qr` | `404` |
|
||||
| Hợp lệ | `200`, `content-type: application/xml; charset=utf-8`, `x-content-type-options: nosniff`, `content-disposition: attachment; filename="recipescam-<id>.recipe"` |
|
||||
|
||||
Nội dung file là **đúng định dạng `.recipe` mà app đã xuất/nhập**: XML bọc
|
||||
payload xor16-v1, sinh tại `docker/backend/src/recipeFile.ts` (bản sao của
|
||||
`shared/utils/recipeShare.ts`). Salt mới mỗi lần tải — hai lần quét cùng một mã
|
||||
ra hai file khác byte nhưng cùng recipe. `test/security.mjs` khoá hình dạng
|
||||
envelope này (+12 check, backend 134/134).
|
||||
|
||||
Vì vậy **mã QR chỉ chứa URL**, không chứa recipe. Vài chục byte, không phải
|
||||
vài KB base64 — camera chỉ cần bắt được một liên kết ngắn.
|
||||
|
||||
Nhãn i18n liên quan (đã có trên web): `adm.pickQr` = `QR card` / `Thẻ QR`,
|
||||
`adm.noPreset` = `no look to hand out` / `không có look để chia sẻ`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Vì sao KHÔNG dùng vision-camera để quét
|
||||
|
||||
Đường thẳng nhất là quét QR bằng chính session camera của viewfinder. Không
|
||||
được: **vision-camera v5 trên Android không có object output.**
|
||||
|
||||
```text
|
||||
modules/.../HybridCameraFactory.kt:112
|
||||
createObjectOutput → throw "CameraObjectOutput is not available on Android!"
|
||||
```
|
||||
|
||||
`useObjectOutput` là API iOS-only. Barcode/QR trên Android phải đi đường khác,
|
||||
nên app dùng **`expo-camera`** — nó bọc chính barcode scanner của Android.
|
||||
|
||||
```bash
|
||||
npx expo install expo-camera # ~57.0.5
|
||||
```
|
||||
|
||||
`app.json` thêm plugin trước `expo-sharing`:
|
||||
|
||||
```json
|
||||
["expo-camera", { "cameraPermission": "Allow RecipesCam to scan a recipe QR code." }]
|
||||
```
|
||||
|
||||
**Hệ quả bắt buộc: phải build lại dev client / APK.** `expo-camera` là native
|
||||
module mới; bundle JS cũ không có nó.
|
||||
|
||||
App giờ có **hai thư viện camera**. CameraX không cho hai client cùng giữ một
|
||||
ống kính, nên khi sheet quét mở thì viewfinder phải nhả camera ra — xem mục 4.
|
||||
|
||||
### 2.1 HTTP thuần (cleartext) — chỉ chạy ở debug
|
||||
|
||||
Route preset hiện phục vụ qua **HTTP thuần** (`http://<host>:8090/...`). Android
|
||||
9+ chặn cleartext mặc định, nhưng manifest debug đã bật sẵn:
|
||||
|
||||
```xml
|
||||
<!-- android/app/src/debug/AndroidManifest.xml -->
|
||||
<application android:usesCleartextTraffic="true" ... />
|
||||
```
|
||||
|
||||
`android/app/src/main/AndroidManifest.xml` **không** có dòng đó. Nghĩa là:
|
||||
|
||||
| Bản build | Quét thẻ QR |
|
||||
| --- | --- |
|
||||
| dev client / APK debug | chạy — cleartext được bật |
|
||||
| APK release | `fetch` bị chặn, `Import failed` |
|
||||
|
||||
Nếu bản release cũng phải quét được thì **một trong hai**: phục vụ landing qua
|
||||
HTTPS (tốt hơn — khi đó không phải đụng gì), hoặc bật cleartext cho release bằng
|
||||
`expo-build-properties` (`android.usesCleartextTraffic: true`) — plugin này chưa
|
||||
cài, nên chỉ thêm khi thật sự cần.
|
||||
|
||||
---
|
||||
|
||||
## 3. `src/components/QrScanModal.tsx` (mới)
|
||||
|
||||
Sheet quét: `Modal` + `CameraView` (`facing="back"`, `barcodeScannerSettings` chỉ
|
||||
`['qr']`), xin quyền bằng `useCameraPermissions()` khi mở, và một **chốt
|
||||
`takenRef`** — scanner báo mã ở mọi frame còn giữ mã, chốt chặn lần đầu để
|
||||
không import mười lần. Mở lại sheet thì chốt reset.
|
||||
|
||||
Props: `{ visible, onCancel, onScanned }`. Khung ngắm màu hổ phách + dòng
|
||||
`POINT AT A RECIPE QR` + nút X đóng. Khung ngắm `pointerEvents="none"` để cú
|
||||
chạm rơi xuống camera bên dưới.
|
||||
|
||||
---
|
||||
|
||||
## 4. Nối vào App
|
||||
|
||||
| File | Thay đổi |
|
||||
| --- | --- |
|
||||
| `App.tsx` | state `scanVisible`; `storeImportedRecipe(xml)` tách ra dùng chung với IMPORT; `handleScanRecipe(value)`; truyền `onScanRecipe` xuống panel; render `<QrScanModal>`; truyền `paused={scanVisible}` xuống viewfinder |
|
||||
| `src/components/AdjustmentPanel.tsx` | prop `onScanRecipe: () => void`; chip `SCAN QR` sau chip `IMPORT` trong tab `recipes` |
|
||||
| `src/components/Viewfinder.tsx` | prop `paused?: boolean` (mặc định `false`); `isActive={appActive && !ultraWideOn && !paused}`, `active={appActive && ultraWideOn && !paused}` |
|
||||
| `app.json` | plugin `expo-camera` + câu xin quyền |
|
||||
| `package.json` | `expo-camera ~57.0.5` |
|
||||
|
||||
`handleScanRecipe` **đóng sheet trước**, rồi mới xử lý — thất bại là một hộp
|
||||
thoại, không phải một scanner còn chạy. Cổng `liteSaveBlocked` /
|
||||
`promptRecipeLimit()` đứng trước, giống đường IMPORT.
|
||||
|
||||
Liên kết phải đúng route preset, không thì từ chối kèm lý do:
|
||||
|
||||
```ts
|
||||
const link = /^https?:\/\/\S+\/api\/photos\/\d+\/preset\.recipe$/.exec(value.trim());
|
||||
if (!link) throw new Error('That code is not a RecipesCam recipe link.');
|
||||
const res = await fetch(link[0]);
|
||||
if (!res.ok) throw new Error(`The recipe link answered ${res.status}.`);
|
||||
await storeImportedRecipe(await res.text());
|
||||
```
|
||||
|
||||
Nhờ vậy mã QR của wifi, link shop, link app store… đều bị chặn bằng câu
|
||||
`That code is not a RecipesCam recipe link.` chứ không im lặng nuốt.
|
||||
|
||||
`storeImportedRecipe` chính là thân cũ của `handleImportRecipe`: dynamic-import
|
||||
`importRecipeXml` → `saveCustomRecipe` → `getAllRecipes` → `applyRecipe` →
|
||||
Haptics success → `Alert.alert('Recipe Imported', …)`. Hai đường (chọn file và
|
||||
quét mã) giờ dùng chung một chỗ lưu, nên recipe quét về nằm cùng danh sách
|
||||
custom như recipe import bằng file.
|
||||
|
||||
---
|
||||
|
||||
## 5. Kiểm thử
|
||||
|
||||
- `node --stack-size=12000 node_modules/typescript/bin/tsc --noEmit` → đúng 15
|
||||
lỗi có sẵn (`Viewfinder.tsx`, `skiaImage.ts`), **không lỗi nào ở
|
||||
`App.tsx` / `QrScanModal.tsx` / `AdjustmentPanel.tsx`.
|
||||
(`npx tsc --noEmit` trên cây Android crash `RangeError: Maximum call stack size exceeded` — lỗi có sẵn, phải truyền `--stack-size`.)
|
||||
- Phía web: `preset-roundtrip.cjs` 11/0 — file backend sinh ra import được bằng
|
||||
chính parser của app (tiếng Việt có dấu, `classic-vivid`, adjustments,
|
||||
`frameId`, `useGeotag`), và envelope lạ bị từ chối.
|
||||
- **Còn phải đo trên máy thật** sau khi build lại: quét thẻ QR trên landing →
|
||||
recipe xuất hiện trong PRESETS và look đổi; quét một mã không phải link
|
||||
RecipesCam → hộp thoại từ chối; mở sheet trong khi viewfinder đang chạy →
|
||||
không có lỗi CameraX "camera already in use".
|
||||
@@ -10,23 +10,29 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
import TopBar from './src/components/TopBar';
|
||||
import Viewfinder, { type ViewfinderHandle } from './src/components/Viewfinder';
|
||||
import AdjustmentPanel from './src/components/AdjustmentPanel';
|
||||
import AdjustmentPanel, { HslPickPanel } from './src/components/AdjustmentPanel';
|
||||
import CameraControls from './src/components/CameraControls';
|
||||
import ToolRail, { TabId } from './src/components/ToolRail';
|
||||
import SettingsModal from './src/components/SettingsModal';
|
||||
import PhotoViewerModal from './src/components/PhotoViewerModal';
|
||||
import LibraryPickerModal from './src/components/LibraryPickerModal';
|
||||
import QrScanModal from './src/components/QrScanModal';
|
||||
import ProModal from './src/components/ProModal';
|
||||
|
||||
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, FlashMode, StartupMode, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT } from './src/types';
|
||||
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, FlashMode, StartupMode, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT, HslBand, HslBandId } from './src/types';
|
||||
import { getAllRecipes, saveCustomRecipe, updateCustomRecipe, deleteCustomRecipe, getFavoriteIds, setFavoriteId } from './src/utils/storageUtils';
|
||||
import { DEFAULT_ADJUSTMENTS, FILM_SIMS, filmSimToRecipe } from './src/utils/defaultRecipes';
|
||||
import { HSL_BANDS, nearestHslBand, rgbToHsl } from './src/utils/colorUtils';
|
||||
import { getCurrentGPS, requestLocationPermissions, reverseGeocode, withDeadline } from './src/utils/locationUtils';
|
||||
// Types are erased at build time, so this one is free.
|
||||
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';
|
||||
@@ -54,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
|
||||
@@ -76,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');
|
||||
@@ -143,6 +138,16 @@ export default function App() {
|
||||
// Custom temporary overrides for the sliders
|
||||
const [adjustments, setAdjustments] = useState<ColorAdjustments>({ ...DEFAULT_ADJUSTMENTS });
|
||||
|
||||
// HSL mixer: the band ruler and the eyedropper are shared by the chip row
|
||||
// (AdjustmentPanel) and by the pick layer over the picture (Viewfinder), so
|
||||
// they live here.
|
||||
const [hslBand, setHslBand] = useState<HslBandId>('red');
|
||||
const [picking, setPicking] = useState(false);
|
||||
const [hslSample, setHslSample] = useState<{ r: number; g: number; b: number } | null>(null);
|
||||
// Where on the picture the sample was read: fractions of the image, the
|
||||
// floating panel's anchor.
|
||||
const [hslPickedAt, setHslPickedAt] = useState<{ fx: number; fy: number } | null>(null);
|
||||
|
||||
const [selectedFrame, setSelectedFrame] = useState<FrameId>('none');
|
||||
// PRO only: the GPS stamp is a paid look, so a LITE session starts without
|
||||
// it and `handleToggleGeotag` refuses to turn it on.
|
||||
@@ -277,6 +282,9 @@ export default function App() {
|
||||
const [viewerIndex, setViewerIndex] = useState<number | null>(null);
|
||||
// In-app library picker (grid -> full-screen preview -> USE/CANCEL).
|
||||
const [libraryPickerVisible, setLibraryPickerVisible] = useState(false);
|
||||
// QR sheet: reads a recipe link off a screen, so the viewfinder camera behind
|
||||
// it is paused while it is up (one lens, two camera libraries).
|
||||
const [scanVisible, setScanVisible] = useState(false);
|
||||
// Gear sheet (sound / ratio / metering mode). Camera captures never auto-open
|
||||
// the viewer anymore — the thumbnail button does.
|
||||
const [settingsVisible, setSettingsVisible] = useState(false);
|
||||
@@ -327,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
|
||||
@@ -760,6 +778,29 @@ export default function App() {
|
||||
lastStepRef.current = 0;
|
||||
setUndoDepth(0);
|
||||
};
|
||||
// FX's MONOCHROME switch: not a look of its own — it swaps the base filter for
|
||||
// the mono stock and swaps right back. Adjustments are never touched, so a knob
|
||||
// turned while it is on survives the round trip.
|
||||
const monoOn = selectedRecipe?.baseFilter === 'monochrome';
|
||||
const monoPrevRef = useRef<Recipe | null>(null);
|
||||
|
||||
const handleToggleMono = () => {
|
||||
commit(true, () => {
|
||||
if (selectedRecipe?.baseFilter === 'monochrome') {
|
||||
const back = monoPrevRef.current ?? filmSimToRecipe(FILM_SIMS.find((f) => f.id === 'sim-provia')!);
|
||||
monoPrevRef.current = null;
|
||||
setSelectedRecipe(back);
|
||||
} else {
|
||||
monoPrevRef.current = selectedRecipe;
|
||||
setSelectedRecipe({
|
||||
...(selectedRecipe ?? filmSimToRecipe(FILM_SIMS[0])),
|
||||
id: 'mono',
|
||||
name: 'MONOCHROME',
|
||||
baseFilter: 'monochrome',
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
const applyLook = (l: Look) => {
|
||||
setSelectedRecipe(l.recipe);
|
||||
setAdjustments(l.adjustments);
|
||||
@@ -849,6 +890,40 @@ export default function App() {
|
||||
});
|
||||
};
|
||||
|
||||
// One band triple, as stored. A band back at [0,0,0] is DELETED, and the
|
||||
// field goes back to `undefined` (not {}) when the last one leaves: the
|
||||
// shallow differs() behind resetDirty then reads a clean mixer as clean.
|
||||
const setBandKnob = (which: 0 | 1 | 2, v: number) => {
|
||||
const band = adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
|
||||
const next: HslBand = [band[0], band[1], band[2]];
|
||||
next[which] = Math.round(v);
|
||||
const bands = { ...adjustments.hslBands };
|
||||
if (next[0] === 0 && next[1] === 0 && next[2] === 0) delete bands[hslBand];
|
||||
else bands[hslBand] = next;
|
||||
handleUpdateAdjustments({ hslBands: Object.keys(bands).length ? bands : undefined });
|
||||
};
|
||||
|
||||
// The mixer's three whole-image knobs: one door for all three, so the ruler
|
||||
// and the field it writes are the same value wherever they were opened.
|
||||
const setHslGlobal = (which: 0 | 1 | 2, v: number) => {
|
||||
const key = which === 0 ? 'hslHue' : which === 1 ? 'hslSat' : 'hslLum';
|
||||
const clamped = Math.max(-10, Math.min(10, Math.round(v)));
|
||||
handleUpdateAdjustments({ [key]: clamped } as Partial<ColorAdjustments>);
|
||||
};
|
||||
|
||||
// The eyedropper's report: print the colour it read, point the ruler at that
|
||||
// colour's band, hang the panel on the spot it came from, and put the tool
|
||||
// away — one pick, one colour.
|
||||
const pickColor = (
|
||||
rgb: { r: number; g: number; b: number },
|
||||
at: { fx: number; fy: number }
|
||||
) => {
|
||||
setHslSample(rgb);
|
||||
setHslBand(nearestHslBand(rgbToHsl(rgb.r, rgb.g, rgb.b).h));
|
||||
setHslPickedAt(at);
|
||||
setPicking(false);
|
||||
};
|
||||
|
||||
const handleUpdateFrame = (frameId: FrameId) => commit(false, () => applyFrame(frameId));
|
||||
const applyFrame = (frameId: FrameId) => {
|
||||
setSelectedFrame(frameId);
|
||||
@@ -1028,6 +1103,18 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
// PRESETS -> IMPORT / SCAN QR: a `.recipe` file that arrived from elsewhere —
|
||||
// picked off storage or fetched from a scanned link — becomes a normal custom
|
||||
// recipe, and the look on screen switches to it.
|
||||
const storeImportedRecipe = async (xml: string) => {
|
||||
const { importRecipeXml } = await import('./src/utils/recipeShare');
|
||||
const created = await saveCustomRecipe(importRecipeXml(xml));
|
||||
setRecipes(await getAllRecipes());
|
||||
applyRecipe(created);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
Alert.alert('Recipe Imported', `"${created.name}" has been added.`);
|
||||
};
|
||||
|
||||
// PRESETS -> IMPORT: a recipe file shared from another phone, picked from
|
||||
// storage, decoded and stored as a normal custom recipe.
|
||||
const handleImportRecipe = async () => {
|
||||
@@ -1038,13 +1125,30 @@ export default function App() {
|
||||
try {
|
||||
const picked = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true });
|
||||
if (picked.canceled) return;
|
||||
const xml = await FileSystem.readAsStringAsync(picked.assets[0].uri);
|
||||
const { importRecipeXml } = await import('./src/utils/recipeShare');
|
||||
const created = await saveCustomRecipe(importRecipeXml(xml));
|
||||
setRecipes(await getAllRecipes());
|
||||
applyRecipe(created);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
Alert.alert('Recipe Imported', `"${created.name}" has been added.`);
|
||||
await storeImportedRecipe(await FileSystem.readAsStringAsync(picked.assets[0].uri));
|
||||
} catch (e) {
|
||||
Alert.alert('Import failed', e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
// PRESETS -> SCAN QR: the landing page's recipe card carries a link to a
|
||||
// photo's own `.recipe` file, so a scan is the same import with the file
|
||||
// fetched instead of picked. The code holds a URL, never the recipe, so
|
||||
// anything that is not the preset route — a shop link, a wifi code — is
|
||||
// refused with the reason. The sheet closes either way; a failure is a
|
||||
// dialog, not a scanner left running.
|
||||
const handleScanRecipe = async (value: string) => {
|
||||
setScanVisible(false);
|
||||
if (liteSaveBlocked) {
|
||||
promptRecipeLimit();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const link = /^https?:\/\/\S+\/api\/photos\/\d+\/preset\.recipe$/.exec(value.trim());
|
||||
if (!link) throw new Error('That code is not a RecipesCam recipe link.');
|
||||
const res = await fetch(link[0]);
|
||||
if (!res.ok) throw new Error(`The recipe link answered ${res.status}.`);
|
||||
await storeImportedRecipe(await res.text());
|
||||
} catch (e) {
|
||||
Alert.alert('Import failed', e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
@@ -1394,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
|
||||
@@ -1454,12 +1608,14 @@ export default function App() {
|
||||
// shutter instant as the EXIF capture date.
|
||||
exported: false,
|
||||
capturedAt,
|
||||
// 300dpi header + full-res screen sharpen. No evFromCamera: the camera
|
||||
// AE bias is never written, so the export applies the same software EV
|
||||
// gain the preview shows.
|
||||
// 300dpi header. No evFromCamera: the camera AE bias is never written,
|
||||
// so the export applies the same software EV gain the preview shows.
|
||||
// No forced output sharpen either: the 0.5 unsharp mask multiplied
|
||||
// sensor noise ~3.2x (5x at Nyquist), so dim/high-ISO captures came
|
||||
// back far noisier than the preview. The SHARPENING knob still
|
||||
// sharpens when the user dials it above 0.
|
||||
// aspect: crop to the on-screen composition ratio (full = keep 4:3 native).
|
||||
dpi: 300,
|
||||
sharpen: true,
|
||||
aspect: aspectRatio === 'full' ? undefined : aspectRatio,
|
||||
watermark: wmForExport(),
|
||||
liteMark,
|
||||
@@ -1471,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 {
|
||||
@@ -1779,6 +1938,10 @@ export default function App() {
|
||||
const handleToggleTab = (tab: TabId) => {
|
||||
setOpenTab((prev) => (prev === tab ? null : tab));
|
||||
setOpenParamKey(null);
|
||||
// The eyedropper belongs to the HSL row: leave the tab (or close the panel)
|
||||
// and the layer goes with it, so an invisible pick layer cannot sit over
|
||||
// the picture swallowing pan and zoom.
|
||||
setPicking(false);
|
||||
};
|
||||
|
||||
// While a panel parameter row is open, a vertical drag anywhere on the live
|
||||
@@ -1821,6 +1984,9 @@ export default function App() {
|
||||
photoStraighten={shown.photoStraighten}
|
||||
cameraPermissionGranted={cameraPermissionGranted}
|
||||
onRequestCameraPermission={requestCameraPermission}
|
||||
// One lens, two camera libraries: while the QR sheet is up its own
|
||||
// scanner owns the camera and this session has to let go.
|
||||
paused={scanVisible}
|
||||
photoOutput={photoOutput}
|
||||
rawOutput={rawPhotoOutput}
|
||||
rawEnabled={rawEnabled}
|
||||
@@ -1855,6 +2021,22 @@ export default function App() {
|
||||
setOpenParamKey(null);
|
||||
setWmEditNonce((n) => n + 1);
|
||||
}}
|
||||
// HSL eyedropper: the viewer owns the pick layer and hangs the panel
|
||||
// it is handed; the state behind both is the HSL row's, above.
|
||||
picking={picking}
|
||||
hslPanelAt={hslPickedAt}
|
||||
onPickColor={pickColor}
|
||||
hslPanel={
|
||||
openTab === 'hsl' && hslSample && hslPickedAt ? (
|
||||
<HslPickPanel
|
||||
sample={hslSample}
|
||||
bandLabel={HSL_BANDS.find((b) => b.id === hslBand)?.label ?? ''}
|
||||
band={shownAdjustments.hslBands?.[hslBand] ?? [0, 0, 0]}
|
||||
onKnob={setBandKnob}
|
||||
onClose={() => setHslPickedAt(null)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -1927,6 +2109,8 @@ export default function App() {
|
||||
openParam={openParamKey}
|
||||
onOpenParamChange={setOpenParamKey}
|
||||
currentBaseFilter={selectedRecipe?.baseFilter}
|
||||
monoOn={monoOn}
|
||||
onToggleMono={handleToggleMono}
|
||||
onCreateRecipe={handleCreateRecipe}
|
||||
editingRecipe={editingRecipe}
|
||||
onCloseEdit={() => setEditingRecipe(null)}
|
||||
@@ -1939,6 +2123,7 @@ export default function App() {
|
||||
onToggleGeotag={handleToggleGeotag}
|
||||
onSaveRecipe={handleSaveRecipe}
|
||||
onImportRecipe={handleImportRecipe}
|
||||
onScanRecipe={() => setScanVisible(true)}
|
||||
recipeLimitReached={liteSaveBlocked}
|
||||
onRecipeLimit={promptRecipeLimit}
|
||||
onDeleteRecipe={handleDeleteRecipe}
|
||||
@@ -1952,6 +2137,14 @@ export default function App() {
|
||||
wmRotation={shown.wmRotation}
|
||||
onRotateWm={() => commit(false, () => setWmRotation((r) => (r + 90) % 360))}
|
||||
onWmPanelChange={setWmPanelOpen}
|
||||
hslBand={hslBand}
|
||||
onHslBand={setHslBand}
|
||||
picking={picking}
|
||||
onPicking={setPicking}
|
||||
hslSample={hslSample}
|
||||
onSetHslGlobal={setHslGlobal}
|
||||
// A live preview has no pixel to read, so PICK is library-only.
|
||||
canPick={mode === 'library'}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
@@ -1965,6 +2158,14 @@ export default function App() {
|
||||
onSelect={handleLibraryPicked}
|
||||
/>
|
||||
|
||||
{/* QR sheet: reads a recipe link off another screen (the landing page's
|
||||
card) and imports the file it points at. */}
|
||||
<QrScanModal
|
||||
visible={scanVisible}
|
||||
onCancel={() => setScanVisible(false)}
|
||||
onScanned={handleScanRecipe}
|
||||
/>
|
||||
|
||||
<CameraControls
|
||||
lastPhotoUri={lastPhotoUri}
|
||||
onCapture={handleCapture}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# Ghi chú port web → Android: sim `CLASSIC VIVIDIPES` + chip FX `MONOCHROME`
|
||||
|
||||
Nguồn: web studio `docker/frontend` (nhánh `recipes-web`), commit `d7d7be2` (sim mới) và
|
||||
`4d0d441` (chip FX). Việc dưới đây là **port sang app Android** (`src/`, nhánh này).
|
||||
|
||||
Đối chiếu: `src/` giữa `recipes-web` và `feat/vision-camera-v5` **chỉ khác**
|
||||
`src/components/RecipeCreateModal.tsx` (+9 dòng) — nên port gần như là chép y nguyên.
|
||||
Số dòng dưới đây tính theo nhánh này.
|
||||
|
||||
---
|
||||
|
||||
## 1. Sim mới `CLASSIC VIVIDIPES`
|
||||
|
||||
Ghép tại dòng blue: **giữ nguyên dòng blue của `classic-chrome`**, lấy **dòng R/G của `velvia`**.
|
||||
|
||||
Sửa 4 file:
|
||||
|
||||
1. `src/types/index.ts:65` — `BaseFilter`: thêm `'classic-vivid'`.
|
||||
2. `src/utils/colorUtils.ts` — thêm nhánh **ngay sau** nhánh `classic-chrome` (dòng 111):
|
||||
```ts
|
||||
} else if (baseFilter === 'classic-vivid') {
|
||||
// Classic Vivid: the Classic Chrome blue row kept verbatim — skies stay the
|
||||
// same muted teal/cyan — with the red and green rows taken from Velvia, so
|
||||
// everything that is not blue reads loud.
|
||||
matrix = [
|
||||
1.3895, -0.2540, -0.0256, 0, -0.0550, // R — velvia
|
||||
-0.0772, 1.2355, -0.0261, 0, -0.0661, // G — velvia
|
||||
0.0652, 0.3973, 0.5575, 0, -0.0100, // B — classic-chrome (nguyên bản)
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
```
|
||||
3. `src/utils/defaultRecipes.ts:43` — `FILM_SIMS`, thêm ngay sau `sim-classic-chrome`:
|
||||
```ts
|
||||
{ id: 'sim-classic-vivid', name: 'CLASSIC VIVIDIPES', baseFilter: 'classic-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
```
|
||||
4. `src/utils/toneShader.ts:190` — `FILM_TONE`, cạnh `'classic-chrome'`:
|
||||
```ts
|
||||
'classic-vivid': { sh: -0.28 },
|
||||
```
|
||||
(Dìm shadow thuộc về stock chứ không thuộc dòng ma trận → đi kèm. Bỏ dòng này nếu muốn
|
||||
VIVIDIPES không dìm shadow.)
|
||||
|
||||
**Không phải sửa gì thêm**: chip `PHOTO STYLE` (`AdjustmentPanel.tsx` `groupDefs.style`), TopBar
|
||||
(`activeRecipe.name`) và picker trong `RecipeCreateModal.tsx` đều map từ `FILM_SIMS`.
|
||||
|
||||
**Kiểm chứng**: strip PHOTO STYLE có **10** sim; chọn `CLASSIC VIVIDIPES` → look khác **cả**
|
||||
`CLASSIC CHRIPES` lẫn `VELVIPES`; `CLASSIC CHRIPES` vẫn nguyên vẹn cạnh nó.
|
||||
|
||||
---
|
||||
|
||||
## 2. Chip `MONOCHROME` ở tab FX (`filters`)
|
||||
|
||||
Đây là **công tắc**, không phải một look: bật thì đổi `baseFilter` sang `monochrome`, tắt thì
|
||||
trả lại đúng cái ảnh đang mặc (kể cả tên sim). **Không được đụng `adjustments`** — knob chỉnh
|
||||
trong lúc đang bật phải sống sót.
|
||||
|
||||
Web: `docker/frontend/src/App.tsx` — `monoPrev` ref + `toggleMono`, chip `mono` đứng đầu
|
||||
`case 'fx'`, `active: monoOn` với `monoOn = recipe.baseFilter === 'monochrome'`.
|
||||
|
||||
Android cần 2 chỗ:
|
||||
|
||||
**a. `src/components/AdjustmentPanel.tsx`** — `case 'filters'` (dòng 888):
|
||||
```ts
|
||||
case 'filters':
|
||||
return chipRow([
|
||||
{ key: 'mono', label: 'MONOCHROME', active: monoOn, amberValue: monoOn, onPress: onToggleMono },
|
||||
...paramChips(paramDefs.filters),
|
||||
]);
|
||||
```
|
||||
+ thêm 2 prop vào `interface AdjustmentPanelProps`: `monoOn: boolean;` và `onToggleMono: () => void;`
|
||||
+ truyền từ `App.tsx` chỗ render `<AdjustmentPanel … />` (`App.tsx:1929` có `currentBaseFilter`,
|
||||
`onSelectRecipe` — đặt cạnh đó).
|
||||
|
||||
**b. `App.tsx`** — Android giữ `adjustments` là state **riêng** (không nằm trong `selectedRecipe`),
|
||||
nên công tắc chỉ chạm `selectedRecipe`:
|
||||
|
||||
```ts
|
||||
// FX's MONOCHROME switch: not a look of its own — it swaps the base filter for
|
||||
// the mono stock and swaps right back. Adjustments are never touched.
|
||||
const monoOn = selectedRecipe?.baseFilter === 'monochrome';
|
||||
const monoPrevRef = useRef<Recipe | null>(null);
|
||||
|
||||
const handleToggleMono = () => {
|
||||
commit(true, () => {
|
||||
if (selectedRecipe?.baseFilter === 'monochrome') {
|
||||
const back = monoPrevRef.current ?? filmSimToRecipe(FILM_SIMS.find((f) => f.id === 'sim-provia')!);
|
||||
setSelectedRecipe(back);
|
||||
} else {
|
||||
monoPrevRef.current = selectedRecipe;
|
||||
setSelectedRecipe({
|
||||
...(selectedRecipe ?? filmSimToRecipe(FILM_SIMS[0])),
|
||||
id: 'mono',
|
||||
name: 'MONOCHROME',
|
||||
baseFilter: 'monochrome',
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
Lưu ý:
|
||||
- `commit(true, …)` (dòng 739) để công tắc vào **undo stack** như mọi edit khác — `handleResetAll`
|
||||
dùng đúng cách này.
|
||||
- `Look` đã có `recipe` → UNDO tự trả lại đúng `baseFilter` cũ, không cần state phụ nào khác.
|
||||
- `id: 'mono'` không trùng sim nào nên khi bật, chip PHOTO STYLE **không** còn sáng tên sim cũ
|
||||
(`groupDefs.style.value` tính từ `currentRecipeId` so với `FILM_SIMS`).
|
||||
- Tắt khi `monoPrevRef` rỗng (ví dụ vào monochrome bằng cách chọn sim ACRIPES, không qua công tắc)
|
||||
→ rơi về PROVIA, tức là "tắt là có màu", không phải về đen trắng.
|
||||
|
||||
**Kiểm chứng**: bật → ảnh xám (mọi pixel `R=G=B`; chỉ lệch khi WB khác 5500K hoặc tint khác 0);
|
||||
tắt → ảnh trở lại **y hệt** trước khi bật (so hash pixel); đổi `CONTRAST` trong lúc bật/tắt →
|
||||
giá trị vẫn giữ; UNDO một nhịp → về màu, chip trở lại trạng thái tắt.
|
||||
|
||||
---
|
||||
|
||||
## 3. Test web đã chạy (tham chiếu — nằm trong scratchpad của agent, không nằm trong repo)
|
||||
|
||||
- `sim-vivid-test.cjs` — **9/0**: chip có mặt, strip đủ 10 sim, chọn được, render khác cả
|
||||
CHRIPES lẫn VELVIPES.
|
||||
- `fx-mono-test.cjs` — **15/0**: đo trực tiếp kênh màu trên pixel preview (spread `>25` khi màu,
|
||||
`<=6` khi mono), hash quay lại y hệt khi tắt, CONTRAST sống sót, UNDO gỡ mono.
|
||||
- Regression web quanh 2 thay đổi: `panel`, `layout`, `fonts 27/0`, `create 52/0`, `share 22/0`,
|
||||
`photo-save 19/0`, `redo 30/0`, `accent-brand` — tất cả xanh.
|
||||
@@ -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`).
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Play release notes
|
||||
|
||||
Text for Google Play Console → Release → "What's new". One entry per version, newest
|
||||
first. Plain text only (the Console renders no markdown), 500 characters max per
|
||||
language — each block below is measured and fits. Paragraphs are one line each so a
|
||||
copy-paste lands in the Console unwrapped.
|
||||
|
||||
Release name field: `1.2.7 (9)`
|
||||
|
||||
## 1.2.7 (versionCode 9)
|
||||
|
||||
```
|
||||
Photos taken in dim light and at high ISO came out much noisier than the preview showed. Every shot was being sharpened in the background by a fixed amount the viewfinder never applied, and it amplified sensor noise about three times over.
|
||||
|
||||
That hidden sharpening is gone, so high-ISO shots now come back as clean as the preview promised. The SHARPENING slider still sharpens exactly as before when you move it above 0.
|
||||
```
|
||||
|
||||
419 characters (1.2.7).
|
||||
|
||||
Artifact: `android/app/build/outputs/bundle/liteRelease/app-lite-release.aab`
|
||||
(appId `com.locphamtran.recipescamera`, versionCode 9, versionName 1.2.7).
|
||||
The pro APK built from the same commit is `app-pro-release.apk`, versionName
|
||||
`1.2.7-pro` - it is the paid build handed out directly, not uploaded here.
|
||||
|
||||
## 1.2.6 (versionCode 8)
|
||||
|
||||
```
|
||||
New film simulation B&W HIGH CONTRAST, next to ACRIPES: a warm-filter black-and-white mix, with deeper blacks and lifted whites, so the greys take a harder push.
|
||||
Its contrast comes from the stock itself, so the EXPOSURE and CONTRAST sliders stay at 0 while the shot renders pushed.
|
||||
```
|
||||
|
||||
281 characters (1.2.6).
|
||||
|
||||
Artifact: `android/app/build/outputs/bundle/liteRelease/app-lite-release.aab`
|
||||
(appId `com.locphamtran.recipescamera`, versionCode 8, versionName 1.2.6).
|
||||
The pro APK built from the same commit is `app-pro-release.apk`, versionName
|
||||
`1.2.6-pro` - it is the paid build handed out directly, not uploaded here.
|
||||
|
||||
## 1.2.5 (versionCode 7)
|
||||
|
||||
```
|
||||
Film simulations are now pure colour and tone: each one is the stock's own colour matrix and curve, with no grain, clarity, exposure or saturation baked in - those are your edits to make.
|
||||
LC STREETLIFE VIVID keeps its brightness from the stock itself, so the EXPOSURE slider stays at 0 while the shot comes out lifted.
|
||||
```
|
||||
|
||||
Artifact: `android/app/build/outputs/bundle/liteRelease/app-lite-release.aab`
|
||||
(appId `com.locphamtran.recipescamera`, versionCode 7, versionName 1.2.5).
|
||||
The pro APK built from the same commit is `app-pro-release.apk`, versionName
|
||||
`1.2.5-pro` - it is the paid build handed out directly, not uploaded here.
|
||||
|
||||
## 1.2.4 (versionCode 6)
|
||||
|
||||
```
|
||||
HSL selective colour is here: eight hue bands (red, orange, yellow, green, aqua, blue, purple, magenta), each with its own HUE, SAT and LUM, plus HUE/SAT/LUM IMAGE knobs that move every colour at once.
|
||||
In the library, tap PICK and touch any colour on the photo: a panel opens on that spot with the three knobs of the band it belongs to.
|
||||
The tool rail is reordered: presets, favorites, white balance, light, effects, HSL, frame.
|
||||
```
|
||||
|
||||
427 characters (1.2.4).
|
||||
|
||||
Artifact: `android/app/build/outputs/bundle/liteRelease/app-lite-release.aab`
|
||||
(appId `com.locphamtran.recipescamera`, versionCode 6, versionName 1.2.4).
|
||||
The pro APK built from the same commit is `app-pro-release.apk`, versionName
|
||||
`1.2.4-pro` — it is the paid build handed out directly, not uploaded here.
|
||||
@@ -1,5 +1,52 @@
|
||||
# Release notes
|
||||
|
||||
## 1.2.7 (versionCode 9)
|
||||
|
||||
Export
|
||||
- Every camera capture came out of the exporter through a full-resolution unsharp mask the viewfinder never showed. App.tsx passed `sharpen: true`, and with the SHARPENING knob at its default 0 both engines read that flag as an *amount* of 0.5 - a value that existed only as the boolean's fallback. The 3x3 kernel `[0,-a,0; -a,1+4a,-a; 0,-a,0]` at a=0.5 has a noise gain of 3.167x at low frequency and 5.0x at Nyquist, so a dim, high-ISO frame was pushed back several times noisier than the preview promised.
|
||||
- The knob is now the only source of output sharpening. The boolean is gone from ExportOptions and NativeExportOptions, the 0.5 fallback is gone with it, and screenSharpenImage no longer carries `amount = 0.5` as a default argument - a second door onto the same value. A knob above 0 sharpens exactly as before, the negative knob still means none, and the Kotlin-side `adjust["sharpen"]` amount is untouched.
|
||||
- Measured on a Xiaomi thor, 3000x4000 captures in one unchanged scene. At 1x / ISO ~330 the noise sigma drops 4.52 -> 2.35, the dark-quartile sigma 3.21 -> 1.56 and a flat 64px tile 0.599 -> 0.349; at 10x / ISO ~1900 the same three fall 1.22 -> 1.09, 0.75 -> 0.54 and 0.464 -> 0.225. With no edit applied, the JPEGs shrink 26% and 35% - the mask had also been spending a third of every file on amplified noise.
|
||||
- Stills now match the preview, which always rendered without the mask.
|
||||
|
||||
Notes
|
||||
- qualityPrioritization was re-tested as the other suspect and cleared: MAXIMIZE_QUALITY ('quality') moved neither noise figure at either ISO, so the shipped 'balanced' capture mode stays.
|
||||
- Zoom to 10x in the same room is the high-ISO bench for this device: AE picks ISO ~1900-2100 there against ~330 at 1x.
|
||||
|
||||
## 1.2.6 (versionCode 8)
|
||||
|
||||
Presets
|
||||
- New film sim B&W HIGH CONTRAST, next to ACRIPES. Acros' grey ramp taken through a warm filter mix - red 0.38 / green 0.56 / blue 0.06, the three rows equal and summing to 1.00, so a neutral stays the neutral the sensor recorded and nothing clips at either end - and then pushed at both ends: the toe goes to -0.32 against Acros' -0.12, and the shoulder LIFTS to +0.26 instead of rolling off. Midtones see neither mask, so the long smooth stretch between the two ends survives, which is what keeps that hard a push off the posterised look.
|
||||
- The stock's own slope (+4 of Contrast) is not in the sim's adjustments: the panel still reads EXPOSURE 0 / CONTRAST 0, and FX still reads GRAIN 0 / CLARITY 0, while the render comes out pushed. It rides STOCK_BIAS in colorUtils.ts, keyed by baseFilter - the old STOCK_EXPOSURE table folded in, one place for the tone a stock carries by itself (LC STREETLIFE VIVID exposure +2, B&W HIGH CONTRAST contrast +4).
|
||||
- Two black-and-white stocks exist now, so every "is this a colour stock?" test goes through the new isMonochromeBase(): saturation, white balance and the R/B fine-tune in the matrix, Color Chrome in the tone shader. A second B&W stock can no longer end up half excluded - a stray Kelvin gain on a grey ramp shows up as a tint.
|
||||
- Measured against the compiled tables: a 0.25 grey goes 0.2500 -> 0.2250 (the +4 slope pivoting on mid-grey 0.5), a full red goes 0.213 -> 0.368 while blue drops to 0.016, and colour-stage knobs moved hard (saturation +8, white balance 9000K, wbR +5 / wbB -5) leave both B&W matrices byte-identical.
|
||||
- On a Xiaomi thor: the chip sits right after ACRIPES, its preview reads mean chroma 0.00 against 17.48 for a colour sim, and the frame's luma spread widens as designed (sd 92.3 / p95 245 against ACRIPES' 88.9 / 238).
|
||||
|
||||
## 1.2.5 (versionCode 7)
|
||||
|
||||
Presets
|
||||
- A film sim is a stock, not an edit: every entry in FILM_SIMS now carries the neutral defaults verbatim, and its whole look comes from the stock's own colour matrix plus its FILM_TONE curve. The effects that had been riding along are gone - grain on ACRIPES, clarity+grain on LC STREETLIFE CLASSIC, and exposure/saturation/clarity/shadow/highlight on LC STREETLIFE VIVID.
|
||||
- LC STREETLIFE VIVID is brighter because the stock is brighter, not because a slider was moved: the +2 exposure lives in STOCK_EXPOSURE (colorUtils.ts), keyed by baseFilter, where the preview and both exporters already read it. It never reaches the panel, so EXPOSURE still reads 0 on that sim.
|
||||
- The naming rule is recorded in the FILM_SIMS header: colour and tone only, plus a reference table of the real stock each sim stands for. Reference only - the UI keeps the sim names.
|
||||
- Measured against the compiled tables: a mid grey goes 0.5000 -> 0.5435 under LC STREETLIFE VIVID, a ratio of 1.1008 against exposure +2's own 1.1000, while the sim's own exposure value stays 0.
|
||||
|
||||
## 1.2.4 (versionCode 6)
|
||||
|
||||
HSL
|
||||
- New HSL tab in the tool rail: the web studio's 8-band selective colour mixer. Each band (RED, ORANGE, YELLOW, GREEN, AQUA, BLUE, PURPLE, MAGENTA) carries hue, saturation and lightness knobs, -10..+10; -10 on SAT is grey, full HUE is a 30 deg turn, full LUM is +0.25.
|
||||
- Band weights are linear tents anchored on the 8 hues and summing to 1 at every hue, so neighbouring bands hand over at exactly half strength and no hue falls in a gap. Pixels under 8% saturation are gated out of the weights (rgb2hsl calls them red, and an ungated neutral would ride the RED band), while the whole-image LUM move is added after the gate, so a picture pulled to grey by -SAT still answers +LUM.
|
||||
- The mixer runs last in TONE_SKSL, after split-tone/kv and vibrance, so a band edit is judged on the colour the user actually sampled.
|
||||
- Library mode gains an eyedropper: PICK arms a layer over the picture, a tap reads the rendered pixel, points the ruler at the nearest band and hangs a panel with that band's three knobs. In camera mode PICK is greyed out - a live preview has no pixel to read - but the mixer still runs in the preview.
|
||||
- HUE / SAT / LUM IMAGE move every hue at once, behind an IMAGE divider in the same chip row.
|
||||
|
||||
Rail
|
||||
- Tool rail order: PRESETS, FAVORITED, WB, LIGHT, FX, HSL, FRAME.
|
||||
|
||||
Notes
|
||||
- HSL is a LITE feature: both builds get it, no PRO gate.
|
||||
- MONOCHROME disables the mixer outright - a mono stock has no hue to select.
|
||||
- The camera worklet's tone buffer grew from 10 to 40 slots; a missed slot would have pushed NaN into every uniform and broken the whole tone pass, not just HSL.
|
||||
- Measured on a Xiaomi thor against an 8-band chart: +10 whole-image HUE rotates all eight bands by exactly +30 deg, -10 on a band's SAT flattens that band to grey while the other seven stay put, and a whole-image -SAT drops the camera preview's mean chroma from 13.7 to 0.4 with +LUM still lifting it 116 -> 166.
|
||||
|
||||
## 1.2.3 (versionCode 5)
|
||||
|
||||
Shutter
|
||||
|
||||
@@ -92,8 +92,8 @@ android {
|
||||
applicationId 'com.locphamtran.recipescamera'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 5
|
||||
versionName "1.2.3"
|
||||
versionCode 9
|
||||
versionName "1.2.7"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "RecipesCam",
|
||||
"slug": "recipescamera",
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.7",
|
||||
"orientation": "portrait",
|
||||
"icon": "./RecipesCamIcon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
@@ -49,6 +49,12 @@
|
||||
"isAccessMediaLocationEnabled": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "Allow RecipesCam to scan a recipe QR code."
|
||||
}
|
||||
],
|
||||
"expo-sharing"
|
||||
],
|
||||
"extra": {
|
||||
|
||||
+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
|
||||
|
||||
Generated
+76
@@ -14,6 +14,7 @@
|
||||
"babel-preset-expo": "~57.0.0",
|
||||
"expo": "~57.0.7",
|
||||
"expo-asset": "~57.0.5",
|
||||
"expo-camera": "~57.0.5",
|
||||
"expo-dev-client": "~57.0.6",
|
||||
"expo-document-picker": "~57.0.2",
|
||||
"expo-file-system": "~57.0.1",
|
||||
@@ -2042,6 +2043,12 @@
|
||||
"integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/emscripten": {
|
||||
"version": "1.41.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.6.tgz",
|
||||
"integrity": "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||
@@ -2448,6 +2455,15 @@
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/barcode-detector": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.2.2.tgz",
|
||||
"integrity": "sha512-/4QOrrNrCRmDSBWiiP4aC72dnkuXUEdcFRidHgbPRXUpy82XcCMvJssI6fEs6JT42LS7DvBVZO70qasJbYKyrA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"zxing-wasm": "3.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -3380,6 +3396,26 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-camera": {
|
||||
"version": "57.0.5",
|
||||
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-57.0.5.tgz",
|
||||
"integrity": "sha512-0JaUXfDWMytmqm4pVKesZfLTSjAumfS2UGhnc1M4tqJ0qATCS0jxc7KjaoyKEzWJY+FEc8GlPCAGprS/7tNWZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"barcode-detector": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-web": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native-web": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-constants": {
|
||||
"version": "57.0.6",
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.6.tgz",
|
||||
@@ -7420,6 +7456,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/tagged-tag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
||||
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.4.19",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
@@ -7976,6 +8024,34 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zxing-wasm": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.3.tgz",
|
||||
"integrity": "sha512-3lC9BJk4fR5ZJxcGjb0hVnDFOW7KpLXHIebiBmVd4FDRQZVeObztoTKxRUPxlWwSgxrMRONK0u50hBD1aTYEKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/emscripten": "^1.41.5",
|
||||
"type-fest": "^5.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/emscripten": ">=1.39.6"
|
||||
}
|
||||
},
|
||||
"node_modules/zxing-wasm/node_modules/type-fest": {
|
||||
"version": "5.10.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.10.0.tgz",
|
||||
"integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"dependencies": {
|
||||
"tagged-tag": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"babel-preset-expo": "~57.0.0",
|
||||
"expo": "~57.0.7",
|
||||
"expo-asset": "~57.0.5",
|
||||
"expo-camera": "~57.0.5",
|
||||
"expo-dev-client": "~57.0.6",
|
||||
"expo-document-picker": "~57.0.2",
|
||||
"expo-file-system": "~57.0.1",
|
||||
|
||||
@@ -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
|
||||
@@ -4,7 +4,8 @@ import Slider from '@react-native-community/slider';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Star, Trash2 } from 'lucide-react-native';
|
||||
import { Skia } from '@shopify/react-native-skia';
|
||||
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, BaseFilter, CropRatio } from '../types';
|
||||
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, BaseFilter, CropRatio, HslBand, HslBandId } from '../types';
|
||||
import { HSL_BANDS, hslToHex, rgbToHsl } from '../utils/colorUtils';
|
||||
import { FRAMES } from '../utils/frameUtils';
|
||||
import { PRO_FRAMES } from '../utils/entitlement';
|
||||
import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs';
|
||||
@@ -17,7 +18,7 @@ import RecipeCreateModal from './RecipeCreateModal';
|
||||
// Tapping a parameter chip toggles its slider row; chips stay visible.
|
||||
// Darkroom-flavoured: amber = active/edited, red = destructive, mono labels.
|
||||
|
||||
type TabId = 'recipes' | 'favorites' | 'iq' | 'wb' | 'filters' | 'frame';
|
||||
type TabId = 'recipes' | 'favorites' | 'iq' | 'wb' | 'filters' | 'hsl' | 'frame';
|
||||
// Choice-group keys that open a strip above the chip row: color chrome groups
|
||||
// (wb tab) and the D.RANGE selector (iq tab).
|
||||
type GroupKey =
|
||||
@@ -80,6 +81,10 @@ interface AdjustmentPanelProps {
|
||||
// Base filter of the currently applied recipe — seeds the CREATE modal's
|
||||
// SIMULATION row so quick-input starts from the active look.
|
||||
currentBaseFilter?: BaseFilter;
|
||||
// FX tab MONOCHROME: a switch, not a look — on it swaps the base filter for
|
||||
// the mono stock, off it hands back the one that was there.
|
||||
monoOn: boolean;
|
||||
onToggleMono: () => void;
|
||||
// CREATE quick-input saves a brand-new recipe (name + adjustments from the
|
||||
// modal) and applies it, unlike onSaveRecipe which snapshots the live state.
|
||||
onCreateRecipe: (recipe: Omit<Recipe, 'id' | 'isCustom'>) => void;
|
||||
@@ -116,6 +121,9 @@ interface AdjustmentPanelProps {
|
||||
// IMPORT: pick a recipe file shared from another phone and store it as a
|
||||
// normal custom recipe.
|
||||
onImportRecipe: () => void;
|
||||
// SCAN QR: read the recipe link on the landing page's card with the camera
|
||||
// and import the file it points at — same store, no file picker.
|
||||
onScanRecipe: () => void;
|
||||
// LITE: this many of its own recipes exist, so the next one is the paid
|
||||
// step. The strip's trailing + chip reads it, the save paths gate on it.
|
||||
recipeLimitReached: boolean;
|
||||
@@ -145,6 +153,21 @@ interface AdjustmentPanelProps {
|
||||
// FRAME → WATERMARK sub-panel is open: App needs this to arm the mark's
|
||||
// drag/pinch layer on the image (the old `activeTab === 'watermark'`).
|
||||
onWmPanelChange: (open: boolean) => void;
|
||||
// HSL tab. The mixer's UI state lives in App because the eyedropper in the
|
||||
// Viewfinder writes into the same four values this row reads: which band the
|
||||
// ruler is on, whether PICK is armed, and the colour it last read.
|
||||
hslBand: HslBandId;
|
||||
onHslBand: (id: HslBandId) => void;
|
||||
picking: boolean;
|
||||
onPicking: (on: boolean) => void;
|
||||
hslSample: { r: number; g: number; b: number } | null;
|
||||
// The three IMAGE knobs are whole-picture moves, not a band's, so they go
|
||||
// through one entry point — the ruler and the field it writes cannot then
|
||||
// disagree about which one moved.
|
||||
onSetHslGlobal: (which: 0 | 1 | 2, v: number) => void;
|
||||
// PICK has nothing to read off a live preview, so in camera mode the chip
|
||||
// greys out rather than arming a layer that would do nothing.
|
||||
canPick: boolean;
|
||||
// BACK (undo) and PEEK (hold to compare) both work on the whole LOOK, not on
|
||||
// one parameter, and both left this panel: BACK is a button in the top bar,
|
||||
// PEEK is a hold on the picture itself (TopBar, Viewfinder).
|
||||
@@ -162,6 +185,7 @@ interface ChipDef {
|
||||
onPressStar?: () => void;
|
||||
disabled?: boolean; // informational label, not tappable
|
||||
amberValue?: boolean; // amber text driven by edited value, not selection
|
||||
color?: string; // swatch dot: a chip naming a colour has to show it
|
||||
pro?: boolean; // a PRO feature: LITE draws the corner tag on it
|
||||
}
|
||||
|
||||
@@ -180,6 +204,11 @@ interface ParamDef {
|
||||
|
||||
const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
// The eyedropper's floating panel is a fixed width so the Viewfinder can hang
|
||||
// it centred on the picked point (it measures the height itself, which the
|
||||
// three slider rows decide).
|
||||
const HSL_PANEL_W = 244;
|
||||
|
||||
// WB preset → engine pair. AUTO and DAYLIGHT both land on the neutral
|
||||
// 5500K / 0 pair (the engine has no scene meter), so the pair alone cannot say
|
||||
// which chip is active — the last tapped preset is remembered to disambiguate.
|
||||
@@ -240,6 +269,8 @@ export default function AdjustmentPanel({
|
||||
openParam,
|
||||
onOpenParamChange,
|
||||
currentBaseFilter,
|
||||
monoOn,
|
||||
onToggleMono,
|
||||
onCreateRecipe,
|
||||
editingRecipe,
|
||||
onCloseEdit,
|
||||
@@ -258,6 +289,7 @@ export default function AdjustmentPanel({
|
||||
onToggleWallLandscape,
|
||||
onToggleGeotag,
|
||||
onImportRecipe,
|
||||
onScanRecipe,
|
||||
recipeLimitReached,
|
||||
onRecipeLimit,
|
||||
onDeleteRecipe,
|
||||
@@ -271,6 +303,13 @@ export default function AdjustmentPanel({
|
||||
wmRotation,
|
||||
onRotateWm,
|
||||
onWmPanelChange,
|
||||
hslBand,
|
||||
onHslBand,
|
||||
picking,
|
||||
onPicking,
|
||||
hslSample,
|
||||
onSetHslGlobal,
|
||||
canPick,
|
||||
}: AdjustmentPanelProps) {
|
||||
// Double-tap on the mark in the viewfinder bumps wmEditNonce -> focus the
|
||||
// input again so the text can be retyped after the keyboard was dismissed.
|
||||
@@ -411,6 +450,17 @@ export default function AdjustmentPanel({
|
||||
className={`flex-row items-center rounded-full border px-3 py-1.5 ${tone}`}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{c.color ? (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
backgroundColor: c.color,
|
||||
marginRight: 6,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Text className={`font-mono text-[11px] font-bold tracking-wide ${textTone}`}>
|
||||
{c.label}
|
||||
</Text>
|
||||
@@ -496,6 +546,26 @@ export default function AdjustmentPanel({
|
||||
: p
|
||||
),
|
||||
filters: toRows(PARAM_DEFS.filters),
|
||||
// The mixer's three whole-image knobs are not PARAM_DEFS entries: they
|
||||
// read and write the picture's own move rather than a flat field of
|
||||
// adjustments, and unlike the panel on the image they do not belong to the
|
||||
// band the ruler points at. Bound here so the existing slider row, its
|
||||
// amber value and its back button all work unchanged.
|
||||
hsl: [0, 1, 2].map((i) => {
|
||||
const own = [a.hslHue ?? 0, a.hslSat ?? 0, a.hslLum ?? 0];
|
||||
const name = i === 0 ? 'HUE' : i === 1 ? 'SAT' : 'LUM';
|
||||
return {
|
||||
key: i === 0 ? 'hsl.h' : i === 1 ? 'hsl.s' : 'hsl.l',
|
||||
label: `${name} IMAGE`,
|
||||
value: own[i],
|
||||
default: 0,
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
display: (v: number) => (v > 0 ? `+${v}` : String(v)),
|
||||
onChange: (v: number) => onSetHslGlobal(i as 0 | 1 | 2, v),
|
||||
};
|
||||
}),
|
||||
// FRAME carries one slider row: the fine STRAIGHTEN angle. The ROTATE
|
||||
// strip's STRAIGHTEN option opens it, exactly like COLOR TEMP's.
|
||||
frame: [
|
||||
@@ -839,6 +909,12 @@ export default function AdjustmentPanel({
|
||||
active: false,
|
||||
onPress: onImportRecipe,
|
||||
},
|
||||
{
|
||||
key: 'scan',
|
||||
label: 'SCAN QR',
|
||||
active: false,
|
||||
onPress: onScanRecipe,
|
||||
},
|
||||
],
|
||||
8
|
||||
);
|
||||
@@ -886,7 +962,46 @@ export default function AdjustmentPanel({
|
||||
]);
|
||||
|
||||
case 'filters':
|
||||
return chipRow(paramChips(paramDefs.filters));
|
||||
// MONOCHROME leads the strip: a switch that swaps the base filter for
|
||||
// the mono stock and swaps right back, so it never touches a knob.
|
||||
return chipRow([
|
||||
{ key: 'mono', label: 'MONOCHROME', active: monoOn, amberValue: monoOn, onPress: onToggleMono },
|
||||
...paramChips(paramDefs.filters),
|
||||
]);
|
||||
|
||||
case 'hsl': {
|
||||
// PICK arms the eyedropper; the eight colour chips pick the band the
|
||||
// ruler edits; the three knobs below the divider move the whole
|
||||
// picture. Row above picks ONE colour, the IMAGE knobs move them all.
|
||||
return chipRow(
|
||||
[
|
||||
{
|
||||
key: 'hsl-pick',
|
||||
label: 'PICK',
|
||||
active: picking,
|
||||
disabled: !canPick,
|
||||
onPress: () => onPicking(!picking),
|
||||
},
|
||||
...HSL_BANDS.map((b): ChipDef => {
|
||||
const band = a.hslBands?.[b.id];
|
||||
const moved = !!band && (band[0] !== 0 || band[1] !== 0 || band[2] !== 0);
|
||||
return {
|
||||
key: `hsl-band-${b.id}`,
|
||||
label: b.label,
|
||||
color: hslToHex(b.hue, 70, 50),
|
||||
active: hslBand === b.id,
|
||||
amberValue: hslBand !== b.id && moved,
|
||||
onPress: () => onHslBand(b.id),
|
||||
};
|
||||
}),
|
||||
// Divider: the row above is colour, the knobs after it are the
|
||||
// whole image.
|
||||
{ key: 'hsl-image', label: 'IMAGE', disabled: true, active: false, onPress: () => {} },
|
||||
...paramChips(paramDefs.hsl),
|
||||
],
|
||||
8
|
||||
);
|
||||
}
|
||||
|
||||
case 'frame':
|
||||
// FRAME -> WATERMARK: the sub-panel row (breadcrumb back to the frames).
|
||||
@@ -954,9 +1069,42 @@ export default function AdjustmentPanel({
|
||||
|
||||
// ---- the single open slider row (ABOVE the chips row) ----------------------
|
||||
const openParamDef = paramDefs[activeTab].find((p) => p.key === openParam) ?? null;
|
||||
// HSL readout: what the eyedropper last saw, and the band the ruler is on.
|
||||
const bandName = HSL_BANDS.find((b) => b.id === hslBand)?.label ?? '';
|
||||
const sampleHsl = hslSample ? rgbToHsl(hslSample.r, hslSample.g, hslSample.b) : null;
|
||||
|
||||
return (
|
||||
<View>
|
||||
{activeTab === 'hsl' && (
|
||||
<View className="flex-row items-center px-3 py-1">
|
||||
{hslSample && sampleHsl ? (
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: '#3f3f46',
|
||||
backgroundColor: `rgb(${hslSample.r}, ${hslSample.g}, ${hslSample.b})`,
|
||||
}}
|
||||
/>
|
||||
<Text className="ml-2 font-mono text-[11px] text-zinc-400">
|
||||
R {hslSample.r} · G {hslSample.g} · B {hslSample.b}
|
||||
</Text>
|
||||
<Text className="ml-2 font-mono text-[11px] text-zinc-400">
|
||||
{Math.round(sampleHsl.h)}° · {Math.round(sampleHsl.s * 100)}% ·{' '}
|
||||
{Math.round(sampleHsl.l * 100)}%
|
||||
</Text>
|
||||
<Text className="ml-2 font-mono text-[11px] font-bold text-amber-400">{bandName}</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text className="font-mono text-[10px] tracking-widest text-zinc-500">
|
||||
BẤM PICK RỒI CHỌN MỘT MÀU TRÊN ẢNH
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{openParamDef && (
|
||||
<View
|
||||
key={openParamDef.key}
|
||||
@@ -1101,3 +1249,92 @@ export default function AdjustmentPanel({
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// The floating panel the eyedropper hangs over the picked spot: the sample
|
||||
// colour after the selected band's knobs, its name and hex, and the three
|
||||
// knobs that edit that band. Lives here rather than in the Viewfinder so it
|
||||
// reuses this file's Slider row and haptics; App builds the node and hands it
|
||||
// to the Viewfinder, which only positions it.
|
||||
export function HslPickPanel({
|
||||
sample,
|
||||
bandLabel,
|
||||
band,
|
||||
onKnob,
|
||||
onClose,
|
||||
}: {
|
||||
sample: { r: number; g: number; b: number };
|
||||
bandLabel: string;
|
||||
band: HslBand;
|
||||
onKnob: (which: 0 | 1 | 2, v: number) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// What this band does to THIS colour, not to the wheel in general: the same
|
||||
// +10 on HUE lands differently on olive and on sky.
|
||||
const picked = rgbToHsl(sample.r, sample.g, sample.b);
|
||||
const mixed = hslToHex(
|
||||
picked.h + band[0],
|
||||
Math.max(0, Math.min(100, picked.s * 100 + band[1])),
|
||||
Math.max(0, Math.min(100, picked.l * 100 + band[2]))
|
||||
);
|
||||
return (
|
||||
<View
|
||||
className="rounded-xl border border-zinc-700/80 bg-black/90 px-3 py-2"
|
||||
style={{ width: HSL_PANEL_W }}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<View
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: '#3f3f46',
|
||||
backgroundColor: mixed,
|
||||
}}
|
||||
/>
|
||||
<View className="ml-2 flex-1">
|
||||
<Text className="font-mono text-[11px] font-bold text-amber-400">{bandLabel}</Text>
|
||||
<Text className="font-mono text-[10px] text-zinc-400">{mixed.toUpperCase()}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
haptic();
|
||||
onClose();
|
||||
}}
|
||||
className="p-1.5"
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text className="font-mono text-sm font-bold text-zinc-400">✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{(['HUE', 'SAT', 'LUM'] as const).map((name, i) => (
|
||||
<View key={name} className="flex-row items-center">
|
||||
<Text className="w-9 font-mono text-[10px] font-bold tracking-widest text-zinc-400">
|
||||
{name}
|
||||
</Text>
|
||||
<Slider
|
||||
style={{ flex: 1, height: 30 }}
|
||||
value={band[i]}
|
||||
minimumValue={-10}
|
||||
maximumValue={10}
|
||||
step={1}
|
||||
onValueChange={(v) => onKnob(i as 0 | 1 | 2, v)}
|
||||
onSlidingComplete={haptic}
|
||||
minimumTrackTintColor="#f59e0b"
|
||||
maximumTrackTintColor="#27272a"
|
||||
thumbTintColor="#f59e0b"
|
||||
/>
|
||||
<Text
|
||||
className={`w-7 text-right font-mono text-[10px] font-bold ${
|
||||
band[i] !== 0 ? 'text-amber-500' : 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
{band[i] > 0 ? `+${band[i]}` : band[i]}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { Modal, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
|
||||
import { X } from 'lucide-react-native';
|
||||
|
||||
// SCAN QR: reads the code on the landing page's recipe card and hands the
|
||||
// decoded string to App, which fetches the `.recipe` file it points at. The
|
||||
// code carries a link, never the recipe itself, so the camera only ever has to
|
||||
// frame a few dozen bytes.
|
||||
//
|
||||
// This is the app's second camera on purpose: `expo-camera` (Android's own
|
||||
// barcode scanner) instead of the vision-camera session the viewfinder uses,
|
||||
// because vision-camera v5's object output is iOS-only — Android's
|
||||
// `createObjectOutput` throws "CameraObjectOutput is not available on
|
||||
// Android!". App keeps the two from fighting over the lens by pausing the
|
||||
// viewfinder while this sheet is up (`Viewfinder paused`).
|
||||
interface QrScanModalProps {
|
||||
visible: boolean;
|
||||
onCancel: () => void;
|
||||
onScanned: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function QrScanModal({ visible, onCancel, onScanned }: QrScanModalProps) {
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
// One code per opening: the scanner reports every frame that holds a code, so
|
||||
// the first one trips this latch and the rest are dropped until the sheet is
|
||||
// opened again.
|
||||
const takenRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
takenRef.current = false;
|
||||
if (permission && !permission.granted && permission.canAskAgain) requestPermission();
|
||||
}, [visible, permission, requestPermission]);
|
||||
|
||||
const handleScanned = useCallback(
|
||||
(result: BarcodeScanningResult) => {
|
||||
if (takenRef.current) return;
|
||||
takenRef.current = true;
|
||||
onScanned(result.data);
|
||||
},
|
||||
[onScanned],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="fade" statusBarTranslucent onRequestClose={onCancel}>
|
||||
<View className="flex-1 bg-black">
|
||||
{permission?.granted ? (
|
||||
<CameraView
|
||||
style={{ flex: 1 }}
|
||||
facing="back"
|
||||
active={visible}
|
||||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||
onBarcodeScanned={handleScanned}
|
||||
/>
|
||||
) : (
|
||||
<View className="flex-1 items-center justify-center p-8">
|
||||
<Text className="text-zinc-400 font-mono text-xs text-center leading-5">
|
||||
CAMERA PERMISSION IS NEEDED TO SCAN A RECIPE CODE
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Reticle. Pointer-transparent so the touch goes to the camera. */}
|
||||
<View className="absolute inset-0 items-center justify-center" pointerEvents="none">
|
||||
<View className="w-64 h-64 rounded-2xl border-2 border-amber-500/80" />
|
||||
<Text className="mt-6 text-amber-500 font-mono text-xs tracking-widest">
|
||||
POINT AT A RECIPE QR
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={onCancel}
|
||||
accessibilityLabel="Close the QR scanner"
|
||||
className="absolute top-12 right-6 w-10 h-10 rounded-full bg-black/60 border border-zinc-700 items-center justify-center"
|
||||
>
|
||||
<X color="#ffffff" size={20} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,15 @@ import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from '../utils/defaultRecipes';
|
||||
// plain textboxes (typed fast, one row each), categorical knobs are chip
|
||||
// dropdowns. Used to enter Fuji-style recipes (DR/Highlight/Shadow/Color/NR/
|
||||
// Sharpness/Clarity/Grain/CC/CCB/WB+shift/ISO/EV) without touching sliders.
|
||||
//
|
||||
// TODO (chưa làm — xem `6_CREATE_COLLAPSE.md`, bản web đối chiếu commit
|
||||
// f17cb08 của nhánh recipes-web):
|
||||
// 1. Gấp sáu nhóm: SIMULATION, DYNAMIC RANGE, GRAIN EFFECT, COLOR CHROME
|
||||
// EFFECT, COLOR CHROME EFFECT BLUE, WHITE BALANCE (WB SHIFT đi kèm WB).
|
||||
// 2. Thêm bốn ô số: EXPOSURE, EV (đổi tên từ EXPOSURE COMP.), WHITE, BLACK.
|
||||
// WHITE/BLACK chưa có trong `ColorAdjustments` — phải thêm cả hai field
|
||||
// (src/types/index.ts) và cặp uniform `wh`/`bl` trong `src/utils/toneShader.ts`
|
||||
// (cubic end-weights), nếu không hai ô đó là ô chết.
|
||||
|
||||
const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
export type TabId = 'recipes' | 'favorites' | 'iq' | 'wb' | 'filters' | 'frame';
|
||||
export type TabId = 'recipes' | 'favorites' | 'iq' | 'wb' | 'filters' | 'hsl' | 'frame';
|
||||
|
||||
interface ToolRailProps {
|
||||
activeTab: TabId | null;
|
||||
@@ -12,9 +12,10 @@ interface ToolRailProps {
|
||||
const TOOLS: { id: TabId; label: string }[] = [
|
||||
{ id: 'recipes', label: 'PRESETS' },
|
||||
{ id: 'favorites', label: 'FAVORITED' },
|
||||
{ id: 'iq', label: 'LIGHT' },
|
||||
{ id: 'wb', label: 'WB' },
|
||||
{ id: 'iq', label: 'LIGHT' },
|
||||
{ id: 'filters', label: 'FX' },
|
||||
{ id: 'hsl', label: 'HSL' },
|
||||
{ id: 'frame', label: 'FRAME' },
|
||||
];
|
||||
|
||||
|
||||
+174
-17
@@ -27,8 +27,11 @@ import {
|
||||
FilterMode,
|
||||
MipmapMode,
|
||||
FontStyle,
|
||||
ColorType,
|
||||
AlphaType,
|
||||
useCanvasRef,
|
||||
} from '@shopify/react-native-skia';
|
||||
import { Lock } from 'lucide-react-native';
|
||||
import { Lock, Pipette } from 'lucide-react-native';
|
||||
import { Recipe, GPSInfo, FrameId, AspectRatio, ColorAdjustments, ASPECT_RATIO_W_H, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT, MIN_CROP_FRAC } from '../types';
|
||||
import { getSkiaColorMatrix, applyExposureGain } from '../utils/colorUtils';
|
||||
import { TONE_SKSL, getToneUniforms, toneUniformArray, GLOW_SKSL, glowUniformArray, GLOW_UNIFORMS, CLARITY_SKSL, clarityUniforms } from '../utils/toneShader';
|
||||
@@ -65,6 +68,10 @@ interface ViewfinderProps {
|
||||
// a nonce so the panel puts the caret back in its input).
|
||||
onWmEdit?: () => void;
|
||||
cameraPermissionGranted: boolean;
|
||||
// Another camera session wants the lens (the QR scanner's own sheet): the
|
||||
// live session stops until it is up, exactly as it does for the app going
|
||||
// to the background — two CameraX clients cannot hold one camera.
|
||||
paused?: boolean;
|
||||
onRequestCameraPermission: () => void;
|
||||
photoOutput: CameraPhotoOutput;
|
||||
// Composition ratio (FULL / 4:3 / 3:2 upright) driving the viewfinder band
|
||||
@@ -159,6 +166,18 @@ interface ViewfinderProps {
|
||||
frameTabActive?: boolean;
|
||||
// WALL FRAME hung landscape (4000x3117) instead of portrait.
|
||||
wallLandscape?: boolean;
|
||||
// HSL eyedropper. The pick layer covers exactly the picture and reads ONE
|
||||
// pixel off the rendered frame — the colour the user sees, after every other
|
||||
// knob — not the file's. Library only: a live preview has no pixel to read.
|
||||
picking?: boolean;
|
||||
onPickColor?: (
|
||||
rgb: { r: number; g: number; b: number },
|
||||
at: { fx: number; fy: number }
|
||||
) => void;
|
||||
// The band panel the pick hangs at `hslPanelAt` (0..1 fractions of the
|
||||
// image). The caller builds it, this viewer only places it.
|
||||
hslPanel?: React.ReactNode;
|
||||
hslPanelAt?: { fx: number; fy: number } | null;
|
||||
}
|
||||
|
||||
export interface ViewfinderHandle {
|
||||
@@ -303,6 +322,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
availableHeight,
|
||||
topInset,
|
||||
cameraPermissionGranted,
|
||||
paused = false,
|
||||
onRequestCameraPermission,
|
||||
photoOutput,
|
||||
aspectRatio,
|
||||
@@ -331,6 +351,10 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
photoStraighten = 0,
|
||||
wmRotation = 0,
|
||||
wallLandscape = false,
|
||||
picking = false,
|
||||
onPickColor,
|
||||
hslPanel,
|
||||
hslPanelAt = null,
|
||||
}: ViewfinderProps,
|
||||
ref
|
||||
) {
|
||||
@@ -491,12 +515,17 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
// Tone uniforms [dr, hl, sh, vib, shadow split RGB, highlight split RGB] for
|
||||
// the preview worklet; all-zero = no tone pass. A stock may keep the pass on
|
||||
// with every user knob at 0 (Classic Neg split tone, Classic Chrome crush).
|
||||
// Tone uniforms for the preview worklet; all-zero = no tone pass (see
|
||||
// toneUniformArray for the layout: dr/hl/sh/vib, the two split-tone triples,
|
||||
// the two Chrome depths, then the HSL mixer's hslOn + 8x3 bands + gh/gs/gl).
|
||||
// A stock may keep the pass on with every user knob at 0 (Classic Neg split
|
||||
// tone, Classic Chrome crush).
|
||||
const toneParams = toneUniformArray(getToneUniforms(adjustments, recipe.baseFilter));
|
||||
// Seeded with the FULL toneUniformArray length: the worklet reads this buffer
|
||||
// positionally, so a short one hands the mixer's slots `undefined` -> NaN
|
||||
// uniforms -> the whole tone pass (not just HSL) dies on the live preview.
|
||||
const toneSync = useMemo(
|
||||
() => createSynchronizable<number[]>(new Array(10).fill(0)),
|
||||
() => createSynchronizable<number[]>(new Array(40).fill(0)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
@@ -1270,7 +1299,8 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
(tone[0] !== 0 || tone[1] !== 0 || tone[2] !== 0 || tone[3] !== 0 ||
|
||||
tone[4] !== 0 || tone[5] !== 0 || tone[6] !== 0 ||
|
||||
tone[7] !== 0 || tone[8] !== 0 || tone[9] !== 0 ||
|
||||
tone[10] !== 0 || tone[11] !== 0);
|
||||
tone[10] !== 0 || tone[11] !== 0 ||
|
||||
tone[12] !== 0); // hslOn: the eight bands live in 13..36, the global moves in 37..39
|
||||
const hasCinema = cinemaEffect != null && cinema[0] > 0;
|
||||
// Draws the full frame with the tone/cinema shader when active (drawRect,
|
||||
// not drawImage: paint shaders only apply to geometry fills — drawImage
|
||||
@@ -2560,15 +2590,77 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
};
|
||||
// The view zoom composes OVER the committed crop (outer·inner), exactly like
|
||||
// the canvas draws it.
|
||||
const libCropTouchStyle = touchZoomStyle(
|
||||
libCropView
|
||||
? {
|
||||
s: viewZoom.s * libCropView.k,
|
||||
tx: viewZoom.s * libCropView.ox + viewZoom.tx,
|
||||
ty: viewZoom.s * libCropView.oy + viewZoom.ty,
|
||||
}
|
||||
: viewZoom
|
||||
const libTouchZoom = libCropView
|
||||
? {
|
||||
s: viewZoom.s * libCropView.k,
|
||||
tx: viewZoom.s * libCropView.ox + viewZoom.tx,
|
||||
ty: viewZoom.s * libCropView.oy + viewZoom.ty,
|
||||
}
|
||||
: viewZoom;
|
||||
const libCropTouchStyle = touchZoomStyle(libTouchZoom);
|
||||
const libTouchZoomStyle = touchZoomStyle(libTouchZoom);
|
||||
|
||||
// ---- HSL eyedropper -------------------------------------------------------
|
||||
// The pick layer sits exactly over the picture, so a touch reaches it instead
|
||||
// of the pan/zoom layer below (it is mounted after that one). A touch maps to
|
||||
// a CANVAS pixel through the same zoom/crop transform the touch layer
|
||||
// carries, and the one pixel under it is read off the rendered frame — the
|
||||
// colour the user sees, not the file's original.
|
||||
// ponytail: one snapshot per pick session, read with readPixels(1 px). If the
|
||||
// delay ever shows on a real device, decode the skia image once instead — the
|
||||
// sample is then the UNgraded colour, which is a different feature.
|
||||
const canvasRef = useCanvasRef();
|
||||
const pickShotRef = useRef<{ width: () => number; height: () => number; readPixels: any } | null>(
|
||||
null
|
||||
);
|
||||
const [pickPos, setPickPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [pickPanelSize, setPickPanelSize] = useState({ w: 0, h: 0 });
|
||||
// The picture as drawn (the camera's ratio band is a camera-only concept, so
|
||||
// in the library the fit rect IS the picture).
|
||||
const pickRect = mode === 'library' && !regionBand ? imageFitRect : null;
|
||||
useEffect(() => {
|
||||
if (!picking) {
|
||||
pickShotRef.current = null;
|
||||
setPickPos(null);
|
||||
return;
|
||||
}
|
||||
// The frame has settled a beat after the chip press: snapshot once here and
|
||||
// every tap of the session reads off that same frame.
|
||||
const id = setTimeout(() => {
|
||||
pickShotRef.current = canvasRef.current?.makeImageSnapshot() ?? null;
|
||||
}, 150);
|
||||
return () => clearTimeout(id);
|
||||
}, [picking]);
|
||||
|
||||
const onPickTouch = (e: any) => {
|
||||
const r = pickRect;
|
||||
if (!r || !(r.dw > 0 && r.dh > 0)) return;
|
||||
const { locationX: lx, locationY: ly } = e.nativeEvent;
|
||||
if (!(lx >= 0 && lx <= r.dw && ly >= 0 && ly <= r.dh)) return;
|
||||
setPickPos((p) => (p && Math.abs(p.x - lx) < 1 && Math.abs(p.y - ly) < 1 ? p : { x: lx, y: ly }));
|
||||
const shot = pickShotRef.current ?? canvasRef.current?.makeImageSnapshot() ?? null;
|
||||
if (!shot) return;
|
||||
pickShotRef.current = shot;
|
||||
// The layer and the picture carry the same transform, so the touch reaches
|
||||
// the canvas the same way: layout units, then the snapshot's own density.
|
||||
const dens = shot.width() / Math.max(vw, 1);
|
||||
const cx = Math.max(
|
||||
0,
|
||||
Math.min(shot.width() - 1, Math.floor((libTouchZoom.s * (r.dx + lx) + libTouchZoom.tx) * dens))
|
||||
);
|
||||
const cy = Math.max(
|
||||
0,
|
||||
Math.min(shot.height() - 1, Math.floor((libTouchZoom.s * (r.dy + ly) + libTouchZoom.ty) * dens))
|
||||
);
|
||||
const px = shot.readPixels(cx, cy, {
|
||||
width: 1,
|
||||
height: 1,
|
||||
colorType: ColorType.RGBA_8888,
|
||||
alphaType: AlphaType.Unpremul,
|
||||
});
|
||||
if (!px || px.length < 3) return;
|
||||
onPickColor?.({ r: px[0], g: px[1], b: px[2] }, { fx: lx / r.dw, fy: ly / r.dh });
|
||||
};
|
||||
|
||||
// Export-facing summary of the current framed-window photo reposition. The
|
||||
// gesture state (libZoom) is canvas-absolute; u/v convert it to the fraction
|
||||
@@ -2871,7 +2963,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
ref={skiaCameraRef}
|
||||
style={StyleSheet.absoluteFill}
|
||||
device={device}
|
||||
isActive={appActive && !ultraWideOn}
|
||||
isActive={appActive && !ultraWideOn && !paused}
|
||||
outputs={cameraOutputs}
|
||||
pixelFormat="yuv"
|
||||
targetResolution={frameTarget}
|
||||
@@ -2886,7 +2978,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
turning one on means turning the other off, never both at once. */}
|
||||
<UltraWidePreview
|
||||
style={StyleSheet.absoluteFill}
|
||||
active={appActive && ultraWideOn}
|
||||
active={appActive && ultraWideOn && !paused}
|
||||
cover={uwCover}
|
||||
/>
|
||||
{/* Gesture layer: tap = AE/AF lock; vertical drag on the image adjusts
|
||||
@@ -3009,6 +3101,15 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
hlTb: toneParams[9],
|
||||
cc: toneParams[10],
|
||||
ccb: toneParams[11],
|
||||
// The mixer's arrays are uniform ARRAYS (`float hslH[8]`), so they go in as
|
||||
// eight-number slots off the same flat buffer the camera and export use.
|
||||
hslOn: toneParams[12],
|
||||
hslH: toneParams.slice(13, 21),
|
||||
hslS: toneParams.slice(21, 29),
|
||||
hslL: toneParams.slice(29, 37),
|
||||
gh: toneParams[37],
|
||||
gs: toneParams[38],
|
||||
gl: toneParams[39],
|
||||
};
|
||||
const cinemaOn = cinemaEffect != null && !!cinemaParams;
|
||||
const useShaderPass = !!imageFitRect && ((toneEffect != null && toneOn) || cinemaOn);
|
||||
@@ -3220,7 +3321,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
|
||||
{libraryImageUri && skiaImage ? (
|
||||
<>
|
||||
<Canvas style={StyleSheet.absoluteFill}>
|
||||
<Canvas ref={canvasRef} style={StyleSheet.absoluteFill}>
|
||||
<Group matrix={viewZoomMatrix}>
|
||||
<Group matrix={libViewMatrix}>
|
||||
<ColorMatrix matrix={colorMatrix} />
|
||||
@@ -3307,6 +3408,62 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
onResponderRelease={onLibTouchEnd}
|
||||
onResponderTerminate={onLibTouchEnd}
|
||||
/>
|
||||
{/* HSL eyedropper: the layer covers the picture alone and takes the
|
||||
gesture whole, so pan and zoom cannot fire from inside it. */}
|
||||
{picking && pickRect ? (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
position: 'absolute',
|
||||
left: pickRect.dx,
|
||||
top: pickRect.dy,
|
||||
width: pickRect.dw,
|
||||
height: pickRect.dh,
|
||||
},
|
||||
libTouchZoomStyle,
|
||||
]}
|
||||
onStartShouldSetResponder={() => true}
|
||||
onMoveShouldSetResponder={() => true}
|
||||
onResponderGrant={onPickTouch}
|
||||
onResponderMove={onPickTouch}
|
||||
>
|
||||
{pickPos ? (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={{ position: 'absolute', left: pickPos.x - 24, top: pickPos.y - 24 }}
|
||||
>
|
||||
<Pipette size={22} color="#ffffff" />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
{/* The band panel, hung on the spot the colour was read from. Placed
|
||||
in SCREEN coordinates so it keeps its size while the picture is
|
||||
zoomed, and it drops below the point unless the point is low
|
||||
enough for the panel to fall off the picture. */}
|
||||
{hslPanel && hslPanelAt && pickRect ? (
|
||||
<View pointerEvents="box-none" className="absolute inset-0">
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: libTouchZoom.s * (pickRect.dx + hslPanelAt.fx * pickRect.dw) + libTouchZoom.tx,
|
||||
top: libTouchZoom.s * (pickRect.dy + hslPanelAt.fy * pickRect.dh) + libTouchZoom.ty,
|
||||
transform: [
|
||||
{ translateX: -pickPanelSize.w / 2 },
|
||||
{ translateY: hslPanelAt.fy > 0.55 ? -(pickPanelSize.h + 14) : 14 },
|
||||
],
|
||||
}}
|
||||
onLayout={(e) => {
|
||||
const { width, height } = e.nativeEvent.layout;
|
||||
setPickPanelSize((p) =>
|
||||
p.w === width && p.h === height ? p : { w: width, h: height }
|
||||
);
|
||||
}}
|
||||
>
|
||||
{hslPanel}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<View className="items-center justify-center p-6">
|
||||
|
||||
+18
-1
@@ -62,7 +62,17 @@ export const MIN_CROP_FRAC = 0.08;
|
||||
// cool tint in shade, plus a seasonal atmospheric haze/fog veil.
|
||||
export type CinemaSeason = 'spring' | 'summer' | 'autumn' | 'winter';
|
||||
|
||||
export type BaseFilter = 'classic-neg' | 'classic-chrome' | 'provia' | 'velvia' | 'astia' | 'eterna' | 'monochrome' | 'leica' | 'leica-vivid' | 'none';
|
||||
// `monochrome` is Acros' grey ramp; `mono-high-contrast` is the B&W HIGH
|
||||
// CONTRAST stock's own — both are black-and-white, so anything asking "is this
|
||||
// a colour stock?" must ask it through isMonochromeBase (colorUtils.ts)
|
||||
// rather than comparing against one of these two strings.
|
||||
export type BaseFilter = 'classic-neg' | 'classic-chrome' | 'classic-vivid' | 'provia' | 'velvia' | 'astia' | 'eterna' | 'monochrome' | 'mono-high-contrast' | 'leica' | 'leica-vivid' | 'none';
|
||||
|
||||
// Selective colour mixer: the eight hue bands the HSL tab edits, and the
|
||||
// [hue, sat, lum] triple each band carries (-10..+10 per knob). A band left at
|
||||
// [0,0,0] is dropped from the recipe rather than stored.
|
||||
export type HslBandId = 'red' | 'orange' | 'yellow' | 'green' | 'aqua' | 'blue' | 'purple' | 'magenta';
|
||||
export type HslBand = [number, number, number];
|
||||
|
||||
export interface ColorAdjustments {
|
||||
exposure: number; // -10 to +10 (mapped to matrix multiplier or offset)
|
||||
@@ -84,6 +94,13 @@ export interface ColorAdjustments {
|
||||
hdf?: number; // 0 to 10 (Highlight Diffusion Filter: glow on the highlights only)
|
||||
vignette?: number; // 0 to 10 (darken the four corners; 0 = off)
|
||||
vibrance?: number; // -10 to +10 (chroma-masked saturation: vivid colours move least)
|
||||
// Selective colour: per-band [hue, sat, lum] moves, -10..+10. One nested field
|
||||
// rather than 24 flat ones — that is what makes it the single adjustment the
|
||||
// recipe importer has to sanitize by hand (see sanitizeHslBands).
|
||||
hslBands?: Partial<Record<HslBandId, HslBand>>;
|
||||
hslHue?: number; // -10..+10 whole-image hue turn
|
||||
hslSat?: number; // -10..+10 whole-image saturation scale
|
||||
hslLum?: number; // -10..+10 whole-image lightness offset
|
||||
exposureCompensation: number; // -3 to +3 EV. Camera: AE bias (hardware). Library: 2^EV matrix gain.
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+173
-10
@@ -1,4 +1,102 @@
|
||||
import { ColorAdjustments, BaseFilter } from '../types';
|
||||
import { ColorAdjustments, BaseFilter, HslBand, HslBandId } from '../types';
|
||||
|
||||
// The eight bands of the selective colour mixer, with the hue (degrees on the
|
||||
// HSL wheel) each band sits at. The anchors are deliberately uneven: red /
|
||||
// orange / yellow sit 30 degrees apart because that is where the eye reads
|
||||
// detail, while the blue side is one 60-degree stretch. TONE_SKSL builds its
|
||||
// eight weights from this same table, so the chip's anchor and the shader's
|
||||
// mask cannot drift apart.
|
||||
//
|
||||
// `label` is the chip's text — app copy, not i18n.
|
||||
export const HSL_BANDS: { id: HslBandId; hue: number; label: string }[] = [
|
||||
{ id: 'red', hue: 0, label: 'RED' },
|
||||
{ id: 'orange', hue: 30, label: 'ORANGE' },
|
||||
{ id: 'yellow', hue: 60, label: 'YELLOW' },
|
||||
{ id: 'green', hue: 120, label: 'GREEN' },
|
||||
{ id: 'aqua', hue: 180, label: 'AQUA' },
|
||||
{ id: 'blue', hue: 240, label: 'BLUE' },
|
||||
{ id: 'purple', hue: 280, label: 'PURPLE' },
|
||||
{ id: 'magenta', hue: 320, label: 'MAGENTA' },
|
||||
];
|
||||
|
||||
// Hue distance (degrees) to the two neighbouring bands, which is what the
|
||||
// shader's tent window needs. Derived from HSL_BANDS so the two cannot argue: a
|
||||
// band's own weight is 1 at its anchor and 0 at the anchors next door, which
|
||||
// makes the eight weights a partition of unity over the hue wheel (they sum to
|
||||
// 1 for every hue).
|
||||
export function hslBandGaps(): { id: HslBandId; hue: number; left: number; right: number }[] {
|
||||
const n = HSL_BANDS.length;
|
||||
return HSL_BANDS.map((b, i) => {
|
||||
const prev = HSL_BANDS[(i - 1 + n) % n].hue;
|
||||
const next = HSL_BANDS[(i + 1) % n].hue;
|
||||
// Red (0 deg) wraps: its left neighbour is magenta at 320, i.e. 40 ahead.
|
||||
return { id: b.id, hue: b.hue, left: (b.hue - prev + 360) % 360, right: (next - b.hue + 360) % 360 };
|
||||
});
|
||||
}
|
||||
|
||||
// 0..255 sRGB -> HSL, hue in degrees and saturation/lightness 0..1 — the
|
||||
// inverse of the shader's hsl2rgb, and what the eyedropper readout prints.
|
||||
export function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
|
||||
const R = r / 255;
|
||||
const G = g / 255;
|
||||
const B = b / 255;
|
||||
const mx = Math.max(R, G, B);
|
||||
const mn = Math.min(R, G, B);
|
||||
const l = (mx + mn) / 2;
|
||||
const d = mx - mn;
|
||||
if (d < 1e-6) return { h: 0, s: 0, l };
|
||||
const s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
|
||||
let h: number;
|
||||
if (mx === R) h = ((G - B) / d + (G < B ? 6 : 0)) * 60;
|
||||
else if (mx === G) h = ((B - R) / d + 2) * 60;
|
||||
else h = ((R - G) / d + 4) * 60;
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
// HSL -> #rrggbb for the band chips: a chip naming a colour has to show that
|
||||
// colour, and a hex string is the one format RN takes directly.
|
||||
export function hslToHex(h: number, s: number, l: number): string {
|
||||
const k = (n: number) => (n + h / 30) % 12;
|
||||
const a = (s / 100) * Math.min(l / 100, 1 - l / 100);
|
||||
const chan = (n: number) =>
|
||||
Math.max(0, Math.min(255, Math.round(255 * (l / 100 - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))))))
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
return `#${chan(0)}${chan(8)}${chan(4)}`;
|
||||
}
|
||||
|
||||
// Which band a sampled hue belongs to: the nearest anchor. The bands overlap in
|
||||
// the shader, so this only decides which band the ruler is pointing at.
|
||||
export function nearestHslBand(hue: number): HslBandId {
|
||||
const wrapped = ((hue % 360) + 360) % 360;
|
||||
let best = HSL_BANDS[0];
|
||||
let bestD = 361;
|
||||
for (const band of HSL_BANDS) {
|
||||
const d = Math.abs(((wrapped - band.hue + 540) % 360) - 180);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = band;
|
||||
}
|
||||
}
|
||||
return best.id;
|
||||
}
|
||||
|
||||
// One band triple as stored: integers -10..10, and an all-zero band dropped so a
|
||||
// recipe only carries what the user actually moved.
|
||||
const clamp10 = (v: unknown): number =>
|
||||
typeof v === 'number' && Number.isFinite(v) ? Math.max(-10, Math.min(10, Math.round(v))) : 0;
|
||||
|
||||
export function sanitizeHslBands(raw: unknown): Partial<Record<HslBandId, HslBand>> | undefined {
|
||||
if (!raw || typeof raw !== 'object') return undefined;
|
||||
const out: Partial<Record<HslBandId, HslBand>> = {};
|
||||
for (const band of HSL_BANDS) {
|
||||
const v = (raw as Record<string, unknown>)[band.id];
|
||||
if (!Array.isArray(v) || v.length !== 3) continue;
|
||||
const triple: HslBand = [clamp10(v[0]), clamp10(v[1]), clamp10(v[2])];
|
||||
if (triple[0] || triple[1] || triple[2]) out[band.id] = triple;
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
|
||||
// Kelvin to RGB compensation for White Balance.
|
||||
// The WB setting names the light the camera is told to expect, so its gain is
|
||||
@@ -52,6 +150,30 @@ function normalizeGainLuma(gain: number[]): { r: number; g: number; b: number }
|
||||
return { r: gain[0] * s, g: gain[1] * s, b: gain[2] * s };
|
||||
}
|
||||
|
||||
// Tone a stock carries BY ITSELF, in the sliders' own units: brightness
|
||||
// (EXPOSURE) and slope (CONTRAST). It is part of the look, not a knob the user
|
||||
// set, so it never reaches the panel (a sim's adjustments stay neutral — see
|
||||
// FILM_SIMS in defaultRecipes) and the slider reads 0 while the render is
|
||||
// already lifted or the ramp already steeper. Keyed by stock because that is
|
||||
// what the renderers know: `baseFilter` travels with every recipe, an armed sim
|
||||
// id does not. ponytail: one entry per stock; if two sims ever need one stock's
|
||||
// matrix with a different tone, the key has to move to the sim id.
|
||||
const STOCK_BIAS: Partial<Record<BaseFilter, { exposure?: number; contrast?: number }>> = {
|
||||
// LC STREETLIFE VIVID reads bright straight off the shutter.
|
||||
'leica-vivid': { exposure: 2 },
|
||||
// B&W HIGH CONTRAST pushes its own greys (+4 of slope), on top of the
|
||||
// toe/shoulder FILM_TONE gives it.
|
||||
'mono-high-contrast': { contrast: 4 },
|
||||
};
|
||||
|
||||
// Black-and-white stocks. Their grey ramp is the whole look, so every colour
|
||||
// stage below — saturation, white balance, the R/B fine-tune — is skipped
|
||||
// for them. One list, asked through one function, so a second B&W stock cannot
|
||||
// end up half excluded (a stray Kelvin gain on a grey ramp shows up as a tint).
|
||||
const MONO_BASES: BaseFilter[] = ['monochrome', 'mono-high-contrast'];
|
||||
export const isMonochromeBase = (baseFilter: BaseFilter | undefined): boolean =>
|
||||
!!baseFilter && MONO_BASES.includes(baseFilter);
|
||||
|
||||
// Generate a 4x5 ColorMatrix (array of 20 floats) based on base style and
|
||||
// adjustments. Tone-domain knobs (dynamicRange/highlight/shadow) and the
|
||||
// exposure-compensation EV gain are intentionally NOT part of the matrix —
|
||||
@@ -59,8 +181,10 @@ function normalizeGainLuma(gain: number[]): { r: number; g: number; b: number }
|
||||
// and EV is applied either as hardware AE bias (camera) or by the caller via
|
||||
// applyExposureGain (library), never doubled.
|
||||
export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments): number[] {
|
||||
const exposure = adj.exposure;
|
||||
const contrast = adj.contrast;
|
||||
// The stock's own tone rides with the sliders: same maths, same band (it is
|
||||
// a property of the look, not of this photo's edit).
|
||||
const exposure = adj.exposure + (STOCK_BIAS[baseFilter]?.exposure ?? 0);
|
||||
const contrast = adj.contrast + (STOCK_BIAS[baseFilter]?.contrast ?? 0);
|
||||
const saturation = adj.saturation;
|
||||
const temperature = adj.temperature;
|
||||
const tint = adj.tint;
|
||||
@@ -120,6 +244,16 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
|
||||
0.0652, 0.3973, 0.5575, 0, -0.0100,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'classic-vivid') {
|
||||
// Classic Vivid: the Classic Chrome blue row kept verbatim — skies stay the
|
||||
// same muted teal/cyan — with the red and green rows taken from Velvia, so
|
||||
// everything that is not blue reads loud.
|
||||
matrix = [
|
||||
1.3895, -0.2540, -0.0256, 0, -0.0550, // R — velvia
|
||||
-0.0772, 1.2355, -0.0261, 0, -0.0661, // G — velvia
|
||||
0.0652, 0.3973, 0.5575, 0, -0.0100, // B — classic-chrome (nguyên bản)
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'astia') {
|
||||
// Astia (Soft): gentler than standard — contrast below neutral (shadows
|
||||
// lift, highlights ease off) and colour held back, with a faint warm/pink
|
||||
@@ -162,9 +296,10 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
|
||||
// digital era: ~20% of chroma gain (still well under Velvia's 39%, so it
|
||||
// sparkles instead of shouting) on the same warm R>G>B bias CLASSIC has, so
|
||||
// VIVID is a brighter read of one look rather than a second, neutral one.
|
||||
// No positive lift here: the brightness is the sim's own exposure value
|
||||
// (defaultRecipes), which keeps the matrix inside 0..1 and pure white out of
|
||||
// the clip — white lands at most at 1.000 on the red row.
|
||||
// No positive lift here either, so the matrix stays inside 0..1 and pure
|
||||
// white stays out of the clip — white lands at most at 1.000 on the red row.
|
||||
// The brightness this look is known for is STOCK_EXPOSURE below: a fixed
|
||||
// exposure move of the stock itself, never a chroma trick.
|
||||
matrix = [
|
||||
1.125, -0.100, -0.010, 0, -0.015,
|
||||
-0.055, 1.110, -0.055, 0, -0.005,
|
||||
@@ -190,10 +325,37 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
|
||||
r, g, b, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'mono-high-contrast') {
|
||||
// B&W HIGH CONTRAST — Acros taken through a warm filter, then pushed at
|
||||
// both ends (the push itself lives in FILM_TONE, its midtone slope in
|
||||
// STOCK_BIAS above).
|
||||
//
|
||||
// The mix is the B&W MIX step: the channel weights are the matrix's whole
|
||||
// job here, and they are deliberately not BT.709. Red is nearly doubled
|
||||
// against its luminance share — so a red roof reads bright, a blue sky
|
||||
// and foliage read deep, and the separation between them is what gives the
|
||||
// ramp its contrast before any curve is applied.
|
||||
//
|
||||
// ponytail: the weights ride the CHANNELS, while the brief that asked for
|
||||
// this stock names HUE groups (red 1.15, orange 1.20, blue 0.70). A 4x5
|
||||
// matrix cannot be hue-selective; if those exact factors are ever wanted,
|
||||
// that is a per-pixel HSL pass in the tone shader, not a row edit here.
|
||||
//
|
||||
// The three rows are identical and sum to 1.00, so a neutral stays the
|
||||
// neutral the sensor recorded and nothing clips at either end.
|
||||
const r = 0.38;
|
||||
const g = 0.56;
|
||||
const b = 0.06;
|
||||
matrix = [
|
||||
r, g, b, 0, 0,
|
||||
r, g, b, 0, 0,
|
||||
r, g, b, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
}
|
||||
|
||||
// 3. Apply Saturation adjustment (standard color matrix transformation)
|
||||
if (baseFilter !== 'monochrome' && saturation !== 0) {
|
||||
if (!isMonochromeBase(baseFilter) && saturation !== 0) {
|
||||
const s = 1 + (saturation / 10) * 0.5; // -10 maps to 0.5x, +10 maps to 1.5x saturation
|
||||
const invS = 1 - s;
|
||||
const r = 0.213 * invS;
|
||||
@@ -216,8 +378,9 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
|
||||
// blue-only gain tints the white point. `colorChrome`/`colorChromeBlue` are
|
||||
// consumed by getToneUniforms instead.
|
||||
|
||||
// 5. White Balance (Kelvin Temperature and Tint) — skip for monochrome to prevent color leakage
|
||||
if (baseFilter !== 'monochrome') {
|
||||
// 5. White Balance (Kelvin Temperature and Tint) — skipped for the B&W
|
||||
// stocks: a grey ramp has no cast to correct, only a tint to leak.
|
||||
if (!isMonochromeBase(baseFilter)) {
|
||||
const rgbTemp = kelvinToRGB(temperature);
|
||||
// Tint is the green↔magenta axis, and it is symmetric: + pushes magenta
|
||||
// (red and blue up, green down), - pushes green (green up, red and blue
|
||||
@@ -246,7 +409,7 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
|
||||
// perceptible size of one Fuji fine-tune step (-9..+9).
|
||||
const wbRed = adj.wbRed ?? 0;
|
||||
const wbBlue = adj.wbBlue ?? 0;
|
||||
if (baseFilter !== 'monochrome' && (wbRed !== 0 || wbBlue !== 0)) {
|
||||
if (!isMonochromeBase(baseFilter) && (wbRed !== 0 || wbBlue !== 0)) {
|
||||
const shiftMat = [
|
||||
1 + (wbRed / 9) * 0.1, 0, 0, 0, 0,
|
||||
0, 1, 0, 0, 0,
|
||||
|
||||
+51
-19
@@ -20,16 +20,46 @@ export const DEFAULT_ADJUSTMENTS: ColorAdjustments = {
|
||||
colorChromeBlue: 'none',
|
||||
dynamicRange: 'auto',
|
||||
sharpening: 0,
|
||||
// The mixer's three whole-image moves. hslBands is deliberately absent: it is
|
||||
// optional and `undefined` is what "no band moved" means — an empty object
|
||||
// would make differs(adjustments, DEFAULT_ADJUSTMENTS) report a dirty recipe.
|
||||
hslHue: 0,
|
||||
hslSat: 0,
|
||||
hslLum: 0,
|
||||
exposureCompensation: 0,
|
||||
};
|
||||
|
||||
// Standard Fuji film simulations. These are the fixed "look" presets shown at
|
||||
// the front of the PRESETS chip row: tapping one applies the simulation — its
|
||||
// baseFilter colour matrix plus whatever the stock itself carries outside the
|
||||
// matrix (its FILM_TONE curve, and a grain amount when the grain IS the look,
|
||||
// as on Acros). Every other knob still resets to its neutral default (0 /
|
||||
// auto), so a sim is a clean starting point, never a baked recipe; any further
|
||||
// look (DR, CC, contrast, more grain) is added by the user afterwards.
|
||||
// Standard film simulations. These are the fixed "look" presets shown at the
|
||||
// front of the PRESETS chip row.
|
||||
//
|
||||
// RULE — a sim carries COLOUR AND TONE ONLY, never an effect. Its whole look
|
||||
// must come from its baseFilter colour matrix plus its FILM_TONE curve
|
||||
// (toneShader.ts): the two things that *define* the stock. No grain, no
|
||||
// clarity, no exposure/saturation/shadow/highlight move — those are edits the
|
||||
// user makes afterwards, never something a sim bakes in. That is why every sim
|
||||
// below shares the neutral DEFAULT_ADJUSTMENTS verbatim; if a look ever needs
|
||||
// more, put it in the matrix or the tone curve instead.
|
||||
//
|
||||
// The one exception is a stock that is simply BRIGHTER: that is a fixed
|
||||
// exposure move of the look itself, so it lives in STOCK_EXPOSURE
|
||||
// (colorUtils.ts), keyed by baseFilter, where the preview and both exporters
|
||||
// already pick it up. It never lands here and never reaches the panel, so the
|
||||
// EXPOSURE slider still reads 0 on such a sim — LC STREETLIFE VIVID (+2).
|
||||
//
|
||||
// Reference only — what each sim stands for, so the colour choices can be
|
||||
// checked against the real stock. The UI keeps the sim names below.
|
||||
//
|
||||
// PROVIPES → PROVIA
|
||||
// VELVIPES → VELVIA
|
||||
// CLASSIC CHRIPES → CLASSIC CHROME
|
||||
// CLASSIC VIVIDIPES → VELVIA + CLASSIC CHROME (Chrome's blue row over
|
||||
// Velvia's red and green rows — see colorUtils.ts)
|
||||
// CLASSIC NEGIPES → Classic Negative
|
||||
// ASTIPES → Astia
|
||||
// ETERNIPES → Eterna
|
||||
// ACRIPES → Acros
|
||||
// LC STREETLIFE CLASSIC → Leica Authentic Classic
|
||||
// LC STREETLIFE VIVID → Leica Vivid
|
||||
export interface FilmSim {
|
||||
id: string; // Recipe-like id used only while the sim is the current selection
|
||||
name: string;
|
||||
@@ -41,23 +71,25 @@ export const FILM_SIMS: FilmSim[] = [
|
||||
{ id: 'sim-provia', name: 'PROVIPES', baseFilter: 'provia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
{ id: 'sim-velvia', name: 'VELVIPES', baseFilter: 'velvia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
{ id: 'sim-classic-chrome', name: 'CLASSIC CHRIPES', baseFilter: 'classic-chrome', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
{ id: 'sim-classic-vivid', name: 'CLASSIC VIVIDIPES', baseFilter: 'classic-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
{ id: 'sim-classic-neg', name: 'CLASSIC NEGIPES', baseFilter: 'classic-neg', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
{ id: 'sim-astia', name: 'ASTIPES', baseFilter: 'astia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
{ id: 'sim-eterna', name: 'ETERNIPES', baseFilter: 'eterna', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
{ id: 'sim-acros', name: 'ACRIPES', baseFilter: 'monochrome', adjustments: { ...DEFAULT_ADJUSTMENTS, grain: 3 } },
|
||||
{ id: 'sim-acros', name: 'ACRIPES', baseFilter: 'monochrome', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
// Acros taken through a warm filter, then pushed at both ends: the mix (red
|
||||
// up, blue down) rides the matrix, the end push rides FILM_TONE, and the
|
||||
// midtone slope the stock owns is STOCK_BIAS in colorUtils — never an
|
||||
// `adjustments` value, so every knob still opens at neutral.
|
||||
{ id: 'sim-bw-high-contrast', name: 'B&W HIGH CONTRAST', baseFilter: 'mono-high-contrast', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
// The rangefinder digital look (M/Q/SL) is not a film stock, so it ships as its own
|
||||
// pair instead of one look that has to compromise:
|
||||
// pair instead of one look that has to compromise. Both read off the
|
||||
// 'leica'/'leica-vivid' colour matrices alone (neither has a FILM_TONE
|
||||
// entry):
|
||||
// CLASSIC — the stock character: deep solid darks, natural shadows, rich but
|
||||
// quiet colour. Its micro-contrast is CLARITY (a local unsharp mask in both
|
||||
// renderers), never a global saturation boost, and a whisker of grain
|
||||
// keeps it feeling like film rather than digital sharpness.
|
||||
// VIVID — the same channel separation opened up for the digital era: lifted
|
||||
// exposure, lifted shadows, a little colour. It reads bright and flattering
|
||||
// straight off the shutter, no editing pass needed.
|
||||
// Both share the 'leica'/'leica-vivid' matrices; the tonal half is the sim's
|
||||
// own adjustments, which every apply path now carries into the panel.
|
||||
{ id: 'sim-leica', name: 'LC STREETLIFE CLASSIC', baseFilter: 'leica', adjustments: { ...DEFAULT_ADJUSTMENTS, clarity: 2, grain: 1 } },
|
||||
{ id: 'sim-leica-vivid', name: 'LC STREETLIFE VIVID', baseFilter: 'leica-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS, exposure: 2, saturation: 2, clarity: 2, shadow: 1, highlight: -1 } },
|
||||
// quiet colour.
|
||||
// VIVID — the same channel separation opened up for the digital era.
|
||||
{ id: 'sim-leica', name: 'LC STREETLIFE CLASSIC', baseFilter: 'leica', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
{ id: 'sim-leica-vivid', name: 'LC STREETLIFE VIVID', baseFilter: 'leica-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
];
|
||||
|
||||
// Film sim → full Recipe (never persisted; sims are applied on the fly).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+39
-11
@@ -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';
|
||||
|
||||
@@ -28,9 +29,6 @@ export interface ExportResult {
|
||||
export interface ExportOptions {
|
||||
// DPI to stamp into the JPEG header (metadata only — pixel size never changes).
|
||||
dpi?: number;
|
||||
// Apply a subtle Lightroom-style "Screen" output sharpen (unsharp mask) to the
|
||||
// final pixels before encoding. Pixels already export at full resolution.
|
||||
sharpen?: boolean;
|
||||
// True for photos captured through the live camera: exposure compensation was
|
||||
// already applied as a hardware AE bias at capture, so no 2^EV matrix gain
|
||||
// should be added on top. Library stills omit this and get the matrix gain.
|
||||
@@ -149,7 +147,7 @@ function release(owned: SkDisposable[], item: SkDisposable | null): void {
|
||||
disposeAll([item]);
|
||||
}
|
||||
|
||||
function screenSharpenImage(image: SkImage, amount = 0.5): SkImage {
|
||||
function screenSharpenImage(image: SkImage, amount: number): SkImage {
|
||||
const w = image.width();
|
||||
const h = image.height();
|
||||
const center = 1 + 4 * amount;
|
||||
@@ -249,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 —
|
||||
@@ -894,12 +923,11 @@ export async function processAndExportPhoto(
|
||||
|
||||
// 9. Snapshot & Encode to JPEG (base64 string, no SkData round-trip)
|
||||
let resultImage = own(outputSurface.makeImageSnapshot());
|
||||
// Sharpening knob (0..10) overrides the legacy boolean sharpen flag:
|
||||
// legacy camera captures keep sharpen:true → 0.5 fallback when knob is 0.
|
||||
// A negative knob means "no sharpening at all" (its blur already ran at
|
||||
// step 4, under the grain), so the legacy 0.5 fallback must not fire.
|
||||
const sharpenAmount =
|
||||
sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : sharpKnob < 0 ? 0 : options?.sharpen ? 0.5 : 0;
|
||||
// Sharpening knob (0..10) is the only source: the old 0.5 fallback that
|
||||
// fired on every camera capture multiplied sensor noise ~3.2x (5x at
|
||||
// Nyquist) and made dim/high-ISO shots unusable. A negative or zero knob
|
||||
// sharpens not at all (a negative one blurs at step 4, under the grain).
|
||||
const sharpenAmount = sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : 0;
|
||||
if (sharpenAmount > 0) {
|
||||
const sharpened = screenSharpenImage(resultImage, sharpenAmount);
|
||||
// The helper returns its own input when it cannot allocate a surface.
|
||||
|
||||
@@ -24,9 +24,6 @@ export interface NativeExportResult {
|
||||
export interface NativeExportOptions {
|
||||
// DPI to stamp into the JPEG header (metadata only — pixel size never changes).
|
||||
dpi?: number;
|
||||
// Apply the subtle "Screen" output sharpen (exportEngine #9) to the final
|
||||
// pixels before encoding (legacy camera captures pass sharpen: true → 0.5).
|
||||
sharpen?: boolean;
|
||||
// True for photos captured through the live camera: the AE bias already
|
||||
// applied exposure compensation at capture, so no 2^EV matrix gain is added.
|
||||
evFromCamera?: boolean;
|
||||
@@ -73,11 +70,11 @@ export async function processAndExportPhotoNative(
|
||||
// (EXPO_PUBLIC_NATIVE_EXPORT is not inlined into a release HBC bundle).
|
||||
const tone = getToneUniforms(adjustments, recipe.baseFilter);
|
||||
const cinema = getCinemaUniforms(recipe.cinema);
|
||||
// Final screen sharpen (engine #9): the sharpening knob (-10..10) overrides
|
||||
// the legacy boolean sharpen flag (camera captures keep it true → 0.5).
|
||||
// Final screen sharpen (engine #9): the sharpening knob (-10..10) is the
|
||||
// only source — the old 0.5 fallback on every camera capture multiplied
|
||||
// sensor noise ~3.2x and made dim/high-ISO shots unusable.
|
||||
const sharpKnob = adjustments.sharpening ?? 0;
|
||||
const sharpenAmount =
|
||||
sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : sharpKnob < 0 ? 0 : options?.sharpen ? 0.5 : 0;
|
||||
const sharpenAmount = sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : 0;
|
||||
const aspect: '4:3' | '3:2' | undefined =
|
||||
options?.aspect && options.aspect !== 'full' && frameId !== 'wallframe'
|
||||
? options.aspect
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Recipe, BaseFilter, FrameId, ColorAdjustments } from '../types';
|
||||
import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from './defaultRecipes';
|
||||
import { sanitizeHslBands } from './colorUtils';
|
||||
|
||||
// Shared-recipe file: a tiny XML envelope around a scrambled hex payload of the
|
||||
// recipe JSON, so a shared recipe imports back into the app on another phone.
|
||||
@@ -94,6 +95,11 @@ export function importRecipeXml(xml: string): Omit<Recipe, 'id' | 'isCustom'> {
|
||||
const name = raw.name.trim().slice(0, 40) || 'Imported recipe';
|
||||
const baseFilter: BaseFilter = BASE_FILTERS.includes(raw.baseFilter) ? raw.baseFilter : 'none';
|
||||
const adjustments: ColorAdjustments = { ...DEFAULT_ADJUSTMENTS, ...(raw.adjustments as Partial<ColorAdjustments>) };
|
||||
// The mixer is the one adjustment that arrives as a nested object, so it is
|
||||
// the one that needs its own gate: unknown band ids are dropped, every value
|
||||
// is clamped to the knob's range, and a band left at zero is dropped instead
|
||||
// of carried around for nothing.
|
||||
adjustments.hslBands = sanitizeHslBands(raw.adjustments.hslBands);
|
||||
const frameId: FrameId = FRAMES.includes(raw.frameId) ? raw.frameId : 'none';
|
||||
return { name, baseFilter, adjustments, frameId, useGeotag: !!raw.useGeotag };
|
||||
}
|
||||
|
||||
+161
-3
@@ -1,4 +1,5 @@
|
||||
import { BaseFilter, ColorAdjustments } from '../types';
|
||||
import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils';
|
||||
|
||||
// Tone-domain adjustments (Fuji-style Dynamic Range + Highlight/Shadow).
|
||||
// SkSL runtime effect over a child image shader.
|
||||
@@ -29,6 +30,17 @@ import { BaseFilter, ColorAdjustments } from '../types';
|
||||
// (rather than the colour matrix) because it needs per-pixel chroma:
|
||||
// already-vivid pixels move least, so skins/skies deepen without the neon
|
||||
// clip a plain Saturation boost causes.
|
||||
// hslOn/hslH/hslS/hslL - selective colour mixer: eight hue bands, each with a
|
||||
// hue shift, a saturation scale and a lightness offset (-1..1, from the
|
||||
// -10..10 knobs). Which band owns which pixel is decided HERE, per pixel
|
||||
// from the pixel's hue — so unlike everything above, the eight bands are
|
||||
// not one global move and cannot live in a colour matrix. See the band
|
||||
// block at the end of TONE_SKSL.
|
||||
// gh/gs/gl - the mixer's whole-image moves: those same three quantities applied
|
||||
// to the WHOLE frame, so they are just the accumulator's seed values and
|
||||
// every hue takes them at full weight. Lightness is NOT gated on
|
||||
// saturation (unlike the bands), so a frame pulled to grey by -SAT still
|
||||
// answers +LUM.
|
||||
// shT/hlT - split tone (per-channel RGB bias, -1..1 each): a fixed cast applied
|
||||
// to the shadows and/or the highlights only. A 4x5 colour matrix cannot do
|
||||
// this — it is one linear map, so any cast it applies must also hit the
|
||||
@@ -36,6 +48,17 @@ import { BaseFilter, ColorAdjustments } from '../types';
|
||||
// warm highlights at once, so the stock ships these values and the pass
|
||||
// stays active for it even when every user knob is 0. All-zero still = no
|
||||
// pass.
|
||||
// The mixer's eight band lines inside TONE_SKSL, generated from HSL_BANDS so the
|
||||
// anchors and gaps in the shader are the numbers the chips were built from. Each
|
||||
// line reads its band's three values at a CONSTANT index — SkSL can only index a
|
||||
// uniform array by a constant, so this block is unrolled rather than looped.
|
||||
const BAND_BLOCK = hslBandGaps()
|
||||
.map(
|
||||
(b, i) => ` float w${i} = bandW(hd, ${b.hue.toFixed(1)}, ${b.left.toFixed(1)}, ${b.right.toFixed(1)}) * gate;
|
||||
acc += vec3(w${i} * hslH[${i}], w${i} * hslS[${i}], w${i} * hslL[${i}]);\n`
|
||||
)
|
||||
.join('');
|
||||
|
||||
export const TONE_SKSL = `
|
||||
uniform shader src;
|
||||
uniform float dr;
|
||||
@@ -50,6 +73,57 @@ uniform float hlTg;
|
||||
uniform float hlTb;
|
||||
uniform float cc;
|
||||
uniform float ccb;
|
||||
uniform float hslOn;
|
||||
uniform float hslH[8];
|
||||
uniform float hslS[8];
|
||||
uniform float hslL[8];
|
||||
uniform float gh;
|
||||
uniform float gs;
|
||||
uniform float gl;
|
||||
// sRGB <-> HSL. The mixer works in HSL because that is the space the knobs are
|
||||
// named after: a hue shift must not change how light a colour is, and a
|
||||
// lightness move must not change its hue — exactly what scaling R,G,B gets
|
||||
// wrong.
|
||||
vec3 rgb2hsl(vec3 c) {
|
||||
float mx = max(max(c.r, c.g), c.b);
|
||||
float mn = min(min(c.r, c.g), c.b);
|
||||
float l = (mx + mn) * 0.5;
|
||||
float d = mx - mn;
|
||||
if (d < 0.00001) return vec3(0.0, 0.0, l);
|
||||
float s = l > 0.5 ? d / max(0.00001, 2.0 - mx - mn) : d / max(0.00001, mx + mn);
|
||||
float h;
|
||||
if (mx == c.r) h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
|
||||
else if (mx == c.g) h = (c.b - c.r) / d + 2.0;
|
||||
else h = (c.r - c.g) / d + 4.0;
|
||||
return vec3(h / 6.0, s, l);
|
||||
}
|
||||
float hueChannel(float p, float q, float t) {
|
||||
t = fract(t);
|
||||
if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t;
|
||||
if (t < 0.5) return q;
|
||||
if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
|
||||
return p;
|
||||
}
|
||||
vec3 hsl2rgb(vec3 hsl) {
|
||||
if (hsl.y < 0.00001) return vec3(hsl.z);
|
||||
float q = hsl.z < 0.5 ? hsl.z * (1.0 + hsl.y) : hsl.z + hsl.y - hsl.z * hsl.y;
|
||||
float p = 2.0 * hsl.z - q;
|
||||
return vec3(
|
||||
hueChannel(p, q, hsl.x + 1.0 / 3.0),
|
||||
hueChannel(p, q, hsl.x),
|
||||
hueChannel(p, q, hsl.x - 1.0 / 3.0)
|
||||
);
|
||||
}
|
||||
// How much of a hue one band owns: full at the band's anchor, falling linearly
|
||||
// to zero at the anchors next door (the gaps are uneven — red is 30 deg from
|
||||
// orange and 40 deg from magenta). The linearity is the point: two neighbouring
|
||||
// tents cross at exactly 0.5 at the midpoint, so the eight weights sum to 1 at
|
||||
// every hue. No pixel is counted twice, no pixel falls in a gap, and a hue
|
||||
// sitting on an anchor takes its band's full value instead of part of it.
|
||||
float bandW(float hue, float anchor, float gapL, float gapR) {
|
||||
float d = mod(hue - anchor + 180.0, 360.0) - 180.0;
|
||||
return d <= 0.0 ? max(0.0, 1.0 + d / gapL) : max(0.0, 1.0 - d / gapR);
|
||||
}
|
||||
vec4 main(vec2 xy) {
|
||||
vec4 c = src.eval(xy);
|
||||
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
|
||||
@@ -102,7 +176,36 @@ vec4 main(vec2 xy) {
|
||||
float mn = min(min(rgb.r, rgb.g), rgb.b);
|
||||
float chroma = mx > 0.0001 ? (mx - mn) / mx : 0.0;
|
||||
float kv = 1.0 + vib * 0.75 * (1.0 - chroma);
|
||||
return vec4(clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0), c.a);
|
||||
rgb = clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0);
|
||||
// Selective colour by hue band — the LAST move, so a band edit is judged on
|
||||
// the colour the user actually sampled from the render.
|
||||
//
|
||||
// Grey pixels are dropped before any weight is read: rgb2hsl hands them hue 0,
|
||||
// so without the gate EVERY neutral pixel in the frame would be taken for pure
|
||||
// red and drift with the red band. Below 8% saturation there is also no hue
|
||||
// worth moving.
|
||||
//
|
||||
// The three accumulators are band values scaled by how much of the hue each
|
||||
// band owns, so a hue halfway between two anchors takes a proportional mix of
|
||||
// both edits — the blend the weights already add up to. Hue is a turn (plus or
|
||||
// minus 30 deg at full), saturation a scale (0 = grey at -10) and lightness an
|
||||
// additive offset (plus or minus 0.25 at full), which is why the ramp cannot
|
||||
// invert.
|
||||
if (hslOn > 0.5) {
|
||||
vec3 hsl = rgb2hsl(rgb);
|
||||
float gate = smoothstep(0.0, 0.08, hsl.y);
|
||||
float hd = hsl.x * 360.0;
|
||||
// The whole-image moves are the seed: every hue takes the hue turn and the
|
||||
// saturation scale at full weight, and the bands add their share on top.
|
||||
// Lightness is added below OUTSIDE the gate, so it still lifts a colour that
|
||||
// -SAT has already pulled to grey.
|
||||
vec3 acc = vec3(gh, gs, 0.0) * gate;
|
||||
${BAND_BLOCK} hsl.x = fract(hsl.x + acc.x * (30.0 / 360.0));
|
||||
hsl.y = clamp(hsl.y * (1.0 + acc.y), 0.0, 1.0);
|
||||
hsl.z = clamp(hsl.z + (acc.z + gl) * 0.25, 0.0, 1.0);
|
||||
rgb = hsl2rgb(hsl);
|
||||
}
|
||||
return vec4(clamp(rgb, 0.0, 1.0), c.a);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -179,6 +282,13 @@ export interface ToneUniforms {
|
||||
hlT: [number, number, number]; // highlight split-tone RGB bias, -1..1
|
||||
cc: number; // 0..1 Color Chrome depth (0 = 'none')
|
||||
ccb: number; // 0..1 Color Chrome FX Blue depth (0 = 'none')
|
||||
hslOn: number; // 1 when a band or a whole-image move is set (0 = skip the mixer)
|
||||
hslH: number[]; // 8 x -1..1 in HSL_BANDS order (+/-30 deg hue at full)
|
||||
hslS: number[]; // 8 x -1..1 (saturation scale, -1 = grey)
|
||||
hslL: number[]; // 8 x -1..1 (additive lightness, +/-0.25 at full)
|
||||
gh: number; // -1..1 whole-image hue turn (+/-30 deg at full)
|
||||
gs: number; // -1..1 whole-image saturation scale
|
||||
gl: number; // -1..1 whole-image lightness offset (+/-0.25 at full, ungated)
|
||||
}
|
||||
|
||||
// Per-stock tone pass. Fuji's Classic stocks are not a plain colour matrix:
|
||||
@@ -188,6 +298,9 @@ export interface ToneUniforms {
|
||||
// without also moving the other.
|
||||
const FILM_TONE: Partial<Record<BaseFilter, Partial<ToneUniforms>>> = {
|
||||
'classic-chrome': { sh: -0.28 },
|
||||
// Classic Vivid keeps Chrome's dived shadow: the shadow pull belongs to the
|
||||
// stock, not to the matrix rows it borrows from Velvia.
|
||||
'classic-vivid': { sh: -0.28 },
|
||||
'classic-neg': { shT: [-0.018, 0.009, 0.013], hlT: [0.024, 0.008, -0.012] },
|
||||
// Acros. A black-and-white stock IS its grey ramp, so this entry only shapes
|
||||
// the two ENDS and leaves the middle an identity: a smooth shadow toe that
|
||||
@@ -199,6 +312,14 @@ const FILM_TONE: Partial<Record<BaseFilter, Partial<ToneUniforms>>> = {
|
||||
// Gains are TONE_SKSL's own (sh * 0.34, hl * 0.22), so -0.12 puts the toe at
|
||||
// ~5% and -0.05 trims the top ~1%.
|
||||
monochrome: { sh: -0.12, hl: -0.05 },
|
||||
// B&W HIGH CONTRAST. Acros' ramp with both ends pushed hard: a deeper toe
|
||||
// (-0.32 against Acros' -0.12) so the darks reach true black, and a shoulder
|
||||
// that LIFTS instead of rolling (-0.05 becomes +0.26), which is the whites
|
||||
// step of the brief. Midtones see neither mask, so the long smooth stretch
|
||||
// between the two ends survives — that is what keeps a hard push off the
|
||||
// posterised look, and the slope this stock needs on the greys is its matrix
|
||||
// gain (STOCK_BIAS in colorUtils), not another mask here.
|
||||
'mono-high-contrast': { sh: -0.32, hl: 0.26 },
|
||||
};
|
||||
|
||||
export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter): ToneUniforms {
|
||||
@@ -213,11 +334,36 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter):
|
||||
// Color Chrome depth per stop of the UI's none/weak/strong. A chrome set is a
|
||||
// monochrome look, so both are forced off there: the effect is colour-only
|
||||
// (the preview/export matrix skips them for monochrome for the same reason).
|
||||
const colour = baseFilter !== 'monochrome';
|
||||
const colour = !isMonochromeBase(baseFilter);
|
||||
const chromeDepth = (v: ColorAdjustments['colorChrome'] | undefined) =>
|
||||
!colour || v === 'none' || v == null ? 0 : v === 'strong' ? 0.9 : 0.45;
|
||||
const blueDepth = (v: ColorAdjustments['colorChromeBlue'] | undefined) =>
|
||||
!colour || v === 'none' || v == null ? 0 : v === 'strong' ? 1.0 : 0.5;
|
||||
// Selective colour: one slot per band, in HSL_BANDS order, so the flat buffer
|
||||
// lines up with the shader's arrays. A band the user has not moved keeps three
|
||||
// zeroes and only costs its own slots.
|
||||
const bands = adj.hslBands ?? {};
|
||||
const tenth = (v: unknown) =>
|
||||
typeof v === 'number' && Number.isFinite(v) ? Math.max(-1, Math.min(1, v / 10)) : 0;
|
||||
const hslH: number[] = [];
|
||||
const hslS: number[] = [];
|
||||
const hslL: number[] = [];
|
||||
let hslOn = 0;
|
||||
for (const band of HSL_BANDS) {
|
||||
const v = bands[band.id];
|
||||
const [h, s, l] = v ? [tenth(v[0]), tenth(v[1]), tenth(v[2])] : [0, 0, 0];
|
||||
hslH.push(h);
|
||||
hslS.push(s);
|
||||
hslL.push(l);
|
||||
if (h || s || l) hslOn = 1;
|
||||
}
|
||||
// A stock black-and-white has no hue to select on.
|
||||
if (!colour) hslOn = 0;
|
||||
// The mixer's whole-image move: every hue takes it at full weight.
|
||||
const gh = tenth(adj.hslHue);
|
||||
const gs = tenth(adj.hslSat);
|
||||
const gl = tenth(adj.hslLum);
|
||||
if (colour && (gh || gs || gl)) hslOn = 1;
|
||||
return {
|
||||
dr,
|
||||
hl: hl + (film.hl ?? 0),
|
||||
@@ -227,17 +373,29 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter):
|
||||
hlT,
|
||||
cc: chromeDepth(adj.colorChrome),
|
||||
ccb: blueDepth(adj.colorChromeBlue),
|
||||
hslOn,
|
||||
hslH,
|
||||
hslS,
|
||||
hslL,
|
||||
gh,
|
||||
gs,
|
||||
gl,
|
||||
};
|
||||
}
|
||||
|
||||
// Flat uniform buffer for `makeShaderWithChildren` / `<Shader uniforms>` — the
|
||||
// order must match TONE_SKSL's declarations.
|
||||
export function toneUniformArray(u: ToneUniforms): number[] {
|
||||
return [u.dr, u.hl, u.sh, u.vib, u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb];
|
||||
return [
|
||||
u.dr, u.hl, u.sh, u.vib,
|
||||
u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb,
|
||||
u.hslOn, ...u.hslH, ...u.hslS, ...u.hslL, u.gh, u.gs, u.gl,
|
||||
];
|
||||
}
|
||||
|
||||
export function toneIsActive(u: ToneUniforms): boolean {
|
||||
return (
|
||||
u.hslOn !== 0 ||
|
||||
u.dr !== 0 ||
|
||||
u.hl !== 0 ||
|
||||
u.sh !== 0 ||
|
||||
|
||||
+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